卷 IV · 流动CH 13深度 13/24

冷流是一段配方,不是一条河

「Flow 是数据流」这个说法害人不浅。它会让你以为那些值正在某处流动着,你只是接了个水管。真相是:在你 collect 之前,什么都没有发生;而你 collect 两次,整件事就从头做两遍。

冷流collectstateIn请求发两遍

一个能省很多事的定义

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、以及所有加了操作符之后的结果
  • 不会 → 热流StateFlowSharedFlowstateInshareIn 之后的东西

操作符不改变冷热: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,只要理由说得通。

⚠ WhileSubscribed 的代价:上游会真的重跑

如果用户离开超过了 t,上游被取消。等他回来,stateIn重新 collect 一遍冷流——网络请求重发、数据库重查。

好在 stateIninitialValue,而且它会保留上一次的值,所以用户看到的是旧数据先显示,新数据到了再刷新,不是白屏。这通常正是你要的。

第 19 章会把这条时间线完整量一遍:切后台多久会触发重启、重启一次的代价是多少。

stateIn 和 shareIn 怎么选

stateInshareIn
产出StateFlow<T>SharedFlow<T>
要不要初始值要(initialValue不要
能不能读 .value能,永远有值不能
相同值会不会重复发不会(自带去重)
适合状态:当前是什么样子事件:发生了什么

一句话:「当前值」用 stateIn,「一串事件」用 shareInUI 状态几乎总是前者。

⎇ 顺带说 Kotlin:flow { } 里为什么不能随便切线程

你可能试过这么写,然后收到一个运行时异常:

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 知道。

⌗ 到你手上

验证方法很土但很有效:在 Repository 的那次请求里打一行日志,然后进那个页面,看它打了几次。

这一章的一句话

冷流是配方不是河:collect 之前什么都没发生,collect 两次就整件事做两遍。想让多个订阅者共享一份结果,就用 stateIn(scope, WhileSubscribed(5000), 初始值)——那个 5000 是「旋转屏幕」和「用户真的走了」之间的分界线。

下一章:既然 collect 是唯一的入口,那 mapfilter 这些操作符是怎么插进去的?答案是套娃——而看懂这层套娃,你就会明白为什么「map 不会切到后台」。