深入浅出 Kotlin SharedFlow (下)——MutableSharedFlow

526 阅读5分钟

上篇:深入浅出 Kotlin SharedFlow(上)——shareIn()

我们知道 Flow.shareIn() 可以将一个现有的 Flow 转化为 SharedFlow,Flow 生产出来的每个数据会被 SharedFlow 广播 📢 给它的所有订阅者 。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val dataFlow: Flow<Int> = flow {
    var i = 0
    while(true) {
      delay(1000)
      println("emit ${i++}")
      emit(i)
    }
  }

  val eventFlow: SharedFlow<Int> = dataFlow.shareIn(
    scope = this,
    started = SharingStarted.Eagerly,
  )

  // eventFlow.emit(-1) 🤷🏻🚫
}

Flow.shareIn() 创建出来的 SharedFlow 有一个局限性,它的数据源完全依赖上游 Flow 的数据发射,无法在 SharedFlow 的维度主动控制数据发射。这在需要动态产生事件或组合多数据源的场景下显得不够灵活,比如:

  • 无法主动发射新事件:当需要手动触发某个事件时(如用户点击事件),shareIn() 生成的 SharedFlow 只能被动转发原始 Flow 的数据;
  • 无法合并多数据源:如果事件需要来自不同模块(网络请求 | 本地数据库 | 用户操作),基于单一 Flow 的 shareIn() 难以实现多源头的事件聚合;

这时可以通过 MutableSharedFlow() 直接创建一个可自主控制的共享流。

MutableSharedFlow

调用 MutableSharedFlow() 创建一个 MutableSharedFlow 对象,后续可以调用这个对象的 emit() 方法从任意地方主动发出数据。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val mutableSharedFlow: MutableSharedFlow<String> = 
      MutableSharedFlow<String>()

  launch {
    mutableSharedFlow.collect{
      println(it)
    }
  }

  delay(100)

  launch {
    mutableSharedFlow.emit("emit from Coroutine A")
  }

  launch {
    mutableSharedFlow.emit("emit from Coroutine B")
  }
}

// emit from Coroutine A
// emit from Coroutine B

点开源码发现 MutableSharedFlow 是 SharedFlow 的子接口,同时它也是 FlowCollector 的子接口:

// SharedFlow.kt    
public interface MutableSharedFlow<T> 
  : SharedFlow<T>, FlowCollector<T> {

  override suspend fun emit(value: T) // 📌

  public fun tryEmit(value: T): Boolean

  public val subscriptionCount: StateFlow<Int>

  public fun resetReplayCache()
}

FlowCollector 的 emit() 让它拥有了发射数据的能力:

// FlowCollector.kt
public fun interface FlowCollector<in T> {
  public suspend fun emit(value: T)
}

FlowCollector 相信大家都不陌生,flow { emit(1) } 发射数据时调用的 emit() 正是 FlowCollector 的函数:

另外,其实 Flow.shareIn() 的底层实现也是 MutableSharedFlow,只不过返回暴露给开发者的是只读的 SharedFlow 实例:

public fun <T> Flow<T>.shareIn(
  ...
): SharedFlow<T> {
  val config = configureSharing(replay)
  val shared = MutableSharedFlow<T>(
    replay = replay,
    extraBufferCapacity = config.extraBufferCapacity,
    onBufferOverflow = config.onBufferOverflow
  )
  @Suppress("UNCHECKED_CAST")
  val job = scope.launchSharing(config.context, config.upstream, shared, started, NO_VALUE as T)
  return ReadonlySharedFlow(shared, job)
}

MutableSharedFlow() VS Flow.shareIn()

如果你已经有一个冷 Flow,且希望在多个收集器之间共享数据,选择 Flow.shareIn();如果需要手动控制数据的发射或者创建一个事件总线,则选择 MutableSharedFlow()

MutableSharedFlow() 参数详解

