Kotlin Flow 冷热流和各种操作符的使用以及源码分析

Flow 流啊流,游啊游,数据你该流向何方?

冷流使用

冷流是指一种不主动产生数据的流,只有在被下游订阅时才开始产生数据,并且当多个流组合在一起,上游是冷流下游是热流时,调用多次上游冷流emit发送数据,下游热流只会收到一次冷流发送的数据,也就是最开始那次,后续的都收不到,这点后面会详细讲。冷流使用如下

热流使用

热流和冷流相反,热流会主动产生数据并把数据放入缓冲区,无论下游订不订阅热流都存有数据,根据使用场景有SharedFlow 和 StateFlow , StateFlow继承自SharedFlow ,并且StateFlow 里面有个value ,如下

关于StateFlow 这个value 的作用后面详细分析,我们先来看下SharedFlow 共享热流 ,为什么叫共享流后面看完的分析就能理解其共享的含义了,我们先来看看SharedFlow的使用吧。官方给我们提供了MutableSharedFlow 方法 ,用来生成一个可读可写的共享热流,可读是指下游可订阅collect , 可写是指上游可发送数据emit,如下

MutableSharedFlow 使用

方法中三个参数含义如下,

  • replay:重读replay的数据项数量,默认为 0。当有新的订阅者加入时,会从缓冲区取最近replay个数据项重新发送给它们,所以这个也可以理解为缓冲区的基础容量。
  • extraBufferCapacity:指额外缓冲区容量,默认为 0。当缓冲区超过基础容量时,可以使用额外的缓冲容量来存储数据项。
  • onBufferOverflow:指定缓冲区溢出时的处理策略,默认为 BufferOverflow.SUSPEND。可以选择 BufferOverflow.DROP_OLDEST 或 BufferOverflow.DROP_LATEST,以确定要丢弃最旧的数据项还是最新的数据项,还是选择挂起等待有空间不丢失任何数据。

总结一下: 最大缓冲区容量 = replay +extraBufferCapacity。 缓冲区溢出策略有三种,丢弃新的(丢弃新的指不让新的加入缓存区了,而不是指从缓冲区移除最前面已经缓存了的)和丢弃旧的的和都不丢弃挂起等待。

好了,了解清楚了之后我们来看个例子。

我们使用MutableSharedFlow 来生成一个可读可写的共享热流,将参数replay =3 , extraBufferCapacity = 0 ,onBufferOverflow = BufferOverflow.DROP_LATEST ,表明指定订阅者订阅的时候从缓冲区中读取三个数据,以及额外缓冲区为 0 ,此时最大缓冲区 = 0 + 3 =3, 缓冲区满了策略为不允许新的加入。所以不出意外的话此时运行应该会打印1,2,3,实践出真知,我们运行一把看看。

看了实际运行结果,还是出了意外了,两个下游都会收到 4 ,5 ,6 ,为什么会这样呢?read fuck source code,发现因为此时还没有订阅者,所以extraBufferCapacity , onBufferOverflow 这两个参数此时是没效果的 ,当没有下游订阅者的时候,无论emit 多少个数据,都只会缓存replay个最近的数据,超过replay了会把前面老的已缓存的数据丢弃,源码如下 。

看源码也很容易理解,nCollectors =0 表明订阅者数量为0, 不走下面溢出逻辑, 并且当bufferSize > replay,会把前面的老的数据丢弃。

当我们把订阅者collect 挪到 emit 之前,就会出现我们之前预料的答案了,两个订阅者只会打印1,2,3

而且这种情况无论你把for循环中5改成10000或者多大,下游都只会收到1,2,3,因为后面的数据不会放入缓冲区中。

MutableSharedFlow增加最大缓冲区的问题

我们再改一下这个例子,把for循环最大改成1000,然后把溢出策略改成 BufferOverflow.DROP_OLDEST 移除旧的,然后额外容量加个4,此时最大缓冲区容量为7,这个时候会打印什么?两个收集者会打印1到1000,还是打印994到1000这七个?

实际运行两个下游订阅者会打印994到1000

由此可见上游同一时间emit 1000次数据,下游并不会立即收到,而是先把值放入缓冲区,然后等同一时间段内1000次emit都结束后再从缓冲区拿数据发送给下游。这里因为最大缓冲区最大值为7个,所以只打印七个数据。

