对比 RxJava 入门 Kotlin-flow

3,341 阅读11分钟

学习 flow 之前,最好先学习一下 kotlin 的 协程 和 Channel,flow 是属于协程标准库的。但是 flow 的思想和 RxJava 一致,如果熟悉 RxJava,学起来也没有门槛。你可以认为 flow 就是 kotlin 版的 RxJava。Channel 是用于协程之间通信的的数据通道。flow 的部分操作符的原理借助了 Channel 来实现。

kotin 官方文档有提到 flow 的灵感来源于 RxJava,所以看起来这两者很像,不同的是 flow 的是基于 kotlin,基于协程。

对于使用者才说,flow 的 api 很友好,相比 RxJava 很直观简洁容易理解。

flow 文档: coroutines-Asynchronous Flow 

为什么要学习 flow?

优点:

  • 目前 kotlin 是官方亲儿子,无缝衔接 kotlin
  • 只需引入 kotlin 协程库就送一个 flow,方法数和体积都小,本身就是基于协程实现,所以和协程完美融合
  • 简洁友好的 api,这一点给我的感觉就是 api 字面上的意思对应的就是实际的操作,不看源码只看 api 就知道是什么意思。
  • 自定义操作符相对容易(看了一些自带操作符的实现,没有 rxjava 那么复杂)

缺点:

  • 操作符没有 RxJava 那么丰富,但是够用,部分操作符还处于预览状态
  • 你的 kotlin 版本最好 >= 1.3.0,因为 1.3.0 是 flow 的第一个 stable release

基本概念和使用

和 RxJava 类似,flow 也是基于流式(Stream)的数据处理。也是观察者模式,为了方便理解,以下的例子都会和 RxJava 作比较来方便理解。

先看一个 RxJava 的例子:

   Observable.create<Int> {e->
        listOf<Int>(1,2,3).forEach {
            e.onNext(it)
        }
    }.subscribe { 
        println(it)
    }
   
   // 日志:
   // 1
   // 2
   // 3

这段代码将 list 的数据依次发射了出去然后打印出来。

Observable 默认是 cold 的,必须订阅才能发射数据。create 创建数据源,通过 subscribe 订阅激活数据源的发射。

我们来看看相同逻辑的 flow 的实现:

    flow {
        listOf<Int>(1,2,3).forEach {
            emit(it)
        }
    }.collect{
        println(it)
    }
   // 日志:
   // 1
   // 2
   // 3

很简单,照猫画虎就可以直接上手了。flow 函数负责创建数据源,对应 RxJava 的 create,collect 对应 RxJava 的 subscribe。同样 flow 默认也是 cold 的,只有在 collect 之后数据流才会发射。

flow 使用 emit 来发射数据

对应 RxJava 的 onNext。

flow 中也有一个创建数据流便捷的方法:flowOf()

  • flowOf(1):等价于 Observable.just(1)

  • flowOf(1, 2, 3):等价于 Observable.fromArray(1, 2, 3),按顺序依次发射 1,2,3

切协程

相信大家初次使用 RxJava 基本都是用来做网络请求切线程(杀鸡用牛刀^_^):

fun getHttp(){
    httpObservable.subscribeOn(IoThread)
        .observeOn(MainThread)
        .subscribe{
            // success
           
        }
}

那么 flow 怎么切线程呢?相比较 RxJava,flow 设计的很简单, 只提供了一个 flowOn 函数来切协程。在 flowOn 上游的作用域会在其协程中执行,看如下代码:

    suspend fun getFlow(){
        flow {
            // 运行在 dispatcher1
            listOf(1, 2, 3).forEach {
                emit(it)
            }
        }.flowOn(dispatcher1).map {
            // 运行在 dispatcher2                
            "2"
        }.flowOn(dispatcher2).collect {
            // 运行的协程取决于整个 flow 在哪个协程调用
            println(it)
        }
    }

可以看到除了 collect 每个 flowOn() 都会影响它之前上游作用域运行的协程。那么 collect 运行在哪个协程呢,取决于你在哪个协程里调用 getFlow。

launch(dispather3){
    // collect 运行在 dispather3
    getFlow()
}

好了,学会怎么切协程之后,你就可以把你的 RxJava 网络请求那一套换掉了(手动狗头):

fun getHttp(){
    launch(mainDisPather){
        flow<HttpModel>{
            // 发起请求
        }
        .flowOn(ioDispather)
        .collect{
            // success
           
        }   
    }
}

捕获异常

