Android 展示编排队列:用 Handler 思想统一管理广告、推送与弹窗
从 MessageQueue 串行调度理论出发,用 Kotlin 协程、Flow 与 Lifecycle 实现 PresentationOrchestrator,让开屏、推送、升级弹窗按优先级依次展示
App 冷启动后的前几秒,往往同时撞上好几件事:开屏广告要播、隐私同意弹窗要出、运营推送要展示 In-App 消息、版本检查发现强制升级。若各自为战,用户会看到广告叠在弹窗上、返回键连关三层、或 Activity 已销毁仍 show 的崩溃。
广告模块 解决了「怎么安全展示一条广告」;本文解决更上一层的问题:全 App 的「强打断式 UI」如何排队、谁先谁后、何时必须让路。设计灵感来自 Android Handler / MessageQueue / Looper——单线程串行、按优先级入队、一次只处理一条;实现则用 Kotlin 协程 + Channel + Mutex + Jetpack Lifecycle 等现代技术栈,而不是在新代码里再堆一套 Handler。
一、问题定义:你需要的是「展示编排器」
1.1 典型冲突场景
| 来源 | 形态 | 冲突 |
|---|---|---|
| 广告中台 | 开屏 / App Open / 插屏 | 全屏占满 |
| FCM / 厂商推送 | In-App 弹窗、底部卡片 | 与开屏抢焦点 |
| 应用内运营 | 活动浮层、公告 Dialog | 与插屏叠加 |
| 合规 | 隐私 / UMP 同意 | 必须优先于广告 |
| 版本管理 | 强制 / 可选升级 | 强制升级应阻断一切 |
这些内容的共同特征:
- 互斥——同一时刻用户只能处理一个「全屏 / 模态」任务;
- 有优先级——合规 > 强制升级 > 开屏 > 运营 > 普通插屏;
- 有生命周期——必须在合法
Activity/LifecycleOwner上展示; - 可取消 / 可过期——页面销毁或超时后,队列项应丢弃而非稍后乱弹。
1.2 与广告模块的关系
- AdManager 不再直接
show(),而是向编排器提交PresentationRequest; - 编排器决定「此刻能不能播、播哪一条、播完再播下一条」;
- 各 Renderer 仍由广告模块实现,编排器只负责调度时序。
二、理论层:Handler 思想到底在教什么
2.1 主线程为什么能「排队做事」
Handler 四件套的核心契约(详见 Handler 机制全解):
多个 Handler(生产者) ↓ enqueueMessage MessageQueue(按 when 排序的链表) ↓ next() 阻塞取队头 Looper.loop()(单线程 for(;;)) ↓ dispatchMessage 一次只执行一条消息
对展示编排而言,可抽取四条与具体 API 无关的原则:
| Handler 原则 | 展示编排中的对应 |
|---|---|
| 单消费者 | 全局同时只展示一个 Presentation |
| FIFO + 优先级 | MessageQueue 按 when 排序;我们用 priority + enqueueTime |
| 消息处理完才取下一条 | 当前项 onDismiss / CompletableDeferred 完成后 processNext() |
| 绑定线程 | UI 展示在 Main;排队逻辑可在单线程协程上下文 |
主线程 Looper 保证:不会两个 Runnable 并行改 UI。展示队列要复制的正是这一点——不要两个全屏 Modal 并行 show。
2.2 队列 vs 栈:名字别搞混
口语里的「管理栈」常指两层含义:
- 串行队列(推荐)——冷启动时隐私 → 开屏 → 推送,先进先出 + 优先级插队;
- 嵌套栈——用户在一个 Dialog 里又触发子 Dialog,后压栈先关闭(LIFO)。
本文默认实现 全局串行队列;嵌套 Dialog 交给 FragmentManager / Compose 自己的 back stack。若业务确有「母弹窗内子弹窗」,在单个 PresentationRequest 内部处理,不拆成两条队列项。
2.3 为什么新代码不直接用 Handler
| Handler | 现代替代 |
|---|---|
postDelayed | delay + 协程 |
Message.what | sealed class PresentationRequest |
callback.run() | suspend fun present(host): PresentationResult |
| 难以单元测试 | TestScope + StandardTestDispatcher |
| 与 Lifecycle 无绑定 | LifecycleOwner + repeatOnLifecycle |
思想继承 Handler,实现用协程——这是目前 Android 官方与社区的主流做法(与 ViewModel + Flow 一致)。
三、架构设计:PresentationOrchestrator
3.1 核心模型
/** 展示请求:队列中的「一条消息」 */
data class PresentationRequest(
val id: String,
val type: PresentationType,
val priority: Int,
val dedupeKey: String? = null,
val expireAt: Instant? = null,
val payload: PresentationPayload,
)
enum class PresentationType {
PRIVACY_CONSENT,
FORCE_UPDATE,
SPLASH_AD,
APP_OPEN_AD,
IN_APP_MESSAGE,
OPS_DIALOG,
INTERSTITIAL_AD,
REWARDED_AD,
}
sealed interface PresentationPayload {
data class AdSlot(val slotId: String) : PresentationPayload
data class PushMessage(val campaignId: String, val content: PushContent) : PresentationPayload
data class Update(val versionCode: Int, val force: Boolean) : PresentationPayload
data object PrivacyConsent : PresentationPayload
data class OpsDialog(val templateId: String) : PresentationPayload
}
/** 处理结果:类似 Handler 处理完一条 message */
sealed interface PresentationResult {
data object Shown : PresentationResult
data object Skipped : PresentationResult
data object Expired : PresentationResult
data class Failed(val error: Throwable) : PresentationResult
}优先级示例(数字越小越优先):
| priority | 类型 |
|---|---|
| 0 | 隐私 / UMP |
| 10 | 强制升级 |
| 20 | 开屏广告 |
| 30 | In-App 推送 |
| 40 | 运营弹窗 |
| 50 | 插屏 / App Open |
| 60 | 激励(通常由用户点击触发,可 bypass 队列立即执行) |
3.2 编排器接口
interface PresentationOrchestrator {
/** 入队;若当前空闲则立即调度 */
fun enqueue(request: PresentationRequest)
/** 取消某 dedupeKey 或 id */
fun cancel(id: String)
fun cancelByDedupeKey(key: String)
/** 观察队列与当前展示(调试、埋点) */
val state: StateFlow<OrchestratorState>
/** 绑定前台 Activity,供展示器消费 */
fun bindHost(host: PresentationHost)
fun unbindHost(host: PresentationHost)
}
data class OrchestratorState(
val current: PresentationRequest?,
val pendingCount: Int,
val isProcessing: Boolean,
)
sealed class PresentationHost {
data class ActivityHost(
val activity: FragmentActivity,
val lifecycleOwner: LifecycleOwner = activity,
) : PresentationHost()
}3.3 串行执行器:Looper 的现代替身
用 单协程消费者 + 优先级阻塞队列 复刻 MessageQueue:
class DefaultPresentationOrchestrator(
private val scope: CoroutineScope,
private val handlers: Map<PresentationType, PresentationHandler>,
private val policy: PresentationPolicy,
private val reporter: PresentationReporter,
) : PresentationOrchestrator {
private val queue = PriorityBlockingQueue<PresentationRequest>(
compareBy({ it.priority }, { it.enqueueTime })
)
private val mutex = Mutex()
private var processing = false
private val _state = MutableStateFlow(OrchestratorState(null, 0, false))
override val state: StateFlow<OrchestratorState> = _state.asStateFlow()
@Volatile
private var boundHost: PresentationHost? = null
override fun enqueue(request: PresentationRequest) {
if (!policy.canEnqueue(request)) {
reporter.skipped(request, "policy_deny")
return
}
request.dedupeKey?.let { key ->
queue.removeIf { it.dedupeKey == key }
}
queue.offer(request.copy(enqueueTime = System.nanoTime()))
_state.update { it.copy(pendingCount = queue.size) }
trySchedule()
}
private fun trySchedule() {
scope.launch {
mutex.withLock {
if (processing) return@withLock
processing = true
}
processLoop()
}
}
private suspend fun processLoop() {
while (true) {
val host = awaitValidHost() ?: break
val next = pollValidRequest() ?: break
_state.update {
OrchestratorState(current = next, pendingCount = queue.size, isProcessing = true)
}
val handler = handlers[next.type] ?: continue
val result = runCatching {
handler.present(host, next)
}.getOrElse { PresentationResult.Failed(it) }
reporter.completed(next, result)
_state.update {
OrchestratorState(current = null, pendingCount = queue.size, isProcessing = false)
}
}
mutex.withLock { processing = false }
}
}要点:
Mutex:保证只有一个processLoop在跑,等同 Looper 不会重入loop();PriorityBlockingQueue:等同按when排序的MessageQueue;awaitValidHost():没有合法 Activity 时挂起,避免Activity has been destroyed;handler.present为 suspend:展示完成前不取下一项,等同dispatchMessage执行完才next()。
3.4 PresentationHandler:各业务类型的「Handler.Callback」
interface PresentationHandler {
val type: PresentationType
suspend fun present(
host: PresentationHost,
request: PresentationRequest,
): PresentationResult
}
/** 广告:委托 AdManager,挂起直到 onDismiss */
class AdPresentationHandler(
private val adManager: AdManager,
) : PresentationHandler {
override val type = PresentationType.INTERSTITIAL_AD
override suspend fun present(
host: PresentationHost,
request: PresentationRequest,
): PresentationResult = suspendCancellableCoroutine { cont ->
val slot = (request.payload as PresentationPayload.AdSlot).slotId
val activityHost = host as PresentationHost.ActivityHost
adManager.show(
slotId = slot,
host = AdHost.ActivityHost(activityHost.activity),
listener = object : AdShowListener {
override fun onDismissed() {
if (cont.isActive) cont.resume(PresentationResult.Shown)
}
override fun onFailed() {
if (cont.isActive) cont.resume(PresentationResult.Skipped)
}
}
)
}
}推送、升级、隐私弹窗各自实现 PresentationHandler,对编排器暴露统一的挂起边界。
四、生命周期与 Host 绑定
4.1 为何需要 bindHost
Handler 默认把消息发到已知 Looper 的线程;展示必须知道当前前台 Activity 是谁。在 Application 注册:
class App : Application() {
@Inject lateinit var orchestrator: PresentationOrchestrator
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
if (activity is FragmentActivity) {
orchestrator.bindHost(
PresentationHost.ActivityHost(activity)
)
}
}
override fun onActivityPaused(activity: Activity) {
if (activity is FragmentActivity) {
orchestrator.unbindHost(
PresentationHost.ActivityHost(activity)
)
}
}
// 其他空实现省略
})
}
}更稳妥的做法是只绑定 resumed 栈顶 Activity,并在 present 前校验 lifecycle.currentState.isAtLeast(STARTED)。
4.2 ProcessLifecycleOwner 与前后台
App 从后台回前台时,推送与 App Open 广告会同时触发。编排器应结合 ProcessLifecycleOwner:
ProcessLifecycleOwner.get().lifecycle.currentStateFlow()
.filter { it.isAtLeast(Lifecycle.State.STARTED) }
.collect { /* 恢复调度 */ }进入后台时:不销毁队列,但暂停 processLoop;回前台且 bindHost 就绪后继续——过期项在 pollValidRequest() 时丢弃。
4.3 与 ViewModel / UDF 协作
业务页面不应直接 Dialog.show() 抢队列,而是 enqueue:
// 推送 SDK 回调
fun onInAppMessageReceived(message: InAppMessage) {
orchestrator.enqueue(
PresentationRequest(
id = message.id,
type = PresentationType.IN_APP_MESSAGE,
priority = 30,
dedupeKey = "push_${message.campaignId}",
payload = PresentationPayload.PushMessage(message.campaignId, message.toContent()),
)
)
}ViewModel 只发 UiEvent;全屏类展示统一走 Orchestrator,Toast / Snackbar 等轻提示可旁路(不互斥)。
五、策略层:去重、过期、互斥
interface PresentationPolicy {
fun canEnqueue(request: PresentationRequest): Boolean
fun canProcess(request: PresentationRequest, host: PresentationHost): Boolean
}
class DefaultPresentationPolicy(
private val dataStore: PresentationDataStore,
) : PresentationPolicy {
override fun canEnqueue(request: PresentationRequest): Boolean {
request.expireAt?.let { if (Instant.now().isAfter(it)) return false }
// 今日已展示过的运营弹窗
if (request.type == PresentationType.OPS_DIALOG) {
if (dataStore.isShownToday(request.dedupeKey)) return false
}
return true
}
override fun canProcess(request: PresentationRequest, host: PresentationHost): Boolean {
val activity = (host as PresentationHost.ActivityHost).activity
// 登录页、支付页不打断
if (activity is BlocklistedForPresentation) return false
return true
}
}| 策略 | 作用 |
|---|---|
dedupeKey | 同活动推送只保留一条 |
expireAt | 超时未展示则丢弃 |
| 页面黑名单 | 支付 / 登录 / 横屏游戏不弹运营 |
| VIP 开关 | 过滤广告类 PresentationType |
广告频控 可保留在 AdPolicy 内,编排器在 AdPresentationHandler 调用前二次校验;也可上提到编排器统一治理。
六、冷启动编排:从理论到一条完整链路
Application 启动示例:
class App : Application() {
override fun onCreate() {
super.onCreate()
val orch = entryPoint.presentationOrchestrator()
orch.enqueue(
PresentationRequest(
id = "privacy_v1",
type = PresentationType.PRIVACY_CONSENT,
priority = 0,
dedupeKey = "privacy_consent",
payload = PresentationPayload.PrivacyConsent,
)
)
if (RemoteConfig.splashAdEnabled) {
orch.enqueue(
PresentationRequest(
id = "splash_cold",
type = PresentationType.SPLASH_AD,
priority = 20,
dedupeKey = "splash_cold_start",
expireAt = Instant.now().plusSeconds(30),
payload = PresentationPayload.AdSlot(AdSlots.SPLASH_COLD),
)
)
}
}
}SplashActivity 可瘦身为只 bindHost + 观察 orchestrator.state,队列清空后 startActivity(Main)——启动页不再写一堆 if-else。
七、现代技术栈落地清单
| 层次 | 推荐技术 | 说明 |
|---|---|---|
| 异步与串行 | Kotlin Coroutines | suspend present、单消费者 loop |
| 队列结构 | PriorityBlockingQueue 或 Channel | 前者简单;后者更易与 Flow 结合 |
| 状态观察 | StateFlow | UI / 调试面板观察 current 与 pendingCount |
| 依赖注入 | Hilt | SingletonComponent 提供 PresentationOrchestrator |
| 生命周期 | LifecycleOwner、ProcessLifecycleOwner | Host 校验、前后台暂停 |
| UI | View 体系 Dialog / Compose Dialog / ModalBottomSheet | Handler 只负责调度,UI 技术可混用 |
| 持久化 | DataStore | 记录「今日已展示」、去重 |
| 远程配置 | Firebase Remote Config / 自建 | 动态调整 priority、开关 |
| 推送 | FCM + 厂商通道;In-App 走 enqueue | 勿在 onMessageReceived 里直接 show |
| 测试 | runTest、StandardTestDispatcher、Fake Handler | 断言展示顺序与跳过逻辑 |
7.1 Hilt 模块骨架
@Module
@InstallIn(SingletonComponent::class)
object PresentationModule {
@Provides @Singleton
fun provideOrchestrator(
@ApplicationScope scope: CoroutineScope,
handlers: Set<@JvmSuppressWildcards PresentationHandler>,
policy: PresentationPolicy,
reporter: PresentationReporter,
): PresentationOrchestrator = DefaultPresentationOrchestrator(
scope = scope,
handlers = handlers.associateBy { it.type },
policy = policy,
reporter = reporter,
)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class PresentationHandlerModule {
@Binds @IntoSet abstract fun bindAd(handler: AdPresentationHandler): PresentationHandler
@Binds @IntoSet abstract fun bindPush(handler: PushPresentationHandler): PresentationHandler
@Binds @IntoSet abstract fun bindPrivacy(handler: PrivacyPresentationHandler): PresentationHandler
}7.2 Compose 侧消费(可选)
@Composable
fun PresentationOverlay(
orchestrator: PresentationOrchestrator = hiltViewModel<ShellViewModel>().orchestrator,
) {
val state by orchestrator.state.collectAsStateWithLifecycle()
val push = state.current?.takeIf { it.type == PresentationType.IN_APP_MESSAGE }
push?.let { req ->
val content = (req.payload as PresentationPayload.PushMessage).content
AlertDialog(
onDismissRequest = { /* 通知 Handler 完成 */ },
title = { Text(content.title) },
text = { Text(content.body) },
confirmButton = { TextButton(onClick = { }) { Text("去看看") } },
)
}
}全屏广告仍建议走 Activity / SDK 自带全屏;Compose 适合 In-App 消息、运营 Dialog。
八、测试:验证顺序比「能跑」更重要
@Test
fun `privacy before splash ad`() = runTest {
val fake = FakePresentationOrchestrator(testScope)
fake.enqueue(privacyRequest(priority = 0))
fake.enqueue(splashRequest(priority = 20))
fake.drainQueue()
assertEquals(
listOf(PresentationType.PRIVACY_CONSENT, PresentationType.SPLASH_AD),
fake.shownTypes,
)
}
@Test
fun `expired request is skipped`() = runTest {
val expired = splashRequest(expireAt = Instant.now().minusSeconds(1))
fake.enqueue(expired)
fake.drainQueue()
assertTrue(fake.shownTypes.isEmpty())
}Fake 实现里用 Channel 代替真 UI,PresentationHandler 注入 Immediate 完成器——这与测 Handler 时替换 Looper 的思路一致。
九、常见坑
- 在
enqueue里同步show——破坏串行,应只入队。 - 激励广告也走队列——用户点击「看广告领奖励」应旁路或最高优先级立即执行,否则体验极差。
- 忘记
dedupeKey——冷启动埋点触发两次推送,用户连看两遍。 - Handler 与 Orchestrator 双轨——旧代码
post { showAd() }与新队列并行,互斥失效;迁移期用StrictMode或 lint 禁止直接AdManager.show。 - 队列无限增长——设
pendingCount上限,超出则丢弃低优先级。 - 配置变更重复入队——
Application.onCreate只入队一次;旋转屏不要再次enqueue开屏。
十、总结
| Handler 世界 | 展示编排世界 |
|---|---|
Message | PresentationRequest |
MessageQueue | PriorityBlockingQueue / Channel |
Looper.loop() | processLoop() 协程 |
Handler.dispatchMessage | PresentationHandler.present |
Looper.getMainLooper() | Main + bindHost(Activity) |
处理完才 next() | suspend 直到 onDismiss |
从理论到实践的路径可以概括为:
- 认定问题——广告、推送、弹窗是互斥的串行任务,不是 scattered API 调用;
- 借用 Handler 契约——单消费者、优先级、完成后再取下一条;
- 用现代栈实现——协程挂起边界 + Hilt 多 Handler 注入 + Lifecycle 绑定 Host;
- 与广告模块解耦——AdManager 负责「怎么播」,Orchestrator 负责「何时播、和谁排队」;
- 可测试、可配置——Fake Handler、Remote Config、DataStore 去重。
当你的 App 矩阵里第二款产品也要接开屏 + 推送 + 活动时,复制 :lib-presentation-api 比复制一整份 MainActivity 里的 if-else 值钱得多——这正是 大中台 在前端编排层的落点之一。