有没有让下游收到1000次数据呢?当然是有的,只在 for 循环中加个delay(1)就行了,有没有不加delay(1)更优雅的处理方式呢?

不加delay 把额外缓冲区改成1000或者Int.MaxValue,然后溢出策略改成挂起/移除,可以吗?按理说这种情况应该是最好的,但是实际试了之后,结果并非如此,运行了十几次,只有2-3次两个收集器都收到1到1000的情况,大部分情况都是一个收集器收到1000另一个出现数据丢失的情况,不按套路出牌啊,不是说好溢出策略为挂起就不会出现数据丢失吗?方法没用对?将tryEmit改成emit ,令人遗憾结果还是一样数据丢失。

如何在不加delay保证数据不丢失呢?增加基础容量replay 可以吗,经测试把replay 改成2000也还是会有数据丢失的情况。

最后测试把最大缓冲区改成1或者0 ,即0<=replay+extraBufferCapacity<=1,然后溢出策略改成挂起,这种也是默认构建共享热流的方式 ,用emit就不会出现数据丢失情况了,所以通过增大缓冲区来减少数据丢失反而有点南辕北辙了。

总结一下

如果热流MutableSharedFlow,增加了最大缓冲区,并且最大缓冲区大于1,当一次性发送很多数据的时候,会出现数据丢失的情况,这种情况也不是多线程的原因导致的,上述测试代码都跑在主线程中。关于SharedFlow就先了解到这里,接下来看下StateFlow。

StateFlow 使用

官方给我提供了MutableStateFlow方法 ,参数可以传入任意值。

是不是当这个值/地址改变了,下游就会收到,没改变就不会收到?下游一订阅就会收到这个传入的默认值?写个例子试试

果不其然和我们想象一样,与SharedFlow相比 StateFlow使用还是比较简单的,因为SharedFlow多了个缓冲区链表,而StateFlow就只有个value,StateFlow emit方法实现也是比较简单的。

接下来我们来看下flow那些操作符的作用和使用。

Flow 操作符

combine

用于将两个流(Flow)合并为一个新的流,并根据提供的转换函数对它们进行组合处理。

看官方注释就很好理解了,combine 返回的是一个冷流,collect 时候会先从另外两个流拿到数据,再经过闭包里的函数处理再发送给下游。

flatMapLatest

先看官方注释

根据注释,我们可以看到 flow 发射的数据会经过flatMapLatest 中的那条流,所以最终到下游的数据全取决于flatMapLatest中的那条流。

flatOn

定义上游在什么线程执行

举个栗子

下面来个实际例子来讲解这些操作符

flow1 状态热流 和 flow2 冷流并成一条新流combineFlow 冷流 ,然后combineFlow.flatMapLatest 插入了一条中流,中流之中又有中流。然后combineFlow .stateIn 转成热流 hotFlow,stateIn中设置了协程域以及停止订阅上面这些流的超时时间和状态热流的初始值。流都定义好了,就要使用了,下面是对这些流的使用

先给hotFlow注册下游收集器收集数据,然后 上游热流flow1,冷流flow2 ,中游热流 1,2,3 依次发送数据,猜会打印什么?直接发出来吧

嘿嘿上游全军覆没,上游发的数据下游都收不到。看打印我们发现只有第一次hotFlow.collect()的时候冷流flow2有效果 ,后续调用冷流flow2.collect触发emit 都没用,在主线程执行,下游中游都不会收到数据,为什么会这样,后面讲了冷流的原理大家就知道了。

上游热流flow1发送了数据,中游能收到,但是下游收不到,下游之所有收不到数据是因为热流发送的 10 + 第一次冷流的数据7964 大于 10 了,再一次执行midFlow1.flatMapLatest 其实啥事没干,没触发midFlow3.emit 动作,只有触发midFlow3.emit 动作下游才会收到数据,也就解释了中游发送数据为什么下游都能收到。

冷流实现原理

最简单的flow使用例子如下

scss 复制代码
      MainScope().launch() {
            (1..3).asFlow().collect{}
      }

在这个实例中,上游发送1,2,3 三个数据,下游会按顺序收到1,2,3。asFlow方法会将IntRange转为Flow

继续跟进flow{ } 闭包,会调用到unsafeFlow(block)

kotlin 复制代码
@PublishedApi
internal inline fun <T> unsafeFlow(@BuilderInference crossinline block: suspend FlowCollector<T>.() -> Unit): Flow<T> {
    return object : Flow<T> {
        override suspend fun collect(collector: FlowCollector<T>) {
            collector.block()
        }
    }
}

