主题
更新日志
AdMix 广告聚合 SDK 的发布说明。交付物固定为三个 aar:com.admix:core / com.admix:adapter-csj / com.admix:adapter-gdt, 版本号三者一致。带 ⚠️ 的条目是会影响接入方行为的改动,升级前先看这些。
版本一览
| 版本 | 发布日期 | 说明 |
|---|---|---|
| 1.0.0 | 2026-09-20 | 首个正式发布版 |
v1.0.0
发布日期 2026-09-20 · 首个正式发布版
无破坏性变更
本版没有需要接入方改代码的变更。
这一节是给接入方看的发布说明。下面「开发期变更明细」是 1.0.0 成型过程中的内部流水, 供维护者查阅,接入方看这一节就够了。
交付坐标(https://admin.maemi.cn/maven/,公开只读):
groovy
implementation "com.admix:core:1.0.0" // 必需
implementation "com.admix:adapter-csj:1.0.0" // 穿山甲(GroMore 通道),自动带上穿山甲 SDK 7.7.1.6
implementation "com.admix:adapter-gdt:1.0.0" // 优量汇,自动带上优量汇 SDK 4.680.1550离线包:https://admin.maemi.cn/maven/downloads/admix-android-sdk-1.0.0.zip(含全套文档,不含两家广告 SDK,协议不允许转发)。
环境要求:minSdk 25(Android 7.1.1 —— 7.0 不信任配置服务所用的 Let's Encrypt 根证书)、 compileSdk 34、JDK 17、AGP 8.x。仅 Android。
这一版能做什么
- 三种广告位:开屏、激励视频、Banner。两家广告平台(穿山甲 GroMore 通道 / 优量汇) 在华为 ALN-AL10 / Android 12 上,Debug 与 Release 混淆包均真机跑通
- 客户端竞价 + 瀑布混合调度:并行竞价 → 取最高价 → 按预估价降序遍历瀑布层 (竞价价 ≥ 当前层预估价就不再往下请求)→ 回传 win / loss。三种广告位共用一套引擎
- 接入方只配两个凭证:
AdMix.init(context, AppID, App密钥)。服务器地址内置; 各广告平台的 appId 与代码位 ID 全部由平台配置服务下发,SDK 侧没有本地写入口 —— 运营改价格、调顺序、开关广告位、停用一整家平台,接入方都不用发版 - 按需裁剪:只接一家就只引那一个 adapter,另一家(连同它的广告 SDK)不进包。 适配器通过 Java SPI 自注册,服务端按上报的能力裁剪下发,不需要写任何
packagingOptions - 预加载与缓存池:
AdMix.preload(),命中缓存时load()同步回调 0 等待; 自动补位、过期主动销毁、开屏超时后迟到的填充进池留给下一次 - 广告事件与埋点:SDK 自动采集广告维度事件并上报出报表; 另有可选的
AdMix.setEventListener()把同一批事件接进接入方自己的数据平台 - 激励视频服务端发奖验证:
RewardAd.setServerSideVerification(userId, customData), 平台验签后 HMAC 签名转发到接入方的接收地址 - 合规两段式初始化:
init()只读本地配置、不联网不采集;start()才初始化广告 SDK, 必须在用户同意隐私政策之后。未同意时任何load()直接失败(-1006),不会碰到广告 SDK - 混淆开箱可用:R8 规则随 aar 自动生效(
consumer-rules.pro),接入方无需写任何-keep。 minSdk 29 + R8 的外部最小工程实测零缺失类告警
对外 API 的两条约定
- 不返回真实 eCPM,也不返回广告平台的代码位 ID。
onAdLoaded(adnType)只给平台名称,AdEvent里没有价格和slotId,Release 包日志里这两项打*** - 只使用文档列出的类:
AdMix、AdMixConfig、AdError、AdnType、com.admix.api下的广告类与监听器、AdPreloader.PreloadListener、AdEvent/AdEventListener/AdEventType。 其余包是内部实现,标了@RestrictTo,随版本变化且不保证兼容
文档
docs/ 下五份,离线包内同样带一份:
| 文档 | 给谁 |
|---|---|
04-集成快速指南.md | 接入时先看这份,一页纸跑通 + 自检清单 + 常见报错对照表 |
01-接入文档.md | 完整版 |
02-隐私合规说明.md | 合规 / 法务 |
05-金融类App合规清单.md | 上架应用商店前 |
03-配置格式说明.md | 平台运营 |
已知限制(接入前请知悉)
- 机型覆盖:只在华为 ALN-AL10 / Android 12 上做过完整真机验证, 上架前建议在自有机型矩阵上回归一轮
- 优量汇仍会探测约 276 个指定包名:华为「读取已安装应用列表」弹窗已修复 (成因是优量汇不是穿山甲,
GlobalSetting.setEnableCollectAppInstallStatus(false)), 但关掉开关后优量汇的安全模块改为逐个getPackageInfo探测约 276 个指定包名(不弹窗)。 金融类 App 上架前必须知悉这条,处置口径见docs/05-金融类App合规清单.md第 7 节, 已向优量汇提工单 - 缓存只在进程内存里:进程被杀后清空,真正的冷启动第一条开屏一定是实时请求
- 穿山甲加载阶段拿不到真实 eCPM(展示时才有),与优量汇竞价位比价用的是后台配置的预估价。 预估价填虚高会把优量汇的真实出价比下去 —— 这是运营侧要按近 7 日真实 eCPM 校准的事
- 未覆盖的竞价分支:优量汇竞价位价格低于某瀑布层预估价、而该层实际价格又更低的情况, 以及 Release 包下的 loss 回传(Debug 已验)
以下是 1.0.0 成型过程中的内部变更明细,按时间倒序。
开发期变更明细
1.0.0 成型过程中的内部变更流水,按时间倒序,供维护者查阅。接入方看上面的版本说明就够了。
展开 2 组开发期明细
适配器自注册 + Demo 拆 flavor(2026-09-19)
变更
⚠️ 适配器改为自注册,
core里不再有 ADN 清单(core、三个 adapter)- 原状态:
AdnRegistry.BUILT_IN写死 csj / gdt / mock 三个类名,加一家 ADN 就要改聚合核心, 与服务端「ADN 是数据不是代码」的模型不对称 —— 后台加一家只是插一行数据,端上却要动 core 并重发三个 aar - 现在:每个 adapter 在自己的
src/main/resources/META-INF/services/com.admix.adapter.IAdnAdapter里声明实现类全名,core用ServiceLoader扫描 classpath 发现(Java SPI)。 ADN 代号取自各 adapter 的adnType(),core 里一个 ADN 名字都没有 - 选 SPI 而不是 assets 清单 / 注解处理器:SPI 不需要 Context,是 JDK 自带、Gradle / AGP / R8 三方都原生认识的机制;assets 会和接入方的资源混在一起;注解处理器绕一圈还是得让 core 知道一个固定类名
- 混淆下可靠性:AGP 默认把
META-INF/services/**合并(不是取其一),三家的声明在 APK 里拼成一个文件; R8 要么改写ServiceLoader.load、要么保留并同步重写服务文件内容,两条路径都自洽; 各 adapter 的consumer-rules.pro本来就整包 keep 自己的实现类,名字不会变。 接入方不需要写任何packagingOptions - 「宿主只引部分 adapter 时其余静默跳过」这个特性更干净了:没引的那家在包里连一条声明都没有, 不是"跳过"而是不存在。实例化阶段仍 catch
Throwable(服务文件在而类加载失败时只丢这一家) AdnRegistry.discoveredCodes()行为不变(仍是"实际加载成功的代号,逗号分隔"), 改为按代号排序:ServiceLoader的遍历顺序取决于 classpath 上各 aar 的先后, 而这个字符串要上报给服务端,不能随构建顺序抖动。探测时机也不变(仍在AdMix.init()里)- 探测结果为空时由 debug 日志改为
warn(「一家都没有」意味着这个包不可能有广告,不该只有一行 debug) AdnType的常量保留,但降级为「字面量字典」:core 不读它,新增一家 ADN 不改它也能跑。 留着的理由是它属于对外 API(接入方要拿它比AdError.adnType/AdEvent.adn), 且代号必须与平台后台 ADN 注册表的拼写逐字一致,写在一处便于核对- 删掉了从未被调用的
AdnRegistry.all() - 新增一家 ADN 现在要做什么:见
README.md「新增一家 ADN 要做什么」(8 步,core一行不改)
- 原状态:
⚠️ Demo 拆成两个 flavor:
sample(对外示例)与internal(内部联调)(demo)- 原状态:对外示例和我方联调工具混在一个包里,开发者照抄跑不起来 ——
build.gradle里有不发布的adapter-mock(照抄即编译失败),applicationId是借用正式媒体的xiaoe.jishuny.cn(别人的包名) sample:中性包名com.admix.demo,用公网 Maven 坐标依赖(com.admix:core等), 不含adapter-mock与 Mock 测试台。它顺带成了「我们发布出去的 aar 到底能不能用」的常设检查internal:xiaoe.jishuny.cn+ module 依赖 +adapter-mock,日常开发与联调用它- 三种广告位的示例代码在
src/main里只有一份;差异全部通过DemoFlavor一个类表达 (两个 flavor 各一份实现)。MockScenarioActivity、Mock 场景配置、Mock 常量移入src/internal lintSampleRelease的RestrictedApi为 0 条 —— 对外示例只用对外 API,这是硬门槛- 构建任务名变了:
assembleDebug→assembleInternalDebug/assembleSampleDebug;deploy/发布到服务器.sh改为编internalRelease(sample依赖的正是"正在发布的那个版本"), 并在上传成功后从公网仓库反向复核sampleRelease能编过、SPI 声明含 csj + gdt 不含 mock
- 原状态:对外示例和我方联调工具混在一个包里,开发者照抄跑不起来 ——
新增
开屏兜底放行示例(
demo、《接入文档》第三节)—— 开屏接入的必备项,此前 Demo 里没有- 三段不依赖任何回调的定时兜底:load 后无回调(5s)、
show()后无onAdShow(2s, 优量汇缓存命中白屏就是这条)、展示后无onAdClose(12s,且只计前台时间, 用户点进落地页时暂停、回来重新计时) - 《接入文档》写清了三段各救哪个场景,以及为什么"把回调收敛到 goMain()"不够
- 三段不依赖任何回调的定时兜底:load 后无回调(5s)、
隐私政策弹窗示例补全(
demo、《隐私合规说明》4.1)—— 同意结果持久化、 点「不同意」时一次start()都不调(禁掉广告入口 + 留「重新查看隐私政策」按钮), 文案对应实际集成的第三方 SDK 清单《接入文档》补 Kotlin 写法(初始化 + 三种广告位 + 事件回调),与 Java 示例等价。 Demo 本身保持 Java
《接入文档》新增「只接一家会怎样」:自注册机制、服务端按能力裁剪下发、 为什么不需要配
packagingOptions、adapter 就绪这行日志怎么用《隐私合规说明》新增「只披露实际集成的那一家为什么是可证的」: 给出
unzip -p your-app.apk META-INF/services/com.admix.adapter.IAdnAdapter的取证方法DemoConfig注释补全:两个凭证从管理后台哪里取、不要提交到公开仓库(给出三种替代做法)、 泄露后怎么轮换;onDestroy里destroy()的后果在三个 Activity 里都写明了拉配置时上报 SDK 版本与已集成的 adapter 列表,服务端据此裁剪下发(
core)GET /v1/config新增两个查询参数:sdk_ver(=BuildConfig.SDK_VERSION)、adapters(AdnRegistry探测到的 adapter 代号,逗号分隔)- 解决的问题:SDK 按需裁剪时,宿主只引
adapter-csj的包里根本不存在优量汇的 adapter。 服务端此前不知道这件事、全量下发, 结果是每次调度少一家竞争、填充率变低而两端都看不出异常。现在服务端把端上没有的 ADN 从adns和各 unit 的slots里摘掉;unit本身不删(广告位消失和"暂时没填充"是两件事) - 两个参数不纳入签名。签名原文仍是
app_id / nonce / ts / key四项 —— 纳进去意味着以后每加一个参数都要两端同步发版,而老版本算出来的签名会因少一个参数全部 401 (配置拉不到 = 线上没广告)。篡改它的收益只是"让自己少拿一家 ADN",且传输走 HTTPS - 探测时机无需调整:
AdnRegistry.discover()在AdMix.init()里就完成, 而联网拉配置只发生在AdMix.start()之后。AdnRegistry.discoveredCodes()自身再兜一道幂等探测 adapters取自探测结果:上报的必须是"实际有什么"而不是"理论上支持什么" (自注册改造之后,"理论上支持什么"在端上已经不存在了)- 服务端按能力裁剪时会回一个
X-Admix-Adn-Trimmed头,SDK 日志里原样打出来 —— 「为什么配置里只有一家」在端上日志里也有答案,不用两边对着看 - 不带这两个参数的老版本按全量下发(行为与改造前一致),后台能看出哪些应用在跑这种版本
修复
- 优量汇开屏命中缓存时白屏、开屏页不关闭(
adapter-gdt):缓存命中时接入方在开屏页onCreate里就调show,容器尚未布局, 优量汇showAd后回调了展示却不渲染。现在容器未布局时等布局完成后再展示,接入方代码不用改 - 优量汇竞价回传参数(
adapter-gdt、core):改用优量汇文档的 Map 版本sendWinNotification/sendLossNotification; 竞胜时EXPECT_COST_PRICE传自身出价、HIGHEST_LOSS_PRICE传最高竞败价(原来只有一个竞价位时传的是 0);竞败时补ADN_ID。 最高竞败价现在包含被竞价结果比下去的瀑布层价格
新增
预加载与缓存补齐(
core、adapter-csj、adapter-gdt)- 自动补位(
auto_refill):缓存被取走或过期后补一条;最短间隔refill_min_interval_sec(默认 30 秒)、失败指数退避封顶 10 分钟、连续失败 3 次暂停;只在 App 前台发起 - 缓存到期主动销毁(不再等下次访问才发现);优量汇开屏
expireTimestamp、穿山甲激励getExpirationTimestamp()叠加判断 - 开屏
load()未命中缓存时以入池方式请求并等待total_timeout_ms,超时放行后请求继续,结果进缓存池留给下一次开屏 - 激励视频
load()未命中但有预加载在飞时等待其结果,不重复请求;同一广告位的预加载 / 补位合并为一轮 - 预加载 / 补位调度使用独立超时
preload_timeout_ms(默认 10000,不小于total_timeout_ms) - 埋点
ext新增load_src/refill_reason/cache_hit/cache_age_s/cache_expired;AdEvent新增loadSource/cacheHit - 《接入文档》新增「预加载与缓存」,《配置格式说明》补充缓存相关字段
- 自动补位(
激励视频服务端发奖验证(
core、adapter-csj、adapter-gdt)RewardAd.setServerSideVerification(String userId, String customData):在load之前调用,透传给穿山甲 GroMore(AdSlot.setUserID+MediationConstant.CUSTOM_DATA_KEY_GROMORE_EXTRA)与优量汇(RewardVideoAD.setServerSideVerificationOptions)- 不调用时请求不变;调用后该对象的请求不使用预加载缓存
- 平台服务器验签后签名转发到开发者服务器,《接入文档》「激励视频 → 服务端发奖验证」给出验签规则与 Java / Python 示例
变更
⚠️ 缓存行为调整(
core)- 缓存池有多条时取价格最高的一条(同价取先入池的)
expire_ms为 0 或负数时按默认 30 分钟处理,不再表示「永不过期」- Banner 只有首次
load()取缓存,定时刷新不取缓存、不自动补位;配置未下发cache_size时 Banner 默认 0 - 开屏
load()在开了缓存的广告位上,等待上限从「ADN 初始化 +total_timeout_ms」收紧为从调用起算的total_timeout_ms
⚠️ SDK 通过公网 Maven 仓库分发,adapter 自动带上广告平台 SDK(
adapter-csj、adapter-gdt)- 仓库
https://admin.maemi.cn/maven/;接穿山甲还需配置穿山甲官方仓库https://artifact.bytedance.com/repository/pangle adapter-csj的 pom 声明com.pangle.cn:mediation-sdk:7.7.1.6、adapter-gdt声明com.qq.e.union:union:4.680.1550,均为runtime范围: 接入方引 adapter 即打包对应 SDK,无需再手动放 aar;SDK 的类不进宿主编译类路径- 接入方工程里不要再放这两家的 aar 文件,否则重复类
- 不再发布
-sources.jar - 离线 zip 包
https://admin.maemi.cn/maven/downloads/admix-android-sdk-<版本>.zip:三个 aar + README + 文档, 不含穿山甲、优量汇 SDK(两家协议不允许转发),需接入方自行下载指定版本 - 《接入文档》「引入依赖」改为 Maven / 离线包两种方式,并补充同时接两家时的清单合并冲突处理(
tools:replace)
- 仓库
⚠️ 对外 API 去掉代码位 ID(
core、adapter-csj)—— 业务规则「开发者看不到代码位 ID」在 SDK 侧的落地AdEvent:删除slotId字段,toString()也不再带代码位 ID。内部TrackEvent与/v1/events上报内容、落盘格式不变- 日志中的代码位 ID(瀑布层
csj:<代码位>、[埋点]事件行、GroMore 成交明细代码位=)统一经AdLog.slotId(): 发布的 release aar 打成***,本工程 debug 包显示真实值,规则与价格一致 - 《接入文档》事件类型表、
AdEvent字段表、日志示例同步去掉代码位 ID
⚠️ SDK 内部类标注
@RestrictTo(LIBRARY_GROUP)(core、adapter-csj、adapter-gdt)—— 宿主直接调用内部类时 Lint 报RestrictedApi错误- 范围:
com.admix.adapter、cache.AdCachePool、config、mediation、track下的EventTracker/TrackSession/TrackingCallback、util,以及 csj / gdt / mock 适配器的实现类;AdPreloader只标静态方法(PreloadListener是对外 API),AdMix.getContext()/getConfig()为方法级标注 core新增api 'androidx.annotation:annotation:1.3.0'(POM compile 范围)。必须是 api:Lint 需要在宿主编译类路径上解析到注解类, compileOnly / implementation 时宿主若没有自带 androidx.annotation,拦截会静默失效- SDK 各模块之间(同 group
com.admix)与 Demo 不报错;consumer 混淆规则不变
- 范围:
⚠️ 对外 API 去掉 eCPM,开发者拿不到真实价格(
core)—— 业务规则「开发者看不到真实 eCPM」在 SDK 侧的落地SplashAdListener/RewardAdListener/BannerAdListener:onAdLoaded(String adnType, int ecpm)→onAdLoaded(String adnType)SplashAd/RewardAd/BannerAd:删除getEcpm()AdPreloader.PreloadListener(AdMix.preload的回调):onSuccess(String unitId, String adnType, int ecpm)→onSuccess(String unitId, String adnType)AdEvent(AdMix.setEventListener抛出的事件):删除ecpm字段,toString()也不再带价格。 内部上报改用包级私有的TrackEvent(带ecpm),分发时拷出不含价格的AdEvent给接入方,/v1/events上报内容与落盘格式不变- 日志中的价格(
ecpm=、瀑布层price=、GroMoreecpm原始值=、win/loss 回传价)统一经AdLog.price(): 发布的 release aar 打成***,只有本工程 module 依赖的 debug 包显示真实值。 logcat 对宿主可见,debug开关在接入方手里,不能作为遮挡依据 - Demo、《接入文档》、README 改为新写法
⚠️ 初始化简化为
AdMix.init(context, AppID, App密钥)(core)—— 接入方只提供凭证- 服务器地址内置为
https://api.maemi.cn(com.admix.config.Endpoints),拉配置/v1/config、埋点/v1/events - 签名密钥由全局
clientSalt改为每个应用自己的 App 密钥,算法不变:sha256("app_id=..&nonce=..&ts=..&key=<App密钥>") - 新增重载
AdMix.init(context, appId, appKey, AdMixConfig)承载可选项;AdMixConfig.Builder只保留debug、limitPersonalAds、channel、subChannel、userValueGroup、segmentCustomInfo - 移除
init(Context, AdMixConfig)及 Builder 的configUrl、eventUrl、admixAppId、clientSalt、configAsset(固定读 assets 下可选的admix_config.json)、startTimeoutMs(内置 5 秒)、eventReportEnabled(上报不再可关)、eventBatchSize/eventFlushIntervalSec/eventMaxQueueSize(内置 50 条 / 30 秒 / 1000 条) - 去掉「不带凭证按 OSS/CDN 静态 JSON 裸 GET」的拉取形态,请求一律签名
- AppID 或 App 密钥为空时
init()抛IllegalArgumentException - assets 里没有兜底配置时只打 INFO,不再打 ERROR 堆栈
- Demo 改为新写法(
DemoConfig.APP_ID/APP_KEY),删除SERVER_BASE_URL/CONFIG_URL/EVENT_URL/CLIENT_SALT/CHANNEL/SUB_CHANNEL - 《接入文档》初始化与凭证章节重写;《隐私合规说明》6.5 由「关闭上报」改为「为什么不提供关闭开关」
- 服务器地址内置为
⚠️ Demo 配置服务切到 HTTPS(
demo)—— 地址改为https://api.maemi.cn(Let's Encrypt 证书),删除network_security_config.xml及 manifest 引用: 文件里只剩为 IP 开的明文例外,删掉例外后与系统默认策略(禁止明文)完全一致。 IP 入口http://101.201.117.15已不再提供服务⚠️ Demo 切到生产配置服务(
demo)—— 地址从本机联调的http://127.0.0.1:8090换成甲方服务器公网入口http://101.201.117.15(Nginx :80 → 内部 8090), 凭证换成生产clientSalt- 地址收敛到
DemoConfig.SERVER_BASE_URL一个常量,配置地址与埋点地址都从它拼, 以后换域名只改一处 - 不再需要
adb reverse tcp:8090 tcp:8090,相关说明已从代码注释和文档里清掉 network_security_config.xml的明文例外从127.0.0.1改为101.201.117.15。 没有用usesCleartextTraffic="true"全局放开 —— 那会把两家 ADN SDK 的上报和素材 请求也一起降级成明文可用。服务器还没有域名、签不了证书,域名到位后必须切 HTTPS 并删掉这条例外,代码注释和接入文档里都标了- 真机(华为 ALN-AL10)实测:远端配置拉取成功(
adns=[gdt, csj]、3 个广告位), Banner 用生产代码位7347574095648303填充并曝光,埋点上报accepted:5 rejected:0
- 地址收敛到
⚠️ ADN 的 appId 改由服务端下发(
core)—— 模式 B 下每个开发者在穿山甲/优量汇后台 都是独立创建的应用,appId 各不相同。写在宿主 App 里意味着平台每接一个开发者 就要重新出一个接入包,接入成本没有下限- 配置下发的 JSON 根对象新增
adns数组:[{"adn":"csj","app_id":"..."}, ...], 由ConfigManager解析,是 ADN appId 的唯一来源 - 移除
AdMixConfig.Builder的csjAppId()/gdtAppId()/appId(adnType, ...), SDK 不再有任何接收 ADN appId 的本地入口 —— 留着它就等于给接入方留了一个 把流量改到自己账号上的口子 - 接入方现在只需要配
admixAppId+clientSalt+configUrl ConfigManager内部改为adns与units合成一个不可变快照、volatile 引用整体替换, 两者必须同生同死,否则会出现「拿到了新广告位、appId 还是旧的」这种半截状态
- 配置下发的 JSON 根对象新增
⚠️ 初始化改为串行:配置到手后才算就绪(
core)—— 原先start()是 「起线程拉配置」和「初始化 ADN」并行的,首次冷启动时adns根本还没到手- 本地已有缓存配置 → 立即
onSuccess,不等网络,后台静默刷新 - 本地什么都没有(装机后真正的第一次冷启动)→ 等一次网络请求, 默认上限 5 秒,新增
AdMixConfig.Builder.startTimeoutMs(int)可调 - 超时或拉取失败 →
onFail,宿主按无广告降级。后台线程仍会把请求跑完, 再调一次start()即可就绪 isReady()的判定增加一条「配置已到手」——拿不到配置就不知道该初始化谁- 不做「本地兜底 appId」:模式 B 下每个开发者的 appId 都不同,兜底值没有意义。 assets 兜底配置带了
adns也照常能用,但随包发布 appId 本身就是要消掉的东西
- 本地已有缓存配置 → 立即
ForwardingListener的构造函数增加埋点上下文参数(core内部类,接入方不接触)—— 它是所有 ADN 回调的必经之路,展示期埋点放在这里,三个对外广告类一行都不用改adapter-mock的激励视频在展示时序里补了一次onClick(测试件,不发布)—— 展示期漏斗少了点击这一环就验不全《隐私合规说明》新增第六节「AdMix 自身的广告埋点上报」,逐字段列出采集与不采集的内容。 这份文档是甲方上架材料,埋点上线前必须先改它
新增
埋点采集与上报(
core,新增com.admix.track包)—— 全链路最后一个断点补齐, 报表从此跑的是真实数据- 采集 10 种事件:
ad_request/adn_request/adn_fill/adn_fail/fill/no_fill/impression/click/close/reward,字段严格按服务端docs/埋点协议.md,一个自造字段都没有 - 采集点是"包一层"接进去的,不是散进调度分支:
MediationEngine里只多了三处 ——load()开一轮 session、createCandidate()说一声发了请求、结果回调被TrackingCallback包一层。竞价超时、瀑布下探、单层超时、整体超时兜底这些分支里 一行上报代码都没有。展示期事件(曝光/点击/关闭/激励)由ForwardingListener就地采集 —— 每一条 ADN 回调都必然经过它,三个对外广告类同样不用改 - 超时类
adn_fail不在引擎的三个超时分支里各埋一行,而是在本轮收尾时统一补: 凡是发了adn_request却没有终态的代码位一律补adn_fail(-1004)。 语义一致,而且以后加调度分支也不会漏 - 同一个代码位的终态先到先得:引擎丢弃的迟到
onLoaded不会再产生第二条adn_fill,否则adn_request与adn_fill + adn_fail永远对不上 request_id一轮调度一个。Banner 每次刷新都是一轮完整调度(new MediationEngine()), 「每次刷新换新 request_id」因此是结构性成立的,不靠约定- 穿山甲 Banner 在 GroMore 模式下是一段式,Adapter 拿到非空
ExpressAdView才回调onLoaded,所以adn_fill天然打在「渲染成功」而非「加载成功」上
- 采集 10 种事件:
本地队列与上报(
core,EventReporter)- 攒批发送:满
eventBatchSize(默认 50)或间隔eventFlushIntervalSec(默认 30 秒) - 每条事件产生时立刻追加落盘(App 私有目录
files/admix_events.log,无用户态缓冲), 进程被am force-stop也不丢,下次启动读回来继续报。不攒着定时落盘 —— 那个窗口期正好是 App 被系统回收的高发时刻 - 补报保留事件发生时的真实
event_time,绝不在上报时重取 now。 取错了报表会悄悄失真(昨天凭空少一块、今天凭空多一块)且不报任何错 - 失败退避:5s → 10s → 20s …封顶 5 分钟;连续失败 8 次熔断 30 分钟, 期间事件继续落盘。400 / 413 这类"重传也没用"的响应直接丢弃本批, 避免一个坏批次把后面的事件永远堵住
- 队列上限 1000 条(可配),超了丢最老的 —— 老事件本来就更可能超出服务端 7 天的 迟到回溯窗口,报上去也进不了报表
- 全部跑在独立的
AdMix-Event线程上,入队只是一次 Handler.post, 主线程和广告请求路径上没有任何磁盘或网络操作 - 鉴权复用配置接口的签名机制与凭证(
app_id+ts+nonce+sign), 为此把ConfigSigner提为 public,并在ConfigManager上开了signingNowMs()/correctClockFromServer()两个复用点 —— 两边各写一份签名实现迟早会出现"配置能拉、埋点一直 401" event_time用的是配置模块校正过的时钟,手机时间不准时不会被服务端判成荒谬值
- 攒批发送:满
对外事件回调(
core,新增AdMix.setEventListener(AdEventListener))—— 接入方可把广告事件接进自有数据平台(神策 / GrowingIO / 自研)- 与内部上报是两条独立的路径:不注册不影响上报,注册了也不改变上报行为
- 固定在主线程回调;接入方实现里抛异常会被吞掉打日志,不影响广告业务和内部上报
AdEvent.adnSource带 GroMore 通道下真正出广告的那一家, 接入方不必自己在每个onAdShow里读getAdnSourceName()
埋点相关配置项(
core,AdMixConfig.Builder):eventReportEnabled/eventUrl/eventBatchSize/eventFlushIntervalSec/eventMaxQueueSize。 上报地址不填时从configUrl推导(/v1/config→/v1/events),凭证同一组AdMix.flushEvents()—— 立刻发一次,供宿主退出前抢报一次,正常接入不需要调用core打开buildConfig,BuildConfig.SDK_VERSION从version.gradle生成, 埋点的sdk_version用它,避免版本号在代码里再写一份副本ADN 懒初始化(
core,新增AdnInitializer)——start()不再初始化任何 ADN, 某家 ADN 只在「首次加载到配置了它的广告位」时才初始化- 收益一:启动更快,用不到的 ADN 一次初始化都不做
- 收益二:合规更好 —— 未被使用的 ADN 一行代码都不跑、不采集任何设备信息。 真机实测:只跑 Mock 场景时,日志里只有
mock被初始化,穿山甲和优量汇 全程零动作,哪怕它们的 appId 早就下发到端上了 - 合规两段式语义未变:
init()不联网不采集;用户同意隐私政策前 (start()未调用前)不初始化任何 ADN,AdnInitializer.ensureReady()开头的授权检查是这条红线的守门人 - 并发:同一家 ADN 可能被多个广告位同时触发,每家只允许一次初始化在飞, 后来的请求挂进等待队列一起唤醒,而不是直接判失败
- 初始化失败不拉黑:下次 load 会再试一次 —— 失败多半是网络原因, 一次失败就永久拉黑等于白白丢掉后续所有填充
- 等待上限 =
min(5 秒, 该广告位的 total_timeout_ms)。不能超过广告位自己的预算, 开屏配 3.5 秒就最多等 3.5 秒,否则会出现「业务侧早已放行进主页、广告还在等初始化」 - ⚠️ 首次加载会多出一次 ADN 初始化耗时。实测穿山甲装机后第一次初始化要 2~4.4 秒 (之后每个进程 200ms 上下),开屏的 3 秒预算可能因此被吃掉。 缓解办法:用
AdMix.preload()预热(开屏本来就该预加载),或把开屏的total_timeout_ms放宽
配置接口签名鉴权(
core)—— 此前 SDK 拉远端配置是裸 GET, 和 admix-server 的GET /v1/config实际接不上,联调只能把后端auth.enabled关掉, 等于配置接口公网裸奔AdMixConfig.Builder新增admixAppId()/clientSalt()。两个都填上, SDK 拉配置时自动带app_id / ts / nonce / sign;任一为空则按「OSS/CDN 静态 JSON」 发不带签名的 GET,两种部署形态都支持- 签名算法与服务端一致:
sha256("app_id={id}&nonce={n}&ts={t}&key={salt}")取小写 hex, 拼接顺序写死不做字典序排序 nonce= 8 字节随机 + 进程内自增序号,保证单次请求唯一(服务端有防重放校验)- ⚠️ AppSecret 不进 SDK。它只用于服务端对服务端(激励视频回调验签、报表接口), SDK 没有任何接收它的入口。
clientSalt是另一个值,会进 APK 且逆向能拿到, 定位是提高门槛而非身份凭证 - 401 时打印带业务码的明确日志(40101 签名错 / 40102 时间戳超窗 / 40103 nonce 重放), 保留当前配置不清空,与原有的「远端拉取失败必须保留本地兜底」约束一致
客户端时钟偏差自动校正(
core)—— 签名带时间戳,服务端只接受 ±300 秒以内的请求。 用户手机时间不准是常态,偏差超窗后配置就再也拉不下来,且表现是「一切正常只是配置不更新」, 没有任何报错。SDK 收到 40102 时用响应Date头算出时钟差、落盘保存并立刻重签重试一次, 下次冷启动直接带校正后的时间戳。只重试一次,避免在真正的配置错误上反复打服务端
变更
- Demo 默认接 admix-server,并新增网络安全策略:默认禁止明文流量,只对配置服务放行 —— 签名不加密内容,HTTP 下代码位 ID 和底价在链路上是明文的
ConfigManager.loadFromUrl()增加appId/salt两个参数
变更
- 穿山甲改走 GroMore 聚合通道(
adapter-csj,模块名与 artifactId 不变)- 起因:穿山甲已将「基础变现」并入 GroMore 并停止受理基础变现的开通申请, 账号里创建的代码位「是否用于 GroMore」锁死为「是」,这类代码位只在
useMediation(true)下返回广告,单 ADN 模式一律报20001 reason:234 聚合代码位只能在聚合场景使用。不是配置问题,此路被官方封死 adapter-csj实际代表的是「GroMore 聚合通道」:adnType仍是csj, 但出广告的可能是瀑布流里的任意一家 ADN。模块名保持不变,语义差异写进了 类注释、《接入文档》和《配置格式说明》- 配置里
csj的slot_id填 GroMore 广告位 ID(「应用管理」页), 不是瀑布流里的穿山甲代码位 ID。Demo 配置已换成104537022/104536161/104535580 - ⚠️ 后台必须配两步:①「应用管理」建广告位 ②「瀑布流管理」给该广告位加代码位。 只做第一步会直接无填充,且错误码只说"全部代码位请求失败",看不出是漏配
- 取价改用
getMediationManager().getBestEcpm()/getShowEcpm()返回的实际成交价, 取不到时回落到配置里的price。csj的bidding字段不再有意义,填false - 不再调用
TTClientBidding.win()/loss()—— 竞价在 GroMore 内部完成, 请求时已setBidNotify(true)由它自己通知各 ADN。两个方法保留为空实现加日志, 因为调用点在调度引擎里,引擎不区分候选是不是聚合通道 - 三个广告类补上了合规/体验相关的聚合参数:开屏关摇一摇、Banner 静音、激励视频不静音
- 起因:穿山甲已将「基础变现」并入 GroMore 并停止受理基础变现的开通申请, 账号里创建的代码位「是否用于 GroMore」锁死为「是」,这类代码位只在
adapter-mock移除maven-publish。AdnRegistry会自动发现 classpath 上的MockAdapter,一旦这个 aar 和三个正式 aar 躺在同一个仓库里,接入方很容易顺手引进 正式包 —— 届时线上会出现一批永远 100% 填充、eCPM 却是伪造的广告位,报表被污染 且没有任何报错提示。改为源码依赖 +adapter-mock/README.md注明仅供内部测试- Demo 的穿山甲配置改用甲方的 GroMore 广告位 ID(appId
5883462, 开屏104537022/ 激励104536161/ Banner104535580), 配置里加了_note说明这是「应用管理」页的广告位 ID 而非瀑布流代码位 ID
新增
真实广告来源可读(
getAdnSourceName())RewardAd/SplashAd/BannerAd三个类上各加一个方法,返回 GroMore 瀑布流里 实际出广告的那一家(pangle/ks/baidu/sigmob)。埋点记这个值才是真实来源, 只记getAdnType()会把所有收入算到穿山甲头上- 该值在广告展示后最完整,埋点建议在
onAdShow时读 - SPI 侧是
IAdnAd上的 default 方法,默认返回 null(直连 ADN 的来源就是 adnType 本身), 其他 Adapter 无需改动
流量分组标识(
AdMixConfig.channel()/subChannel()/userValueGroup()/segmentCustomInfo())- 随请求带给 GroMore,并在广告返回的价格信息里原样读回, 用于把收入按接入方/产品线拆分 —— 模式 B 下平台能不能算清账,取决于这个
- 取值由宿主 App 或服务端下发,SDK 不写死
- 真机实测:打进去的
channel/subChannel在MediationAdEcpmInfo里原样读回, 并带回 GroMore 分配的segmentId
ADN 级错误明细:
debug(true)时自动打开 GroMore 的show_adn_load_error_detail,20005的 message 里会带上每家 ADN 的失败原因 —— 没有它就无法区分"瀑布流没配代码位"和"确实没有广告"⚠️ 整体超时时保留已到手的广告(
AdUnit.useAdOnTimeout/ 配置项use_ad_on_timeout)- 此前
total_timeout_ms一到点就无条件判失败,即便竞价阶段早就拿回了一条可用广告, 也要sendLoss(0, OTHER)后销毁。典型触发场景是"低价的那家已经回来了、 高价的那层迟迟不回调",白丢的是一条真金白银的填充 - 默认值按广告类型区分:
splash默认false,其余默认true。 开屏必须是 false —— 业务侧超时后已经放行进主页,此时再回调会让广告突然盖在主页上, 这是体验事故;激励视频 / Banner 晚一点出也比不出强 - 只影响"整体超时"这一条路径。
ERR_ALL_FAILED(所有广告源均无填充)的语义一个字没动 - 启用时走完整的
succeed():竞胜方sendWin(第二名价)、竞败方sendLoss、其余候选销毁 - 真机实测(Debug + Release 混淆包):激励视频超时后按 1200 分填充成功并
sendWin(0); 同配置换成ad_type=splash仍回调ERR_TIMEOUT(-1004)+sendLoss(0, OTHER); 显式写use_ad_on_timeout=false能反向覆盖默认值。Mock 场景 ⑤ / ⑤-2 / ⑤-3
- 此前
配置本地持久化 + ETag 协商缓存(
ConfigManager)- 请求远端配置时带
If-None-Match,服务端回 304 就完全不解析、保持当前配置 - 成功拉到的配置连同 ETag 存进 SharedPreferences,冷启动优先用这份缓存, assets 只作为它不存在或损坏时的兜底。不做持久化的话每次冷启动都要全量下载一遍, ETag 等于没用;而且启动那几秒用的还是随包发布的旧配置, 运营刚下线的代码位每次冷启动仍会被请求一次
- 加载优先级明确为:本地缓存(若有)> assets 兜底 → 再异步拉远端更新
- 缓存损坏时清掉缓存回退 assets,同时清掉内存里的 ETag —— 否则下次会带着 If-None-Match 拿到 304,端上却一直用着 assets,新配置永远拉不下来
- 真机实测:首次 200 并落盘 → 杀进程冷启动打印"本地缓存配置加载完成 etag=..." → 第二次请求服务端日志为
status:304、端上打印"远端配置未变更(304)"; 把缓存 JSON 截断成半截后冷启动不崩溃,回退 assets 且下次请求为 200 而非 304 AdMix.init()内部改调ConfigManager.loadLocal();接入方代码无需改动
- 请求远端配置时带
Mock 测试台新增三个场景:⑤ 整体超时(激励,默认启用已到手广告)、 ⑤-2 整体超时(开屏,默认丢弃)、⑤-3 显式
use_ad_on_timeout=false。 ⑤-2 是测试台里第一个走SplashAd的用例Demo 的远端配置地址集中到
DemoConfig.CONFIG_URL(默认留空 = 只用本地兜底配置)广告缓存池与预加载(
AdMix.preload()/AdCachePool/AdPreloader)- 开屏只有 3 秒出头的超时预算,冷启动实时请求大概率拿不到广告;预加载把调度提前到 上一次会话,
load()时直接取,耗时从 1.2 秒降到 1 毫秒(Mock 实测) - 走的是和实时请求完全相同的
MediationEngine,竞价、比价、win/loss 回传一条不少 - 配置项:
cache_size(默认 1)、auto_refill(取走后是否立刻补一条) - 取走即移除;容量满时丢弃新来的那条,因为旧的更早过期,先用掉才不浪费
- 开屏只有 3 秒出头的超时预算,冷启动实时请求大概率拿不到广告;预加载把调度提前到 上一次会话,
广告素材过期判断(
AdExpiry+AdnSlot.expireMs,默认 30 分钟)- 过期广告对象本身还在、
isValid也可能仍返回 true,展示出来却是一块空白, 这种"填充成功却没有曝光"的损失在报表上极难归因 - 缓存池取广告时沿途销毁过期的那些,
show()对过期素材返回 false - 可按广告位(
expire_ms)或按代码位单独配置
- 过期广告对象本身还在、
重复 load 保护:三种广告位在加载中再次
load()会被静默忽略并打AdLog.w。 ⚠️ 选择静默忽略而不是回调失败 —— 回调失败会让业务侧以为"没广告"从而错误降级Mock Adapter(
adapter-mock):假 ADN 广告源,行为由代码位的ext字段驱动。 比价、win/loss 回传、竞价超时、全失败兜底、子线程回调这些分支此前一次都没真实跑过 (竞价要白名单、无填充靠碰运气、子线程回调没法主动制造),现在每条都是可重放的用例。 配套 Demo 测试台MockScenarioActivity+assets/admix_config_mock.jsonAdMix.getCachedCount()/AdMix.clearCache()/AdMix.loadConfigAsset()
修复
Banner 在聚合模式下必须一段式取 View
- 聚合模式下调
render()一样会回调onRenderSuccess,但参数 view 恒为 null (官方聚合 Demo 里写明了这一点),表现是日志一路"填充成功"、广告位却始终空着 - 改为 load 成功后直接
getExpressAdView();拿不到 View 视为无填充,让聚合层继续下探 - 顺带补上
setDislikeCallback:用户点掉 Banner 的关闭按钮后要停止刷新, 否则被关掉的广告过几十秒又自己冒出来
- 聚合模式下调
⚠️ 远端配置只增不删。
ConfigManager.parse()的注释写的是"解析成功后整体替换, 避免半截配置生效",实现却是sUnits.putAll(parsed)增量合并 —— 注释和代码不符。 后果:远端下线某个广告位后,端上内存里还留着上一次的副本,会继续请求已下线的广告位, 而且运营在后台看不出任何异常。现改为真正的整体替换:- 先把整份 JSON 解析完,全部成功了才换上;任何一步失败都保持原配置不动
units字段缺失当成解析失败(最常见的是后端给裸 JSON 套了一层{code,msg,data}壳), 而不是把配置清成空的- 远端拉取失败(网络异常 / 非 200 / JSON 不合法)必定保留本地配置,不会出现"完全没广告"
- 并发安全:配置读取在广告请求路径上、写入在配置线程,改为 不可变 Map + volatile 引用整体替换,读方要么看到完整的旧配置、 要么看到完整的新配置,不存在半截状态,也不用加锁
AdMix.loadConfigAsset()(Demo 追加加载 Mock 场景配置用)保留合并语义, 不会把已生效的正式广告位冲掉- 真机实测:后端把
banner_main下线并发布 → 端上日志"远端配置加载完成,广告位数量: 2", 进 Banner 页回调AdError{-1002 广告位无配置: banner_main},不再发起请求
⚠️ 瀑布层
timeout_ms从未生效。配置格式文档里写了"该广告源单独的超时", 引擎却根本没读这个字段。后果是瀑布串行、只要有一层迟迟不回调,它后面的层 (通常是保底源)一个都轮不上,只能干等整体超时 —— 白白丢填充。 现在按层计时,超时即下探,并丢弃该层的迟到回调避免两条分支同时推进瀑布⚠️ 同一个
RewardAd/SplashAd对象二次load()后show()永远返回 false。mShown标记没有随新广告复位,表现成"加载成功却怎么也放不出来"; 同时旧广告对象没被销毁,连同它持有的 Activity 一起泄漏。热启动开屏正是这个场景调度失败(
ERR_ALL_FAILED)没有任何收尾日志,排查时只能看到最后一层下探就断了
核心框架首次成型
这一节记录的是框架刚跑通时的状态。其中「已知问题」几条大多已在后续明细里闭环 (适配器改为 SPI 自注册、埋点已完成、优量汇已打通并跑通竞价、应用列表弹窗已修)—— 1.0.0 的实际状态以本文件顶部的发布说明为准,不要拿这一节的「已知问题」当现状。
三种广告位在华为 ALN-AL10 / Android 12 上 Debug 与 Release 混淆包均真机跑通。
新增
- 聚合核心:
core不依赖任何广告 SDK,通过反射(Class.forName+ catch Throwable) 加载 Adapter。宿主只接一家时,缺另一家的类只会静默跳过 —— 这是 SDK 能按需裁剪的前提 - 泛型调度引擎
MediationEngine<T>:客户端竞价 + 瀑布混合,对广告类型完全无感知, 三种广告位共用一套。并行竞价 → 取最高价 → 按预估价降序遍历瀑布 (竞价价 ≥ 当前层预估价就提前停止下探)→ 回传 win / loss。价格单位统一为分 - 两类展示形态抽象:全屏型
IAdnRewardAd(show(Activity))/ View 型IAdnViewAd→IAdnSplashAd、IAdnBannerAd(show(ViewGroup))。 新增广告位只需实现对应形态的接口 + 在IAdnAdapter加工厂方法,不用碰调度引擎 - 三种广告位:开屏、激励视频、Banner,穿山甲与优量汇各一套 Adapter
- 合规两段式初始化:
init()只读本地配置、不联网不采集,可在 Application 调用;start()才初始化各 ADN,必须在用户同意隐私政策之后。违反这条应用商店会驳回上架 - 敏感权限收敛:
TTCustomController全量开关 + 优量汇GlobalSetting四项 (必须在 init 之前设置) - 配置系统:assets 本地兜底 + 可选远端 JSON 覆盖(OSS/CDN 静态文件即可)。 这是"伪后台"方案 —— 改价格、调顺序、开关广告位都不用发版
- Banner 刷新由聚合层驱动:两个 Adapter 都显式关掉了 ADN 自带刷新 (穿山甲
setSlideIntervalTime(0)、优量汇setRefresh(0)), 由BannerAd按refresh_interval_sec定时重走完整调度。 否则广告位会永远锁死在最初中标的那一家,聚合失去意义 - 发布配置:
maven-publish产出三个 aar,已验证外部工程可通过 Maven 坐标引用并正常运行 - R8 规则收进各 adapter 的
consumer-rules.pro,接入方开混淆无需额外配置 - Demo 即接入文档:每种广告位一个独立 Activity,统一的「接入步骤 1→5」注释结构, 全部 ID 集中在
DemoConfig - 三份交付文档:
docs/01-接入文档.md/02-隐私合规说明.md/03-配置格式说明.md
修复
以下三个都是真机才暴露的问题:
onAdClose回调静默丢失。穿山甲各回调返回的未必是同一个广告对象实例 (实测onRewardVideoAdLoad ad=@1b95d9e与onRewardVideoCached ad=@c5c7111是两个实例)。 在前者上绑监听器、却把mAd指向后者,展示时用的就是没绑监听器的对象。 修法:把mAd = ad收进bindInteraction()内部,让"持有"和"绑定"永远是同一个对象destroy()后迟到的回调导致广告对象永久泄漏。ADN 回调可能晚于destroy()到达, 聚合层超时下探或竞败销毁后,迟到的回调会让广告对象重新被持有。 六个 Adapter 广告类统一加volatile boolean mDestroyed守卫:置位后丢弃迟到回调, 绝不重新持有广告对象。新增 Adapter 必须沿用这个模式- 调度引擎并发竞态。ADN 未承诺回调线程,而调度过程中有
mBiddingPending递减、 状态标志置位等非原子操作。MediationEngine用runOnMain()把所有回调收口到主线程, 以"单线程访问"替代加锁,既避免竞态也避免锁带来的死锁风险
已知问题
- 穿山甲
TTCustomController.alist()返回 false 未能阻止运行时读取应用列表, 华为系统照样弹窗。宿主侧tools:node="remove"移除清单权限有效(aapt 直读可验证), 但运行时行为不受影响。未解决,需与穿山甲技术支持确认 —— 这是工信部通报高发项,金融类 App 上线前必须闭环 - 优量汇三个代码位报
100133 广告位不可用,SDK 侧正常、服务端拒绝,等甲方后台排查 - 客户端竞价(bidding)分支只在 Mock 下验证过,真实竞价需等甲方申请白名单
- 仅在华为 ALN-AL10 / Android 12 单机型验证过
- 埋点上报模块未做