StateFlow 与 Android:那个 5000 到底是什么
这一行代码你抄过一百遍:stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)。四个参数,你大概只说得清第一个和第三个。那个 5000 是在等屏幕旋转 —— 这一章会把它讲透,顺便处理协程在 Android 上最后几个真实坑:StateFlow 用 equals 去重带来的静默失效(它和第 13 章的不可变直接咬合)、为什么 collectAsState 会在后台白白烧电、以及一次性事件到底该用 SharedFlow 还是 Channel。
状态 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 上的标准答案 |
它在等屏幕旋转。
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 秒超时后重试」的逻辑,测试毫秒级跑完。
| LiveData | StateFlow | |
|---|---|---|
| 生命周期感知 | 内建(这是它当年的杀手锏) | 需要 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 里有没有「事件性」的字段(toastMsg、navigateTo、showDialog)。它们会在旋转屏幕后重放。改用 Channel,或者想清楚它是不是其实该是个状态。
5. 给一个 ViewModel 补一个 Turbine 测试。如果这是你的第一个 Flow 测试 —— 恭喜,你刚刚拿到了重构 ViewModel 的安全网。
浇铸炉结束。协程的六章走完,你现在有了一个完整的心智模型:状态机 → Job 树 → 调度器 → 异常路径 → Flow → Android 落地。下一炉「淬火」处理错误建模、函数式与性能 —— 从一个 Kotlin 一直没答好的问题开始:这个函数会怎么失败?