Flow是一个接口,这里构建了一个flow的匿名内部类并返回,内部类里实现了Flow的collect方法,参数 block: suspend FlowCollector.() -> Unit 可以理解为FlowCollector类的一个参数扩展方法,或者就理解为扩展方法也可以,但这个扩展方法只能在这方法才能里调用,作用域就是此方法。

调用collect要求传一个流收集器FlowCollector ,FlowCollector 有个挂起方法 emit ,通过实现这个方法就可以收集上游发送过来的数据了。

kotlin 复制代码
public fun interface FlowCollector<in T> {
    public suspend fun emit(value: T)
}

总结一下: 构建Flow会实现collect方法,collect方法里面调用了FlowCollector 的 block 扩展方法,FlowCollector的block 扩展方法中,又会调用FlowCollector的emit 方法,emit 方法发送数据给FlowCollector,至此数据就传输完了。

可以看到这种传输方式,控制权完全是在下游,只有下游调用了collect方法,Flow才会传输数据,这种方式又被称为冷流。

冷流实现原理也很简单,上游(发送端)实现了FlowCollector的扩展方法block , 并在Flow的collect方法中调用block方法,block方法内部调用了emit。下游(收集端)实现了FlowCollector的emit方法接受数据。下游调用flow.collect 方法传入收集器,并触发上游发送操作。

操作符flowOn切换线程源码分析

flowOn用于指定上游在哪个线程中执行,其实这样说法其实也并不准确,当我们指定flowOn(Dispatchers.IO) , 上游就是在IO线程中执行了,所以这种说法又是准确的。

flowOn 通俗一点说就是修改上游的协程上下文,并且返回一个Flow。

以上面例子和flowOn(Dispatchers.IO)为例

kotlin 复制代码
public fun <T> Flow<T>.flowOn(context: CoroutineContext): Flow<T> {
    return when {
        context == EmptyCoroutineContext -> this
        this is FusibleFlow -> fuse(context = context)
        else -> ChannelFlowOperatorImpl(this, context = context)
    }
}

调用flowOn ,最终会返回ChannelFlowOperatorImpl ,看着有点像静态代理模式的感觉,传入原始的Flow并返回一个继承自FLow的ChannelFlowOperatorImpl。继承链如下

ChannelFlowOperatorImpl <- ChannelFlowOperator <- ChannelFlow <-FusibleFlow <- Flow。

可以看到为了支持上游切换到IO线程,足足多了4层继承关系。

ChannelFlowOperator 的collect方法, 先判断传入的coroutineContext, 如果和当前的协程上下文一样则啥事不干直接调用原来的flow.collect,如果 newContext[ContinuationInterceptor] == collectContext[ContinuationInterceptor] ,新的和旧的上下文要调度的线程一样,即不切换线程,这种情况不需要使用channel,上游会新建并开启一个协程并在新协程中调用flow.collect 。

如果newContext[ContinuationInterceptor] != collectContext[ContinuationInterceptor] 并且 newContext[ContinuationInterceptor] != collectContext[ContinuationInterceptor] ,这种情况下游会调用suspendCoroutineUninterceptedOrReturn 挂起当前协程,并启动新协程 去执行collector.emitAll(produceImpl(this)) , produceImpl 会返回ReceiveChannel对象实例,并且以新的协程上下文启动协程 , 两个协程通过ReceiveChannel连接,并且原FlowCollector会替换成SendingCollector

kotlin 复制代码
@InternalCoroutinesApi
public class SendingCollector<T>(
    private val channel: SendChannel<T>
) : FlowCollector<T> {
    override suspend fun emit(value: T): Unit = channel.send(value)
}

可以看到原来的FlowCollector 的emit方法被channel.sned取代了。这点就可以证明为什么下游emit函数执行的线程和上游为什么不是同一个了,因为在这里被替换了 ,原来FlowCollector的emit函数不执行了。

那么原来FlowCollector的emit函数 在哪里执行?

原来Flow的 collect(collector) ,collector被替换成SendingCollector ,collect方法会被封装成一个block , 并在新协程中执行。

新Flow的collect(collector)中依旧使用原来的collector ,并且在原来的协程作用域中启动协程,然后通过channel获取原来Flow emit过来的值 ,最终调用原来的collector 的emit方法把值发送给下游 。

