Android 一次性事件怎么设计:Channel、SharedFlow 还是 UI State?

"点击保存后显示 Toast"与"支付成功后进入订单详情"看起来都是一次性事件,但它们的可靠性要求完全不同。Toast 在页面不可见时丢掉也许没关系;支付结果若只存于内存广播中,用户旋转屏幕或进程被回收后就可能看不到。API 选择应当从业务语义 出发,而不是先争论 Channel 与 SharedFlow 谁更高级。

本文以 ViewModel 到 UI 的通信为背景,先给出一张选择表,再分别解释热流、通道与状态确认模式的边界。示例假定使用 kotlinx.coroutines 和 AndroidX 生命周期组件,省略常规 import 与外围类定义。

一、先回答四个问题,再选 API

  1. **页面暂时没有订阅者时,允许丢失吗?**例如 App 切到后台。
  2. **新订阅者要收到过去的值吗?**例如旋转屏幕后的新 Fragment。
  3. **多个订阅者都要处理,还是只交给一个消费者?**广播与任务队列语义不同。
  4. **进程结束后还必须保留吗?**内存中的 Flow、SharedFlow、Channel 都不能单独满足持久性。
场景 允许无人订阅时丢失? 重建后要保留? 倾向方案
"保存成功" Toast 往往可以 通常不要重播 SharedFlow(replay = 0) 等临时效果流
页面导航请求 视业务而定 有时需要 明确消费协议;重要导航可建模为待处理状态
支付完成、订单创建 不可以 必须可恢复 Repository/数据库持久状态,UI 观察状态
一个后台命令交给一个处理者 取决于缓冲策略 通常不跨进程 Channel 的点对点语义

"一次性"只描述 UI 想处理几次,没说明事件何时生成、能否丢、由谁确认。因此没有一种通用的 SingleLiveEvent 替代品能覆盖所有情况。

二、SharedFlow:广播给正在收听的人

kotlin 复制代码
sealed interface UiEffect {
    data class Toast(val message: String) : UiEffect
}

private val _effects = MutableSharedFlow<UiEffect>(replay = 0)
val effects: SharedFlow<UiEffect> = _effects.asSharedFlow()

fun onSaved() {
    viewModelScope.launch {
        _effects.emit(UiEffect.Toast("保存成功"))
    }
}

默认 replay = 0 的 SharedFlow 会向当前订阅者广播;没有订阅者时发出的值不会等到页面回来再补发。这非常适合允许错过的轻提示。

有一个常见误会:MutableSharedFlow(replay = 0, extraBufferCapacity = 1) 不会 在没有订阅者时替你缓存一个事件。无人订阅时只会保存 replay 允许的历史值;tryEmit() 返回 true 也不能解释成"未来 UI 一定收到"。额外缓冲主要用于已有订阅者但消费速度暂时跟不上的情况。

若把 replay 改成 1,新页面会收到旧事件;对 Toast 或导航,这可能造成旋转后重复弹出或重复跳转。不要用"多缓存一个"修复丢失,除非业务真的要求重放,并且有清楚的消费规则。

三、Channel:交给一个接收者,不是广播

kotlin 复制代码
private val commands = Channel<UiCommand>(Channel.BUFFERED)
val commandFlow = commands.receiveAsFlow()

suspend fun sendCommand(command: UiCommand) {
    commands.send(command)
}

Channel 更接近队列。多个接收者同时从同一个 Channel 接收时,每个元素通常只交给其中一个,而不是广播给所有人;receiveAsFlow() 也保留这种竞争消费语义。容量决定 send 是否需要等待、能暂存多少元素,但它不是跨进程可靠消息队列。

在 UI 场景还要注意一个竞态:某个收集者从 Channel 取走事件后,在真正执行导航前因生命周期取消,事件可能已经不在队列里。Channel 可以表达"单消费者",却不能自动保证"UI 恰好执行一次"。如果一个结果必须可恢复,就需要持久状态或消费确认协议。

四、重要导航:用待处理状态与确认机制表达意图

当业务要求"页面重新创建后仍知道要去哪里",可以显式保存待处理导航:

kotlin 复制代码
data class PendingNavigation(val id: Long, val route: String)
data class UiState(val pendingNavigation: PendingNavigation? = null)

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
private val nextNavigationId = AtomicLong()

fun requestNavigation(route: String) {
    val request = PendingNavigation(nextNavigationId.incrementAndGet(), route)
    _uiState.update { current -> current.copy(pendingNavigation = request) }
}

fun navigationHandled(id: Long) {
    _uiState.update { current ->
        if (current.pendingNavigation?.id == id) {
            current.copy(pendingNavigation = null)
        } else {
            current
        }
    }
}

