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

778 阅读7分钟

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

Flow

image.png

众所周知,Flow 是一个数据流工具,最上游是数据的生产者(本质上是一系列数据的生产规则),每当下游开始调用 Flow.collect(),数据的生产线就开始启动,根据生产规则将数据一个个生产出来,往下流动,最终到达收集处被消费处理。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val flow: Flow<Int> = flow {
    println("[flow]: start")
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }

  launch {
    println("[collector A]: start")
    flow.collect {
      println("[collector A]: $it")
    }
  }

  launch {
    println("[collector B]: start")
    flow.collect {
      println("[collector B]: $it")
    }
  }
}

/*
[collector A]: start
[collector B]: start
[flow]: start
[flow]: start
[collector A]: 1
[collector B]: 1
[collector A]: 2
[collector B]: 2
[collector A]: 3
[collector B]: 3
*/

上面的代码里,Flow 的生产规则是:每隔 1 秒生产一个数据,一共生产 3 个数据,分别是 1、2、3。由运行结果可知,数据是在调用 Flow.collect() 后才开始生产的,且每次调用 Flow.collect() 都会启动一个全新的生产流程。换句话说,collect() 函数的作用是启动数据的生产流程,并对产出的数据进行收集处理。

Flow.launchIn()

Flow 有一个拓展函数 Flow.launchIn(),它的原理很简单,就是利用给定的 CoroutineScope 开启一个新协程,然后在这个新协程里面调用 Flow.collect()

// Collect.kt
public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch {
  collect()
}

但这里的 collect() 并没有对数据进行什么处理,没有打印、也不回调,启动数据生产流程,收集了产出的数据但不用?搞什么啊... 别忘了生产出来的数据可能会流经各个中间操作符,collect 只是数据的最后一站。launchIn() 一般是配合中间操作符 onEach() 一起使用的:

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val flow: Flow<Int> = flow {
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }

  // this.launch {
  //   flow.collect {
  //     println(it)
  //   }
  // }

  // this.launch {
  //   flow
  //     .onEach { println(it) }
  //     .collect()
  // }

  // 等同于上面的两种写法
  flow
    .onEach { println(it) }
    .launchIn(this)
}

SharedFlow

了解完 Flow 的生产-消费模式、collect()launchIn() 这些基础知识后,我们开始今天的主题——SharedFlow。

对使用过 Flow 的开发者而言,或多或少都对 SharedFlow 有所了解,知道它是一种事件流工具,能够作为 EventBus 的替代方案。下面这张图来自 EventBus 仓库的 README:

image (5).png

发布者将 event 事件 post 出去,所有订阅该该类型事件的订阅者都会收到通知(onEvent)。

我们可以将 EventBus 的 post 看作是 Flow 的 emit,将 Event 的 onEvent 看作是 Flow 的 collect,但很快就能发现不对劲,在 Flow 里面,每次调用 collect 都会启动一个新的生产流程,也就是说消费者和生产者是一一对应的。而在 EventBus 里面,事件发布者只有一个,某个事件被发送出来,多个订阅者能同时收到该事件,发布者和订阅者的关系是一对多。

image.png

用 Flow 怎么实现事件订阅呢?答案不言而喻,是 SharedFlow。

Flow.shareIn()

Flow 有一个拓展函数 Flow.shareIn() 用于将一个 Flow 转换为 SharedFlow:

// Share.kt
fun <T> Flow<T>.shareIn(
    scope: CoroutineScope, // [Where] 指定在哪开启新协程调用 Flow.collect()
    started: SharingStarted, // [When] 指定什么时候调用 Flow.collect()
    replay: Int = 0
): SharedFlow<T>

返回的 SharedFlow 对象,会将上游 Flow 产出的每个数据作为事件转发给所有的 SharedFlow.collect() 收集者。

SharedFlow 官方文档里面写道:An active collector of a shared flow is calle a subscriber.

意思是普通 Flow 的 collector 称为(数据)收集者,但是 SharedFlow 的 collector 可以被视为订阅者。

image.png

下面的代码里,我们将一个现有的 Flow 使用 shareIn() 转换为 SharedFlow:

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val flow: Flow<Int> = flow {
    println("[flow] start")
    (1..3).forEach {
      delay(1000)
      println("[flow] emit $it")
      emit(it)
    }
  }

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

  delay(1500)

  launch {
    println("[collector A] start")
    sharedFlow.collect { println("[collector A]: $it") }
  }

  launch {
    println("[collector B] start")
    sharedFlow.collect { println("[collector B]: $it") }
  }
}

