返回 开发模式
开发模式
16 分钟阅读

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 广告中台

业务层只回答三个问题

  1. 在什么时机(冷启动、关卡结束、导出完成)?
  2. 用哪个广告位 ID(slot)?
  3. 展示失败时要不要降级(跳过 / 重试 / 换源)?

其余——初始化、缓存、展示容器、回调线程、频控、埋点——全部下沉到 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,内部再路由;业务层无感。
  • 加载失败时由 AdLoaderfallbackPlacementIds 或 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)
  • 禁止在 ApplicationContextshow() 全屏类广告。

2.4 AdRenderer:按形态拆分展示器

形态Renderer 职责典型容器
开屏 SPLASH冷/热启动遮罩、超时跳过、与首屏并行专用 SplashActivityMainActivity 全屏 FrameLayout
插屏 INTERSTITIAL页面切换间隙、阻塞式覆盖SDK 自带全屏 Activity 或当前 Activity 上展示
激励 REWARDED播放完成发奖、中途退出无奖励全屏视频 Activity
全屏 APP_OPEN从后台回前台时展示Application.ActivityLifecycleCallbacks 感知前台

各 Renderer 共享:

  • 展示锁(同一时刻只允许一个全屏广告)
  • 超时(开屏 3~5s 未加载则进主页)
  • onDismiss 统一通知 AdManager 释放引用

三、四类广告的工程实现要点

3.1 开屏广告(Splash)

场景:冷启动品牌曝光 + 变现;热启动可选。

推荐流程

实现要点

  1. 独立 SplashActivity 作为 LAUNCHER,避免与 MainActivity 初始化抢资源;主题用 windowFullscreen + 品牌背景,广告未到时用户不看到白屏。
  2. 超时必进主页withTimeout(4_000) 加载,失败或超时 goMain(),绝不卡死启动链路。
  3. 区分冷/热启动:热启动可在 ApplicationActivityLifecycleCallbacks.onActivityStarted 里判断「从后台回前台 + 间隔 > 30s」再触发 APP_OPEN 或轻量开屏,避免每次切换都弹。
  4. 隐私同意前置:欧盟 / 青少年模式未通过时,AdPolicy 直接 Skip,不调用 SDK initialize
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 里 showViewModel 发出 UiEvent.ShowInterstitial(slotId)Activity collect 后调用 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 从后台回到前台时展示的全屏形态。

实现要点

  1. Application 注册 ActivityLifecycleCallbacks,记录 isInBackgroundlastBackgroundAt
  2. 当前台 Activity onStart 且满足「后台超过 30s、非首启 Splash、非支付/登录页」时,触发 AdSlots.APP_OPEN
  3. 与插屏共用 全屏互斥锁:已有插屏/激励在展示时,跳过 App Open。
  4. 注意 Android 10+ 后台启动 Activity 限制:App Open 必须在合法的前台 Activity 上下文中由 SDK 弹出,必要时只在 currentActivityFragmentActivity 时展示。

四、预加载池与缓存策略

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

不要在 ContentProviderattachBaseContext 里偷偷初始化广告 SDK——启动耗时、合规审计、多进程都会踩坑。


六、埋点与收益归因

统一 AdReporter,业务与 Adapter 只报语义事件:

事件字段用途
ad_requestslot, format, network填充率分析
ad_load_successlatency_ms, ecpm_est源质量对比
ad_showscene, activity_name体验与漏斗
ad_clickCTR
ad_rewardamount, type激励转化
ad_revenuevalue, 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 在 productFlavorsbuildTypes 里按需打入,矩阵 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 Adapterdebug 变体返回假 LoadedAd,UI 走完整状态机
开关BuildConfig.AD_ENABLED = falseAdManager 空实现
截图 / 自动化使用 SDK 测试广告位 ID;Espresso 避开 WebView 广告层
降级加载失败 → 直接进入下一屏,不阻塞核心路径

铁律:广告失败永远不能挡住登录、支付、导出等主流程。


九、常见反模式

  1. Fragment.onDestroyView 之后 show() — 必崩;用 viewLifecycleOwner + 状态机拦截。
  2. 每个页面直接调 SDK — 无法频控、无法换源、埋点口径分裂。
  3. 开屏无超时 — 弱网用户永远进不了 App。
  4. 激励先发奖再等回调 — 被刷奖励;必须 onUserEarnedReward 后发放。
  5. 前后台插屏 + App Open 叠加 — 回前台连弹两次;全屏互斥 + 统一 Policy。
  6. 主线程 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 衔接;广告是变现手段,不应反客为主绑架架构。

相关文章

Android
17 分钟
Android 展示编排队列:用 Handler 思想统一管理广告、推送与弹窗
从 MessageQueue 串行调度理论出发,用 Kotlin 协程、Flow 与 Lifecycle 实现 PresentationOrchestrator,让开屏、推送、升级弹窗按优先级依次展示
开发模式
39 分钟
小队+大中台:数字化时代,企业高效创新的底层逻辑
小队大中台?这个概念到底有多强呢?
Android
10 分钟
App矩阵化思维:让应用体系像生态一样进化
大中台技术支援与小队支撑业务的「中台化战略」。
Android
17 分钟
当我们在谈MVP、MVVM、MVI的时候,我们到底在谈什么?
Presentation Layer 架构模式的演进脉络、职责边界与数据流向——从 MVC 到 MVI 的选型与实践
Android
4 分钟
如何在Android开发中实现MVVM的架构
MVVM与Android的实践简要指导