卷 II · CH 08 · 深度 08/18

Flow 的问题不是流动,是谁还在听

一个 suspend 函数给一次结果;Flow 描述一段会持续变化、也会被取消的关系。

FLOWSTATEFLOWBACKPRESSURE
▷ 先答一下

Snackbar「消息已发送」最适合建模成什么?

  1. 永久留在 UiState 的 Boolean
  2. StateFlow<Boolean>,显示后由 UI 改回 false
  3. 一次消费的效果信号,或从持久状态推导出的 UI 行为
  4. 放在 Activity 的全局变量里

冷流每次收集都重新开始

flow { } 是冷流:没有 collector 就不执行,每个 collector 都启动一份独立生产过程。Room 查询 Flow 也遵循这种订阅语义,数据库变化时重新发值。冷流适合描述「如何得到这一串值」,而不是一个已经持续存在的广播源。

StateFlow 是热的、有当前值、按相等性合并的状态容器。新订阅者立刻拿到最新状态,而不是历史全部过程。SharedFlow 是可配置重放与缓冲的广播;Channel 更像队列,一条元素通常交给一个接收者。

可以带走的判断:状态回答「现在是什么」,事件回答「刚才发生了什么」;先定语义,再选 Flow 类型。

UI 状态不是事件垃圾桶

把导航、Toast、Snackbar 全塞进 UiState,旋转或重新订阅时可能再次触发;再加一个 consumed 回调,会引入 UI 与 ViewModel 的双向握手和竞态。优先问:这件事能否由持久状态推导?例如发送失败气泡应留在消息状态里,根本不需要瞬时 Toast。

真正的一次性界面效果可以用不重放的 SharedFlow 或 Channel,但要接受一个事实:没有订阅者时它可能丢失。如果「绝不能丢」,它就不是一次性效果,而是应该持久化的业务状态。

操作符是在声明并发政策

map 顺序变换;combine 用各上游最新值合成状态;zip 一一配对;flatMapLatest 在新查询到来时取消旧查询,适合搜索;buffer 让生产与消费解耦;conflate 丢过时中间值,只保留最新;collectLatest 新值到来时取消旧的处理。

它们不是风格差异。搜索建议该取消旧请求,消息发送却不能因为新消息到来而取消上一条。选错一个 latest,就可能把业务承诺当作过时帧丢掉。

val uiState: StateFlow<ChatUiState> = combine(
    repository.observeMessages(conversationId),
    repository.observeConnection(),
    draft
) { messages, connection, draft ->
    ChatUiState(messages, connection, draft)
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = ChatUiState.Loading
)

在 Compose 里用 collectAsStateWithLifecycle()。它会在生命周期不活跃时停止收集,配合 WhileSubscribed 让昂贵上游在短暂配置变更时不抖动、长时间无人订阅时又能停下。

⌨ 自己跑一遍

StateFlow 保存的是当前值,不是事件历史。订阅前连续赋值,collector 只先看到最新值:

collector sees 2
collector sees 3
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    val state = MutableStateFlow(0)
    state.value = 1
    state.value = 2

    val job = launch {
        state.take(2).collect { println("collector sees $it") }
    }
    yield()
    state.value = 3
    job.join()
}

在 Android Studio Kotlin/JVM scratch 运行。再把它改成 MutableSharedFlow<Int>(replay = 0),在订阅前发值,观察为什么一个「绝不能丢」的业务动作不该只靠瞬时流。

▸ 在现实里

聊天列表来自 Room Flow,网络连接状态来自另一条热流,草稿来自保存状态。ViewModel 用 combine 产出一个可渲染快照。网络推送本身不是 UI 状态;先写入本地真相,再由同一条 Room Flow 更新页面,前后台路径才不会分叉。

✗ 这个直觉是错的

「Flow 就是更现代的 LiveData,所以全部换成 StateFlow。」冷数据管道、共享状态、广播事件与工作队列的消费语义不同。

先回答重放多少、多个订阅者各拿什么、没人订阅时是否生产、慢消费者怎么办,再选 Flow、StateFlow、SharedFlow 或 Channel。

◇ 面试收口

答案是第 3 项。永久 Boolean 会在重建后重复触发;让 UI 改回 false 又引入双向竞态。能落成状态的结果就渲染状态;真正瞬时、允许无人订阅时丢失的效果才走一次性信号。

这一章的一句话

Flow 类型的差别不在 API,而在订阅、重放、丢失与共享的合同。

下一章进入 Compose:一个计数器变化时,函数明明被重新调用了,为什么屏幕上绝大多数节点既没有重建,也没有重新布局?答案藏在 Composition 的槽位身份里。