上个月有个做工具类 APP 的朋友找我,说他那款日活两万多的产品,广告收入一个月只有两百来块,怀疑是不是被平台扣量了。我把他的后台数据要过来翻了一遍,问题根本不在平台——他只接了一家联盟的 SDK,只放了开屏和底部横幅两个位置,激励视频和插屏压根没做,填充率常年在 40% 上下晃。这种情况在刚起步的产品里太普遍了:所有人都知道 APP 要靠广告变现,但很少有人认真比较过SDK 直连、聚合平台、H5 免 SDK这三种对接方式到底差在哪、各自适合什么阶段的产品、落地时又会撞上哪些具体问题。
这三种模式我这些年完整跑过一遍,也见过团队在不同阶段反复横跳——早期图省事接 H5,中期嫌收益低换聚合,后期为了对接某个独家预算又回头补 SDK 直连。每种选择背后都有它的合理性,也都有它的代价。下面我按"钱怎么分""代码怎么写""坑怎么避""路怎么选"四条线索,把三种模式拆开讲透,看完你基本能判断自己的产品现在该走哪条路,以及怎么把这条路的收益榨到合理区间。
1. 三种对接方式的成本账:抽成、工期与隐性开销
做变现决策的时候,很多人只盯着"eCPM 谁高",这是最容易踩的思维陷阱。真实成本至少有三块:平台侧的抽成比例、你自己投入的研发工期、以及上线后长期维护的隐性开销。这三块在不同模式下权重完全不同,必须放在一起算。
1.1 SDK直连的钱是怎么流动的
SDK 直连的本质是你直接和某一家广告联盟签协议,把它的 SDK 编进包里,调用它的加载和展示接口。钱从广告主流向联盟,联盟扣掉自己的技术服务费后再结算给你。这个抽成比例各家不同,但通常在一个相对固定的区间里,而且因为是直签,你能拿到的结算单价往往比经过中间层要高一些。
代价在于你得把每一家都单独接一遍。两家联盟就是两套初始化、两套回调、两套错误码、两套后台报表。三家就是三套。我见过一个团队为了把填充率从 60% 拉到 85%,硬生生接了五家 SDK,包体积涨了将近 8MB,冷启动时间多了 300 毫秒,最后因为维护成本太高又砍回到三家。这个来回折腾的过程本身就是成本。
直连还有个容易被忽略的点:它不解决调度问题。你接了五家,谁先出、谁兜底、超时多久切下一家,全得自己写代码判断。这段调度逻辑写得好不好,直接决定了你的实际填充率和展示率,而这两个指标对收入的影响,往往比单家平台的单价差异更大。
1.2 聚合平台那一层抽成值不值
聚合平台做的事情,说白了就是把你上面说的那套手动调度逻辑产品化了。你只接它一个 SDK,它在内部管理多家广告源,按你配置的策略依次请求、超时切换、失败兜底,最后把填充结果回调给你。对开发者来说,接入工作量从"乘以 N"变成"乘以 1"。
它当然要收钱,通常是从流水里按比例分走一部分,或者在某些模式下赚差价。这笔钱值不值,取决于两件事:一是你自己写调度逻辑的成本,二是聚合方能不能拿到你拿不到的预算。前者对中小团队来说往往很划算——一套稳定的瀑布流调度加缓存管理,写起来至少两三周,还要长期维护;后者则看聚合平台的商务能力,大平台确实能接到一些直签不容易谈下来的量。
但这里有个认知误区要澄清:不是接了聚合收益就一定涨。如果你的 APP 本身量级很小,日请求量撑不起多源比价,聚合带来的额外收益可能还不够覆盖它抽走的那部分。我见过日活几千的产品接聚合,最后算下来比单家直连还低一点,因为聚合层的抽成是刚性的,而小量级下多源比价的增益非常有限。
1.3 H5免SDK省下来的工期去哪了
H5 免 SDK 模式听起来最轻松:不用集成任何原生 SDK,广告在一个 WebView 里渲染,你只需要把页面加载出来,剩下的交给 H5 侧。适合的场景很明确——原生开发资源紧张、需要快速验证变现可行性、或者某些能力受限不方便集成原生库的产品。
省下来的工期是真的,但它会以另一种形式还回去。首先是性能问题:WebView 的初始化本身就重,冷启动时拉起一个 WebView 实例平均要 100 到 300 毫秒,再加上页面加载、资源下载、脚本执行,用户感知到的等待明显长于原生广告。其次是能力受限:H5 拿不到原生的生命周期回调,你在后台切前台、页面销毁这些时机的控制会变粗;视频类广告的播放体验、全屏控制、音量管理也都不如原生。
还有一个现实问题:H5 模式的结算口径经常和原生不一致。有些平台对 H5 流量的单价压得比较低,理由是归因链路更长、作弊识别难度更高。你把工期省下来了,但可能把单价也省掉了。这笔账要提前算清楚,别等上线一个月才发现收入不及预期。
2. SDK直连的落地链路:从初始化到展示的每一处细节
决定走直连之后,真正的活儿才开始。这段我按实际接入顺序讲,每一处都说说为什么要这么写,而不是只给一段能跑通的代码。
2.1 初始化时机与调用线程
初始化的第一原则是只做一次,且越早越好,但不能阻塞主线程。很多 SDK 的初始化内部会做网络探测、配置拉取、本地缓存读取,放在 Application.onCreate 里同步执行会直接拖慢冷启动。常见做法是在 onCreate 里触发异步初始化,等首个广告位真正要加载时再检查初始化状态。
class App : Application() { override fun onCreate() { super.onCreate() // 异步初始化,不阻塞冷启动 AdSdk.initAsync( context = this, appId = BuildConfig.AD_APP_ID, config = AdConfig( debug = BuildConfig.DEBUG, // 用户是否已授权个性化广告,必须在初始化时传入 personalizedAllowed = ConsentStore.hasAdConsent() ) ) } }这里有两个细节值得强调。第一,personalizedAllowed这个参数一定要在初始化阶段就确定,因为部分 SDK 会根据它决定请求哪一类广告,初始化之后再改往往不生效,得重新初始化。第二,初始化是异步的,就意味着第一个广告位的加载必须等初始化完成,你需要一个状态回调或者 CompletableDeferred 来做衔接,否则会出现"初始化还没好就去 load,直接返回失败"的情况,而这种失败在日志里看起来就像填充失败,极难排查。
2.2 广告位实例的复用与缓存
广告位实例该不该复用,取决于广告类型。激励视频和插屏这类"用完即弃"的,一个实例展示完就必须重新 load;开屏和横幅这类常驻的,实例可以长期持有。但即便是常驻类型,也不要用单例硬扛——用户切换页面时如果没解绑,很容易出现"广告展示在一个已经销毁的 Activity 上"的崩溃。
// 推荐做法:广告位实例跟随页面生命周期 class FeedActivity : AppCompatActivity() { private lateinit var nativeSlot: AdSlot override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) nativeSlot = AdSdk.createSlot( slotId = "feed_native_001", type = AdType.NATIVE, timeoutMs = 3000 ) nativeSlot.load(loadListener) } override fun onDestroy() { // 关键:销毁时释放,避免回调打到已销毁的页面 nativeSlot.release() super.onDestroy() } }timeoutMs这个参数很多人随手设成 5000 甚至 10000,觉得给足时间总能加载出来。实际上超时设太长会直接拖垮用户体验——用户在信息流里等了五秒才看到广告位被填上,中间那段空白比不展示广告更伤。我的经验值是开屏 3000、信息流 2000 到 3000、激励视频可以放宽到 5000,因为用户对激励视频的等待容忍度天然更高,他是有预期来换奖励的。
2.3 展示时机与生命周期绑定
加载成功不等于可以展示。SDK 的onLoaded回调只说明素材准备好了,能不能展示还得看当前页面是否在前台、Activity 是否处于 resumed 状态。我见过最典型的 bug 是:用户在信息流里滑得很快,广告加载完成的回调回来时,那个位置早就被复用给别的 item 了,结果广告展示在了错误的位置上,或者干脆展示在了已经 detach 的 view 上。
解决办法是给每个广告位绑定一个"宿主标识",回调回来时先校验当前宿主是否还是当初那个。听起来啰嗦,但这是唯一稳妥的做法。另外插屏和激励视频展示前要检查isFinishing和isDestroyed,这两个判断能挡掉大部分线上崩溃。
2.4 多平台直连时的手动调度
如果你决定自己写调度,核心逻辑其实不复杂:按优先级排序,依次请求,谁先返回有效填充就用谁,全部失败则走兜底。难点在于超时控制和并发控制。串行请求会累加延迟,五家串行每家 3 秒,最坏情况用户等 15 秒;全并发请求又会造成大量无效请求,拉低你的整体请求质量分。
我的做法是分组串行、组内并发:把单价接近的几家分成一组,组内并发请求争抢,组失败再进下一组。这样既控制了最坏延迟,又不会浪费太多无效请求。至于分组的粒度和超时值,得根据自己的实际数据调,没有通用答案——但一定要在后台记录每一家的请求量、返回量、耗时,否则你根本不知道调度策略该往哪调。
3. 聚合平台的核心机制:waterfall 和实时竞价怎么选
接聚合不是接完就完事,你得理解它在内部做了什么,否则配置参数就是瞎填,收益波动也解释不清。
3.1 分层瀑布的价格排序与超时设置
传统瀑布流(waterfall)的逻辑是按历史 eCPM 从高到低排序,依次请求,前一家没填再请求下一家。它的核心假设是"历史单价能预测未来单价",这个假设在流量结构稳定的时候成立,在流量结构剧烈变化时会失效——比如你突然做了一波拉新,进来的用户画像和存量完全不同,历史排序就全乱了。
配置瀑布流的时候有几个参数必须认真填:每一层的超时时间、是否允许并行请求相邻层、以及缓存有效期。超时时间设太短会导致本该填充的广告被提前放弃,设太长会让整体加载时间不可控。我一般按层递减设置,头部平台给 1500 到 2000 毫秒,尾部给 800 到 1000 毫秒,因为头部平台的响应速度通常更快,尾部平台本身就不稳定,给再多时间也未必有用。
{ "slotId": "splash_001", "strategy": "waterfall", "layers": [ { "network": "platform_a", "adUnitId": "xxx", "timeoutMs": 1800 }, { "network": "platform_b", "adUnitId": "yyy", "timeoutMs": 1200 }, { "network": "platform_c", "adUnitId": "zzz", "timeoutMs": 900 } ], "preloadCount": 2, "cacheTTLSec": 1800 }preloadCount是预加载数量,设 2 意味着缓存里常备两个可展示的广告。开屏位建议设 1 到 2,因为开屏的展示是瞬时的,缓存太多没意义还占内存;信息流可以设 3 到 5,因为滑动速度快,需要提前备货。
3.2 实时竞价改写了的排序逻辑
实时竞价(bidding)把"猜价格"改成了"问价格":一次请求同时发给所有参与竞价的平台,各家在几十到几百毫秒内返回自己的出价,聚合方按出价高低决定谁展示。理论上这比瀑布流的收益更高,因为它消除了信息不对称,也不用再维护那套越来越复杂的排序表。
但实时竞价不是银弹。它的前提是各平台都支持竞价协议,实际接入时会发现总有些平台只能走瀑布流,于是你最终拿到的是一个混合模式:能竞价的走竞价,不能的走瀑布兜底。这时候配置的复杂度反而上升了,你得想清楚竞价失败后的兜底顺序、竞价超时怎么和瀑布超时协调。
还有一个实操中的坑:竞价模式下,某些平台的"出价"是预估值,实际结算按展示后计算,两者之间可能有偏差。你在后台看到竞价赢了但收入没涨,未必是数据出错,可能就是预估和实际的差异。这个偏差在流量质量波动大的产品上尤其明显。
3.3 接聚合SDK时最容易出问题的地方
聚合 SDK 相比单家 SDK,体积大、初始化重、内部状态多,出问题的概率也高。我总结下来几类高频问题:
| 问题现象 | 常见原因 | 处理方式 |
|---|---|---|
| 初始化超时,后续全部失败 | 聚合内部拉取配置的网络请求阻塞 | 设置初始化超时上限,超时后降级到本地缓存配置 |
| 首屏广告加载慢 | 聚合内部串行初始化多个适配器 | 开启并行初始化,或延后非核心适配器 |
| 某一家平台始终无填充 | 适配器版本与聚合版本不匹配 | 按聚合方文档锁定适配器版本,不要随手升级 |
| 收益统计和平台后台对不上 | 归因窗口和去重规则差异 | 以聚合后台为准做运营决策,单平台后台只做参考 |
| 包体积超标 | 引入了全部适配器 | 只引入实际配置了广告位的适配器 |
最后一条特别值得说。很多聚合 SDK 提供"全家桶"依赖,一行 gradle 就把所有平台的适配器全拉进来,包体积轻松涨十几 MB。正确做法是按需引入,配置文件里写了哪几家就引哪几家。这个操作能省下的包体积非常可观,尤其对本身就在控制包大小的产品。
4. H5免SDK模式:原理、桥接与能力边界
H5 模式的定位很清晰:它不是原生变现的替代品,而是一个低门槛的补充方案。理解这一点,才能用对地方。
4.1 H5广告在WebView里怎么跑起来
基本链路是这样的:你的原生页面里嵌入一个 WebView,加载一个由广告方提供的 H5 地址,页面里的 JS 脚本负责向广告服务请求素材、渲染、上报曝光和点击。原生侧只需要做好两件事——把 WebView 配置对,以及提供一个 JS 桥接对象让 H5 能回调原生。
// 原生侧注册桥接对象 webView.addJavascriptInterface(AdJsBridge(), "AdBridge") class AdJsBridge { @JavascriptInterface fun onAdRendered(slotId: String) { // H5 通知原生:广告已渲染完成 mainHandler.post { trackRender(slotId) } } @JavascriptInterface fun onAdClicked(slotId: String) { mainHandler.post { trackClick(slotId) } } @JavascriptInterface fun onAdClosed(slotId: String) { mainHandler.post { closeContainer(slotId) } } }H5 侧的调用很直接:
// 渲染完成后通知原生 if (window.AdBridge) { window.AdBridge.onAdRendered('h5_banner_001'); }这里有个必须注意的点:@JavascriptInterface标注的方法运行在 WebView 的 JS 线程上,任何 UI 操作都必须 post 回主线程,否则会出现各种诡异问题。另外桥接对象的命名不要用太通用的词,避免和页面里已有的全局变量冲突。
4.2 WebView配置清单与常见报错
WebView 的默认配置对广告场景很不友好,下面这几项基本是必调的:
webView.settings.apply { javaScriptEnabled = true // H5 广告必须开 domStorageEnabled = true // 部分广告脚本依赖 localStorage mediaPlaybackRequiresUserGesture = false // 视频广告自动播放 mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW // 禁止混合内容 cacheMode = WebSettings.LOAD_DEFAULT userAgentString = userAgentString + " YourApp/1.0" }mixedContentMode设成 NEVER_ALLOW 是安全底线,不要为了兼容某个老页面放开它。userAgentString追加自己的标识很重要,很多广告方会按 UA 做流量识别和单价分层,UA 里带上应用标识能让归因更准确。
常见报错里,"页面白屏但没报错"通常是因为 JS 执行出错被吞掉了,需要打开WebChromeClient.onConsoleMessage把日志打出来看。"广告位高度为 0"多半是 CSS 里容器没给高度,H5 页面依赖父容器撑开,原生侧如果用了 wrap_content 而 H5 又没设固定高度,就会塌成 0。
4.3 H5模式做不到的事情
必须清楚 H5 的能力边界,别指望它承担核心变现任务。它做不到原生级别的展示时机控制——页面在后台时 H5 依然可能在跑,曝光数据可能被虚高统计;它也做不好视频广告的完播判定和奖励发放,因为拿不到可靠的原生播放状态;更关键的是,很多高价预算(尤其是品牌类和重互动的)根本不投 H5 流量。
所以我的判断是:H5 适合做补充位和快速验证。比如你想试某个新广告形式值不值得投入原生开发,先用 H5 跑两周看数据;或者在原生广告位填充失败的兜底场景里挂一个 H5 位,能捞一点是一点。把它当主力,长期看收益天花板很低。
5. 横向对比与选型:什么样的APP该走哪条路
前面讲了三套实现,现在把它们放到同一张表里比。选型的关键不是找"最好的",而是找"当前阶段成本收益最匹配的"。
5.1 六项关键指标对比
| 维度 | SDK直连 | 聚合平台 | H5免SDK |
|---|---|---|---|
| 接入工期 | 每接一家 2-5 人天 | 一次接入 3-7 人天 | 1-3 人天 |
| 包体积增量 | 每家 1-3MB | 3-10MB(可控) | 接近 0 |
| 填充率上限 | 取决于接入家数 | 较高 | 偏低 |
| 单价水平 | 较高(直签无中间层) | 中高(扣抽成) | 偏低 |
| 维护成本 | 高(每家单独维护) | 中(跟聚合方版本) | 低 |
| 可控性 | 完全可控 | 部分可控 | 弱 |
这张表里最容易被忽视的是"维护成本"这一行。直连三家以上,每年的维护投入(版本升级、适配新系统、处理各家的接口变更)是实打实的,很多团队算收益的时候把这块漏了,导致最后算出来的 ROI 是虚高的。
5.2 按产品阶段选型
验证期(日活几千以内):这个阶段的核心任务是验证变现可行性,不是最大化收益。我的建议是先用一家主流平台的 SDK 直连,把开屏、信息流、激励视频三个主力位跑通,看整体 eCPM 落在什么区间。这时候上聚合属于过度设计,抽成会吃掉本来就不多的收益。
成长期(日活几万到几十万):这是聚合平台价值最大的阶段。你有了一定的请求量,多源比价能带来实际增益;同时团队也开始吃不消多平台直连的维护开销。这个阶段接聚合,把适配器按需引入,包体积控制在可接受范围内,是比较稳的选择。
成熟期(日活百万以上):很多成熟产品会走"聚合 + 直连独家"的混合路线。聚合负责常规填充和比价,某些愿意给保底或独家政策的平台单独直连,拿到更好的结算条件。这个阶段往往有自己的商业化团队,有能力维护这套复杂度。
5.3 混合方案:三种模式同时用
实际项目里,三种模式并存是很常见的。我自己经手的一个信息流产品就是:主信息流用聚合管着五六家源,某个独家合作的预算单独直连走一个固定位置,然后在一个低频页面的底部挂了个 H5 兜底位。三条链路各管一摊,互不干扰。
做混合方案的关键是位置隔离,别让同一个广告位有多种模式的广告争抢,否则数据统计会乱成一团,出了问题也定位不到是哪条链路。每个位置明确归属一种模式,后台报表按位置维度拆开看,这样每种模式的真实表现才清晰。
6. 实操中反复出现的几个问题
这几条是我在不同项目里反复撞见的问题,写下来供对照。
6.1 填充率和展示率不是一回事
很多人看后台只看填充率,觉得填充率 90% 就万事大吉。实际上从"填充成功"到"用户真正看到",中间还隔着展示率这一层。填充满但展示率低,说明素材回来了但没展示出去——可能是页面早被销毁、可能是展示时机判断有问题、也可能是广告位被别的元素遮挡了。
我遇到过一个 case:信息流广告填充率 88%,展示率只有 52%。排查下来是列表滑动太快,广告位在可视区域内停留的时间太短,SDK 判断不构成有效曝光就没上报。后来把广告位的插入策略改成"每滑动 8 个 item 插一个",而不是固定位置插入,展示率直接拉到 70% 以上。
判断标准很简单:填充率看供给,展示率看你的接入质量。填充率低是平台或配置问题,展示率低一定是自己的代码问题。
6.2 收益对不上的时候怎么排查
聚合后台显示的收益和各平台后台加起来的收益对不上,这是常态,差异通常来自三处:归因窗口不同(有的按点击时间归因,有的按展示时间)、去重规则不同(同一用户多次展示的计费方式)、以及结算周期的滞后。
排查顺序建议这样走:先确认统计口径,看两个后台的时间范围和时区是否一致;再对比同一广告位的请求量和展示量,如果请求量就差很多,那是数据上报的问题;最后才看单价差异。我见过最离谱的一次是两端时区差了 8 小时,导致跨日的收益全串了,白折腾了两天。
6.3 隐私合规配置的落地细节
这块现在绕不开,而且做错了代价很大。核心是三件事:初始化前必须确认用户已授权、提供关闭个性化广告的入口、以及保证未授权状态下请求的是非个性化广告。
技术上要注意的是,很多 SDK 的初始化是自动触发的(比如通过 ContentProvider 在 Application 启动时自动执行),如果你的应用在用户点击"同意"之前就已经拉起了 SDK 初始化,那这个初始化就是无效的。正确做法是关闭 SDK 的自动初始化开关,改由自己在用户授权后再手动调用。这一步在集成文档里通常写得很小,但漏掉就是合规风险。
还有一个细节:用户撤回授权后,不只是停止后续请求,已经缓存的广告素材和用户标识也要清理。有些 SDK 提供了clearCache之类的接口,记得在撤回流程里调用。
最后分享一个我在配置这块养成的小习惯:把广告相关的所有开关(个性化开关、初始化状态、缓存状态)集中到一个管理类里,任何一处读取都走这个类,不直接调 SDK 的查询接口。这样排查问题时只需要看一个地方,也能避免"某处绕过管理类直接初始化"这种低级错误。这套做法在三个项目上用过,每次都能在上线前把配置类问题挡掉大半。