卷 VI · 落地CH 21深度 21/24

一次性事件为什么不该放进 State

「保存成功了,弹个 Snackbar」——这件事听起来简单到不该占一整章。但它是 Android 社区吵了好几年、Google 自己也改过口的问题,而且它的每一种错法都对应着前面某一章讲过的机制。

一次性事件Channel事件建模成状态旋转屏幕

问题的形状

有些东西天生「只该发生一次」:

  • 弹一个 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(广播给所有人)都没有。

⚠ Channel 方案的两个前提

① 只能有一个消费者。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)

状态式的好处在旋转屏幕、进程死亡恢复的时候很明显:用户回来时还在详情页,因为那是状态的一部分。事件式做不到——事件已经消费掉了。

◆ 一个判断顺序

碰到一个「一次性事件」,按这个顺序问:

  1. 它能不能改写成状态?——「有一条待显示的消息」「当前打开的是哪个页面」「有一个待确认的对话框」。能就改写,这是最稳的。
  2. 不能的话,它必须在 UI 层处理吗?——播音效、震动这类,其实可以直接在 ViewModel 里调(不需要 UI 参与)。
  3. 确实是「必须由 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。

⌗ 到你手上

这一章的一句话

一次性事件要同时满足「不重复」和「不丢失」。State 会重复,SharedFlow(replay=0) 会丢,只有 Channel 两关都过。但更好的答案通常是上一层:把事件改写成一个终究会被消费掉的状态。

下一章:回到性能。一个 100 项的列表,在头部插入一项——重跑 103 个节点,还是 3 个?差别只在一个 key