上述的网络请求在发生 404 之类的错误时,retrofit 框架是直接会抛出一个 HttpException 的,我们的程序就会直接 crash 了。而 RxJava 却没啥事。因为在 RxJava 中,会自动帮我们捕获大部分的 error,捕获不了的,你可以在 RxJavaPlugin 去自定义捕获。那么 flow 怎么捕获呢?

flow 提供了一个操作符: catch()

        flow<Int> {
            emit(1)
            emit(2)
            emit(3)
            emit(4)            
        }.map{
            if(it > 2){
                throw NullPointerException("不应该为 2")
            }else{
                it
            }
        }.catch { e ->
            println(e)
        }.collect {
            println(it)
        }
        
 // log:
 // 1
 // 2
 // java.lang.NullPointerException: 

catch 操作符会把上游的错误捕获,而上游一旦发生错误,数据流就不再向下游发射数据了。所以上述代码只会收到 1 和 2。

这样的设计我觉得更加灵活,按需去捕获异常,RxJava 一股脑的把错误捕获了,有的时候异常直接被 RxJava 吞掉了,你的程序出现了不应该出现的异常而你却不知道,有时候这是很难去发现的。

如果在捕获错误的同时再发射一个值,就等价于 RxJava 的 onErrorReturn()

flow.catch{
    emit(0)
}

回调

在 RxJava 中我们经常使用一些的一些回调,flow 有没有呢?有的!

  • onStart:数据流开始发射

  • onEach:数据流中的每一个数据发射时的回调

  • onCompletion:数据流结束发射

  • onEmpty:当数据流中没有发射任何数据时

  • catch: 发生异常时

用这些回调可以结合业务做一些提示,比如 onStart 的时候就会显示一个 loading,onEmpty 说明请求的数据为空,显示当前没有任何数据。catch 说明请求中出错了,提示错误的信息。onCompletion 代表请求结束,将 loading 隐藏。

        flow {
            emit(1)
            emit(2)
            emit(3)
            emit(4)
        }.catch { e ->
            // 发生了异常。显示异常信息
            showToast("加载错误")
        }.onEmpty {
            // 空白数据
            showToast("什么数据都没有")
        }.onStart {
            showToast("正在加载中")
        }.onEach {
            showToast("开始处理 $it")
        }.onCompletion { e->
            if(e == null){
                showToast("加载结束 $it")
            }else{
                showToast("加载失败 $e")
            }
            
        }.collect {
            showToast("加载成功 $it")
            println(it)
        }

背压

背压的定义:上游发射数据的速度太快了,下游来不及处理。

上游每 1s 发射一个数据,下游每 2s 才处理完一个数据。

和线程池的饱和拒绝策略也是一个道理:RejectedExecutionHandler

生产者的产出速度过快,消费者来不及处理。

flow 不可避免的也绕不过背压的限制,下游无法控制上游发数据的速度,下游需要作出应对策略。

处理背压无非就是缓存之后怎么去取。

先来回忆下 RxJava 的背压策略:RxJava之背压策略

  • MISSING:缓存满了就抛出异常MissingBackpressureException
  • ERROR:直接抛出异常MissingBackpressureException
  • BUFFER:无限增大缓存,不丢任何数据,
  • DROP:缓存满了之后,新的数据会被丢弃
  • LATEST:缓存满了之后,始终会把最新的数据加到缓存区的最后一个

flow 的策略特别简单,使用一个操作符解决:buffer(capity, onBufferOver):

  • capacity:缓存的大小

  • onBufferOverflow:缓存超出的策略。

  • SUSPEND:挂起,等待执行,不丢弃任何数据。效果等同 RxJava 的 BUFFER,但是由于协程的加持不会占用缓存

  • DROP_OLDEST:丢掉老的数据,只处理新数据。等同 RxJava 的 LATEST

  • DROP_LATEST:丢掉新的数据。等同 RxJava 的 DROP

                 buffered values
             /-----------------------\
                                          queued emitters
                                      /----------------------\
     +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
         |   | 1 | 2 | 3 | 4 | 5 | 6 | E | E | E | E | E | E |   |   |   |
         +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
    

    如果 buffer size = 4

    DROP_OLDEST: [ 1,2,3,4] <- [5,6,7] , [1,2,3] 会被丢弃变成 -> [4,5,6,7] DROP_LATEST: [ 1,2,3,4]<- [5,6,7] [5.6.7] 会被丢弃变成-> [1,2,3,4]

    基于 buffer 衍生了一系列和背压有关的操作符:

  • conflate(): 只取最新的数据,等价 buffer(0, DROP_OLDEST),即不缓存数据,直接取最新数据的处理

  • collectLatest():类似conflate,但是不会直接用新数据覆盖老数据,而是每一个都会被处理,只不过如果前一个还没被处理完后一个就来了的话,处理前一个数据的逻辑就会被取消。

  • mapLatest:同理 collectLatest

  • flatMapLatest:同理 collectLatest