/*
[flow] start
[flow] emit 1
[collector A] start
[collector B] start
[flow] emit 2
[collector B]: 2
[collector A]: 2
[flow] emit 3
[collector A]: 3
[collector B]: 3
*/

我们给 shareIn() 传递了参数 SharingStarted.Eagerly,Eagerly 是 "急切地、急迫地" 的意思,它会立刻调用上游 Flow 的 collect() 启动其生产流程,并将收集到的每个数据转发给所有收集者。第一条数据是在第 1 秒时被生产出来的,但是在 1.5 秒时才调用的 SharedFlow.collect(),所以两个订阅者只收到了数据(事件) 2 和 3。

热的 SharedFlow

观察上面的运行结果,会发现 SharedFlow 是一种 Hot Flow(热流),而普通的 Flow 是一种 Cold Flow(冷流)。

何谓冷流🥶和热流🥵呢?冷热的概念来源于 Flow 的特性,它们描述了流的行为方式和数据的发出时机。

冷流指的是每次收集(collect)流时,数据生产线会从头开始执行、发出数据。称其为冷流是因为它的行为像是在冷藏的状态下,只有在“激活”它(即调用 collect)时,才开始从头运行并发出数据。

🥶 冷流的特点:

  • 懒加载:数据的生产代码只会在开始收集时才开始执行。
  • 冷流的数据生产和消费是一体的,每次调用 collect() 时,流会从头开始生产数据。
image (6).png

热流指的是流的数据是实时生成并广播给所有观察者的。也就是说,热流的生产者在流创建后就会生产并发射数据,而不管有没有人去收集它,热流的数据生产和消费是独立的。称其为热流是因为它的行为像是一个实时的广播,总是活跃的,像是一个正在加热的设备,不会因是否有人“收听”而改变其状态。

🥵热流的特点:

  • 非懒加载:热流会立即开始生产并发出数据。
  • 热流的数据生产和消费是独立的,多个收集者可以共享数据。

再看这张图,应该非常容易理解 SharedFlow 的“热”了:

虽然 SharedFlow 被 collect() 了多次,但它们都共享同一套数据源,而不是各自一套,这就是 SharedFlow 中的 Share 的意思,即共享。

永不结束的收集

SharedFlow 与普通 Flow 的 collect() 不同,它的收集并不会随着上游 Flow 的生产过程结束而一起结束:

suspend fun main(): Unit = coroutineScope {
  val flow: Flow<Int> = flow {
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }

  launch {
    flow.collect {
      println("[flow collector]: $it")
    }
    println("[flow collector] END")
  }

  launch { // this: CoroutineScope
    val sharedFlow: SharedFlow<Int> = flow.shareIn(
      scope = this,
      started = SharingStarted.Eagerly,
    )
    sharedFlow.collect {
      println("[shared flow collector]: $it")
    }
    println("[shared flow collector] END")
  }
}

/*
[shared flow collector]: 1
[flow collector]: 1
[flow collector]: 2
[shared flow collector]: 2
[shared flow collector]: 3
[flow collector]: 3
[flow collector] END    👈 只有普通 Flow 的 collect 函数返回了
*/

即使数据全部生产完毕,SharedFlow.collect() 的下一行代码也得不到运行,因为它的收集流程是不会结束的。

点开 SharedFlow 的源码可以看到:

// Flow.kt
public interface Flow<out T> {
  public suspend fun collect(collector: FlowCollector<T>) // 没写返回值类型,默认 Unit
}

// SharedFlow.kt
public interface SharedFlow<out T> : Flow<T> {
  override suspend fun collect(collector: FlowCollector<T>): Nothing // 👈
}

它把 Flow 的 collect 函数重写了,返回类型是 Nothing,也就是说它要么永不返回,要么抛异常。

SharedFlow.collect() 虽然永远不会返回,但不代表它永远不会结束,collect() 终究是一个挂起函数,它必须在协程里面运行,如果协程被取消,collect() 函数自然就会被取消(结构化取消),因为 SharedFlow 的 collect() 函数做了交互式取消的工作:

// SharedFlow.kt
internal open class SharedFlowImpl<T>(
  ...
) : AbstractSharedFlow<SharedFlowSlot>(), MutableSharedFlow<T>, CancellableFlow<T>, FusibleFlow<T> {

  override suspend fun collect(collector: FlowCollector<T>): Nothing {
    val slot = allocateSlot()
    try {
      if (collector is SubscribedFlowCollector) collector.onSubscription()
      val collectorJob = currentCoroutineContext()[Job]
      while (true) {
        var newValue: Any?
        while (true) {
          newValue = tryTakeValue(slot)
          if (newValue !== NO_VALUE) break
          awaitValue(slot)
        }
        collectorJob?.ensureActive() // 👈 注意这里的 ensureActive
        collector.emit(newValue as T)
      }
    } finally {
      freeSlot(slot)
    }
  }
}