UI 看到 pendingNavigation 后执行导航,再以 id 确认消费。新订阅者能看到当前待处理状态,避免 SharedFlow(replay = 0) 在页面暂不可见时直接丢掉请求。id 防止旧确认误清除后来产生的新导航。

但这仍不是数学意义上的恰好一次 :如果导航已经发生、确认前进程崩溃,重建后可能再看到请求;如果先清状态再导航,则可能在两步之间丢失。对支付、订单、上传结果等重要事实,要把事实写入 Repository/数据库,让 UI 根据稳定业务状态决定下一步,并让操作具备幂等性。ViewModel 状态能跨常见配置变化,进程死亡后的恢复则需要 SavedStateHandle 或持久存储。

五、生命周期收集放在哪里?

Fragment 收集临时效果时,应绑定视图生命周期:

kotlin 复制代码
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is UiEffect.Toast -> showToast(effect.message)
            }
        }
    }
}

低于 STARTED 时收集块会被取消,再次进入时重新启动。对 replay = 0 的效果流,这段不可见期间的消息可能被丢弃;如果不接受这种行为,就不应把事件建模为可丢弃的临时效果。Compose 中也要让收集器有明确的生命周期和 Key,避免每次重组创建新的订阅者。

六、怎样测试"不重复、不丢失"?

别只断言"调用了 emit() 一次"。更有价值的测试场景是:

  • 无人订阅时发送:新订阅者是否应该收到?按预期验证丢弃或保留。
  • 同时有两个订阅者:是两者都收到,还是仅一个处理?分别验证 SharedFlow 与 Channel 语义。
  • 收集者短暂取消再回来:旋转、后台切换后是重播、继续待处理还是忽略?
  • 处理前后发生中断:重要业务状态能否恢复?重复执行是否安全?

这些测试比给每个事件换一种 Flow 类型更能暴露真实产品语义。

七、面试高频问答

Q1:StateFlow 能直接装一次性 Toast 吗?

技术上能赋值,但新订阅者会看到当前值,可能重复弹出。若需要消费确认,必须显式建模,不要假装它是"天然一次性"。

Q2:SharedFlow(replay = 0) 在没有订阅者时会缓存事件吗?

不会。后来的订阅者看不到那段时间的事件。

Q3:extraBufferCapacity = 1 能修复无人订阅时的丢失吗?

不能。没有订阅者时历史保留受 replay 决定,而不是额外缓冲容量。

Q4:Channel.receiveAsFlow() 是广播吗?

不是。多个收集者竞争接收,同一元素通常只到其中一个。

Q5:为什么 Channel 也可能丢掉已经发送的导航?

接收者可能取走后、执行 UI 操作前被取消;队列交付不等于 UI 已完成处理。

Q6:如何保证支付结果不会因旋转或进程死亡消失?

把支付结果当业务事实持久化,再由 UI 观察;不要只发一条内存事件。

Q7:能否只靠内存 Flow 保证恰好执行一次?

不能。需要定义确认、持久化与幂等策略,尤其要处理执行和确认之间的中断窗口。

结论:**轻提示可以广播,单消费者命令可以用 Channel,必须恢复的事实应成为状态。**先决定"允许丢吗、要重放吗、谁确认处理",再写 MutableSharedFlow 或 Channel,代码才不会跟业务目标打架。

参考资料与延伸阅读

相关推荐
三少爷的鞋43 分钟前
现代 Android 架构指南:不要再主动设计 Callback 接口
android
染指11102 小时前
158.Agent-LangGraph-子图(共享状态和不共享状态模式)
开发语言·人工智能·python·ui·agent·agents
TimeFine7 小时前
AI 编码工作流的设计
android·ai编程
骑着蜗牛撵大象3277 小时前
C++20 协程 × 屏障:手写一个 co_await 版 Barrier,让等待不再钉死线程
android·c++20·协程·并发编程·co_await·屏障同步
山东孙燕姿9 小时前
设备台账重构:从一张表到三层模型
android·java·网络·低代码·重构·rxjava
蒸鱼Yuzheng10 小时前
移动端 CPU 调度分析:线程亲和性、大小核迁移与卡顿
android·perfetto·性能分析·线程优化·cpu调度
booboosui10 小时前
安卓唤醒词:12 维 MFCC 加 DTW,阈值 1.8
android
传奇开心果10 小时前
Compose Multiplatform 共享边界实战:架构契约、互操作同步与可回退设计
android·kotlin
事圆则缓10 小时前
从开机到首帧:Android 系统与应用启动流程源码分析
android