对比 flow 和 Rxjava 可以发现策略是完全一样的。都是基于缓存做了一些策略处理。只是 flow 没有错误抛出而已。并且默认的挂起 SUSPEND 不会有内存溢出问题。

// buffer 源码
public fun <T> Flow<T>.buffer(capacity: Int = BUFFERED, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND): Flow<T> {
    
    // 缓存容量必须是 >=0 / BUFFERED/CONFLATED
    require(capacity >= 0 || capacity == BUFFERED || capacity == CONFLATED) {
        "Buffer size should be non-negative, BUFFERED, or CONFLATED, but was $capacity"
    }
    
    // CONFLATED 和 SUSPEND 挂起冲突
    require(capacity != CONFLATED || onBufferOverflow == BufferOverflow.SUSPEND) {
        "CONFLATED capacity cannot be used with non-default onBufferOverflow"
    }
    // desugar CONFLATED capacity to (0, DROP_OLDEST)
    var capacity = capacity
    var onBufferOverflow = onBufferOverflow
    if (capacity == CONFLATED) {
        capacity = 0
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    }
    // create a flow
    return when (this) {
        is FusibleFlow -> fuse(capacity = capacity, onBufferOverflow = onBufferOverflow)
        else -> ChannelFlowOperatorImpl(this, capacity = capacity, onBufferOverflow = onBufferOverflow)
    }
}


        // 丢弃缓存里老的值,缓存新的值
        flow {
            repeat(10000) {
                emit(it)
            }
        }.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST).collect {
            println("backpressureDemo :$it")
        }
        
// log
// backpressureDemo :0        
// backpressureDemo :9990
// backpressureDemo :9991
// backpressureDemo :9992
// backpressureDemo :9993
// backpressureDemo :9994
// backpressureDemo :9995
// backpressureDemo :9996
// backpressureDemo :9997
// backpressureDemo :9998
// backpressureDemo :9999

        // 丢弃新的值
        flow {
            repeat(10000) {
                emit(it)
            }
        }.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_LATEST).collect {
            println("backpressureDemo :$it")
        }
        
// log
// backpressureDemo :0        
// backpressureDemo :1
// backpressureDemo :2
// backpressureDemo :3
// backpressureDemo :4
// backpressureDemo :5
// backpressureDemo :6
// backpressureDemo :7
// backpressureDemo :8
// backpressureDemo :9
// backpressureDemo :10

所以从背压来看 flow api 设计的是不是超级简洁,一目了然?

得益于协程的挂起操作,默认我们不做任何背压处理也不会出现问题,会按顺序慢慢执行。对比 RxJava 那边是不是很方便,RxJava Flowable 写起来也怪麻烦的,强制指定背压策略,- 换 flow 吧!

操作符

除了 flowOn,几乎 RxJava 中的操作符在 flow 里都有对应的。flow 的操作符分类这几大类,每一大类在源码中都有一个独立的 kt 文件

操作符很多,就不一一赘述了,先说几个我们 RxJava 里最常用的。

map

对应 RxJava 的 map

这个很简单,变换操作符。 将数据 A -> B

flowOf(A).map{
    B
}

flatMapConcat(目前还是预览版,后续版本可能会发生改动)

对应 RxJava 的 concatMap

将 flow 里面的值铺平分别再迭代发射,发射是有序的,和生产数据的顺序一致

        // 将 [1,2,3,4,5] 依次发射,再根据 每个值继续发射 一个flow
        flow {
            List(5) { emit(it) }
        }.flatMapConcat {
            flow { List(it) { emit(it) } }
        }.collect {
            println(it)
        }
        
        
        // 输出:
        // 0,0,1,0,1,2,0,1,2,3,0,1,2,3,4

可以看到上面的输出是完全有序的。

flatMapMerge(目前还是预览版,后续版本可能会发生改动)

对应 RxJava 的 flatmap

和 flatMapConcat 类似,但是呢,它的发射是并发的。

// 在 io 协程中才有可能并发
launch(io){
        flow {
            List(5) { emit(it) }
        }.flatMapMerge {
            flow { List(it) { emit(it) } }
        }.collect {
            println(it)
        }
    
}

        // 输出:
        // 0,0,0,1,0,1,1,2,2,3