Flow.shareIn() 参数详解

// Share.kt
fun <T> Flow<T>.shareIn(
  scope: CoroutineScope, 
  started: SharingStarted, 
  replay: Int = 0
): SharedFlow<T>

scope: CoroutineScope

第一个参数需要填的是一个 CoroutineScope,跟 Flow.launchIn() 是一样的,它决定从哪里启动新协程,然后在这个新协程里去收集上游 Flow。

replay: Int

第三个参数,replay “重播“

在前面的例子里我们发现 SharedFlow 的 Subscriber (Collector) 是收不到它收集开始前流生产的数据的。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val sharedFlow: SharedFlow<Int> = flow {
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }.shareIn(
    scope = this,
    started = SharingStarted.Eagerly,
    replay = 0, // 默认值
  )

  launch {
    delay(1500)
    sharedFlow.collect {
      println("collect1: $it")
    }
  }

  launch {
    delay(2500)
    sharedFlow.collect {
      println("collect2: $it")
    }
  }
}

// collect1: 2
// collect1: 3
// collect2: 3

比如上面的代码,数据 1、2、3 分别在第 1、2、3 秒被生产出来,但第一次收集是在 1.5 秒开始的,错过了数据 1,第二次收集是在 2.5 秒开始的,错过了数据 1 和 2。

image.png

参数 replay 指定的是 SharedFlow 的重放数量。

缓存

若将 replay 指定为 n(非负数,默认为 0),那么 SharedFlow 就会开启缓存,意味着它会缓存发射过的最近 n 个数据,在未来发送给新的 Subscriber,即“重放”。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val sharedFlow: SharedFlow<Int> = flow {
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }.shareIn(
    scope = this,
    started = SharingStarted.Eagerly,
-   replay = 0, // 默认值
+   replay = 1,
  )

  delay(1500)
  sharedFlow.collect {
    println("collect $it")
  }
}

+  // collect1: 1
   // collect1: 2
+  // collect2: 2
   // collect1: 3
   // collect2: 3

image.png

上图以 replay = 1 为例 :

  1. Emitter 发送 ❶ ,被 Buffer 缓存;
  2. Subscriber1 订阅 SharedFlow 后,接收到缓存的 ❶ 进行处理;
  3. Emitter 发送 ❷,替换掉 Buffer 里的 ❶,同时发给现有的 Subscriber1 处理;
  4. Subscriber2 订阅 SharedFlow 后,接收到缓存的 ❷ 进行处理;
  5. Emitter 发送 ❸,替换掉 Buffer 里的 ❷,同时发给现有的 Subscriber1 和 Subscriber2 处理;

Q1:为什么 buffer size = 64,不是说 replay 指定缓存多少个最近的数据吗?
A1:由 Flow.shareIn() 创建出来的 SharedFlow,如果 replay < 64,则 buffe size = 64,如果 replay > 64,那么 buffer size = replay。

Q2:buffer 大小(64)完全够容纳两个数据,为什么 ❷ 进入 buffer 时要把 ❶ 替换掉?
A2:因为 replay = 1,只需要缓存一个最新的数据,用于在未来发给新的订阅者,将旧的 ❷ 留在 buffer 里面没有意义。

以上例子体现了 replay 的缓存功能:能将最近发射的数据缓存下来,发给新增的 Subscriber。

缓冲

在上面我们接触了 SharedFlow 的 buffer,buffer 配合 replay 起到了缓存的作用。其实 buffer 还有另一个作用:在下游来不及消费时缓冲上游的数据。

在生产者-消费者模型中,当生产速度超过消费速度时,系统需要采取措施加以控制,通常的做法是暂停生产或丢弃来不及消费的数据。这种现象在 SharedFlow 中同样存在:当 Subscriber 的处理效率较低时,Buffer 中的数据若无法及时处理,缓冲区总有一刻将被填满。默认情况下,buffer 被填满后数据发射操作会被挂起(onBufferOverflow = SUSPEND)。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  flow<Int> {
    val start = System.currentTimeMillis()
    (1..100).forEach {
      emit(it)
      println(
        "emit $it finished at " +
          (System.currentTimeMillis() - start)
          .toDuration(DurationUnit.MILLISECONDS)
          .toString()
      )
    }
  }.shareIn(
    scope = this,
    replay = 1,
    started = SharingStarted.Eagerly,
  ).collect {
    delay(3000)
  }
}

