炉 IV · 浇铸CH 19进度 19/26

StateFlow 与 Android:那个 5000 到底是什么

这一行代码你抄过一百遍:stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)。四个参数,你大概只说得清第一个和第三个。那个 5000 是在等屏幕旋转 —— 这一章会把它讲透,顺便处理协程在 Android 上最后几个真实坑:StateFlowequals 去重带来的静默失效(它和第 13 章的不可变直接咬合)、为什么 collectAsState 会在后台白白烧电、以及一次性事件到底该用 SharedFlow 还是 Channel

StateFlowWhileSubscribedcollectAsStateWithLifecycleTurbine

状态 vs 事件:先分清这个

StateFlow(状态)SharedFlow(事件)
语义「现在是什么」「发生了什么」
是否总有一个当前值✓ 必须有初始值✗ 可以没有
新订阅者会收到当前值(立刻)取决于 replay(默认 0:什么都收不到)
发相同的值不发射(内建去重,用 equals照发
屏幕旋转后应该恢复(UI 要重建)不该重放(谁想旋转一次屏幕看两次 Toast?)
典型用途UiState、表单内容、开关导航、Toast、SnackBar、震动

把事件塞进 State 是 Android 上最经典的架构错误data class UiState(val toastMsg: String?) —— 然后你会发现旋转屏幕后 Toast 又弹了一次,于是你加一个 toastShown: Boolean 标志位,然后又要在消费后把它清掉…… 你已经在手工实现「事件消费」了,而且做得很糟。

那个 5000

val uiState: StateFlow<UiState> = repo.userFlow
    .map { it.toUiState() }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),   // ← 这里
        initialValue = UiState.Loading
    )

stateIn 做的事:把一个冷流(第 18 章:每个收集者都会触发一次数据库查询)变成一个热的、共享的、带当前值的 StateFlow(一份数据,多个收集者共享)。

SharingStarted 决定上游什么时候启动和停止

策略行为代价
Eagerly立刻启动,永不停止没人看的时候也在跑 —— 数据库监听、网络轮询照常烧电
Lazily第一个订阅者来了启动,之后永不停止同上(只是晚一点开始)
WhileSubscribed(5000)有订阅者才跑;最后一个走了,再等 5 秒才停无。这是 Android 上的标准答案
◆ 锭 · 5000 毫秒在等什么

它在等屏幕旋转。

Android 的配置变更(旋转、深色模式切换、字体大小变化)会销毁并重建 Activity。这一瞬间,UI 层先取消所有订阅,然后立刻重新订阅

如果 WhileSubscribed() 的停止是立即的:上游会被停掉 → 数据库监听关闭、网络请求取消 → 然后 0.2 秒后新 Activity 起来重新订阅 → 全部重新来一遍。用户看到一次不必要的加载闪烁,你浪费一次请求。

5 秒的宽限期让上游「假装没停过」:订阅者在 5 秒内回来 → 无缝续上;用户真的离开了页面 → 5 秒后干净地停掉。

5000 不是魔法数字,是「配置变更的重建时间 < 5 秒」这个工程判断。(还有个可选的第二参数 replayExpiration,控制多久后连缓存的值也丢掉 —— 默认永不过期,通常不用动。)

坑:StateFlow 的去重靠 equals

StateFlow 内建去重:新值和旧值 equals 相等,就不发射。这通常是好事(防止 UI 无谓重绘),但它有两个咬人的后果:

// 坑 A:可变对象 —— 内容变了,但引用没变,equals 也没变 → UI 永不更新
val state = MutableStateFlow(mutableListOf<Item>())
state.value.add(item)          // ✗ 改的是同一个对象,StateFlow 完全没察觉
state.value = state.value      // ✗ 赋值给自己 —— equals 相等,还是不发射

// ✓ 正确:永远产出新的、不可变的值
state.update { it + item }     // 或 state.value = state.value.copy(...)

这就是第 13 章那条「状态类必须不可变」的又一个理由StateFlow 的整个更新机制建立在「新值 ≠ 旧值」之上,而可变对象让这个判断永远失灵。一个 MutableList 字段,足以让你的 UI 停在旧数据上,而且没有任何报错。

// 坑 B:data class 的 equals 只看主构造(第 2 章)
data class UiState(val items: List<Item>) {
    val timestamp = System.currentTimeMillis()   // 类体里 → 不参与 equals
}
// 你以为每次更新都是新状态,其实 items 没变时 StateFlow 一次都不发

顺带一提:update { } 而不是 value = 。前者是原子的 CAS 循环,后者在并发更新时会丢更新(两个协程同时读到旧值、各自算出新值、后写的覆盖先写的)。

