热流:四个旋钮,和一个会丢事件的默认值
MutableSharedFlow() 不带参数地写出来,它的行为是:没有订阅者的时候 emit,那个值直接消失;有订阅者但它很慢的时候,emit 会被卡住。这两条都不是 bug,但它们都需要你知道。
热流和冷流的分界
第 13 章的判据是「collect 两次会不会跑两遍」。换个角度说:
冷流:值是为你生产的。没人要就不生产。 热流:值是已经在发生的。你订不订阅,它照发。 —— 所以热流有一个冷流没有的问题: 你订阅之前发生的那些,怎么办?
SharedFlow 的四个参数,全都是在回答这个问题的不同侧面。
四个旋钮
MutableSharedFlow<T>(
replay = 0, // ① 新订阅者能补看几个
extraBufferCapacity = 0, // ② 除了 replay 之外还能囤几个
onBufferOverflow = BufferOverflow.SUSPEND // ③ 囤满了怎么办
)
// ④ 第四个不是参数,是「订阅者有几个、快不快」——它同样决定行为
| 旋钮 | 管什么 | 默认 |
|---|---|---|
replay | 缓存最近 N 个值,新订阅者一进来就先收到这 N 个 | 0——什么都不补 |
extraBufferCapacity | 额外的囤货位。缓冲总容量 = replay + 这个 | 0 |
onBufferOverflow | 缓冲满了:卡住发送方 / 丢最老的 / 丢最新的 | SUSPEND——卡住发送方 |
| 订阅者 | 0 个订阅者 + replay=0 → 发出去的值直接消失 | — |
把默认值连起来读一遍:缓冲容量 0,溢出就卡住,没人订阅就丢。这是一个非常严格的默认值——它保证「不丢给已订阅的人」,代价是「发送方会被最慢的订阅者拖住」。
四个旋钮全在这儿
默认配置(replay=0、extra=0、SUSPEND),一个每次处理要 50 毫秒的慢订阅者,发送方连发 6 个:
emit 了 6 个 订阅者收到 1,2,3,4,5,6 ← 一个不丢 emit 被卡住 4 次 ← 发送方被拖了 4 次 整轮耗时 811 ms
把 extraBufferCapacity 拨到 4:卡住次数降到 1 次,耗时降到 660 毫秒。
把 onBufferOverflow 换成 DROP_OLDEST、extraBufferCapacity 留 1:订阅者只收到 1,6,丢了 4 个,但发送方一次都没被卡住。
- SUSPEND:一个都不丢,代价是发送方被最慢的订阅者绑架
- DROP_OLDEST:发送方永不阻塞,代价是慢订阅者只能看到最新的
- DROP_LATEST:发送方永不阻塞,代价是新值可能进不来(很少用,因为通常最新的最重要)
选哪个,取决于一个问题:「这些值,是状态还是事件?」
状态 → 只要最新的 → DROP_OLDEST;事件 → 一个都不能少 → SUSPEND 或者足够大的缓冲。
那个会丢事件的默认值
把 demo 里「订阅者迟到」那个开关打开,你会看到最重要的一格:
replay = 0,订阅者在 emit 全部发完之后才来:
emit 了 6 个,订阅者收到 0 个,丢了 6 个
replay = 1,同样的时序:
emit 了 6 个,订阅者收到 1 个(最后那个 6)
这就是用 SharedFlow 做事件总线时最容易翻车的地方。想象这个时序:
t=0 用户点了「保存」
t=10 ViewModel 保存成功 → emit(ShowSnackbar("已保存"))
← 但这一刻,用户正好在旋转屏幕,
旧的 collector 已经取消,新的还没建立
→ 订阅者 0 个 → 这个事件直接消失
t=300 新界面就位,开始 collect —— 什么也没等到
用户保存成功了,但什么提示都没有。这个 bug 在你的机器上几乎不会复现,因为你不会正好在那 300 毫秒里点按钮。
第 21 章会把这个问题完整处理一遍,包括为什么 Channel 在这一格上表现最好。
StateFlow 就是一组特定的取值
现在把旋钮拨到这一组:replay = 1、extraBufferCapacity = 0、DROP_OLDEST。
你会看到订阅者收到 1,6——中间那些被合并掉了。这就是 StateFlow。它比这组配置只多了两条规矩:
- 必须有初始值,所以
.value永远能读到东西 - 和上一个值相等就不发(
distinctUntilChanged内建)
// 这两行的行为几乎一样
val a = MutableStateFlow(0)
val b = MutableSharedFlow<Int>(replay = 1, onBufferOverflow = DROP_OLDEST)
.apply { tryEmit(0) }
// 差别:a 会自动去重,b 不会;a 有 .value,b 没有
相等就不发,意味着这段代码只会触发一次:
_state.value = UiState(loading = true) // 做点什么… _state.value = UiState(loading = true) // ← 和上一个 equals,不发
对状态来说这是好事(省掉无意义的重组)。但如果你想用它表达「又发生了一次同样的事」,它就会吃掉第二次。
这是另一个「事件不该放进 State」的具体理由。
MutableSharedFlow 的两种 emit
还有一个细节值得知道:
emit(v) | tryEmit(v) | |
|---|---|---|
| 是不是挂起函数 | 是 | 不是,普通函数 |
| 缓冲满了怎么办 | 挂起等(SUSPEND 时) | 返回 false,值被丢掉 |
| 能在非协程里调用吗 | 不能 | 能 |
tryEmit 很方便(不用开协程),但它会静默失败——返回值是 Boolean,而大多数人不看它。
如果你用 tryEmit,就必须保证缓冲永远够用(extraBufferCapacity 至少 1,或者用 DROP_OLDEST)。否则你写的是一个「有时候会丢事件、但不会报错」的东西。
什么时候该自己造一个 SharedFlow
说实话:比你以为的少。三种情况:
- 把一个昂贵的冷流共享出去——用
shareIn,不用手写 - 一个真正的事件广播(App 内的全局通知、WebSocket 消息分发)——这时候手写
MutableSharedFlow(replay = 0, extraBufferCapacity = 64, DROP_OLDEST)是合理的 - UI 事件(Snackbar、导航)——先看第 21 章,很多时候有更好的做法
其余情况,你要的多半是 StateFlow。
在项目里搜 MutableSharedFlow,对每一个问四句:
1. replay 是多少?
0 → 新订阅者什么都补不到。这是你要的吗?
2. extraBufferCapacity 是多少?
0 + SUSPEND → 一个慢订阅者会拖慢所有发送方
3. 用的是 emit 还是 tryEmit?
tryEmit + 缓冲 0 → 它会静默丢值,而且没人看返回值
4. 这些值是「状态」还是「事件」?
状态 → 换成 StateFlow
事件 → 看第 21 章,可能该用 Channel
卷 IV 到此结束
四章下来,Flow 这一侧的三个动词也齐了,而且和前两卷严格对应:
| Compose | 协程 | Flow | |
|---|---|---|---|
| 活下来 | remember 的那一格 | 状态机的 L$ 字段 | replay 缓存、StateFlow.value |
| 重跑 | 脏 scope 被重新调用 | resumeWith 跳到某个 label | 每次 collect 把配方跑一遍 |
| 取消 | 离开组合 → effect 取消 | cancel() + 你自己检查 | collectLatest 掐上一个、订阅归零停上游 |
三套机制、三套词汇、同一个骨架。
下一卷讲它们缝在一起的地方——那也是线上事故最集中的地方。
这一章的一句话
SharedFlow 的默认值是「容量 0、溢出就卡住、没人订阅就丢」。四个旋钮的取舍只有一个问题:这些值是状态还是事件。而 StateFlow 就是 replay=1 + DROP_OLDEST + 自动去重这一组特定取值。
下一章:Compose 和协程的第一个接缝——LaunchedEffect。它的 key 是一个看起来很小、实际决定了「这次请求用的是新参数还是旧参数」的东西。key 少了用旧参数,key 多了白重启,而两种都很常见。