Kotlin SharedFlow 的三个参数到底有啥用

0 阅读12分钟

0.png

这周同事跟我吃饭的时候,谈到了我前几天写的文章,对于里面的 LiveData 颇有同感,不过他说虽然用了几年 SharedFlow,但是一直没有搞懂那三个参数到底什么意思。

我跟他说,没事儿,小菜一碟!

那么,今天,我们就好好讲讲 SharedFlow 三个参数的含义,话不多说,正文开始。

SharedFlow 是 Kotlin Coroutines 中非常重要的一个 API,尤其是在 Android 开发里,它经常被用来替代过去 LiveData + EventWrapper 的一次性事件写法。

但是,SharedFlow 并不是一个“开箱即用就一定正确”的事件流。它的行为很大程度上取决于几个关键参数:

MutableSharedFlow<T>(
    replay = 0,
    extraBufferCapacity = 0,
    onBufferOverflow = BufferOverflow.SUSPEND
)

这三个参数分别决定了:

replay                 // 新订阅者能收到几个旧值
extraBufferCapacity    // 除 replay 之外,额外给发送方多少缓冲
onBufferOverflow       // 缓冲区满了以后怎么办

理解这几个参数,是正确使用 SharedFlow 的关键。

粗略介绍 SharedFlow

我相信大部分开发者对这个 SharedFlow 已经非常熟悉了,但我还是要稍微介绍一下。

SharedFlow 是 Kotlin Coroutines 提供的一种热流。

普通 Flow 是冷流,只有被 collect 的时候才会开始执行。而 SharedFlow 更像一个广播器:事件可以被发送出去,也可以被多个 collector 同时接收。

最常见的写法是:

private val _events = MutableSharedFlow<UiEvent>()
val events = _events.asSharedFlow()

然后在 ViewModel 中发送事件:

viewModelScope.launch {
    _events.emit(UiEvent.ShowToast("保存成功"))
}

在 UI 层收集事件:

lifecycleScope.launch {
    viewModel.events.collect { event ->
        handleEvent(event)
    }
}

这看起来很简单,不过一旦你开始用 SharedFlow,你会发现你会碰到很多小 Bug。

搞清楚 SharedFlow,你需要问自己这几个问题:

  • 这个事件要不要保存?
  • 新订阅者要不要收到旧事件?
  • 发送太快时要不要缓冲?
  • 缓冲区满了以后怎么办?

这些问题,正是 MutableSharedFlow 参数要解决的。

默认行为

如果你这样写:

val flow = MutableSharedFlow<String>()

它等价于:

val flow = MutableSharedFlow<String>(
    replay = 0,
    extraBufferCapacity = 0,
    onBufferOverflow = BufferOverflow.SUSPEND
)

这意味着它是一个:没有 replay、没有额外 buffer、缓冲区满了就挂起发送方的 SharedFlow

在这种情况下:

flow.emit("Hello")

如果当前没有 collector,这个值不会被保存下来。因为 replay = 0,后面再来的订阅者也收不到它,相当于这个值凭空消失了。

只有当前有 collector 的时候,这个值才能被正常收到!

所以默认的 MutableSharedFlow() 更像是:有人听的时候,我就同步广播;没人听的时候,这个事件就过去了。

这也是为什么你在写 UI 事件时,不能只知道 SharedFlow 可以发事件,还要知道它到底有没有缓冲、有没有 replay

replay

replay 表示:新订阅者开始 collect 时,可以收到最近几个已经发送过的值。

比如:

val flow = MutableSharedFlow<Int>(
    replay = 2
)

如果已经发送过:

flow.emit(1)
flow.emit(2)
flow.emit(3)

然后一个新的 collector 才开始 collect

flow.collect {
    println(it)
}

它会先收到最近的两个值:

2
3

所以 replay 的作用就是:给新订阅者重放历史值。

那么 replay 适合什么场景呢?

1. 希望新订阅者拿到最近的数据

比如一个新闻刷新流:

val latestNews = MutableSharedFlow<List<News>>(
    replay = 1
)

新的页面订阅时,可以马上拿到最近一次新闻列表。

