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

热流:四个旋钮,和一个会丢事件的默认值

MutableSharedFlow() 不带参数地写出来,它的行为是:没有订阅者的时候 emit,那个值直接消失;有订阅者但它很慢的时候,emit 会被卡住。这两条都不是 bug,但它们都需要你知道。

SharedFlowreplayonBufferOverflowStateFlow

热流和冷流的分界

第 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_OLDESTextraBufferCapacity 留 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 = 1extraBufferCapacity = 0DROP_OLDEST

你会看到订阅者收到 1,6——中间那些被合并掉了。这就是 StateFlow它比这组配置只多了两条规矩:

  1. 必须有初始值,所以 .value 永远能读到东西
  2. 和上一个值相等就不发distinctUntilChanged 内建)
// 这两行的行为几乎一样
val a = MutableStateFlow(0)
val b = MutableSharedFlow<Int>(replay = 1, onBufferOverflow = DROP_OLDEST)
        .apply { tryEmit(0) }
// 差别:a 会自动去重,b 不会;a 有 .value,b 没有
⚠ StateFlow 会自动去重,这有时候是坑

相等就不发,意味着这段代码只会触发一次

_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

⌗ 到你手上

卷 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 多了白重启,而两种都很常见。