上面的代码里,生产每条数据几乎不耗时,处理每条数据则要花费 3 秒。

  1. Emmiter 发送 1,进入 buffer 被缓存,同时发给 Subscriber 处理(耗时 3 秒);
  2. Emmiter 发送 2,替换掉 buffer 中的 1,替换掉是因为 replay = 1,只需保留最新的 1 条数据;
  3. Emmiter 发送 3,进入 buffer 被缓冲,它不能替换掉 2,因为 2 还没有被现有的所有 Subscriber 消费,还得留着 2,等 Subscriber 处理完 1 了,把 2 给它处理;
  4. ...
  5. Emmiter 发送 66,想要进入 buffer,此时 buffer 已经满了,于是生产流程只能停止(挂起);
  6. 直到第 3 秒,数据 1 被处理完毕,Subscriber 开始处理 buffer 中的 2,此时 2 被现有的所有 Subscriber 消费了,它不需要被缓冲了,同时,因为 replay = 1,2 不是最新那条数据,也没必要留着发送给未来的新订阅者。是时候将 2 逐出 buffer 了,腾出位置让门外的 66 进来。
  7. 生产流程得以继续,Emitter 发送 67,想要进入 buffer,此时 buffer 又已经满了,于是生产流程又只能停止(挂起)了......

注意 SharedFlow 作为一个多播可以有多个 Subscriber,上面例子中,数据 2 被消费的时间点,取决于最后一个开始处理它的 Subscriber。

为了学习 Flow.shareIn() 函数参数 replay,我们学了 SsharedFlow 的 buffer,知道它可以配合 replay“缓存”最新的数据,以便在未来发给新的订阅者。还知道了 buffer 可以在下游来不及消费时“缓冲”上游生产的数据。

started: SharingStarted

Flow.shareIn() 最后一个参数,SharingStarted,直译就是“共享开始的时间”,

// SharingStarted.kt
public fun interface SharingStarted {
  
  public companion object {

    public val Eagerly: SharingStarted = StartedEagerly()

    public val Lazily: SharingStarted = StartedLazily()

    public fun WhileSubscribed(
      stopTimeoutMillis: Long = 0,
      replayExpirationMillis: Long = Long.MAX_VALUE
    ): SharingStarted =
    StartedWhileSubscribed(stopTimeoutMillis, replayExpirationMillis)
  }
}
SharingStarted.Eagerly

Eagerly 的意思是“急切地、急迫地”,也就是在调用 Flow.shareIn() 的时候就立马开始启动上游数据的生产。

SharingStarted.Lazily

lazy 就是懒的意思,填入 SharingStarted.Lazily,那么在调用 Flow.shareIn() 的时候,不会立马开始启动上游 Flow 的生产流程,直到第一次调用 SharedFlow.collect()

SharingStarted.WhileSubscribed

第三种 SharingStarted 叫 WhileSubscribed,通过它可以不仅可以设置在第一次调用 SharedFlow.collect() 的时候启动上游 Flow 的生产,还可以配置上游 Flow 结束和重启的规则。

使用 SharingStarted.WhileSubscribed(),当下游的所有订阅全都结束之后,它会把上游的所有的生产过程也结束掉,而且这时候如果再来新的订阅,它就会重新启动上游的数据流。

比如下面的代码:

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val flow = flow {
    println("start emit")
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }

  val sharedFlow = flow.shareIn(
    scope = this,
    started = SharingStarted.WhileSubscribed(),
    replay = 1,
  )

  val job = launch {
    println("collect1 start")
    sharedFlow.collectIndexed { index, value ->
      println("collect1: $value")
      if (index == 1) cancel() // 收集完第二个数据后停止收集
    }
  }

  job.join()
  println("collect1 finished")
  delay(1000)

  launch {
    println("collect2 start")
    sharedFlow.collect {
      println("collect2: $it")
    }
  }
}

第一次收集的时候,收集了两个数据就结束收集了,此时唯的一 collect 已经结束,所以上游的 Flow 生产流程也结束了,随后再次调用 SharedFlow.collect(),上游 Flow 生产流程再次被启动了,所以打印了两次 "start emit".

请注意,第二次调用 SharedFlow.collect() 时,上游的生产还没被重启,就先打印了一条数据(2),这是因为参数 replay = 1,那是 SharedFlow 缓存下来的数据。

WhileSubscribed() 有两个参数,它们都是用于配置延时。

stopTimeoutMillis: Long