不过,如果你只是想获取“当前状态”,一般更推荐使用 StateFlow。因为 StateFlow 本身就是为状态设计的。

2. 希望事件具有“粘性”

比如:

val loginEvent = MutableSharedFlow<LoginResult>(
    replay = 1
)

如果登录结果已经发出,页面旋转之后重新 collect,还能收到最近一次登录结果。

但这也是危险点。

如果你用它发送 Toast、Snackbar、导航这类一次性事件,replay = 1 可能会导致事件重复消费。

这个当,可能很多开发者都上过。

比如:

val events = MutableSharedFlow<UiEvent>(
    replay = 1
)

发送:

_events.emit(UiEvent.ShowToast("保存成功"))

页面旋转后重新 collect,可能又收到上一次 Toast 事件,导致 Toast 再弹一次。

所以:

replay = 1

适合“新订阅者需要旧数据”的场景,不适合普通一次性 UI 事件。

选择建议

一般可以这样理解:

replay = 0

表示:不给新订阅者补发旧值。适合以下场景:

  • Toast
  • Snackbar
  • 导航事件
  • 点击事件
  • 一次性弹窗事件

而:

replay = 1

表示:新订阅者可以收到最近一次值。适合:

  • 最近一次结果
  • 最近一次配置
  • 最近一次广播数据
  • 需要“粘性”的事件

如果你发现自己写:

MutableSharedFlow(replay = 1)

只是为了保存页面状态,那最好考虑一下 MutableStateFlow

关于 replay 的一个小规律:当你想把 SharedFlow 当做事件,replay0,想把 SharedFlow 当状态,replay1

extraBufferCapacity

这个参数又有什么用呢?

extraBufferCapacity 表示:在 replay 缓存之外,额外给 SharedFlow 增加多少缓冲空间。

比如:

val flow = MutableSharedFlow<String>(
    replay = 0,
    extraBufferCapacity = 1
)

这表示:它不会把旧值重放给新订阅者,但可以在 collector 暂时没来得及处理时,额外缓冲 1 个值。

这点和 replay 最大的区别是:会影响新订阅者能不能收到旧值。

extraBufferCapacity 主要影响发送方会不会被挂起,以及慢订阅者来不及处理时能不能暂存。

也就是说,extraBufferCapacity 的主要作用是处理背压问题!

replay vs extraBufferCapacity

这两个参数都和“缓存”有关,但目的不同。

参数作用新订阅者能收到吗
replay决定新订阅者能收到最近几个值能,最多收到 replay
extraBufferCapacity额外缓冲空间,避免发送方马上挂起单独设置时不参与 replay,但与 replay 配合时总缓冲可能参与

比如:

MutableSharedFlow<String>(
    replay = 1,
    extraBufferCapacity = 0
)

含义是:保存最近 1 个值,新订阅者可以收到。

而:

MutableSharedFlow<String>(
    replay = 0,
    extraBufferCapacity = 1
)

含义是:不给新订阅者补发旧值,但允许发送方多塞 1 个还没被处理的值。

所以对于一次性 UI 事件,很多时候会这样写:

private val _events = MutableSharedFlow<UiEvent>(
    replay = 0,
    extraBufferCapacity = 1
)

这样做的好处是:避免页面重新订阅后重复消费旧事件,同时允许事件在 collector 暂时没准备好时,有一个小缓冲空间。

extraBufferCapacity = 0

如果:

MutableSharedFlow<String>(
    replay = 0,
    extraBufferCapacity = 0
)

那么它没有任何额外缓冲。

这时 emit() 可能会挂起,等待订阅者接收。

tryEmit() 在无缓冲 SharedFlow 中通常不适合用。

因为 tryEmit() 不是一个挂起函数,不能挂起等待,这个函数会立即执行返回结果。如果没有缓冲空间,它很可能发不出去。

不过这个 tryEmit 有一点比较反直觉。

我们可以通过查看关于 tryEmit 的文档知道:

仅当 onBufferOverflowSUSPEND(挂起)且存在订阅者正在收集该共享流时,本次调用才会返回 false
若不存在订阅者,则不会使用缓冲区;此时若配置了 replay,最新发送的值会直接存入缓存并覆盖其中的旧数据;若未配置 replay,则该值会被直接丢弃。无论哪种情况,tryEmit 都会返回 true