结果是乱序的,必须在 io 环境下协程才是乱序的哦,如果你启动的还是一个单线程协程,那还是顺序的

flatMapConcat 和 flatMapMerge 内部的实现其实差不多。

@FlowPreview
public fun <T, R> Flow<T>.flatMapConcat(transform: suspend (value: T) -> Flow<R>): Flow<R> = map(transform).flattenConcat()
@FlowPreview
public fun <T> Flow<Flow<T>>.flattenConcat(): Flow<T> = flow {
    collect { value -> emitAll(value) }
}

@FlowPreview
public fun <T, R> Flow<T>.flatMapMerge(concurrency: Int = DEFAULT_CONCURRENCY,    transform: suspend (value: T) -> Flow<R>): Flow<R> = map(transform).flattenMerge(concurrency)


// 如果并发数量 concurrency == 1那么转为顺序发射,否则是利用 ChannelFlowMerge 来进行并发发射的
@FlowPreview
public fun <T> Flow<Flow<T>>.flattenMerge(concurrency: Int = DEFAULT_CONCURRENCY): Flow<T> {
    require(concurrency > 0) { "Expected positive concurrency level, but had $concurrency" }
    return if (concurrency == 1) flattenConcat() else ChannelFlowMerge(this, concurrency)
}

zip

等价 RxJava 的 Observavle.zip()

        flow {
            delay(2000)
            emit(2)
        }.zip(flow {
            delay(1000)
            emit("2")
        }){a,b->
            println(a)
            println(b)
            a to b
        }.collect {
            println(it)
        }

等待 2 个 flow 都发射完成,然后合并为一个 flow 统一发射

buffer

对应 RxJava Observable.buffer(),但是 RxJava 会将缓存的数据合并成一个 List 再发射。flow 还是一个一个的发出去。

缓存数据,见上文 #背压 的介绍。flow 将背压的处理整合到了 buffer 操作符

debounce

防抖,等价 Observable.debounce,在 L 时间内,取最后一次发射的值。

        flow{
            repeat(5){
                emit(it)
            }
        }.debounce(200).collect {
            println(it)
        }
        
        // 输出 4

sample(目前还是预览版,后续版本可能会发生改动)

等价 RxJava 的 sample

阶段性的取一定时间内的最后一个数据

        val flow = flow {
            emit("A")
            delay(1500)
            emit("B")
            delay(500)
            emit("C")
            delay(250)
            emit("D")
            delay(2000)
            emit("E")

        }
        
        // 取每  1000ms 时间内发送的最后一个数据
        val result = flow.sample(1000).toList()
        assertEquals(listOf("A", "C", "D"), result)

上述的例子每隔一段时间会发送数据 A、B、C、D 等,我们的把这些时间以 1000 ms 为单位进行分割:

 1000   2000  3000  4000   5000
|-----|-----|-----|------|-----|
A        B  C D            E

可以直观的看到每个1000 ms 的最后一个发射的数据。D 和 E 之间的间隔超过了 1000 ms ,数据走到这里就会发射中断了,不管 E 之后的数据是否发射间隔存在 1000m 内的。

在第 2 个 1000ms 时,C 刚好处于分界点上,官方没有对这一特殊情况进行说明,从实际运行结果看,此时 C 被认为是第二个 1000ms 发射的最后一个数据。

这里官方的 test case 写的 期望结果是 [A,B,D],而实际的运行结果是 [A,C,D]。

感觉是官方的 bug。这个 sample 目前还是预览版,不建议大家使用。

热的 flow

冷和热的定义:

  • 冷:观察者订阅数据源后才会发射数据

  • 热:数据源自己可以随意发射数据

RxJava 和 flow 一样默认也是冷的。

RxJava 中 的 PublishSubject 就是热的数据源:RxBus 就是用它来实现的。协程之间使用 channel 来传输数据,那么 channel 天生就是可以作为热的数据通道,只管发送数据。

RecicedChannel 和 BroadcastChannel(预览版)

关于 Channel 的基础知识:破解 Kotlin 协程(9) - Channel 篇

  • RecicedChannel:一对一发送,同一个数据只能被一个接受者接收
  • BroadcastChannel:广播发送,一对多发送,同一个数据能被所有接受者接收

