"点击保存后显示 Toast"与"支付成功后进入订单详情"看起来都是一次性事件,但它们的可靠性要求完全不同。Toast 在页面不可见时丢掉也许没关系;支付结果若只存于内存广播中,用户旋转屏幕或进程被回收后就可能看不到。API 选择应当从业务语义 出发,而不是先争论 Channel 与 SharedFlow 谁更高级。
本文以 ViewModel 到 UI 的通信为背景,先给出一张选择表,再分别解释热流、通道与状态确认模式的边界。示例假定使用 kotlinx.coroutines 和 AndroidX 生命周期组件,省略常规 import 与外围类定义。
一、先回答四个问题,再选 API
- **页面暂时没有订阅者时,允许丢失吗?**例如 App 切到后台。
- **新订阅者要收到过去的值吗?**例如旋转屏幕后的新 Fragment。
- **多个订阅者都要处理,还是只交给一个消费者?**广播与任务队列语义不同。
- **进程结束后还必须保留吗?**内存中的 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,代码才不会跟业务目标打架。