注意第一句话的措辞 —— “正在收集”,也就是说,只有在出现背压的情况下,这个函数才会返回 false,因为下游的订阅已经来不及收集这些值了,出现了“无法立刻接收”的情况。

而其他情况,都是 SharedFlow 的正常处理逻辑,都会返回 true

很多开发者用 tryEmit 发 UI 事件失败的原因可能就在这里。

onBufferOverflow

onBufferOverflow 表示:当缓冲区满了,但又有新值要发送时,SharedFlow 应该怎么办?

它常见有三个取值:

BufferOverflow.SUSPEND
BufferOverflow.DROP_OLDEST
BufferOverflow.DROP_LATEST

默认值是 BufferOverflow.SUSPEND

1. SUSPEND

MutableSharedFlow<Int>(
    replay = 0,
    extraBufferCapacity = 1,
    onBufferOverflow = BufferOverflow.SUSPEND
)

SUSPEND 的含义是:如果缓冲区满了,发送方挂起,等 collector 消费。

适合不能丢事件的场景,例如

  • 订单状态事件
  • 支付流程事件
  • 必须按顺序处理的业务事件

它的特点是安全,但可能让发送方(上游)等待。

2. DROP_OLDEST

它的含义是:缓冲区满了以后,丢掉最旧的值,放入最新的值。

适合:只关心最新事件的场景,比如:

  • 搜索关键词变化
  • 滚动位置变化
  • 进度刷新
  • 高频传感器数据

这类场景里,旧值过期很快。

比如用户快速输入:

a
an
and
andr
andro
android

如果处理不过来,保留最新的 android 通常比保留最早的 a 更有意义。

3. DROP_LATEST

它的含义是:缓冲区满了以后,丢掉最新来的值,保留已有缓冲。

适合:当前正在处理的东西更重要,新来的可以暂时忽略,比如:

  • 正在处理上一个请求时,新请求到来可以暂时丢弃
  • 某些日志采样,保留已有记录,新采样可忽略
  • 批量处理任务中,当前批次未完成时新任务可延后

不过在 UI 事件中,DROP_LATEST 用得相对少一些。因为很多时候我们更倾向于保留最新值。

在一起

现在,把三个参数放到一起讨论。可以把 MutableSharedFlow 想象成一个事件管道。

MutableSharedFlow<T>(
    replay = A,
    extraBufferCapacity = B,
    onBufferOverflow = C
)

其中:replay = A 决定:新订阅者能不能拿到旧值,能拿几个。

extraBufferCapacity = B 决定:发送方最多可以额外缓冲几个值,而不必马上挂起(如果你设置的是挂起的话)。

onBufferOverflow = C 决定:缓冲区满了以后,是挂起,丢旧的,还是丢新的。

所以,如果单从缓存来看,SharedFlow 总缓冲能力可以粗略理解为:

总缓冲 = replay + extraBufferCapacity

但是这两个空间的语义不同:

  • replay 部分会给新订阅者重放。
  • extraBufferCapacity 部分不会给新订阅者重放。

实战场景

1. 一次性 UI 事件

例如:Toast、Snackbar、Navigation 等推荐:

private val _events = MutableSharedFlow<UiEvent>(
    replay = 0,
    extraBufferCapacity = 1
)
val events = _events.asSharedFlow()

发送:

fun save() {
    viewModelScope.launch {
        _events.emit(UiEvent.ShowToast("保存成功"))
    }
}

收集:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.events.collect { event ->
            when (event) {
                is UiEvent.ShowToast -> showToast(event.message)
                is UiEvent.Navigate -> navigate(event.route)
            }
        }
    }
}

replay = 0 避免页面重建后重复收到旧事件。

extraBufferCapacity = 1 给短暂的生命周期切换留一点缓冲空间。

这个配置适合大多数 ViewModel 到 UI 的一次性事件。

甚至,也可以作为 EventBus(应用内事件总线)的推荐写法,例如:

  • 应用内部广播
  • 模块间事件通知
  • 当前在线的消费者才关心的事件

2. 最近一次结果

推荐:

private val _latestResult = MutableSharedFlow<Result<Data>>(
    replay = 1
)
val latestResult = _latestResult.asSharedFlow()

适合:新订阅者需要马上拿到最近一次结果

比如:

  • 网络请求结果
  • 最近一次同步状态
  • 最近一次定位结果

但如果这是 UI 状态,更推荐 MutableStateFlow()

本篇文章我提到过了很多次的 MutableStateFlow,这个对比,我放到了文章的后部分,各位耐心看完即可。

3. 高频事件流

比如搜索框输入、滑动位置、进度更新。

推荐:

private val _queryChanges = MutableSharedFlow<String>(
    replay = 0,
    extraBufferCapacity = 64,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)

含义是:不重放旧值,但允许一定缓冲(这个缓冲的值可以按照业务而定,64 只是一个假设值);如果处理不过来,丢掉旧值,保留新值。

适合:

  • 搜索输入
  • 滚动事件
  • 拖拽事件
  • 实时进度

这种场景一般不要求每一个中间值都被处理,但新值通常比旧值更重要。

4. 不能丢的业务事件

推荐:

private val _events = MutableSharedFlow<BusinessEvent>(
    replay = 0,
    extraBufferCapacity = 16,
    onBufferOverflow = BufferOverflow.SUSPEND
)

含义是:可以缓冲一部分事件,但满了以后不要丢,发送方等待。适合:

  • 订单事件
  • 支付事件
  • 关键业务状态变化
  • 需要按顺序处理的任务

SharedFlow 和 StateFlow

很多人容易把 SharedFlowStateFlow 混在一起。

可以用一句话区分:

StateFlow 表示“现在是什么状态”。
SharedFlow 表示“发生了什么事件”。

比如页面状态:

data class UiState(
    val loading: Boolean = false,
    val data: List<Item> = emptyList(),
    val error: String? = null
)

更适合:

private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()

而 Toast、Snackbar、导航:

sealed interface UiEvent {
    data class ShowToast(val message: String) : UiEvent
    data class Navigate(val route: String) : UiEvent
}

更适合:

private val _events = MutableSharedFlow<UiEvent>(
    replay = 0,
    extraBufferCapacity = 1
)
val events = _events.asSharedFlow()

判断标准很简单:

如果你问的是:页面现在应该长什么样?用 StateFlow

如果你问的是:刚刚发生了什么动作?用 SharedFlow

一个小规律:要默认值!StateFlow;不要默认值!SharedFlow

速查表

场景replayextraBufferCapacityonBufferOverflow
Toast / Snackbar / 导航01SUSPEND
普通事件总线00SUSPEND
新订阅者需要最近一次数据10SUSPEND
高频 UI 变化016 / 64DROP_OLDEST
不能丢的业务事件0适当设置SUSPEND
只保留最新值(不 replay)01DROP_OLDEST
只保留最新值(类似 StateFlow)10SUSPEND
页面状态不建议不建议不建议,优先 StateFlow

一点想法

SharedFlow 的参数真不是为了故弄玄虚,让 API 看起来复杂,而是为了让它能覆盖不同的事件模型。

replay 决定旧事件要不要给新订阅者。

extraBufferCapacity 决定发送方能不能先把事件放进缓冲区。

onBufferOverflow 决定事件太多时(出现了背压),是等一等,丢旧的,还是丢新的。

其实,Flow 在整个 Kotlin 协程体系中扮演着至关重要的角色。它不仅仅是 Android 开发中处理异步数据流的利器,更是协程生态里连接“数据生产”与“数据消费”的核心桥梁。从冷流到热流,从 StateFlowSharedFlow,Kotlin 协程通过 Flow 提供了一套统一、优雅且线程安全的响应式编程方案,让复杂的异步逻辑变得可组合、可测试、可预测。

当然,Flow 的强大离不开协程本身对线程调度的良好支持。

而在 Android 开发中,线程(后台任务)处理的发展历程同样是一段值得回味的演进史——从早期的 AsyncTaskHandlerThread,到后来的 RxJava,再到如今的协程和 Flow。后续我会专门写一篇文章,和大家聊聊 Android 线程处理的过去、现在与未来,敬请期待。