Android 广告模块架构:统一管理开屏、插屏、激励与全屏广告
从多 SDK 聚合、Activity/Fragment 生命周期到预加载与频控,拆解一套可复用的 Android 广告中台模块设计
一款工具类 App 接入广告,往往不到两周就会演变成「广告地狱」:MainActivity 里塞开屏逻辑,某个 Fragment 偷偷调插屏,设置页退出时再弹一次;AdMob、穿山甲、GroMore 各写一套回调,崩溃日志里全是 Activity has been destroyed。
根因不是某个 SDK 难用,而是缺少统一的广告模块(Ad Module)——把「加载、展示、频控、埋点、多源竞价」从业务页面里剥离,变成 App 矩阵可复用的中台能力。这与 小队+大中台 的思路一致:业务小队只管「在哪个时机触发什么广告位」,广告中台负责「怎么安全地展示出来」。
本文讨论一套在 Activity / Fragment 体系下可落地的广告模块架构,覆盖开屏、插屏、激励视频、全屏(App Open)四类最常见形态,以及如何实现统一管理。
一、为什么要单独做广告模块
1.1 广告集成的真实痛点
| 痛点 | 表现 | 模块化后的目标 |
|---|---|---|
| SDK 碎片化 | 每家初始化参数、回调、错误码不同 | Adapter 层统一接口 |
| 生命周期耦合 | 页面销毁后仍 show() → 崩溃 | 绑定 LifecycleOwner,自动取消 |
| 展示时机混乱 | 连点、旋转屏、前后台切换重复弹 | 统一状态机 + 频控 |
| 难以 A/B | 换源、调价要改 N 处业务代码 | 策略配置化 |
| 合规风险 | GDPR / 未成年人 / 同意弹窗散落各处 | Privacy Gate 统一拦截 |
1.2 模块边界:业务 vs 广告中台
业务层只回答三个问题:
- 在什么时机(冷启动、关卡结束、导出完成)?
- 用哪个广告位 ID(slot)?
- 展示失败时要不要降级(跳过 / 重试 / 换源)?
其余——初始化、缓存、展示容器、回调线程、频控、埋点——全部下沉到 Ad Module。
二、分层设计
2.1 核心接口:把一切广告形态抽象成「槽位 + 状态」
/** 广告形态 */
enum class AdFormat {
SPLASH, // 开屏
INTERSTITIAL, // 插屏
REWARDED, // 激励视频
APP_OPEN, // 全屏 / App Open(含部分厂商「全屏视频」)
BANNER, // 横幅(可选)
NATIVE // 原生(可选)
}
/** 广告单元配置:远程可下发 */
data class AdUnitConfig(
val slotId: String,
val format: AdFormat,
val primaryPlacementId: String,
val fallbackPlacementIds: List<String> = emptyList(),
val preload: Boolean = true,
val expireMs: Long = 3_600_000L
)
/** 统一状态机 */
sealed interface AdState {
data object Idle : AdState
data object Loading : AdState
data class Ready(val ad: LoadedAd) : AdState
data class Showing(val ad: LoadedAd) : AdState
data class Rewarded(val amount: Int, val type: String) : AdState
data class Failed(val reason: AdError) : AdState
data object Dismissed : AdState
}
/** 对外门面:业务唯一入口 */
interface AdManager {
fun initialize(app: Application, config: AdSdkConfig)
suspend fun preload(slotId: String)
suspend fun show(
slotId: String,
host: AdHost,
listener: AdShowListener = AdShowListener.NO_OP
): AdShowResult
fun observeState(slotId: String): Flow<AdState>
}LoadedAd 是模块内部对「已加载未展示」广告的封装,业务层不应持有 SDK 原生对象(如 com.google.android.gms.ads.interstitial.InterstitialAd),避免泄漏与线程问题。
2.2 AdAdapter:多 SDK 适配层
每家 SDK 实现同一套 AdAdapter:
interface AdAdapter {
val networkName: String
fun supports(format: AdFormat): Boolean
suspend fun load(
context: Context,
placementId: String,
format: AdFormat
): Result<LoadedAd>
fun show(loadedAd: LoadedAd, host: AdHost, callbacks: InternalAdCallbacks)
fun destroy(loadedAd: LoadedAd)
}- Mediation(GroMore、AdMob Mediation、MAX)可作为一个
MediationAdapter,内部再路由;业务层无感。 - 加载失败时由
AdLoader按fallbackPlacementIds或 waterfall 顺序尝试下一源,对业务返回单一Result。
2.3 AdHost:统一 Activity / Fragment 宿主抽象
广告展示必须绑定可见的 Activity,Fragment 场景需解析到 requireActivity():
sealed class AdHost {
data class ActivityHost(val activity: Activity) : AdHost()
data class FragmentHost(val fragment: Fragment) : AdHost()
fun lifecycleOwner(): LifecycleOwner = when (this) {
is ActivityHost -> activity
is FragmentHost -> fragment.viewLifecycleOwner
}
fun activity(): Activity = when (this) {
is ActivityHost -> activity
is FragmentHost -> fragment.requireActivity()
}
}关键规则:
- Fragment 必须用
viewLifecycleOwner,不能用fragment自身——否则onDestroyView之后仍可能收到展示回调。 show()前检查lifecycleOwner().lifecycle.currentState.isAtLeast(STARTED)。- 禁止在
ApplicationContext上show()全屏类广告。
2.4 AdRenderer:按形态拆分展示器
| 形态 | Renderer 职责 | 典型容器 |
|---|---|---|
开屏 SPLASH | 冷/热启动遮罩、超时跳过、与首屏并行 | 专用 SplashActivity 或 MainActivity 全屏 FrameLayout |
插屏 INTERSTITIAL | 页面切换间隙、阻塞式覆盖 | SDK 自带全屏 Activity 或当前 Activity 上展示 |
激励 REWARDED | 播放完成发奖、中途退出无奖励 | 全屏视频 Activity |
全屏 APP_OPEN | 从后台回前台时展示 | Application.ActivityLifecycleCallbacks 感知前台 |
各 Renderer 共享:
- 展示锁(同一时刻只允许一个全屏广告)
- 超时(开屏 3~5s 未加载则进主页)
onDismiss统一通知AdManager释放引用
三、四类广告的工程实现要点
3.1 开屏广告(Splash)
场景:冷启动品牌曝光 + 变现;热启动可选。
推荐流程:
实现要点:
- 独立
SplashActivity作为 LAUNCHER,避免与MainActivity初始化抢资源;主题用windowFullscreen+ 品牌背景,广告未到时用户不看到白屏。 - 超时必进主页:
withTimeout(4_000)加载,失败或超时goMain(),绝不卡死启动链路。 - 区分冷/热启动:热启动可在
Application的ActivityLifecycleCallbacks.onActivityStarted里判断「从后台回前台 + 间隔 > 30s」再触发APP_OPEN或轻量开屏,避免每次切换都弹。 - 隐私同意前置:欧盟 / 青少年模式未通过时,
AdPolicy直接Skip,不调用 SDKinitialize。
class SplashActivity : AppCompatActivity() {
private val adManager: AdManager by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
lifecycleScope.launch {
val result = withTimeoutOrNull(4_000) {
adManager.show(
slotId = AdSlots.SPLASH_COLD,
host = AdHost.ActivityHost(this@SplashActivity)
)
}
navigateToMain()
}
}
}3.2 插屏广告(Interstitial)
场景:关卡结束、导出完成、Tab 切换等「自然间隙」。
与 Activity / Fragment 的配合:
- 在 ViewModel 里发意图,在 Activity 里 show:
ViewModel发出UiEvent.ShowInterstitial(slotId),Activitycollect后调用adManager.show()——避免ViewModel持有Activity。 - 导航完成后展示:
NavController跳转动画结束再show,防止广告盖住转场中间帧。 - Fragment 回退栈:
onResume里自动弹插屏极易引发「返回又弹一次」,应用 频控 key(如interstitial_level_clear每日最多 N 次)。
// ViewModel
fun onLevelCleared() {
viewModelScope.launch {
_events.emit(UiEvent.ShowAd(AdSlots.INTERSTITIAL_LEVEL_END))
}
}
// Activity
lifecycleScope.launch {
viewModel.events.collect { event ->
if (event is UiEvent.ShowAd) {
adManager.show(event.slotId, AdHost.ActivityHost(this@GameActivity))
}
}
}3.3 激励视频(Rewarded)
场景:看广告换金币、解锁功能、加倍奖励。
核心契约:只有 SDK 回调 onUserEarnedReward 后,业务才发奖;中途关闭必须 不发放。
adManager.show(
slotId = AdSlots.REWARDED_DOUBLE_COIN,
host = AdHost.FragmentHost(this),
listener = object : AdShowListener {
override fun onRewarded(amount: Int, type: String) {
viewModel.grantCoins(amount)
}
override fun onDismissed() {
viewModel.onRewardFlowEnd()
}
}
)防作弊:客户端发奖仅作 UX,高价值奖励应服务端校验 SSV(Server-Side Verification)或自建订单号;模块层预留 rewardToken 上报接口。
预加载:进入关卡前 preload(REWARDED),点击「看广告领奖励」时几乎零等待;加载失败给按钮置灰或 Toast,勿假 loading。
3.4 全屏 / App Open 广告
Google 称 App Open Ad,国内 SDK 常称「全屏视频」或「热启动开屏」。本质都是:App 从后台回到前台时展示的全屏形态。
实现要点:
- 在
Application注册ActivityLifecycleCallbacks,记录isInBackground、lastBackgroundAt。 - 当前台 Activity
onStart且满足「后台超过 30s、非首启 Splash、非支付/登录页」时,触发AdSlots.APP_OPEN。 - 与插屏共用 全屏互斥锁:已有插屏/激励在展示时,跳过 App Open。
- 注意 Android 10+ 后台启动 Activity 限制:App Open 必须在合法的前台 Activity 上下文中由 SDK 弹出,必要时只在
currentActivity为FragmentActivity时展示。
四、预加载池与缓存策略
class AdLoader(
private val adapters: List<AdAdapter>,
private val cache: AdCache
) {
suspend fun load(slot: AdUnitConfig): Result<LoadedAd> {
cache.getValid(slot.slotId)?.let { return Result.success(it) }
val errors = mutableListOf<AdError>()
for (placementId in listOf(slot.primaryPlacementId) + slot.fallbackPlacementIds) {
val adapter = resolveAdapter(placementId) ?: continue
adapter.load(appContext, placementId, slot.format)
.onSuccess { loaded ->
cache.put(slot.slotId, loaded, slot.expireMs)
return Result.success(loaded)
}
.onFailure { errors.add(it as? AdError ?: AdError.Unknown) }
}
return Result.failure(AdError.AllSourcesFailed(errors))
}
}| 形态 | 是否预加载 | 过期时间建议 | 说明 |
|---|---|---|---|
| 开屏 | 是(Application init 后) | 3~4 小时 | 冷启动路径最短 |
| 插屏 | 是 | 1 小时 | 展示后立即预加载下一条 |
| 激励 | 是 | 1 小时 | 用户点击时零等待 |
| App Open | 可选 | 4 小时 | 回前台前后台预拉 |
内存:缓存的是 SDK 广告对象 + 元数据,全 App 同时缓存条数设上限(如每 slot 1 条),onTrimMemory 时清空低优先级 slot。
五、频控、合规与配置化
5.1 AdPolicy:集中决策「能不能播」
interface AdPolicy {
fun canShow(slotId: String, context: PolicyContext): PolicyVerdict
}
data class PolicyContext(
val isVip: Boolean,
val isChildMode: Boolean,
val consentGranted: Boolean,
val lastShowTimestamps: Map<String, Long>,
val sessionShowCount: Map<String, Int>
)
sealed interface PolicyVerdict {
data object Allow : PolicyVerdict
data class Deny(val reason: String) : PolicyVerdict
}典型规则:
- VIP / 订阅用户:全禁或仅保留激励(用户主动)
- 同一插屏位:间隔 ≥ 60s,每会话 ≤ 3 次
- 开屏:每日首冷启动 1 次
- 未同意隐私 / UMP 未通过:禁止请求个性化广告
远程配置(Firebase Remote Config、自建配置中心)下发 AdUnitConfig 与频控参数,发版不改代码即可换源、调价、关广告。
5.2 隐私与初始化顺序
Application.onCreate
→ 隐私同意 / UMP 表单
→ AdManager.initialize()(仅一次)
→ 预加载高优先级 slot不要在 ContentProvider 或 attachBaseContext 里偷偷初始化广告 SDK——启动耗时、合规审计、多进程都会踩坑。
六、埋点与收益归因
统一 AdReporter,业务与 Adapter 只报语义事件:
| 事件 | 字段 | 用途 |
|---|---|---|
ad_request | slot, format, network | 填充率分析 |
ad_load_success | latency_ms, ecpm_est | 源质量对比 |
ad_show | scene, activity_name | 体验与漏斗 |
ad_click | — | CTR |
ad_reward | amount, type | 激励转化 |
ad_revenue | value, currency, network | 聚合收益 |
与 插件化 / 矩阵化 中的 analytics-module 对齐:广告模块只产标准事件,数据中台负责入库与大屏。
七、依赖注入与模块打包
Gradle 建议独立 module::feature-ad 或 :lib-ad-core + :lib-ad-admob + :lib-ad-pangle。
:app
└── :feature-ad // AdManager 实现、Renderer、Policy
├── :lib-ad-api // 接口、AdHost、AdState(业务仅依赖此 module)
├── :lib-ad-admob // AdMobAdapter(runtimeOnly 或 productFlavor)
└── :lib-ad-pangle // 穿山甲 Adapter业务 App 依赖 lib-ad-api;具体 SDK 在 productFlavors 或 buildTypes 里按需打入,矩阵 App 共享同一套 AdManager 接口。
Hilt 绑定示例:
@Module
@InstallIn(SingletonComponent::class)
object AdModule {
@Provides @Singleton
fun provideAdManager(
loaders: AdLoader,
renderers: Set<AdRenderer>,
policy: AdPolicy,
reporters: AdReporter
): AdManager = DefaultAdManager(loaders, renderers, policy, reporters)
}八、测试与降级
| 手段 | 做法 |
|---|---|
| Mock Adapter | debug 变体返回假 LoadedAd,UI 走完整状态机 |
| 开关 | BuildConfig.AD_ENABLED = false 时 AdManager 空实现 |
| 截图 / 自动化 | 使用 SDK 测试广告位 ID;Espresso 避开 WebView 广告层 |
| 降级 | 加载失败 → 直接进入下一屏,不阻塞核心路径 |
铁律:广告失败永远不能挡住登录、支付、导出等主流程。
九、常见反模式
- 在
Fragment.onDestroyView之后show()— 必崩;用viewLifecycleOwner+ 状态机拦截。 - 每个页面直接调 SDK — 无法频控、无法换源、埋点口径分裂。
- 开屏无超时 — 弱网用户永远进不了 App。
- 激励先发奖再等回调 — 被刷奖励;必须
onUserEarnedReward后发放。 - 前后台插屏 + App Open 叠加 — 回前台连弹两次;全屏互斥 + 统一 Policy。
- 主线程
load()阻塞 — 用协程suspend+Dispatchers.Main仅做 UI 展示。
十、总结
| 层次 | 职责 |
|---|---|
| 业务 Activity / Fragment | 触发时机、AdHost、处理激励发奖 |
AdManager | 门面、状态机、互斥 |
AdPolicy | 频控、VIP、合规 |
AdLoader + AdCache | 预加载、瀑布流、过期 |
AdRenderer | 开屏 / 插屏 / 激励 / App Open 展示细节 |
AdAdapter | 多 SDK 差异屏蔽 |
AdReporter | 统一埋点与收益 |
Android 广告模块的本质,不是「接几个 SDK」,而是把全屏、强打断、强生命周期敏感的第三方能力,封装成可配置、可观测、可降级的内部服务。Activity 和 Fragment 只提供合法的展示宿主;开屏、插屏、激励、全屏的差异收拢在 Renderer 与 Adapter 里——这样矩阵里第二款 App 接入广告,往往只需要配一张 slot 表,而不是再复制粘贴一遍 MainActivity。
若你正在做 App 矩阵或 插件化演进,建议把 lib-ad-api 尽早抽成与 UI 无关的纯 Kotlin 模块,与 MVVM 展示层 通过 UiEvent 衔接;广告是变现手段,不应反客为主绑架架构。