主题
AdMix 广告聚合 SDK 接入文档
文档版本 v1.0.0
本页对应 SDK 1.0.0,与离线包内 docs/01-接入文档.md 是同一份内容。其他版本见文档中心。
版本 1.0.0 · 支持 开屏 / 激励视频 / Banner · minSdk 25(Android 7.1.1)· 仅 Android
想先跑起来? 看
04-集成快速指南.md—— 一页纸走完引依赖、初始化、三种广告位、 自检清单与常见报错对照表,本文是它的完整版。 要上架应用商店? 看02-隐私合规说明.md;金融类等强监管行业另见05-金融类App合规清单.md。
最低支持 Android 7.1.1(API 25)。 配置服务
https://api.maemi.cn使用 Let's Encrypt 证书, 证书链最终锚定在 ISRG Root X1,而 Android 7.0(API 24)及以下的系统证书库里没有这个根证书,HTTPS 握手直接失败, 拉不到配置也就出不了广告。宿主 App 的minSdk低于 25 时 Gradle 合并清单会报错,需要把宿主的minSdk提到 25。
一、引入依赖
两种方式二选一:能访问外网 Maven 仓库的用方式 A(推荐),内网构建环境用方式 B。
方式 A:Maven(推荐)
项目根 settings.gradle 加入两个仓库:
groovy
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://admin.maemi.cn/maven/' } // AdMix
maven { url 'https://artifact.bytedance.com/repository/pangle' } // 穿山甲官方仓库(接穿山甲时必需)
}
}仓库公开只读,不需要账号密码。优量汇 SDK 在 Maven Central 上,不用另配仓库。
App 模块 build.gradle 按需引入 —— 只接哪家就引哪个 adapter,不引的那家(连同它的广告 SDK)不会进包:
groovy
dependencies {
implementation "com.admix:core:1.0.0" // 必需
implementation "com.admix:adapter-csj:1.0.0" // 穿山甲(GroMore 聚合通道),可选,自动带上穿山甲 SDK
implementation "com.admix:adapter-gdt:1.0.0" // 优量汇,可选,自动带上优量汇 SDK
}不需要再单独引穿山甲、优量汇的 SDK。 adapter 会传递引入以下版本(均为真机验证过的版本):
| adapter | 带入的广告 SDK | 仓库 |
|---|---|---|
adapter-csj | com.pangle.cn:mediation-sdk:7.7.1.6 | 穿山甲官方仓库 |
adapter-gdt | com.qq.e.union:union:4.680.1550 | Maven Central |
注意几点:
- 广告 SDK 以 runtime 范围引入:会打进 App,但宿主代码里 import 不到它们的类 —— 宿主只应调用 AdMix 的对外 API
- 工程里如果另外声明了同一个广告 SDK 的其他版本,Gradle 会取较高的那个。adapter 只在上表版本下验证过,不要自行升级,需要升级请联系平台
- 已有工程里放过穿山甲 / 优量汇 aar 文件(
libs/下)的,要删掉这些文件和对应的implementation files(...),否则会出现重复类编译错误
只接一家会怎样(不需要任何配置)
SDK 不在代码里写死"支持哪几家" —— 每个 adapter 通过 Java SPI (META-INF/services/com.admix.adapter.IAdnAdapter)自己声明身份,core 启动时扫描一遍, 引了谁就发现谁。所以:
- 只引
adapter-csj的包里根本不存在优量汇的声明,不是"跳过一家",而是那一家不存在。不会崩、不报错 - 拉配置时 SDK 会把实际发现的 adapter 列表报给平台,服务端据此把你没集成的 ADN 从下发的配置里摘掉。 日志里能看到:
拉取远端配置 … adapters=[csj]与远端配置加载完成,ADN: [csj] … 服务端按本机能力裁掉: gdt - 你不需要写
packagingOptions/packaging { resources { merges … } }:AGP 默认就把META-INF/services/**合并而不是取其一,混淆规则也随 aar 自动生效。 真要自定义打包策略时注意别把META-INF/services/**排除掉 —— 排掉了 SDK 就发现不了任何 adapter, 日志会打没有发现任何 adapter,一条广告都不会有
以后平台新增一家 ADN(美团 / 京东 / 快手 / 汇川等),你只需要多引一个 com.admix:adapter-xxx 坐标, core 不需要升级、业务代码一行不改。
方式 B:离线 zip 包
从开发者后台「接入指引」页下载,或直接下载 https://admin.maemi.cn/maven/downloads/admix-android-sdk-1.0.0.zip。
包内是三个 AdMix aar + 文档,不含穿山甲、优量汇的 SDK 文件(两家的开发者协议不允许第三方转发), 需要自行从穿山甲后台 / 优量汇开发者平台下载上表所列的同一版本。本地 aar 不会带出传递依赖, core 依赖的 androidx.annotation:annotation:1.3.0 要手动声明。具体步骤见包内 README.md。
清单合并冲突
穿山甲、优量汇的 SDK 清单里都写了 <application> 属性(穿山甲 allowBackup="true"、label="@string/app_name", 优量汇 allowBackup="false"),同时接两家时 Gradle 合并清单会报 Attribute application@allowBackup ... is also present at 一类错误。 宿主的 <application> 上显式写出自己的值并用 tools:replace 覆盖即可(label 与 @string/app_name 不同时才会冲突):
xml
<application
android:allowBackup="false"
android:label="@string/app_name"
tools:replace="android:allowBackup,android:label">ABI 建议只保留两种,可显著减小包体积:
groovy
defaultConfig {
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' }
}混淆规则已随 aar 自动生效(consumer-rules.pro),接入方无需额外配置。
只使用本文档列出的类和方法:AdMix、AdMixConfig、AdError、AdnType、com.admix.api 下的广告类与监听器、 AdPreloader.PreloadListener、AdEvent / AdEventListener / AdEventType。 其余类(com.admix.adapter、cache、config、mediation、util 等包及各 adapter 的实现类)是 SDK 内部实现, 标注了 @RestrictTo,在宿主代码里直接调用时 Android Studio 会标红、lint 报 RestrictedApi 错误。 这些内部类随版本随时变化,不保证兼容。core 会带入 androidx.annotation(纯注解包,不进 dex,引了 AndroidX 的工程本来就有)。
一·五、穿山甲侧走的是 GroMore 聚合通道
这一点影响配置怎么填、报表怎么看,接入前务必先读。
穿山甲已将「基础变现」并入 GroMore 并停止受理基础变现的开通申请,账号里创建的代码位 「是否用于 GroMore」一律是「是」,这类代码位只在聚合场景下返回广告。因此 adapter-csj 以 GroMore 模式接入(useMediation(true)),由此带来三点:
| 说明 | |
|---|---|
| 配置里填什么 | slot_id 填 GroMore 广告位 ID(后台「应用管理」页,10 开头),不是瀑布流里的穿山甲代码位 ID |
| 广告从哪来 | adnType 是 csj,但真正出广告的是 GroMore 瀑布流里的某一家(穿山甲 / 快手 / 百度 / Sigmob)。当前策略是瀑布流里只配穿山甲一家 |
| 谁在竞价 | 竞价由 GroMore 内部完成,SDK 不再向穿山甲回传 win / loss。配置里 bidding 对 csj 不起作用,填 false 即可 |
后台必须配两步,缺一不可:先在「应用管理」建广告位,再在「瀑布流管理」里给这个广告位添加代码位。 只建广告位不配瀑布流会直接无填充,且错误信息只说"全部代码位请求失败",看不出是漏配。 详见《配置格式说明》。
埋点要记真实广告来源时,用 getAdnSourceName():
java
rewardAd.getAdnType(); // "csj" —— 走哪条聚合通道
rewardAd.getAdnSourceName(); // "pangle" / "ks" / "baidu" / "sigmob" —— 真正出广告的那一家这个值在广告展示后最完整(部分广告位在加载回调阶段还取不到),埋点建议在 onAdShow 时读。 SDK 自己的埋点已经在采集这个值(AdEvent.adnSource,见第二·六节),接入方接事件回调即可, 不需要自己在每个 onAdShow 里补一遍。
二、初始化(两步,顺序不可颠倒)
在写代码之前,先明确一件事:接入方不需要配置穿山甲 / 优量汇的 appId,也不需要配代码位 ID。
原因是平台的运营模式 —— 每个开发者在穿山甲、优量汇后台都是独立创建的应用, appId 和代码位各不相同(优量汇还会校验包名,共用代码位会直接报 5006 包名校验错误)。 如果这些值要写进宿主 App,平台每接一个开发者就得重新出一个接入包,接入成本没有下限。 所以 ADN 的 appId 和代码位一律由配置服务下发,SDK 侧没有任何本地写入口。
| 谁来配 | 配什么 |
|---|---|
| 接入方(你) | AppID + App 密钥,就这两个。服务器地址内置在 SDK 里,不用配 |
| 平台运营 | 各 ADN 的 appId、广告位与代码位、底价、超时、瀑布顺序 —— 全部在管理后台维护,改动不需要你发版 |
AppID 和 App 密钥由平台运营在管理后台「应用管理 → 接入」里查看并发给你。
第一步:init() —— 在 Application 中调用
只读取本地配置,不联网、不采集任何设备信息,一家 ADN 也不会被初始化。
java
public class MyApplication extends Application {
@Override public void onCreate() {
super.onCreate();
AdMix.init(this, "你的 AppID", "你的 App 密钥");
}
}绝大多数接入方写到这里就够了。AppID 或 App 密钥为空时 init() 直接抛 IllegalArgumentException, 开发期第一次运行就能发现。
可选项(按需)
需要打开详细日志、处理用户关闭个性化推荐、或按渠道/人群分组时,用带可选项的重载:
java
AdMix.init(this, "你的 AppID", "你的 App 密钥", new AdMixConfig.Builder()
.debug(BuildConfig.DEBUG) // 输出完整调度日志,上线包请关闭
.limitPersonalAds(userClosedPersonalAds) // 用户是否关闭个性化推荐,见《隐私合规说明》4.2
.channel("渠道标识") // 流量分组,可选,见下
.subChannel("子渠道标识")
.build());| 可选项 | 默认 | 为什么留给接入方 |
|---|---|---|
debug(boolean) | false | 只有你知道当前是不是调试包。关闭时仍会输出配置拉取、初始化结果、错误等关键日志 |
limitPersonalAds(boolean) | false | 合规要求:用户关闭个性化推荐后必须透传给各 ADN,只有你知道用户的选择 |
channel / subChannel / userValueGroup / segmentCustomInfo | 不填 | 流量分组,只对穿山甲(GroMore 通道)生效,不填也能正常出广告 |
关于流量分组:这些值会随请求带给 GroMore,并在广告返回的价格信息里原样读回, GroMore 后台可以按分组配置不同瀑布流(例如按用户价值分层)。取值应由宿主 App 或服务端决定,不要写死。 ⚠️ 这些字段会上传给广告平台,不得放入手机号、身份证号等个人信息。
第二步:start() —— 必须在用户同意隐私政策之后
java
AdMix.start(new AdMix.InitListener() {
@Override public void onSuccess() { /* 可以开始请求广告 */ }
@Override public void onFail(AdError error) { /* 本次会话不要请求广告,按无广告降级 */ }
});start() 做的事是把配置拿到手,而不是初始化 ADN:
- 本地已有配置(上一次拉到的缓存)→ 立即回调
onSuccess,不等网络,同时后台静默刷新一次 - 本地什么都没有(装机后真正的第一次冷启动)→ 必须等一次网络请求, 拿到
adns才知道要初始化谁。最多等 5 秒 - 超时或拉取失败 → 回调
onFail。此时不要请求广告,按无广告降级; 后台线程仍会把这次请求跑完,配置到手后再调一次start()即可就绪
onSuccess= 可以请求广告,onFail= 不要请求。判据只有一条:ADN 配置到手没有。
⚠️ 合规硬性要求:用户同意隐私政策前调用
start(),属于违规采集个人信息, 应用商店会驳回上架。详见《隐私合规说明》。
ADN 是按需初始化的
start() 成功之后,仍然一家 ADN 都没有初始化。 某家 ADN 只在「首次加载到配置了它的广告位」时才初始化。
两个收益:
- 启动更快 —— 用不到的 ADN 一次初始化都不做
- 合规更好 —— 未被使用的 ADN 一行代码都不跑、不采集任何设备信息。 比如某个 App 只用激励视频,而激励视频的瀑布里没有优量汇,那优量汇 SDK 在这台设备上就从未运行过。做隐私自查和应用商店审核时,这是能直接拿出来的证据
接入方不需要为此写任何代码,SDK 内部完成。只有一处需要留意:
⚠️ 首次加载会多出一次 ADN 初始化的耗时。实测穿山甲装机后的第一次初始化要 2~4 秒 (之后每个进程只要 200ms 上下)。开屏的超时预算本来就只有 3 秒出头, 这段耗时可能把预算吃掉。两个办法,任选:
- 用
AdMix.preload()提前预热(开屏本来就该预加载,见第三节),初始化成本由预加载承担- 让平台运营把开屏的
total_timeout_ms放宽一点,留出余量等待初始化的时间不会超过该广告位自己的
total_timeout_ms—— 开屏配 3.5 秒就最多等 3.5 秒, 绝不会出现「业务侧早就放行进主页了,广告还在等初始化」的情况。
二·五、远端配置与凭证
配置服务下发的内容有两块:各 ADN 的 appId(adns)和广告位与代码位策略(units)。 改价格、调顺序、开关广告位、甚至停用一整家 ADN,都不用发版。
拉取时机见上一节:有本地缓存就先用缓存、后台再刷新;没有缓存则 start() 会等这一次请求。
SDK 只和平台服务 https://api.maemi.cn 通信(拉配置 /v1/config、上报埋点 /v1/events),地址内置,接入方无需也无法修改。
三个凭证,哪些能进 APK
| 凭证 | 从哪来 | 能进 APK 吗 |
|---|---|---|
| AppID | 管理后台创建应用时生成 | ✅ 能。它只是身份标识,不是密钥 |
| App 密钥 | 管理后台「应用管理 → 接入」,每个应用各自一套 | ✅ 需要进。SDK 用它给请求签名(密钥本身不随请求发送)。逆向能拿到,它的作用是挡住抓包重放和裸爬;泄露后见下方「更换 App 密钥」 |
| AppSecret | 管理后台生成,只显示一次 | ❌ 绝对不能。它只用于你的服务器与平台之间(激励视频发奖回调验签等),进了 APK 等于公开。SDK 没有任何接收 AppSecret 的入口,看到哪份文档让你把它填进客户端,那份文档是错的 |
更换 App 密钥
同一个应用允许多把 App 密钥同时有效。怀疑泄露时按这个顺序操作,线上用户不受影响:
- 在管理后台为该应用新增一把 App 密钥(旧的先不动)
- 宿主 App 换成新密钥,发版
- 大部分用户升级后,在后台作废旧密钥 —— 作废立即生效,仍在用旧密钥的老版本会拉不到新配置(已缓存的配置照常使用)
传输安全:只用 HTTPS
签名只证明请求来源,不加密内容。SDK 内置地址全部是 HTTPS,配置里的代码位 ID 和底价不会在链路上明文传输。
宿主 App 不需要为 AdMix 配置 network_security_config,也不要用 android:usesCleartextTraffic="true" —— 那会把两家 ADN SDK 的全部上报和素材请求 也一起降级成明文可用,授权范围远超需要。
拉取行为
- 拉到的配置会持久化到 SDK 私有的 SharedPreferences,下次冷启动直接用它,不再从 assets 起步
- 请求带
If-None-Match,配置没变时服务端回 304,不传输也不解析,省流量 - 签名参数走 query、协商缓存走请求头,两者互不干扰
- 每次请求的
nonce都是新的(服务端有防重放校验),所以 URL 每次都不一样, 不要在中间加一层按 URL 做缓存的代理,那样协商缓存会失效 - 可选:在宿主
assets/admix_config.json放一份兜底配置(只含广告位策略),格式见《配置格式说明》。不放也完全正常
失败了会怎样
远端拉取失败一律保留当前配置,绝不清空 —— 拉不到配置就没广告,比配置旧一天严重得多。
唯一的例外是装机后真正的第一次冷启动:本地一份配置都没有,这时拉取失败就意味着 连 ADN 的 appId 都不知道,start() 只能回调 onFail(日志:start 失败:没有拿到任何 ADN 配置)。 这是把 appId 从宿主包里拿掉之后必然要付的代价 —— 不这样做,就要退回到「每个开发者一个接入包」。
排查时看 AdMix 标签的日志:
| 日志 | 原因 | 怎么办 |
|---|---|---|
远端配置鉴权失败(40101 签名错误) | AppID 与 App 密钥对不上,或该密钥已作废 | 对照管理后台逐字核对,注意别把 AppSecret 当成 App 密钥 |
已按服务端时间校正时钟…重新签名重试一次 | 本机时间不准,SDK 已自动校正 | 不用管,正常自愈 |
远端配置鉴权失败(40103 nonce 重放) | 同一请求被重发 | 检查链路上有没有会重放请求的代理 |
远端配置响应码异常 404 | AppID 不存在,或该应用还没发布过配置版本 | 找平台运营确认 AppID、发布一次配置 |
远端配置加载失败… | 网络异常、超时(SDK 连接/读取超时都是 3 秒) | 检查设备网络 |
本机时钟不准怎么办(SDK 已自动处理,了解即可)
签名带时间戳,服务端只接受 ±5 分钟以内的请求。用户手机时间不准是常态 (手动改过时间、长期断电后未联网对时),偏差超窗后配置就再也拉不下来了, 而且表现是「一切正常,只是配置一直不更新」,没有任何报错。
SDK 的处理:收到「时间戳过期」时,用响应的 Date 头算出本机与服务端的时钟差, 落盘保存,立刻重签重试一次;下次冷启动直接带校正后的时间戳,不再浪费一个来回。 接入方不需要做任何事,也不要去改用户的系统时间。
二·六、广告事件与埋点上报
SDK 会把广告调度和展示过程中的关键事件采集下来,一路上报给配置服务,用于出填充率 / 曝光率 / CTR / eCPM 估算等报表。同时提供一个可选的事件回调,接入方可以把同一批事件接进自己的数据平台。
这两条路径互相独立:不注册回调不影响上报,上报失败回调也照常抛。
价格和代码位 ID 不对接入方开放:内部上报带 eCPM 和 ADN 代码位 ID(平台对账和统计要用), 抛给接入方的事件对象里没有这两个字段。SDK 的所有公开回调与方法(加载回调、预加载回调、事件回调) 都只给出 ADN 名称,不给价格和代码位 ID。
采集什么、不采集什么(先看这条)
采集的内容只有广告维度的数据:广告位 ID、ADN、代码位、价格、耗时、错误码、事件类型。
不采集任何个人信息和设备标识 —— IMEI / OAID / AndroidID / MAC / IP / 手机号 / 用户 ID / 位置一个都没有,也不采集接入方通过 channel() / segmentCustomInfo() 填进来的任何内容 (那些值的内容不受 SDK 控制,详见《隐私合规说明》第六节)。
采集和上报的总闸门是 AdMix.start():在它之前 SDK 不会产生任何埋点事件、不碰磁盘、 更不会发出任何上报请求。
事件类型
| 事件 | 触发时机 | 关键字段 |
|---|---|---|
ad_request | 一轮聚合调度开始 | unit_id、ad_type |
adn_request | 向某个代码位发起请求 | adn、bidding |
adn_fill | 某个代码位返回可用广告 | adn、cost_ms |
adn_fail | 某个代码位失败或超时 | adn、err_code、err_msg、cost_ms |
fill | 决出中标方,回调宿主成功 | adn、cost_ms |
no_fill | 全部失败或整体超时 | err_code、cost_ms |
impression | 广告曝光 | adn |
click | 广告点击 | adn |
close | 广告关闭 | adn |
reward | 激励发放 | adn |
adn_fill 与 fill 不是一回事:一轮里可能有多条 adn_fill(几个竞价位都填充了), 但 fill 只有一条,指最终被采用的那一家。算广告位填充率用 fill / ad_request, 算某家 ADN 的填充率才用 adn_fill / adn_request。
request_id 把一轮调度的所有事件串起来。一次调度 = 一个 request_id, Banner 每次刷新都是一轮完整调度,所以每刷新一次就换一个新的。
⚠️ 一条口径要知道:ad_request 是按「调度轮次」记的,不是按 load() 调用次数记的。 预加载(AdMix.preload())本身就是一轮完整调度,会产生自己的 ad_request → fill; 之后 load() 命中缓存时不再发起任何请求,因此不会再产生一条 ad_request, 那条广告的曝光挂在当初把它调度出来的那个 request_id 上。 这样每个 request_id 的漏斗才是自洽的(一条请求 → 至多一条填充 → 至多一条曝光), 不会出现「曝光数大于填充数」这种没法解释的报表。
缓存相关的区分都放在事件的 ext 里(AdEvent 上对应 loadSource / cacheHit):
| ext 字段 | 出现在 | 含义 |
|---|---|---|
load_src | 这一轮的全部事件 | preload(主动预加载)/ refill(自动补位)/ load_wait(开屏未命中时的入池请求);不带 = load() 的实时请求 |
refill_reason | load_src=refill 的事件 | take(缓存被取走)/ expire(缓存过期被丢弃) |
cache_hit / cache_age_s | 曝光、点击、关闭、激励 | 这条广告是 load() 从缓存直接取出的,以及取出时已缓存的秒数 |
cache_expired | ad_request | 自该广告位上一条 ad_request 以来因过期被丢弃的缓存条数 |
事件回调(可选)
java
// init() 之后任何时刻都能注册,它本身不产生采集或网络行为
AdMix.setEventListener(new AdEventListener() {
@Override public void onAdEvent(AdEvent event) {
// 同一次调度的事件 requestId 相同,可以直接拼成漏斗
myTracker.track(event.type, event.unitId, event.adn, event.requestId);
}
});AdEvent 的字段(全部 public final,直接读;不含价格和代码位 ID):
| 字段 | 类型 | 说明 |
|---|---|---|
type | String | 事件类型,取值见上表,常量在 AdEventType |
unitId / adType | String | 聚合广告位 ID / splash·reward·banner |
adn | String | csj / gdt。与代码位无关的事件(ad_request、no_fill)为 null |
adnSource | String | 走 GroMore 时真正出广告的那一家(pangle/ks/baidu/…),单 ADN 直连为 null |
bidding | Boolean | 该代码位是否竞价位。null = 本事件与代码位无关,不是 false |
costMs | int | 该阶段耗时 |
errCode / errMsg | int / String | 原样透传 ADN 的错误码与信息,没有做任何映射 |
requestId | String | 一轮调度的唯一标识 |
sessionId | String | 单次启动随机串,不落盘、不跨启动复用,不是设备标识 |
eventTime | long | 事件发生时刻,毫秒时间戳 |
几条边界:
- 回调固定在主线程,接入方不需要自己切线程
- 实现里不要做耗时操作;要落库或发请求请自行转子线程
- 实现里抛出的异常会被 SDK 吞掉并打日志,不影响广告业务,也不影响内部上报
上报行为
无需任何配置:上报地址内置,鉴权凭证与配置接口相同(AppID + App 密钥)。 攒够 50 条或每隔 30 秒发一批,本地队列上限 1000 条(超出丢最早的)。
不丢数据的保证
- 断网:上报失败不清队列,按 5s → 10s → 20s …(封顶 5 分钟)退避重试; 连续失败 8 次后熔断 30 分钟,期间事件继续落盘。网络恢复后一条不少地补上去
- 进程被杀:每条事件在产生的同时就立刻追加落盘(SDK 私有目录), 下次启动读回来继续报。
am force-stop实测 10 条事件一条不丢 - 补报保留原始时间:补上去的事件带的是它当初发生那一刻的时间戳, 不是补报那一刻的。报表因此不会出现「昨天的数据凭空少一块、今天凭空多一块」
AdMix.flushEvents() 可以立刻发一次,正常接入不需要调用 —— 它的用途是宿主明确知道马上要退出时抢一次上报,减少下次启动才补报的量。
三、开屏
java
public class SplashActivity extends Activity {
// ---- 兜底放行的三段时限。真实项目按自己的启动体验调,但三段都不能没有 ----
private static final long GUARD_LOAD_MS = 5000; // load 后没有任何回调
private static final long GUARD_SHOW_MS = 2000; // show 后没有 onAdShow(白屏)
private static final long GUARD_SHOWN_MS = 12000; // 展示后没有 onAdClose
private SplashAd mSplashAd;
private boolean mJumped;
private boolean mAdShown;
private final Handler mGuard = new Handler(Looper.getMainLooper());
@Override protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_splash);
final ViewGroup container = findViewById(R.id.splash_container); // 需铺满屏幕
mSplashAd = new SplashAd("splash_main");
// 先武装兜底,再 load。反过来写的话,命中缓存时回调是同步的,定时器还没装上
armGuard(GUARD_LOAD_MS, "load 后无回调");
mSplashAd.load(this, new SplashAdListener() {
@Override public void onAdLoaded(String adnType) {
// show() 返回 false = 素材已过期或不可用,必须立刻放行
if (!mSplashAd.show(container)) { goMain(); return; }
armGuard(GUARD_SHOW_MS, "show 后无 onAdShow");
}
@Override public void onAdLoadFailed(AdError error) { goMain(); }
@Override public void onAdShow() {
mAdShown = true;
armGuard(GUARD_SHOWN_MS, "展示后无 onAdClose");
}
@Override public void onAdClick() { }
@Override public void onAdClose() { goMain(); }
});
}
private void armGuard(long delayMs, final String why) {
mGuard.removeCallbacksAndMessages(null);
mGuard.postDelayed(new Runnable() {
@Override public void run() { Log.w("Splash", "兜底放行: " + why); goMain(); }
}, delayMs);
}
// 用户点了广告跳到落地页:暂停计时,回来重新计时。
// 不做这一步,用户在落地页读 20 秒回来会发现开屏自己跳走了
@Override protected void onPause() {
if (mAdShown) mGuard.removeCallbacksAndMessages(null);
super.onPause();
}
@Override protected void onResume() {
super.onResume();
if (mAdShown && !mJumped) armGuard(GUARD_SHOWN_MS, "回到页面后仍无 onAdClose");
}
/** 五条路径(四个回调 + 兜底定时器)都收敛到这里,且只执行一次 */
private synchronized void goMain() {
if (mJumped) return;
mJumped = true;
mGuard.removeCallbacksAndMessages(null);
startActivity(new Intent(this, MainActivity.class));
finish();
}
@Override protected void onDestroy() {
mGuard.removeCallbacksAndMessages(null);
if (mSplashAd != null) mSplashAd.destroy();
super.onDestroy();
}
}要点
- 开屏卡在启动路径上,超时应配置得短(建议 3 秒内),拿不到就放行进主页
- 线上事故最常见的形态不是没广告,而是卡在开屏页进不去
⚠️ 必须有一道不依赖任何回调的兜底放行
上面的 armGuard 不是可选的最佳实践,是开屏接入的必备项。把全部回调都收敛到 goMain() 只能覆盖"回调来了"的情况,真正出事故的是回调不来:
| 场景 | 现象 | 哪一段兜底救它 |
|---|---|---|
| ADN 回调丢失 / 被自身异常吞掉 | 开屏页白着不动 | GUARD_LOAD_MS |
show() 返回 true,但广告平台不渲染 | 画面全白,页面永远不关 | GUARD_SHOW_MS |
广告展示了,onAdClose 没来 | 广告播完后卡在开屏页 | GUARD_SHOWN_MS |
中间那一条是实测踩到的:优量汇开屏在容器尚未完成布局时展示,回调了"展示成功"却不出画面。 SDK 侧已经修了这个具体的成因,但下一个 ADN、下一个版本还会有别的成因 —— 开屏页能不能进主页,不该取决于第三方 SDK 的回调是否守约。
时限取值:GUARD_LOAD_MS 取该广告位后台配的 total_timeout_ms 再加 1~2 秒余量 (首次 ADN 初始化会占掉一部分);另两段按上面的值即可。
四、激励视频
java
RewardAd rewardAd = new RewardAd("reward_main");
rewardAd.load(activity, new RewardAdListener() {
@Override public void onAdLoaded(String adnType) {
rewardAd.show(activity); // 加载成功后展示
}
@Override public void onAdLoadFailed(AdError error) { }
@Override public void onAdShow() { }
@Override public void onAdClick() { }
@Override public void onAdClose() { }
@Override public void onReward() { /* 发放奖励 */ }
@Override public void onVideoComplete() { }
});
// Activity onDestroy 中
rewardAd.destroy();要点
- 一次
load的广告只能show一次,展示后需重新load - 穿山甲点「跳过」不会直接关闭,会先弹挽留弹窗,用户点「坚持退出」才触发
onAdClose。业务侧不要假设「点跳过 == 广告结束」 - ⚠️ 奖励有现金价值时,
onReward只用于界面反馈,真实发奖以服务端通知为准(见下节),否则会被刷量
服务端发奖验证(可选)
什么时候需要:onReward 发生在用户手机上,可被 Hook、重放伪造。奖励能提现、抵扣、抽奖,或者有人会为它刷量时, 都应当接服务端验证;奖励只是「多一次复活」这类无价值的,只用 onReward 即可,本节可以跳过。
链路:用户看完广告 → 穿山甲(GroMore)/ 优量汇服务器通知 AdMix 平台 → 平台用广告平台的密钥验签, 按代码位找到你的应用 → 用该应用的 AppSecret 签名 → POST 到你配置的接收地址 → 你的服务器验签后给用户发奖。
① 配置接收地址:管理后台登录「开发者」→ 我的应用 → 应用详情 →「服务端发奖验证」,填写 https 公网地址。 页面下方可以看到每条通知的发送状态与明细。AppSecret 由平台运营单独交付(只显示一次)。
② 请求时带上用户 ID:
java
RewardAd rewardAd = new RewardAd("reward_main")
// 用户 ID、自定义数据,均可传 null;必须在 load 之前调用,对之后每次 load 都生效
.setServerSideVerification(currentUser.getId(), "scene=daily_sign");
rewardAd.load(activity, listener);- 用户 ID 用你自己系统里的用户标识,不要放手机号、身份证号;自定义数据原样带回,不超过 1024 个字符
- SDK 分别透传给穿山甲 GroMore(
AdSlot.setUserID+MediationConstant.CUSTOM_DATA_KEY_GROMORE_EXTRA)和优量汇(ServerSideVerificationOptions),接入方不需要关心 - 带了这两个参数的请求不使用预加载缓存(缓存里的广告请求时没有这次的用户 ID)
- 不调用时,请求与不接服务端验证完全一致
- 日志里能看到
[CSJ激励] 服务端验证参数已设置 userId=...,排查「通知里没有用户 ID」时先看这一行
③ 开发者服务器验签:
请求为 POST,Content-Type: application/json,请求头:
| 请求头 | 含义 |
|---|---|
X-Admix-App-Id | AppID |
X-Admix-Event-Id | 通知 ID,与 body 里 event_id 相同 |
X-Admix-Timestamp | 秒级时间戳 |
X-Admix-Nonce | 随机串 |
X-Admix-Signature | 签名,小写 hex |
signature = hex( HMAC-SHA256( AppSecret, app_id + "\n" + timestamp + "\n" + nonce + "\n" + 请求体原始字节 ) )json
{
"event_id": "rw_3f2a…", // 通知 ID,全局唯一;平台重试、人工重推都不变,按它去重
"app_id": "你的AppID",
"unit_id": "reward_main",
"adn": "csj", // csj=穿山甲 gdt=优量汇
"trans_id": "…", // 广告平台交易 ID
"user_id": "10086",
"custom_data": "scene=daily_sign",
"reward_name": "金币", // 广告平台后台配置的奖励名称,优量汇为空串
"reward_amount": 10, // 优量汇为 null
"verified_at": 1789612473 // 平台验签通过的时间(秒)
}验签规则:
- 用收到的原始字节计算签名(不要解析 JSON 后再序列化),与
X-Admix-Signature做常量时间比较,不一致返回 401 timestamp与本机时间相差超过 5 分钟拒绝;nonce10 分钟内重复拒绝- 核对
app_id是自己的应用;按event_id去重,处理过的直接返回 200 - 给
user_id发奖,事务提交后返回 HTTP 2xx。其他状态码或 5 秒超时视为失败,平台按 15 秒、1 分钟、5 分钟、15 分钟、1 小时、3 小时、6 小时后重试(共 8 次),用尽后可请平台运营人工重推。不跟随重定向
Java(JDK 17+):
java
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((appId + "\n" + ts + "\n" + nonce + "\n").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
signature.toLowerCase().getBytes(StandardCharsets.UTF_8));Python:
python
expected = hmac.new(APP_SECRET.encode(), f"{app_id}\n{ts}\n{nonce}\n".encode() + raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, signature.lower())完整示例(含时间戳、nonce、去重)见管理后台「接入指引 → 激励视频服务端发奖验证」。
GroMore 通道的额外效果:穿山甲侧开启服务端激励回调后,平台验签结果会回传给 GroMore,onReward 只在验证通过时触发。 这仍然不能代替开发者服务器验签 —— 客户端回调本身可被伪造。
五、Banner
java
ViewGroup container = findViewById(R.id.banner_container);
BannerAd bannerAd = new BannerAd("banner_main");
bannerAd.load(activity, container, new BannerAdListener() {
@Override public void onAdLoaded(String adnType) { } // 每次刷新都会回调,adnType 可能变
@Override public void onAdLoadFailed(AdError error) { }
@Override public void onAdShow() { }
@Override public void onAdClick() { }
@Override public void onAdClose() { } // 用户关闭后刷新自动停止
});
// Activity onDestroy 中 —— 不调必然泄漏
bannerAd.destroy();要点
- 广告 View 由 SDK 挂进
container,业务侧不需要自己 addView - 刷新由聚合层按配置间隔驱动,每次重走完整调度,无需业务侧干预
- Banner 长期持有 Activity 引用,
onDestroy必须调destroy()
五·二、Kotlin 写法
SDK 是纯 Java API,Kotlin 工程直接调用,不需要任何适配层或额外依赖。 监听器都是单方法之外还有多个方法的 Java 接口,所以用 object : XxxListener { … }, 不能用 SAM lambda。下面三段与上文的 Java 示例完全等价。
初始化(Application 里 init,用户同意隐私政策后 start):
kotlin
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// 只读本地配置:不联网、不采集、不碰任何 ADN SDK
AdMix.init(this, BuildConfig.ADMIX_APP_ID, BuildConfig.ADMIX_APP_KEY,
AdMixConfig.Builder()
.debug(BuildConfig.DEBUG)
.build())
}
}
// 用户点「同意」之后(不是在 Application 里!)
AdMix.start(object : AdMix.InitListener {
override fun onSuccess() { /* 可以请求广告 */ }
override fun onFail(error: AdError) { /* 按无广告降级,本次会话不要请求 */ }
})开屏(兜底放行同样不能省,取值与理由见上一节):
kotlin
class SplashActivity : Activity() {
private companion object {
const val GUARD_LOAD_MS = 5000L
const val GUARD_SHOW_MS = 2000L
const val GUARD_SHOWN_MS = 12000L
}
private lateinit var splashAd: SplashAd
private var jumped = false
private var adShown = false
private val guard = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
val container: ViewGroup = findViewById(R.id.splash_container)
splashAd = SplashAd("splash_main")
armGuard(GUARD_LOAD_MS, "load 后无回调") // 先武装兜底,再 load
splashAd.load(this, object : SplashAdListener {
override fun onAdLoaded(adnType: String) {
if (!splashAd.show(container)) { goMain(); return }
armGuard(GUARD_SHOW_MS, "show 后无 onAdShow")
}
override fun onAdLoadFailed(error: AdError) = goMain()
override fun onAdShow() {
adShown = true
armGuard(GUARD_SHOWN_MS, "展示后无 onAdClose")
}
override fun onAdClick() {}
override fun onAdClose() = goMain()
})
}
private fun armGuard(delayMs: Long, why: String) {
guard.removeCallbacksAndMessages(null)
guard.postDelayed({ Log.w("Splash", "兜底放行: $why"); goMain() }, delayMs)
}
override fun onPause() {
if (adShown) guard.removeCallbacksAndMessages(null)
super.onPause()
}
override fun onResume() {
super.onResume()
if (adShown && !jumped) armGuard(GUARD_SHOWN_MS, "回到页面后仍无 onAdClose")
}
@Synchronized private fun goMain() {
if (jumped) return
jumped = true
guard.removeCallbacksAndMessages(null)
startActivity(Intent(this, MainActivity::class.java))
finish()
}
override fun onDestroy() {
guard.removeCallbacksAndMessages(null)
splashAd.destroy()
super.onDestroy()
}
}激励视频(setServerSideVerification 返回 this,可链式调用;不接服务端发奖就别调):
kotlin
private var rewardAd: RewardAd? = null
private fun loadReward() {
rewardAd?.destroy() // 重新 load 前先销毁上一个
val ad = RewardAd("reward_main")
.setServerSideVerification(currentUser.id, "scene=daily_sign") // 可选
rewardAd = ad
ad.load(this, object : RewardAdListener {
override fun onAdLoaded(adnType: String) { ad.show(this@MyActivity) }
override fun onAdLoadFailed(error: AdError) {}
override fun onAdShow() {}
override fun onAdClick() {}
override fun onAdClose() {}
// ⚠️ onReward 与 onVideoComplete 是两件事,不要互相替代(见第四节要点)
override fun onReward() { /* 奖励有现金价值时只更新界面,发奖以服务端通知为准 */ }
override fun onVideoComplete() {}
})
}
override fun onDestroy() {
rewardAd?.destroy() // ⚠️ 不调必然泄漏
super.onDestroy()
}Banner(广告 View 由 SDK 挂进容器,不要自己 addView):
kotlin
private var bannerAd: BannerAd? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_banner)
val container: ViewGroup = findViewById(R.id.banner_container)
bannerAd = BannerAd("banner_main").also { ad ->
ad.load(this, container, object : BannerAdListener {
// 每次定时刷新成功都会回调,adnType 可能与上次不同
override fun onAdLoaded(adnType: String) {}
override fun onAdLoadFailed(error: AdError) {}
override fun onAdShow() {}
override fun onAdClick() {}
override fun onAdClose() {} // 用户关闭后刷新自动停止
})
}
}
override fun onDestroy() {
// ⚠️ Banner 常驻页面且定时刷新,不 destroy 的话页面 finish 后定时器还在转:
// 内存持续上涨 + 曝光数虚高
bannerAd?.destroy()
super.onDestroy()
}可选的事件回调:
kotlin
AdMix.setEventListener { event -> // AdEventListener 只有一个方法,可以用 lambda
myTracker.track(event.type, event.unitId, event.adn, event.requestId)
}五·五、预加载与缓存
广告位在后台开了缓存(cache_size > 0)时,SDK 会把「提前调度好、还没被展示」的广告放进内存缓存池, load() 优先从池子里取。接入方代码不需要为缓存写任何分支:命中就同步回调 onAdLoaded,没命中照常请求。
什么时候预加载
java
// 主页 onResume:为下一次开屏备货(缓存已满或已有预加载在飞时 SDK 自动跳过 / 合并)
AdMix.preload(this, "splash_main");
// 激励视频入口页打开时:用户点按钮时大概率已经就绪
AdMix.preload(this, "reward_main", new AdPreloader.PreloadListener() {
@Override public void onSuccess(String unitId, String adnType) { }
@Override public void onFail(String unitId, AdError error) { } // 未开缓存、缓存已满、无填充
});
AdMix.getCachedCount("splash_main"); // 当前可用条数(过期的已剔除)
AdMix.clearCache(); // 用户撤回隐私授权时调用- 用户同意隐私政策(
start())之前调用一律失败,一个请求都不会发 - 用来预加载的 Activity 会被广告对象引用到它被取走或过期,不要用马上就 finish 的页面预加载(如开屏页自己); 主页这类长期存活的页面最合适。开屏广告 View 在
show()时才挂进开屏页的容器,预加载与展示用不同的 Activity 没有问题(真机已验证)
各广告位的行为
| 命中缓存 | 未命中 | 自动补位 | |
|---|---|---|---|
| 开屏 | 同步回调,0 等待 | 发起请求并等待,最多 total_timeout_ms(从 load 调用起算,含 ADN 初始化)。超时放行后请求继续跑完,拿到的广告进缓存池留给下一次开屏,不会像纯实时请求那样超时即丢弃 | 按配置 |
| 激励视频 | 同步回调 | 有预加载在飞就等它(不重复请求),否则实时请求 | 按配置,一般不开 |
| 激励视频 + 服务端验证参数 | 不取缓存、不等预加载,一律实时请求(缓存里的广告请求时没带这次的用户 ID) | — | — |
| Banner | 只有首次 load 取缓存 | 实时请求 | 不补。每次定时刷新都是实时调度 |
⚠️ 进程被杀后缓存清空
广告平台的广告对象不能持久化,缓存池只在进程内存里。所以:
- 真正的冷启动(新进程)第一条开屏一定是实时请求。它的价值在于超时后的结果会进池子
- 缓存命中发生在进程还活着的时候:热启动开屏(App 从后台回到前台时补放开屏)、返回桌面后再从图标进入、下一次激励视频
- 热启动开屏是缓存收益最大的场景,建议接入:App 在后台停留超过一定时长(通常 30 秒以上)回到前台时打开开屏页。Demo 的
DemoApplication给了写法
过期与补位(SDK 自动处理,了解即可)
- 素材有效期默认 30 分钟(优量汇官方开源聚合 Adapter 对穿山甲 / 快手 / 百度统一取 30 分钟);平台自己给出更早的过期时刻时以平台为准。 过期的广告到点即销毁,绝不会被展示
- 开了
auto_refill的广告位,缓存被取走或过期后补一条:两次补位至少间隔refill_min_interval_sec(默认 30 秒), 失败按间隔翻倍退避(封顶 10 分钟),连续失败 3 次暂停,等下一次取走 / 过期 / 主动preload再恢复; 只在 App 前台发起,开屏刚被开屏页取走时会等开屏页退出再补 - 缓存中的广告不再和实时请求比价:它本身就是一轮完整调度的胜出者;池子里有多条时取价格最高的一条
- 缓存相关的配置项见《配置格式说明》,埋点区分方式见上文「广告事件与埋点上报」
六、错误码
| 码 | 含义 | 处理 |
|---|---|---|
| -1001 | SDK 未初始化 | 检查 init() / start() 调用顺序 |
| -1002 | 广告位无配置 | 检查 unitId 是否存在于配置文件 |
| -1003 | 所有广告源均无填充 | 正常现象,检查代码位状态与填充率 |
| -1004 | 整体超时 | 适当调大 total_timeout_ms |
| -1005 | Adapter 不可用 | 该 ADN 的 adapter 没被发现。先看启动日志里有没有 adapter 就绪: xxx —— 没有就是依赖没引,或自定义打包策略把 META-INF/services/** 排除了 |
| -1006 | 用户尚未同意隐私政策 | start() 还没调(或调了但回调 onFail)就请求了广告。这是 SDK 的兜底红线:未同意时任何 load() 都不会碰到 ADN SDK |
| 其他 | ADN 原始错误码 | AdError.adnType 标明来源平台 |
常见 ADN 错误码:
| 平台 | 码 | 含义 |
|---|---|---|
| 穿山甲 | 20005 | GroMore 广告位下全部代码位均请求失败。要么瀑布流没配代码位,要么确实无填充 —— 打开下方的 ADN 明细开关才能区分 |
| 穿山甲 | 20001 reason:234 | 聚合代码位用在了非聚合场景。出现它说明 useMediation 没生效 |
| 穿山甲 | 20001 reason:112 | 无效重复请求过多,测试期高频请求同一代码位时常见,换台设备或隔一会儿再试 |
| 穿山甲 | 40006 | 广告位 ID 不合法 —— 常见于 Banner 尺寸与后台广告位规格不匹配 |
| 优量汇 | 5004 | 无匹配广告,调试期属正常现象 |
| 优量汇 | 3003 | 网络不可用 |
| 优量汇 | 100133 | 广告位状态异常 / 新建未满 30 分钟,属平台侧配置问题 |
区分「瀑布流漏配」和「真无填充」:debug(true) 时 SDK 会自动打开 GroMore 的 ADN 错误明细, 20005 的 message 里会带上每一家的失败原因,形如:
csj 20005 [{"adn_name":"pangle","mediation_rit":"<代码位 ID>","error_code":20001,"error_msg":"reason: 112 ..."}]方括号里是空的 → 瀑布流里一个代码位都没配;有内容 → 瀑布流配了,是各家自己没填充。
七、日志排查
SDK 全部日志使用 AdMix 标签,debug(true) 时输出完整调度过程:
bash
adb logcat -s "AdMix:V"启动阶段先核对这两行(不受 debug 开关影响,关了也打):
adapter 就绪: csj (SDK 7.7.1.6) ← 每个引入的 adapter 一行,没有就是依赖没引进来
adapter 就绪: gdt (SDK 4.680.1550)
拉取远端配置 … adapters=[csj,gdt] ← 上报给平台的能力清单,服务端据此裁剪下发一行 adapter 就绪 都没有、只有 没有发现任何 adapter 时,不用再往下查调度 —— 要么三个坐标没引全,要么打包策略把 META-INF/services/** 排除了(见第一节「只接一家会怎样」)。
典型输出:
开始加载 reward_main 竞价位=0 瀑布层=2
瀑布层无填充 gdt AdError{gdt 5004 ...},下探
请求瀑布层[1] csj:***(price=***)
瀑布填充 csj ecpm=***
最终胜出: csj ecpm=***
[CSJ激励] GroMore 出价 adn=pangle ecpm原始值=*** 代码位=*** 分组=1636713 channel=xxx subChannel=yyy日志里的价格和代码位 ID 一律显示为 ***:logcat 对宿主开发者可见,这两项不对接入方开放,这与 debug 开关无关。 需要定位到具体代码位时,把日志里的广告位 ID、ADN 和时间点交给平台运营查询。
最后一行是 GroMore 的成交明细:adn 是真正出广告的那一家, channel / subChannel 应与初始化时传入的一致 —— 对不上就说明流量分组没生效,报表将无法按渠道拆分。
上线前请将
debug置为false,否则影响性能。