一次性事件为什么不该放进 State
「保存成功了,弹个 Snackbar」——这件事听起来简单到不该占一整章。但它是 Android 社区吵了好几年、Google 自己也改过口的问题,而且它的每一种错法都对应着前面某一章讲过的机制。
问题的形状
有些东西天生「只该发生一次」:
- 弹一个 Snackbar/Toast
- 导航到另一个页面
- 播放一段音效、震动一下
- 关闭当前对话框
而它们要满足两个条件,两个都不能少:
① 不重复:不管界面重建几次,用户只该看到一次 ② 不丢失:不管界面在哪个瞬间重建,事件都得送到
把这两条当成两道关卡,跑一遍三种常见做法:
做法 正常 旋转屏幕的瞬间 两关都过 StateFlow<String?>(放进 UI State) 弹 1 次 弹 2 次 ✗ MutableSharedFlow<Event>(replay = 0) 弹 1 次 一次都没弹 ✗ Channel<Event>(BUFFERED).receiveAsFlow() 弹 1 次 弹 1 次 ✓
三种失败,三个已经学过的原因
① 放进 State:会重复
data class UiState(val toast: String? = null)
// UI 侧
LaunchedEffect(state.toast) {
state.toast?.let { snackbarHostState.showSnackbar(it) }
}
问题出在第 5 章那个定义:State 表达的是「当前是什么样」,它天生要被反复读取。
旋转屏幕之后,UI 重建,重新读一遍 uiState——那个 toast 字段还是非空的,于是又弹一次。
标准补丁是加一个「已消费」的回调:
LaunchedEffect(state.toast) {
state.toast?.let {
snackbarHostState.showSnackbar(it)
vm.onToastShown() // ← 告诉 ViewModel 清掉它
}
}
这能用,代价是每个事件都要多一个「我看过了」的回调。事件一多,ViewModel 里就全是 onXxxShown()、onXxxHandled()。
② SharedFlow(replay = 0):会丢
第 16 章那台 demo 已经量过:没有订阅者的那一刻 emit,值直接消失。
而旋转屏幕正好会制造出一个「零订阅者」的窗口(大约 300 毫秒)。如果事件正好落在那个窗口里,用户什么也看不到。
加 replay = 1 能救「丢」,但立刻带来「重」——新的订阅者一进来就补收到,于是又弹一次。这两个方向是矛盾的,靠调 replay 解决不了。
③ Channel:两关都过
class MyViewModel : ViewModel() {
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun save() {
viewModelScope.launch {
repo.save()
_events.send(UiEvent.ShowSnackbar("已保存"))
}
}
}
// UI 侧
LaunchedEffect(Unit) {
vm.events.collect { event -> handle(event) }
}
为什么它行?因为 Channel 的语义和前两个都不一样:
Channel 是一个队列,不是一个「当前值」,也不是一个广播。 没有接收者 → 值待在缓冲里等着 (不丢) 一个值被接收 → 它就从队列里消失了 (不重)
这两条正好对上那两道关卡。「取走即消失」是 Channel 独有的性质,StateFlow(一直保留)和 SharedFlow(广播给所有人)都没有。
① 只能有一个消费者。receiveAsFlow() 是「多个收集者会瓜分这些值」,不是每人一份。所以这个 Flow 只能在一个地方 collect——两个地方 collect,事件会被随机分走。
② 收集必须是生命周期感知的。如果用 LaunchedEffect(Unit) { collect { } },App 在后台时它还在收——你会在用户看不见的时候「导航」到了别的页面。正确写法:
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.events.collect { handle(it) }
}
}
注意这时候 Channel 的「不丢」性质变得更重要了:后台期间产生的事件会在缓冲里等着,回到前台一起送达。
但官方现在推荐的是另一条路
上面那套 Channel 方案很好用,你的项目里大概率也在用。但 Google 的架构指南在 2023 年之后改了口,现在的建议是:
尽量把「事件」建模成「状态」。
理由不是 Channel 不好用,是大多数所谓的一次性事件,仔细想想都不是一次性的。
例子一:Snackbar
「弹一个 Snackbar」 ← 这是一个动作 「现在有一条待显示的消息」 ← 这是一个状态
如果用户旋转屏幕的时候 Snackbar 正显示着,正确行为其实是「继续显示」,不是「消失」也不是「重弹一次」。而这恰恰就是「状态」的语义。
data class UiState(
val messages: List<UserMessage> = emptyList() // 一个待办消息队列
)
// UI 侧
val message = state.messages.firstOrNull()
if (message != null) {
LaunchedEffect(message.id) {
snackbarHostState.showSnackbar(message.text)
vm.messageShown(message.id) // 显示完了,从队列里去掉
}
}
看起来还是有个「已消费」回调,但意义变了:它不是「清一个标记位」,而是「这条消息已经处理完,从待办队列里移除」——一个有明确业务含义的操作。
例子二:导航
// ❌ 事件式:「导航到详情页」 _events.send(NavigateToDetail(id)) // ✅ 状态式:「当前应该展示哪个页面」 data class UiState(val openedDetailId: String? = null)
状态式的好处在旋转屏幕、进程死亡恢复的时候很明显:用户回来时还在详情页,因为那是状态的一部分。事件式做不到——事件已经消费掉了。
碰到一个「一次性事件」,按这个顺序问:
- 它能不能改写成状态?——「有一条待显示的消息」「当前打开的是哪个页面」「有一个待确认的对话框」。能就改写,这是最稳的。
- 不能的话,它必须在 UI 层处理吗?——播音效、震动这类,其实可以直接在 ViewModel 里调(不需要 UI 参与)。
- 确实是「必须由 UI 做、而且只能做一次」的——用
Channel+receiveAsFlow()+repeatOnLifecycle。
第三步是兜底,不是首选。你项目里能走到第三步的事件,通常不超过两三个。
那 SingleLiveEvent / Event 包装类呢
如果你的项目历史比较长,可能见过这些:
// LiveData 时代的老办法
class Event<T>(private val content: T) {
private var handled = false
fun getContentIfNotHandled(): T? = if (handled) null else { handled = true; content }
}
它们解决的是同一个问题(不重复),思路是「给值加一个已消费标记」。它们能用,但都有同一个毛病:只解决了「不重复」,没解决「不丢失」。
而且它们把「已消费」这个状态藏进了数据里,导致这个值不再是纯数据——测试、比较、序列化都会变得奇怪。
新代码里别用了。要么改写成状态,要么用 Channel。
在项目里找出所有「一次性事件」,逐个过一遍:
1. 搜 SharedFlow / Channel / SingleLiveEvent / Event<
2. 对每一个问:
能改写成状态吗?(「有一条待显示的消息」而不是「弹消息」)
能 → 改写
不能 → 确认它是 Channel + repeatOnLifecycle 收的
3. 然后做两个测试(这两个测试能找出几乎所有问题):
测试 A:触发事件的同时旋转屏幕 —— 会重复吗?会丢吗?
测试 B:触发事件之后立刻按 Home,几秒后回来 —— 事件还在吗?
(Channel:在。SharedFlow(replay=0):没了)
土办法但很有效:给「保存」按钮加 500ms 延迟,
然后点完立刻旋转屏幕。
这一章的一句话
一次性事件要同时满足「不重复」和「不丢失」。State 会重复,SharedFlow(replay=0) 会丢,只有 Channel 两关都过。但更好的答案通常是上一层:把事件改写成一个终究会被消费掉的状态。
下一章:回到性能。一个 100 项的列表,在头部插入一项——重跑 103 个节点,还是 3 个?差别只在一个 key。