public fun <T> MutableSharedFlow(
  replay: Int = 0,
  extraBufferCapacity: Int = 0,
  onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T>

replay: Int

第一个参数 replay 其实和 Flow.shareIn() 的参数 replay 是一样的,因为 Flow.shareIn() 的 replay 参数就是填到这里来的:

replay 的作用是利用 buffer 缓存最近发射过的数据,以在未来发送给新的订阅者(重播),这里不再赘述,详见上一篇文章。

extraBufferCapacity: Int

第二个参数 extraBufferCapacity,“额外的缓冲大小”

它的作用是增大 buffer 区域,extraBufferCapacity 默认为 0。设置 extraBufferCapacity > 0 有助于提升 Emitter 的吞吐量,因为 SharedFlow 的 buffer size = replay + extraBufferCapacity.

在上文中曾经提到过:

Flow.shareIn() 函数所创建出来的 SharedFlow,如果 replay <= 64,那么 buffer size = 64,如果 replay > 64,那么 buffer size = replay

在 MutableSharedFlow 这里又说 buffer size = replay + extraBufferCapacity,为什么呢?我们不妨看一下源码:

Flow.shareIn() 内部创建 MutableSharedFlow 的时候,replay 参数是原封不动填上去的,而 extraBufferCapacity 是用的 SharingConfig.extraBufferCapacity,那我们就找创建 SharingConfig 的 configSharing() 方法:

configSharing() 方法接收了 replay 作为参数,在计算 extraBufferCapacity 的时候,从 replay 和 64 之间取较大值,再减去 replay。

因此,对于 SharedFlow 来说,buffer size 的大小确实等于 replay + extraBufferCapacity。

至于Flow.shareIn() 函数,如果 replay <= 64,那么 buffer size = 64,如果 replay > 64,那么 buffer size = replay,那是因为 Flow.shareIn() 内部自己做了处理。

话说回来,为什么增大 extraBufferCapacity 可以提升 Emitter 的吞吐量呢?

来看下面的代码,replay 为 0,extraBufferCapacity 也为 0,那么 buffer = replay + extraBufferCapacity = 0,处理每条数据要花费 1 秒,我们在发射数据时,记录生产每条数据花费的时间:

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val mutableSharedFlow: MutableSharedFlow<Int> =
    MutableSharedFlow<Int>(
      replay = 0, // 默认值
      extraBufferCapacity = 0, // 默认值
      onBufferOverflow = BufferOverflow.SUSPEND, // 默认值
    )

  launch {
    mutableSharedFlow.collect {
      delay(1000) // 消费每条数据花费 1 秒
    }
  }

  val totalTime: Duration = measureTime {
    (1..5).forEach {
      val time: Duration = measureTime {
        mutableSharedFlow.emit(it)
      }
      println("emit $it in $time")
    }
  }
  println("emit all numbers in $totalTime")
}

生产第一条数据只花了几毫秒,后面的生产每条数据都花费了约 1 秒,总生产时间约为 4 秒:

因为 buffer size = 0,所以每一条数据的发送要等待上一条数据被处理完毕,不然生产出来也没地方存放,由此可见,下游的消费速度过慢,会导致上游生产发生阻塞:

image.png

现在我们把 extraBufferCapacity 设置为 2:

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val mutableSharedFlow: MutableSharedFlow<Int> =
  MutableSharedFlow<Int>(
     replay = 0, // 默认值
-    extraBufferCapacity = 0, // 默认值
+    extraBufferCapacity = 2,
     onBufferOverflow = BufferOverflow.SUSPEND, // 默认值
  )

  launch {
    mutableSharedFlow.collect {
      delay(1000) // 消费每条数据花费 300ms
    }
  }

  val totalTime: Duration = measureTime {
    (1..5).forEach {
      val time: Duration = measureTime {
        mutableSharedFlow.emit(it)
      }
      println("emit $it in $time")
    }
  }
  println("emit all numbers in $totalTime")
}