UI 侧:必须用带 lifecycle 的收集

// ✗ Compose:应用进后台了,它还在收集 —— 白白烧电、白白重组
val state by viewModel.uiState.collectAsState()

// ✓ Compose:进后台自动停止收集,回前台自动恢复
val state by viewModel.uiState.collectAsStateWithLifecycle()

// ✓ View 体系里的等价物
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {   // STARTED 以下就取消,回来重启
        viewModel.uiState.collect { render(it) }
    }
}

为什么重要?collectAsState 只在 Composable 离开组合树时才停止 —— 而应用进后台时,Composable 还在树上。于是你的 Flow 继续收集、上游继续查询数据库、继续轮询网络 —— 在一个用户根本看不见的界面上

注意这两者是配套的collectAsStateWithLifecycle(UI 停止订阅)+ WhileSubscribed(5000)(上游随之停止)—— 前者切断订阅,后者才知道该停。只用一半,另一半就白搭。

一次性事件:Channel 还是 SharedFlow

这是个至今仍有争论的话题,给你一个可执行的判断:

方案行为判决
MutableSharedFlow(replay = 0)没有订阅者时,事件直接丢失⚠ 如果事件在「UI 正好没在订阅」的瞬间发出(比如刚进后台),它就永远消失
Channel(Channel.BUFFERED).receiveAsFlow()没有订阅者时缓存;每个事件只会被消费一次✓ 更适合一次性事件(导航、Toast)。它的语义天然是「恰好一次」
把事件塞进 StateFlow会被重放✗ 别这么干(除非你手工做「已消费」标记,而那很容易出错)

还有个更彻底的思路,值得一提:很多「事件」其实是伪装的状态。「显示错误 Toast」→ 其实是「当前状态是 Error」;「导航到详情页」→ 其实是「当前导航目标是 X」。能建模成状态的,就别做成事件 —— 状态是可重放、可恢复、可测试的,而事件不是。

测试:Turbine

测 Flow 的痛点:它是异步的、可能无限的,用 toList() 会永远挂住。Turbine 是事实标准:

@Test fun `加载成功后状态变为 Success`() = runTest {
    viewModel.uiState.test {                      // Turbine
        assertEquals(Loading, awaitItem())        // 初始值
        viewModel.load()
        assertEquals(Success(items), awaitItem()) // 下一个发射
        cancelAndIgnoreRemainingEvents()
    }
}

配合第 16 章的 runTest(自动跳过 delay),一个「等 5 秒超时后重试」的逻辑,测试毫秒级跑完。

▸ 旧模具 · StateFlow 和 LiveData 的取舍
LiveDataStateFlow
生命周期感知内建(这是它当年的杀手锏)需要 collectAsStateWithLifecycle / repeatOnLifecycle
初始值可以没有(value 可空)必须有 —— 更安全
操作符只有 map / switchMap,弱Flow 的全套操作符
平台依赖绑定 Android(domain 层用它就脏了)纯 Kotlin,KMP 可用
线程只能在主线程 setValue任意线程

今天的默认选择是 StateFlow(Google 自己的指南也已经转向它)。LiveData 唯一的优势 —— 自动的生命周期感知 —— 已经被 collectAsStateWithLifecycle 补齐了。但存量项目不必为此专门重构:LiveData 没有坏,它只是不再是新代码的首选。

✚ 动手锻打

1. collectAsState()(不带 WithLifecycle 的)。每一处都在后台白白烧电。全部换掉 —— 这可能是本书里投入产出比最高的一次全局替换。

2. 检查你的 stateIn:用的是 Eagerly 还是 WhileSubscribed(5000)?如果是前者,你的上游在用户看不见的时候还在跑。

3. _state.value = ,改成 _state.update { }。前者在并发更新时会丢更新,而这类 bug 只在压力下偶发。

4. 检查你的 UiState 里有没有「事件性」的字段(toastMsgnavigateToshowDialog)。它们会在旋转屏幕后重放。改用 Channel,或者想清楚它是不是其实该是个状态。

5. 给一个 ViewModel 补一个 Turbine 测试。如果这是你的第一个 Flow 测试 —— 恭喜,你刚刚拿到了重构 ViewModel 的安全网。

浇铸炉结束。协程的六章走完,你现在有了一个完整的心智模型:状态机 → Job 树 → 调度器 → 异常路径 → Flow → Android 落地。下一炉「淬火」处理错误建模、函数式与性能 —— 从一个 Kotlin 一直没答好的问题开始:这个函数会怎么失败?