stopTimeoutMillis 用于配置结束的延时。具体来说,如果 SharedFlow 的所有 collect 都结束了,此时先不结束上游 Flow 的生产流程,从现在开始计时,如果计时过程中,有新的 SharedFlow.collect() 被调用,那么就不取消上游 Flow 的生产流程了。当然,如果计时结束了仍没有新的 SharedFlow.collect() 被调用,那么就会正式结束上游 Flow 的生产。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val flow = flow {
    println("start emit")
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }

  val sharedFlow = flow.shareIn(
    scope = this,
    started = SharingStarted.WhileSubscribed(
+     stopTimeoutMillis = 1000,
    ),
    replay = 1,
  )

  val job = launch {
    println("collect1 start")
    sharedFlow.collectIndexed { index, value ->
      println("collect1: $value")
      if (index == 1) cancel()
    }
  }

  job.join()
  println("collect1 finished")
- delay(1000)
+ delay(500)

  launch {
    println("collect2 start")
    sharedFlow.collect {
      println("collect2: $it")
    }
  }
}

在上一个例子的基础上,我们把代码改改,延迟 1 秒结束上游 Flow(stopTimeoutMillis = 1000),在第一次收集结束后,隔 0.5 秒就启动二次收集。

"start emit" 只打印了一次,可见上游 Flow 确实没有重启。

replayExpirationMillis: Long = Long.MAX_VALUE

第二个参数, replayExpirationMillis,replay expiration millis 重播过期毫秒?

它设置的是缓存的失效时间。在上游 Flow 结束了,并且设置的 replayExpirationMillis 也超时之后(期间没有新的 SharedFlow.collect() 调用),SharedFlow 缓存下来的那些最近数据就会被清空。

replayExpirationMillis 的默认值是无限大,也就是永远不会在上游 Flow 结束后丢弃缓存的最近数据,如果设置成 0,它就是在上游 Flow 结束的一瞬间就清空缓存的最近数据。

suspend fun main(): Unit = coroutineScope { // this: CoroutineScope
  val flow = flow {
    println("start emit")
    (1..3).forEach {
      delay(1000)
      emit(it)
    }
  }

  val sharedFlow = flow.shareIn(
    scope = this,
    started = SharingStarted.WhileSubscribed(
-      stopTimeoutMillis = 1000,  
+      stopTimeoutMillis = 0,
+      replayExpirationMillis = 250,
    ),
    replay = 1,
  )

  val job = launch {
    println("collect1 start")
    sharedFlow.collectIndexed { index, value ->
      println("collect1: $value")
      if (index == 1) cancel()
    }
  }

  job.join()
  println("collect1 finished")
  delay(500)

  launch {
    println("collect2 start")
    sharedFlow.collect {
      println("collect2: $it")
    }
  }
}

这次我们仍然在上个例子的基础上修改,所有 collect 结束收集时,立马停止上游 Flow 的生产,同时将缓存过期时间设置为 250 毫秒,由于两次收集间隔为 500 毫秒,所以缓存下来的数据 2 被提前清空了,并没有被发送给新的订阅者。

stopTimeoutMillis 和 replayExpirationMillis 两个参数都是配置延时,一个是对于结束 Flow 的延时,一个是 Flow 结束之后,清空缓存数据的延时。

如果程序里有一个可以被订阅的事件流,该事件流会在多个地方被订阅,同时这个事件流的生产流程非常消耗资源,需要在所有订阅都结束的时候,及时的结束 Flow 的生产,这种场景下就非常适合用 WhileSubscribed() 来配置这种自动结束自动重启的 SharedFlow 了。

SharedFlow 的使用场景

所谓深入浅出,最后总结一下 SharedFlow 的几个特性以及它的使用场景。

SharedFlow 是一种事件流工具,自然是适用于 "事件订阅" 的场景。为什么它适合事件订阅?因为共享,多个 Collector / Subscriber 可以共享同一个生产流程。这究其根本是因为 SharedFlow 是“热”的,它的数据生产和消费是独立的。

也正是因为生产和消费是独立的,数据的生产可能比数据收集要早,如果一个流的初始化工作非常耗时,也可以利用这一点特性,使用 SharedFlow 让生产流程提前启动。但是要注意,数据生产早于数据收集意味着可能会漏数据,因为 SharedFlow 不像普通 Flow 那样每次都从头完整地收集数据,它只关心收集过程中流产出的每条数据。

"事件订阅"需要的就是共享同一套生产流程,它也不需要从头开始收集数据,因此 SharedFlow 最典型的应用场景就是事件流。

当然,如果你有任何的场景,需要共享同一套生产流程、或者将数据生产和消费的流程拆分,能够接收"漏数据"的后果,那么都可以考虑使用 SharedFlow。


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