通过对比可以发现,上游生产被阻塞的情况得到了一定改善,生产前面 3 条数据只花费了几微秒,阻塞从第 4 个数据才开始,总耗时也从 4 秒减少到 2 秒:

可见增大 extraBufferCapacity 确实能提升 Emitter 的吞吐量,防止在下游处理速度较慢时,上游的生产被阻塞。

image.png

当然,因为 buffer = replay + extraBufferCapacity,增大 replay 也能达到同样效果,但它的侧重点是“缓存重放”,如果你不想要重放,只想提升 Emitter 吞吐量,那么就应该使用 extraBufferCapacity,这也是为什么它命名为 extra BufferCapacity 而不是 BufferCapacity。

onBufferOverflow: BufferOverflow

// BufferOverflow.kt
public enum class BufferOverflow {
    SUSPEND,
    DROP_OLDEST,
    DROP_LATEST
}

第三个参数,onBufferOverflow 是缓冲溢出策略,这个缓冲溢出策略跟 Flow.buffer() 操作符及 Channel 的缓冲溢出策略是一样的。MutableSharedFlow 的默认缓冲溢出策略是挂起 SUSPEND,我们可以指定为 DROP_LATEST 或者 DROP_OLDEST

Flow.shareIn() 是不允许我们更改缓冲策略的,创建出的 SharedFlow 缓冲策略默认为挂起 SUSPEND。

当 Buffer 被填满时,emit() 会被挂起,这都是建立在 onBufferOverflow 为 SUSPEND 的前提下。除了 SUSPEND,还有两个数据丢弃策略:

  • DROP_LATEST:丢弃 buffer 中最新的数据;
  • DROP_OLDEST:丢弃 buffer 中最旧的数据;

要注意,当 BufferSize = 0 时,只支持 SUSPEND 挂起策略,指定丢弃策略是无效的。这很好理解,因为 Buffer 中没有数据,所以丢弃无从下手,丢弃策略的前提是 Buffer 的大小不为零,且数据被填满

下面以实际例子看看两个缓冲溢出丢弃策略的行为:

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val mutableSharedFlow: MutableSharedFlow<Int> =
  MutableSharedFlow<Int>(
    replay = 1,
    extraBufferCapacity = 1,
    onBufferOverflow = BufferOverflow.DROP_LATEST,
  )

  launch {
    mutableSharedFlow.collect {
      delay(1000) // 消费每条数据花费 1 秒
      println(it)
    }
  }

  (1..5).forEach {
    mutableSharedFlow.emit(it)
  }
}

image.png

上图展示 DROP_LATEST 的效果。[replay = 1,extraBufferCapacity = 1]

  1. Emitter 发送 ❶ 进入 buffer,同时发给下游消费,buffer 保留 ❶是因为 replay = 1,需要缓存 1 个最新数据在未来重放;
  2. Emitter 发送 ❷ 进入 buffer 替换掉 ❶,因为 ❶ 已经被所有订阅者消费,而且也不是最新的一个数据,故而丢弃;
  3. Emitter 发送 ❸ 进入 buffer,现在 buffer 中的数据为 ❷❸。❷❸ 都还没有被消费,其中的 ❸ 将在未来被重放;
  4. Emitter 发送 ❹ 时,由于 buffer 已经满了,缓冲策略 DROP_LATEST 为丢弃最新数据,故 ❹ 被丢弃;
  5. Emitter 发送 ❺ 时,由于 buffer 已经满了,缓冲策略 DROP_LATEST 为丢弃最新数据,故 ❺ 被丢弃;
  6. Subscriber 处理完毕 ❶,开始处理 ❷,❷ 已经被现有的所有 Subscriber 消费了,而且它不是最新的一个数据,最新的是 ❸,因此将 ❷ 从 buffer 中移除;
  7. Subscriber 处理完毕 ❷,开始处理 ❸ ,❸ 已经被现有的所有 Subscriber 消费了,但它是最新的一个数据,不能将 ❸ 从 buffer 中移除,需要保留在未来重放。

