返回 Android
Android
17 分钟阅读

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 同意必须优先于广告
版本管理强制 / 可选升级强制升级应阻断一切

这些内容的共同特征:

  1. 互斥——同一时刻用户只能处理一个「全屏 / 模态」任务;
  2. 有优先级——合规 > 强制升级 > 开屏 > 运营 > 普通插屏;
  3. 有生命周期——必须在合法 Activity / LifecycleOwner 上展示;
  4. 可取消 / 可过期——页面销毁或超时后,队列项应丢弃而非稍后乱弹。

1.2 与广告模块的关系

  • AdManager 不再直接 show(),而是向编排器提交 PresentationRequest
  • 编排器决定「此刻能不能播、播哪一条、播完再播下一条」;
  • 各 Renderer 仍由广告模块实现,编排器只负责调度时序

二、理论层:Handler 思想到底在教什么

2.1 主线程为什么能「排队做事」

Handler 四件套的核心契约(详见 Handler 机制全解):

多个 Handler(生产者) ↓ enqueueMessage MessageQueue(按 when 排序的链表) ↓ next() 阻塞取队头 Looper.loop()(单线程 for(;;)) ↓ dispatchMessage 一次只执行一条消息

对展示编排而言,可抽取四条与具体 API 无关的原则:

Handler 原则展示编排中的对应
单消费者全局同时只展示一个 Presentation
FIFO + 优先级MessageQueuewhen 排序;我们用 priority + enqueueTime
消息处理完才取下一条当前项 onDismiss / CompletableDeferred 完成后 processNext()
绑定线程UI 展示在 Main;排队逻辑可在单线程协程上下文

主线程 Looper 保证:不会两个 Runnable 并行改 UI。展示队列要复制的正是这一点——不要两个全屏 Modal 并行 show

2.2 队列 vs 栈:名字别搞混

口语里的「管理栈」常指两层含义:

  1. 串行队列(推荐)——冷启动时隐私 → 开屏 → 推送,先进先出 + 优先级插队
  2. 嵌套栈——用户在一个 Dialog 里又触发子 Dialog,后压栈先关闭(LIFO)。

本文默认实现 全局串行队列;嵌套 Dialog 交给 FragmentManager / Compose 自己的 back stack。若业务确有「母弹窗内子弹窗」,在单个 PresentationRequest 内部处理,不拆成两条队列项。

2.3 为什么新代码不直接用 Handler

Handler现代替代
postDelayeddelay + 协程
Message.whatsealed 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开屏广告
30In-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 Coroutinessuspend present、单消费者 loop
队列结构PriorityBlockingQueueChannel前者简单;后者更易与 Flow 结合
状态观察StateFlowUI / 调试面板观察 currentpendingCount
依赖注入HiltSingletonComponent 提供 PresentationOrchestrator
生命周期LifecycleOwnerProcessLifecycleOwnerHost 校验、前后台暂停
UIView 体系 Dialog / Compose Dialog / ModalBottomSheetHandler 只负责调度,UI 技术可混用
持久化DataStore记录「今日已展示」、去重
远程配置Firebase Remote Config / 自建动态调整 priority、开关
推送FCM + 厂商通道;In-App 走 enqueue勿在 onMessageReceived 里直接 show
测试runTestStandardTestDispatcher、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 的思路一致。


九、常见坑

  1. enqueue 里同步 show——破坏串行,应只入队。
  2. 激励广告也走队列——用户点击「看广告领奖励」应旁路或最高优先级立即执行,否则体验极差。
  3. 忘记 dedupeKey——冷启动埋点触发两次推送,用户连看两遍。
  4. Handler 与 Orchestrator 双轨——旧代码 post { showAd() } 与新队列并行,互斥失效;迁移期用 StrictMode 或 lint 禁止直接 AdManager.show
  5. 队列无限增长——设 pendingCount 上限,超出则丢弃低优先级。
  6. 配置变更重复入队——Application.onCreate 只入队一次;旋转屏不要再次 enqueue 开屏。

十、总结

Handler 世界展示编排世界
MessagePresentationRequest
MessageQueuePriorityBlockingQueue / Channel
Looper.loop()processLoop() 协程
Handler.dispatchMessagePresentationHandler.present
Looper.getMainLooper()Main + bindHost(Activity)
处理完才 next()suspend 直到 onDismiss

从理论到实践的路径可以概括为:

  1. 认定问题——广告、推送、弹窗是互斥的串行任务,不是 scattered API 调用;
  2. 借用 Handler 契约——单消费者、优先级、完成后再取下一条;
  3. 用现代栈实现——协程挂起边界 + Hilt 多 Handler 注入 + Lifecycle 绑定 Host;
  4. 与广告模块解耦——AdManager 负责「怎么播」,Orchestrator 负责「何时播、和谁排队」;
  5. 可测试、可配置——Fake Handler、Remote Config、DataStore 去重。

当你的 App 矩阵里第二款产品也要接开屏 + 推送 + 活动时,复制 :lib-presentation-api 比复制一整份 MainActivity 里的 if-else 值钱得多——这正是 大中台 在前端编排层的落点之一。

相关文章

开发模式
16 分钟
Android 广告模块架构:统一管理开屏、插屏、激励与全屏广告
从多 SDK 聚合、Activity/Fragment 生命周期到预加载与频控,拆解一套可复用的 Android 广告中台模块设计
Android
17 分钟
Android Handler 机制全解:从源码看懂消息循环
从 Handler、Looper、MessageQueue、Message 四件套出发,结合 ActivityThread 启动链路与 nativePollOnce 底层实现,完整讲清 Android 主线程消息循环的设计与源码细节。
Android
17 分钟
当我们在谈MVP、MVVM、MVI的时候,我们到底在谈什么?
Presentation Layer 架构模式的演进脉络、职责边界与数据流向——从 MVC 到 MVI 的选型与实践
Android
26 分钟
Android 架构核心原则:单一数据源(SSOT)与单向数据流(UDF)实战指南
在Android开发中,随着应用复杂度提升,数据混乱、状态不一致、调试困难等问题频发,而单一数据源(Single Source of Truth, SSOT)与单向数据流(Unidirectional Data Flow, UDF)正是解决这些痛点的核心架构原则。
Java / Kotlin
14 分钟
Kotlin 协程原理详解(挂起 / 恢复 / 取消 / 调度 / 异常处理 全面梳理)
本文由ChatGPT生成