总结 :

调用flowOn 会返回一个新的flow(内部持有原来的flow), 原来的flow 的 collector被替换成了SendingCollector ,并且原来flow的collect 会被封装成一个挂起block 函数,并用flowOn传入的上下文开启子协程,通俗一点来说就是原来的 flow的collect 会在新开启的子协程中执行。

返回的新的flow(也就是我们下游使用的那个flow), collect方法中也会开启一个协程,但这个协程上下文还是使用原来的 ,新开启的协程中会通过Channel获取从旧flow传过来的值,然后调用原来collector的emit 方法,把值转发给下游。

热流SharedFlow 源码分析

先看下基础使用

println("collect")那里爆黄了,编译器提示Unreachable code ,提示我们println("collect") 永远都不可能调用,其实就算我们加上,KT编译器最终也会帮我们把这行代码删了。

collect 方法中有什么黑魔法,导致后面的代码调用不到了。collect方法如下,

kotlin 复制代码
 override suspend fun collect(collector: FlowCollector<T>): Nothing

仅仅是后面加了个Nothing返回值,

vbnet 复制代码
 Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
 if a function has the return type of Nothing, it means that it never returns (always throws an exception).

关于Nothing 官方是这样注释的,Nothing没有实例, 但是Nothing是任何类的子类 ,当Nothing作为函数的返回值,这个函数始终会抛异常。

所以当我们调用完collect之后会抛出异常,抛出异常方法会结束执行导致后面的println("collect")代码执行不到了,关于Nothing先了解到这。

SharedFlow collect方法解析

讲解collect 先来看个例子

这个例子会输出什么结果?会阻塞主线程吗?

答案是: 会每隔5s 打印ddd,并阻塞不会主线程。因为suspendCancellableCoroutine 方法会挂起当前协程,虽然在主线程执行 ,但是test方法被 return了,while true 跳出了循环。

跟进SharedFlow#collect方法

kotlin 复制代码
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()
                collector.emit(newValue as T)
            }
        } finally {
            freeSlot(slot)
        }
 }

这个方法主要有三步:

  1. 给每个收集者分配一个槽位,slots 的初始大小为2,超过阈值会翻倍扩容。SharedFlowSlot里面有个续体cont,Flow调用emit的时候会调用该续体的resume方法。
  2. 如果是订阅类型的收集器,调用onSubscription
  3. 开启while true 循环,如果上游没有发送数据则挂起,有数据则调用resume 恢复执行,接着调用emit将数据发送给下游

emit方法里面会先调用非挂起的方法tryEmit去发送数据,如果tryEmit发送失败才调用挂起方法 emitSuspend(value) ,tryEmit如下

kotlin 复制代码
    override fun tryEmit(value: T): Boolean {
        var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
        val emitted = synchronized(this) {
            if (tryEmitLocked(value)) {
                resumes = findSlotsToResumeLocked(resumes)
                true
            } else {
                false
            }
        }
        for (cont in resumes) cont?.resume(Unit)
        return emitted
    }

剩下的源码分析之后有时间再写续章 。

最后,给自己打个广告!

求职求职求职!!!

个人简介:

两年半 + 两年半 经验老安卓 ,安卓原生开发 / NDK 开发/ SDK 开发/ 逆向 / flutter 都有涉猎。

可内推的大佬们麻烦直接在掘金直接私聊我!

谢谢!!

相关推荐
闲暇部落4 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
长亭外的少年20 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
JIAY_WX20 小时前
kotlin
开发语言·kotlin
麦田里的守望者江1 天前
KMP 中的 expect 和 actual 声明
android·ios·kotlin
菠菠萝宝1 天前
【YOLOv8】安卓端部署-1-项目介绍
android·java·c++·yolo·目标检测·目标跟踪·kotlin
恋猫de小郭1 天前
Kotlin Multiplatform 未来将采用基于 JetBrains Fleet 定制的独立 IDE
开发语言·ide·kotlin
枫__________2 天前
kotlin 协程 job的cancel与cancelAndJoin区别
android·开发语言·kotlin
鸠摩智首席音效师2 天前
如何在 Ubuntu 上配置 Kotlin 应用环境 ?
linux·ubuntu·kotlin
jikuaidi6yuan4 天前
Java与Kotlin在鸿蒙中的地位
java·kotlin·harmonyos
liulanba4 天前
Kotlin的data class
前端·微信·kotlin