借助 receiveAsFlow 或者 consumeAsFlow 可以把 Channel 转化成 hot flow。

  • receiveAsFlow:可以反复消费 flow (同一个 receiveAsFlow 你可以多次调用 collect 进行消费数据)

  • consumeAsFlow:flow 只能被消费一次(同一个 consumeAsFlow 你只能调用1次 collect 进行消费数据)

    fun receiveChannel() { val receiveContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher() val producer = GlobalScope.produce { var count = 0 while (true) { send("hello-count") count++ delay(1000) } } val consumeAsFlow = producer.receiveAsFlow() GlobalScope.launch(receiveContext) { consumeAsFlow.collect { println("Ait") } }

        GlobalScope.launch {
            consumeAsFlow.collect {
                println("B$it")
            }
        }
    }
    

    // 输出:
    Ahello-0 Ahello-1 Bhello-2 Ahello-3 Bhello-4 Ahello-5 Bhello-6 Ahello-7 Bhello-8 Ahello-9 Bhello-10 Ahello-11 Bhello-12 Ahello-13 Bhello-14 Ahello-15

可以看到 RecicedChannel 的结果是互斥的,同一个元素只能被一个消费者去消费。

RecicedChannel 适合一对一消费的场景的,一个数据只会被消费一次。

借助于 asFlow 扩展函数,我们可以把一个 BroadcastChannel 转化成 hot flow

    fun broadcastChannel() {
        val receiveContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
        val producer = GlobalScope.broadcast<String> {
            var count = 0
            while (true) {
                send("hello-$count")
                count++
                delay(1000)
            }
        }
        val consumeAsFlow = producer.asFlow()
        GlobalScope.launch(receiveContext) {
            consumeAsFlow.collect {
                println("A$it")
            }
        }

        GlobalScope.launch {
            consumeAsFlow.collect {
                println("B$it")
            }
        }
    }
    
// 输出
Ahello-0
Ahello-1
Bhello-1
Ahello-2
Bhello-2
Ahello-3
Bhello-3
Ahello-4
Bhello-4
Ahello-5
Bhello-5
Ahello-6
Bhello-6
Ahello-7
Bhello-7
Ahello-8
Bhello-8

可以看到 broadcast 的输出是一对多,同一个元素被多个消费者消费了。

BroadcastChannel 适合一对多消费的场景的,一个数据只会被消费n次,n = 消费者的个数。

BroadcastChannel 相关的 API 大部分被标记为 ExperimentalCoroutinesApi,后续也许还会有调整。

ShareFlow

Flow 里有一种 叫做 ShareFlow 实现了这种热发送数据。

ShareFlow 的功能和 BroadcastChannel 几乎一样,但是 ShareFlow 的实现没有使用 Channel Api。并且是直接提供了 flow 的返回,并且多了下面几个特性:

  • 更简洁的 api,因为内部没有使用 Channel Api

  • 支持配置 replay 和 buffer

  • 提供一个只读的 ShareFlow 和 可写 MutableShareFlow

  • SharedFlow 不可用被关闭,而 channel 是可以主动关闭的

ShareFlow 用法和 Channel 差不多**,**我们来写一个简单的 flow 版的 eventbus 来看看 MutableSharedFlow 的使用场景:

object FlowEventBus {

    // 使用 MutableSharedFlow 来作为事件的通道
    private val _events = MutableSharedFlow<Event>() // private mutable shared flow
    val events = _events.asSharedFlow() // publicly exposed as read-only shared flow

    suspend fun produceEvent(event: Event) {
        _events.emit(event) // suspends until all subscribers receive it
    }
}



        
        // 接收事件
        GlobalScope.launch(sendContext) {
            FlowEventBus.events.collect{
                println(it.name)
            }
        }
        
        // 发送事件
        GlobalScope.launch(recieveContext) {
            repeat(20){
                delay(2000)
                FlowEventBus.produceEvent(Event("a${it}"))
            }
        }

MutableSharedFlow 有 3 个 参数

  • replay:缓存之前发送的过的数据的数量,如有新的订阅者就把值再次发送。和 RxJava 的 BehaviorSubject 是相同的效果,可以用来实现粘性事件的效果

  • extraBufferCapacity:缓存的数量

  • onBufferOverflow:缓存满了之后的策略

extraBufferCapacity 和 onBufferOverflow 和上文的 #buffer 部分是一样的。replay 则和 RxJava 的 BehaviorSubject 效果一样。

所以我们实现 hot flow 最佳方案应该选用 ShareFlow

这里是我学习 shareFlow 实现的 flowbus:github.com/lwj1994/flo…

总结

flow 作为协程的一部分,无缝配合协程且继承了 Reactive 响应式架构的威力。简洁明了的 api,完全可以替代 RxJava 。

参考

coroutines-Asynchronous Flow

破解 Kotlin 协程(11) - Flow 篇

Cold flows, hot channels

StateFlow and SharedFlow