冷流是一段配方,不是一条河
「Flow 是数据流」这个说法害人不浅。它会让你以为那些值正在某处流动着,你只是接了个水管。真相是:在你 collect 之前,什么都没有发生;而你 collect 两次,整件事就从头做两遍。
一个能省很多事的定义
Flow 是一个对象,上面只有一个真正重要的方法:
suspend fun collect(collector: FlowCollector<T>)
在你调用它之前,零行代码被执行过。
每调用一次,整段生产逻辑就从头跑一遍。
所以 flow { } 里那段代码,它的地位和 fun makeCoffee() { } 的函数体是一样的——写下来不等于执行。
那句「不是一条河,是一张菜谱」不是修辞。菜谱的性质它全有:
- 写在纸上不会自己变成菜
- 两个人照着做,就做两份,各花各的材料
- 做到一半可以停(取消)
- 菜谱本身不占厨房
看它跑两遍
默认那个模式:一个 flow { } 里发三次网络请求,两个屏幕各 collect 一次。
冷流 flow { }:
配方的 body 跑了 2 遍 ← 网络请求发了两轮
屏幕 A 收到 3 个
屏幕 B 收到 3 个
热流 stateIn(...):
配方的 body 跑了 1 遍 ← 一轮请求,两个人分
屏幕 A 收到 3 个
屏幕 B 收到 3 个
这就是那个非常常见的线上问题:「为什么我这个接口被调了两次」。答案往往是有两个 composable 各自 collect 了同一个 Repository 的 Flow。
「我 collect 两次,上游的活会不会干两遍?」
- 会 → 冷流。
flow { }、flowOf()、Room 的Flow查询、Retrofit、callbackFlow、以及所有加了操作符之后的结果 - 不会 → 热流。
StateFlow、SharedFlow、stateIn/shareIn之后的东西
操作符不改变冷热:coldFlow.map { } 还是冷的,hotFlow.map { } 会变成冷的(因为 map 返回的是一个新的冷流包装,每次 collect 都会去订阅上游)。
冷是优点,不是缺陷
先别急着把所有东西都 stateIn。冷带来三个很实在的好处:
- 没人要就不干活。没有 collector,就没有网络请求、没有数据库查询、没有传感器监听。这是最省电的默认值。
- 生命周期天然对齐。collector 的协程被取消,生产端自动停——因为它就跑在 collector 的协程里。你不需要写任何「记得取消订阅」的代码。
- 每个订阅者互不干扰。A 慢不影响 B,A 取消不影响 B。热流做不到这一点。
这三条合起来,就是「冷」为什么是 Flow 的默认设计。
什么时候该变热
只有一个理由:多个订阅者要共享同一份结果,而重新生产是有代价的。
class TodoViewModel(repo: TodoRepository) : ViewModel() {
// ❌ 冷的:每个 collect 都会重新查一次数据库
val todos: Flow<List<Todo>> = repo.observeTodos()
// ✅ 热的:一份数据,所有订阅者共享
val todos: StateFlow<List<Todo>> = repo.observeTodos()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
}
stateIn 做的事:在这个 scope 里起一个协程去 collect 上游,把收到的值转发给所有订阅者。上游只被 collect 一次。
那个 5000 到底是什么
SharingStarted 有三个选项,差别全在「什么时候启动上游、什么时候停」:
| 什么时候启动上游 | 什么时候停 | 问题 | |
|---|---|---|---|
Eagerly | 立刻,不管有没有人订阅 | scope 结束才停 | 没人看的时候也在耗电 |
Lazily | 第一个订阅者来的时候 | 永远不停(直到 scope 结束) | App 退到后台,它还在跑 |
WhileSubscribed(t) | 第一个订阅者来的时候 | 订阅者归零超过 t 毫秒之后 | t 选错了各有各的问题 |
WhileSubscribed(5000) 里的 5000 就是那个 t:「订阅者掉到 0 之后,再等 5 秒;这期间要是有人回来了,就当无事发生。」
为什么需要这个宽限期?因为 Android 上有一件事会让订阅者短暂归零然后马上回来:旋转屏幕(以及任何配置变更)。
旋转屏幕的时序:
t=0 旧 Activity 销毁 → collector 取消 → 订阅者 0 个
t≈300 新 Activity 就位 → 新 collector → 订阅者 1 个
WhileSubscribed(5000):300 < 5000 → 上游从没停过,数据还在
WhileSubscribed(0) :立刻停 → 立刻重启 → 网络请求重发一次
而 5 秒又足够短,用户按 Home 键真的走了,五秒后上游就停了——不会一直在后台耗电。
所以 5000 不是什么魔法数字,它是「配置变更的耗时」和「用户真的离开」之间的一个分界线。你完全可以用 3000 或者 10000,只要理由说得通。
如果用户离开超过了 t,上游被取消。等他回来,stateIn 会重新 collect 一遍冷流——网络请求重发、数据库重查。
好在 stateIn 有 initialValue,而且它会保留上一次的值,所以用户看到的是旧数据先显示,新数据到了再刷新,不是白屏。这通常正是你要的。
第 19 章会把这条时间线完整量一遍:切后台多久会触发重启、重启一次的代价是多少。
stateIn 和 shareIn 怎么选
stateIn | shareIn | |
|---|---|---|
| 产出 | StateFlow<T> | SharedFlow<T> |
| 要不要初始值 | 要(initialValue) | 不要 |
能不能读 .value | 能,永远有值 | 不能 |
| 相同值会不会重复发 | 不会(自带去重) | 会 |
| 适合 | 状态:当前是什么样子 | 事件:发生了什么 |
一句话:「当前值」用 stateIn,「一串事件」用 shareIn。UI 状态几乎总是前者。
你可能试过这么写,然后收到一个运行时异常:
e: Flow invariant is violated:
Flow was collected in [CoroutineId(1), Dispatchers.Main]
but emission happened in [CoroutineId(2), Dispatchers.IO]
// ❌ 不允许
flow {
withContext(Dispatchers.IO) { emit(loadFromDisk()) }
}
这条规矩叫上下文保持(context preservation):emit 必须发生在 collect 所在的那个协程上下文里。
为什么?因为它保证了「谁 collect,谁就控制这条流的生命周期」——如果生产者能自己换线程,取消和异常传播就都乱了。
要换线程只有一个正规入口:flowOn(Dispatchers.IO)。下一章会讲它到底做了什么,以及为什么它只影响「它上面那一段」。
顺手澄清:Room 和 Retrofit 的 Flow 冷不冷
- Room 返回的
Flow<List<T>>:冷的。每次 collect 都会跑一次查询,并注册一个表变更观察者。所以两个地方各 collect 一次 = 两个观察者 + 两次查询。 - DataStore 的
data: Flow<Preferences>:冷的。每次 collect 都会读一次文件。 - Retrofit 的
suspend fun:那根本不是 Flow,是一次性的挂起调用。你把它包进flow { emit(api.get()) }之后,它就是冷的——collect 两次就请求两次。
所以「在 ViewModel 里 stateIn 一下」几乎是所有数据源的标准姿势。Repository 暴露冷流,ViewModel 负责变热。这条分工很干净:Repository 不需要知道有几个人在看,ViewModel 知道。
在项目里做一次检查:
1. 打开你的 ViewModel,找出所有 public 的 Flow 属性
类型是 Flow 的 → 冷的,UI 那边 collect 几次就跑几次
类型是 StateFlow 的 → 热的,OK
2. 每一个冷的都问:UI 里有几个地方 collect 它?
只有一个 → 可以不动
两个以上 → 加 .stateIn(viewModelScope, WhileSubscribed(5_000), 初始值)
3. 已经在用 stateIn 的,检查第二个参数:
Eagerly / Lazily → 想清楚为什么,多数情况该换成 WhileSubscribed
WhileSubscribed(0) → 旋转屏幕会重发请求,改成 5_000
验证方法很土但很有效:在 Repository 的那次请求里打一行日志,然后进那个页面,看它打了几次。
这一章的一句话
冷流是配方不是河:collect 之前什么都没发生,collect 两次就整件事做两遍。想让多个订阅者共享一份结果,就用 stateIn(scope, WhileSubscribed(5000), 初始值)——那个 5000 是「旋转屏幕」和「用户真的走了」之间的分界线。
下一章:既然 collect 是唯一的入口,那 map、filter 这些操作符是怎么插进去的?答案是套娃——而看懂这层套娃,你就会明白为什么「map 不会切到后台」。