再把缓冲策略改为 DROP_OLDEST:

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val mutableSharedFlow: MutableSharedFlow<Int> =
  MutableSharedFlow<Int>(
    replay = 1,
    extraBufferCapacity = 1,
-    onBufferOverflow = BufferOverflow.DROP_LATEST,
+    onBufferOverflow = BufferOverflow.DROP_OLDEST,
  )

  launch {
    mutableSharedFlow.collect {
      delay(1000) // 消费每条数据花费 1 秒
      println(it)
    }
  }

  (1..5).forEach {
    mutableSharedFlow.emit(it)
  }
}

image.png

上图展示 DROP_OLDEST 的效果。[replay = 1,extraBufferCapacity = 1]

  1. Emitter 发送 ❶ 进入 buffer,同时发给下游消费,buffer 保留 ❶是因为 replay = 1,需要缓存 1 个最新数据在未来重放;
  2. Emitter 发送 ❷ 进入 buffer 替换掉 ❶,因为 ❶ 已经被所有订阅者消费,而且也不是最新的一个数据,故而丢弃;
  3. Emitter 发送 ❸ 进入 buffer,现在 buffer 中的数据为 ❷❸。❷❸ 都还没有被消费,其中的 ❸ 将在未来被重放;
  4. Emitter 发送 ❹ 时,由于 buffer 已经满了,缓冲策略 DROP_OLDEST 为丢弃最旧数据,故 ❷ 被丢弃,此时 buffer 中的数据为 ❸❹;
  5. Emitter 发送 ❺ 时,由于 buffer 已经满了,缓冲策略 DROP_OLDEST 为丢弃最旧数据,故 ❸ 被丢弃,此时 buffer 中的数据为 ❹❺;
  6. Subscriber 处理完毕 ❶,开始处理 buffer 中最旧的一个数据 ❹,❹ 已经被现有的所有 Subscriber 消费了,而且它不是最新的一个数据,最新的是 ❺,因此将 ❹ 从 buffer 中移除;
  7. Subscriber 处理完毕 ❹,开始处理 ❺ ,❺ 已经被现有的所有 Subscriber 消费了,但它是最新的一个数据,不能将 ❺ 从 buffer 中移除,需要保留在未来重放。

onBufferOverflow 设为 SUSPEND 可以保证 Subscriber 一个不漏的消费掉所有从它开始收集之后生产的数据,但是可能会影响 Emitter 的速度,当设置为 DROP_XXX 时,可以保证 emit() 调用后立即返回,但是会导致 Subscriber 漏掉部分数据。

tryEmit()

如果不想让 emit() 被阻塞挂起,除了设置 DROP_XXX 之外,还有个方法就是调用 MutableSharedFlow.tryEmit(),这是一个非 suspend 版本的 emit():

public interface MutableSharedFlow<T> : SharedFlow<T>, FlowCollector<T> {

  override suspend fun emit(value: T)

  public fun tryEmit(value: T): Boolean

  // ...
}

tryEmit() 返回一个 Boolean 值表示是否发射成功,返回 false 的前提是 onBufferOverflow 设置为 SUSPEND,且 Buffer 中空余位置为 0,此时 tryEmit() 的效果等同于 DROP_LATEST。如果使用缓冲溢出丢弃策略,tryEmit() 将总是返回 true。

asSharedFlow

MutableSharedFlow 提供了一个 asSharedFlow() 扩展函数,它可以将 MutableSharedFlow 转换为 SharedFlow。当你希望将 MutableSharedFlow 对外暴露供订阅使用,但又不希望外部有权限发送数据时,就可以利用该函数创建一个只读的 SharedFlow:

class MyViewModel: ViewModel() {
    private val _event = MutableSharedFlow()
    val event: SharedFlow = _event.asSharedFlow()
}