在Kotlin协程出现之前,RxJava
应该是在Android开发领域最火热的异步编程方案。
其中对于数据流的链式处理,相信用过RxJava
的都很熟悉。
那么在Kotlin协程中又该如何进行数据流操作呢?
这是Kotlin协程系列的第二篇文章。
作为RxJava
曾经的忠实拥护者,本篇尝试从RxJava
使用者的角度,围绕Kotlin协程中的异步数据流操作展开。
Kotlin版本 :1.5.31
Coroutine 版本 : 1.5.2
RxJava版本 : 3.0.10
Kotlin协程系列相关文章导航
Kotlin Flow上手指南(一)基础使用 (本篇)
Kotlin Flow上手指南(二)ChannelFlow
Kotlin Flow上手指南(三)SharedFlow与StateFlow
以下正文。
在Kotlin标准库中虽然也提供了Sequence
与集合操作符,同样都能进行链式数据流操作。
但依然是阻塞线程的同步数据流操作,那么有没有与RxJava
类似的异步数据流操作呢?
在Kotlin协程中提供的响应式编程方式就是Kotlin Flow
。
Flow
的定义很简单,就只是表示这个类能够被订阅收集。
而其中FlowCollector
则定义发送数据的功能。
在基础Kotlin协程中,
launch
启动的协程,没有返回值,运行后就结束,类似于RxJava
的Completable
。async
启动的协程,能通过await
函数获取协程返回值,但只能返回单个值,且无法进行数据流操作。
而Kotlin Flow
的出现,恰好弥补了Kotlin协程对于多个值异步运算的不足,并且允许进行复杂的数据流操作。
相当于RxJava
的Observable
与Flowable
类型,Maybe
类型自然也可以兼容。
创建
首先来看看Flow
数据流是如何创建的。
Flow
的创建方法有很多种,最简单的是使用顶层函数flow
。
其参数是suspend
修饰,以FlowCollector
为接收者的挂起函数,能直接调用emit
发送数据。
val flow = flow<String>{
for(i in 1..5){
emit("result $i")
}
}
复制代码
其他还有诸如asFlow
、flowOf
等创建方式。
官方提供了很多类型转换到Flow
的拓展函数,这里就不多列举了,有兴趣可以看源码。
由于
flow
函数的代码块同样是由suspend
修饰,内部可以调用挂起函数。
flow
代码块的emit
函数是线程不安全的,所以flow
函数的不能修改协程上下文,无法调用如withContext
等函数,避免下游collect
被调度到其他线程。如果要修改数据流的协程调度,只能调用
flowOn
函数。
末端操作符
Flow
创建的数据流,是冷流,也就是必须要在调用末端操作符后才会执行数据流生产操作。
- 数据流的创建与数据流的消费是成对出现的
- 多个数据流订阅消费,也会同样有多个数据源生产创建
在RxJava
中需要调用subscribe
订阅消费数据流,在Flow
中自然就是调用collect
。
但Flow
类中声明的collect
函数是以@InternalCoroutinesApi
注解的,表示外部不能直接调用该函数。
好在官方提供了同名的Flow
类型拓展函数供外部调用。
collect
是挂起函数,限制只能在Kotlin协程(或另一个挂起函数)中调用。但
flow
及其他操作符都是普通函数,可以在协程外部创建。也就是说可以做到数据流创建与数据流消费的分离
fun testFlow(){ val scope = CoroutineScope(SupervisorJob()) val flow = flow{ //创建数据流 (0..2).forEach { println("emit $it") emit(it) } } scope.launch{ flow.collect { //消费数据流 println("last collect result :$it") } }.join() } 复制代码
除此之外,官方还提供了其他末端操作符
- 集合转换类型,如
toList
、toSet
。 - 获取数据流特定元素,如
first
、last
。 - 末端的运算符(累加器),如
reduce
、fold
。
这几种末端操作符都是挂起函数,数据流的消费被限制只能在Kotlin协程(挂起函数)中运行。
另外,还提供了一个便捷的拓展函数launchIn
,指定运行在特定的协程作用域,但会忽略末端的数据流,通常和其他操作符配合使用。
中间操作符
在Flow
中提供了丰富的中间操作符,让数据流能够产生更多玩法。
这些中间操作符会通过拦截(消费)上游的数据流,进行处理后,再返回新的数据流转发给下游数据流。如此便能使不同中间操作符(消费者),链式调用的串联在一起。
中间消费者并不会触发数据流被发射
目前Flow
的中间操作符数量相对RxJava
而言还比较少,但也足够满足日常使用了,甚至功能更加强大,可能有小部分较少使用场景的生僻操作符还需要自定义。
变换
先从熟悉的老朋友,数据流的变换操作符开始
- transform
接收一个FlowCollector
作为接收者的函数类型,数据流上游传递的值作为函数参数。
transform
操作符实现了拦截收集上游数据并转发的功能。
在订阅拦截了上游数据流后,通过以
FlowCollector
作为接收者的函数类型,也就具备向下游发送数据的能力。
transform
能多次调用emit
函数发送数据
作为最基础的实现,其他数据流变换操作符都是在transform
基础上拓展而来。
-
map
将上游数据流的每个值,通过指定变换函数的结果传递给下游。
flow{ (0..2).forEach { println("emit $it") emit(it) } }.map{ println("running map $it") it * 2 + 1 }.collect{ println("last collect result $it") } emit 0 running map 0 last collect result 1 emit 1 running map 1 last collect result 3 emit 2 running map 2 last collect result 5 复制代码
当需要将上游数据流每个值都转化为另一个数据流的使用场景,在RxJava
会用到flatMap
。
但在Flow
并没有同名操作符,不过有类似的存在。
-
flatMapConcat
将上游数据流的每个值,转化为另一个数据流。
将新Flow数据流展开,等待新的数据流将每个值传递给下游,随后才继续从上游获取下一个值,顺序执行。
@FlowPreview fun test() = runBlocking{ flow{ (0..2).forEach { println("emit $it") emit(it) } }.flatMapConcat {index-> flow<String> { println("running flatMapConcat first $index") emit("flatMapConcat first $index") println("running flatMapConcat second $index") emit("flatMapConcat second $index") println("running flatMapConcat end $index") } }.collect { println("last collect result :$it") } } emit 0 running flatMapConcat first 0 last collect result :flatMapConcat first 0 running flatMapConcat second 0 last collect result :flatMapConcat second 0 running flatMapConcat end 0 emit 1 running flatMapConcat first 1 last collect result :flatMapConcat first 1 running flatMapConcat second 1 last collect result :flatMapConcat second 1 running flatMapConcat end 1 emit 2 running flatMapConcat first 2 last collect result :flatMapConcat first 2 running flatMapConcat second 2 last collect result :flatMapConcat second 2 running flatMapConcat end 2 after flow 复制代码
这等效于
RxJava
的flatMap
操作符。 -
flatMapMerge
与
flatMapConcat
相似,但其内部是允许并发操作的,flatMapMerge
接收上游数据流后,会尽可能收集上游数据值,直到达到最大并发数,然后一次性将新的数据流并发的传递到下游。由于
flatMapMerge
是并发的,所以不保证发送顺序@FlowPreview fun test() = runBlocking{ flow{ (0..2).forEach { println("emit $it") emit(it) } }.flatMapMerge {index-> flow<String> { emit("flatMapConcat $index ,1") emit("flatMapConcat $index ,2") } }.collect { println("last collect result :$it") } } emit 0 emit 1 emit 2 last collect result :flatMapConcat 0 ,1 last collect result :flatMapConcat 0 ,2 last collect result :flatMapConcat 1 ,1 last collect result :flatMapConcat 1 ,2 last collect result :flatMapConcat 2 ,1 last collect result :flatMapConcat 2 ,2 复制代码
flatMapMerge
函数接受一个concurrency
参数,表示允许接收上游数据的并发数,默认为16。当concurrency = 1时,就相当于
flatMapConcat
。
但官方其实并不太推荐使用这两个操作符。从操作符注释上看,官方似乎更希望我们保持数据流的线性操作,提高数据流操作的可读性。
毕竟flatMap
比起map
,逻辑相对没有那么清晰,通常情况下使用map
也就足够了。而且官方也提供了FlowCollector
拓展函数emitAll
用于发送数据流。
flatMapConcat
内部实际就是调用了emitAll
来发送新的Flow
数据流。
并且flatMapConcat
和flatMapMerge
都是以@FlowPreview
注解的,属于预览性质,并不保证后续版本的向下兼容。
如果有需要转化为新数据流的需求,在
map
中调用emitAll
可能是更好的选择。
线程调度
还要记得RxJava
的线程调度操作符subscribeOn
和observeOn
吗?
它们分别用于指定数据流上游和数据流下游的运行线程。
而Flow
是基于Kotlin协程的,所以线程调度必然是修改CoroutineContext
调度器。
但对于允许发送数据的操作符来说,会被限制在不允许内部调用诸如withContext
修改协程上下文的挂起函数,仅允许在数据流中使用flowOn
操作符修改协程上下文。
flowOn
操作符指定的调度器,只作用于该操作符上游的数据流。
区别于定位相近的
subscribeOn
,flowOn
可以多次调用。指定的
CoroutineContext
作用范围是从当前flowOn
操作符到前一个flowOn
(或数据源)。下游数据流未指定时,则继承外部协程上下文的调度器。
fun test() = runBlocking{
val myDispatcher = Executors.newSingleThreadExecutor()
.asCoroutineDispatcher()
flow {
println("emit on ${Thread.currentThread().name}")
emit("data")
}.map {
println("run first map on${Thread.currentThread().name}")
"$it map"
}
//作用于前面flow创建与第一个map
.flowOn(Dispatchers.IO)
.map {
println("run second map on ${Thread.currentThread().name}")
"${it},${it.length}"
}
//作用于第二个map
.flowOn(myDispatcher)
.collect {
println("result $it on ${Thread.currentThread().name}")
}
}
emit on DefaultDispatcher-worker-2 @coroutine#3
run first map on DefaultDispatcher-worker-2 @coroutine#3
run second map on pool-1-thread-1 @coroutine#2
result data map,8 on Test worker @coroutine#1
复制代码
相比subscribeOn
与observeOn
,flowOn
在使用上更加灵活。
如果想要达到
observeOn
的效果,可以尝试利用外部协程调用withContext
切换调度器。
事件
在Flow
中还提供了数据流的hook操作符。
-
onStart 在上游数据流开始之前执行操作
参数代码块是以
FlowCollector
作为接收者,具有发送数据功能。如果
onStart
内发送数据,会先将onStart
数据发送传递到下游消费完成后,才开始将上游数据流传递到下游。从功能上看,其相当于
RxJava
中的startWith
与doOnSubscribe
操作符的结合替代品,并且功能更加强大。其参数代码块是以
suspend
修改的挂起函数,使其内部也能调用挂起函数,如delay
,能轻松实现延迟发送数据的操作。 -
onEach
在上游数据流的每个值发送前执行操作。
等效于
RxJava
中的doOnNext
或doOnSuccess
,并且同样有suspend
关键字修饰代码块。 -
onCompletion
在上游数据流完成、取消、异常时,执行的操作。
不同于
RxJava
中onComplete
或doFinally
的结束时兜底,onCompletion
接收的参数同样是以FlowCollector
作为接收者的函数类型,具有发送数据功能。而且函数类型的参数提供了对于异常抛出的收集,如果正常结束则为null。
注意,
onCompletion
并不能捕获异常,出现异常时无法发送数据。需要捕获异常并发送数据应使用
catch
操作符。onCompletion
在数据流中的位置需要注意,详见下文中的重试操作符。该操作符可以等价为命令式代码:
try { flow.collect { value -> ... } } finally { //onCompletion代码块 ... } 复制代码
但既然是使用响应式编程的
Flow
,就尽量把所有操作都放到数据流中吧。 -
onEmpty
在上游数据流完成时,没有任何值传递消费,则触发执行操作,可发送额外的元素。
上游数据流的数据也包括诸如
onStart
、onCompletion
等操作符发送的数据。
异常处理
在RxJava
时代,对于异常的捕获相信是最好用的功能之一,在onError
中可以捕获到数据流中所抛出的异常。这在Flow
里自然也有同样的替代品。
虽说可以直接try-catch
代码块就把异常捕获了,但在数据流操作中,我们最好还是把内部异常透明化,重新抛出到外部来统一处理。
-
catch
捕获上游数据流中所抛出的异常,并允许发送新的数据。
被
catch
所捕获的异常,不会传递到下游。catch
操作符最好放在数据流的最下游,便于捕获所有上游抛出的异常。类似于
RxJava
中的onErrorRetrun
操作符与onError
的结合体。但如果在下游
collect
中抛出异常,是无法捕获到的。此时可以尝试将
collect
的部分操作,前移到上游的onEach
中,然后由其下游的catch
捕获并发送异常时数据。最后可在外部协程中设置
CoroutineExceptionHandler
兜底捕获异常。
重试
与RxJava
相同,当数据流中出现异常,整个数据流将会结束,导致后续数据无法继续发送。
但有些场景我们并不希望数据流就此结束,此时就需要使用重试机制。
-
retry
当上游数据流出现异常时,如果满足条件时,会捕获异常,并重新发出上游数据流,直到超出重试次数后,继续向下游抛出异常。
-
retryWhen
retry
内部就是调用的retryWhen
,其会在捕获到异常后,如果满足重试条件,将在其上游数据流,从重新执行一次上游数据流,直到再次捕获到异常。不满足重试条件,会继续向下游抛出异常
- 如果
catch
操作符在上游数据流,会直接由catch
捕获, 而不会进入retryWhen
。通常需要catch
在下游捕获由retryWhen
抛出的异常。这等效于在
RxJava
中把onErrorReturn
放到retryWhen
上游。
- 当
onCompletion
操作符在上游数据流,异常会先进入onCompletion
执行,但不会发出数据,然后才进入retryWhen
,重试后也是如此。
如果需要使用retryWhen
和retry
操作符,尽可能靠近需要重试的数据流操作,避免出现不符合预期的情况。
合并
某些场景可能需要我们将数据流合并,在RxJava
中可以使用concat
与zip
等合并操作符,在Flow
中也提供了类似功能。
-
combine
将两个数据源中的最新值通过函数合并,并直接传递给下游。
类似于
RxJava
的combineLatest
操作符,但在flow
中可以是任意类型的数据流合并。//每个值延迟50ms发送 val flow1 = flowOf("first","second","third").onEach { delay(50) } //每个值延迟100ms发送 val flow2 = flowOf(1,2,3).onEach { delay(100) } flow1.combine(flow2){first,second-> "$first $second" } .collect { println("result $it") } //flow2元素的1元素发送时,flow1数据源的second元素是最新值, //所以first会被忽略 result second 1 result third 1 result third 2 result third 3 复制代码
-
combineTransform
效果上与
combine
相同。唯一区别只是,
combine
在合并后立即发送数据,而combineTransform
会经过转换由用户控制是否发送合并后的值。 -
zip
将两个数据源中的每个值都合并,发送给下游,只要其中一个数据源结束,则数据流完成结束。
zip
操作符与在RxJava
中的表现一致,但函数类型支持挂起函数。//每个值延迟50ms发送 val flow1 = flowOf("first","second","third").onEach { delay(50) } //每个值延迟100ms发送 val flow2 = flowOf(1,2,3,4).onEach { delay(100) } flow1.combine(flow2){first,second-> "$first $second" } .collect { println("result $it") } //由于flow1只有3个元素,所以flow2的第4个元素被抛弃了。 result first 1 result second 2 result third 3 复制代码
combine
与combineTransform
操作符支持多个数据源合并,而
zip
目前仅支持两个数据源合并。
常用操作符
除此之外,还有一些很多的操作符,这里只列举一些常用的,基本都是等效于RxJava
对应的同名操作符。
-
first
末端操作符,只取上游数据流的第一个元素,或满足条件的第一个元素,后续元素抛弃。
-
last
末端操作符,只取上游数据流的最后一个元素,如果元素为null则抛出异常。
可使用
lastOrNull
获取最后一个可空元素。 -
filter
中间操作符,过滤上游数据流的值,仅允许满足条件的值继续传递。基于
transform
-
take
中间操作符,只从上游数据流获取指定个数的元素,传递到下游,后续元素抛弃
-
decode
中间操作符,在指定时间内,只允许最新的值传递到下游,多用于过滤上游高频率的生产者数据。
在
RxJava
时代,利用RxBinding库可以将View事件转化为数据流,然后利用decode
操作符来实现对于EditText
的输入防抖过滤,以及View
的点击防抖操作。decode
操作符在Flow
中目前是不保证后续版本兼容的预览版本。 -
distinctUntilChanged
中间操作符,过滤上游数据流中的重复元素,等价于
RxJava
内的distinct
操作符。
操作符复用
在RxJava
中,对于一系列通用的数据流操作,我们可能会想在多个数据流中复用,此时会先将这一系列操作利用Transformer
封装起来,然后利用compose
操作符进行统一调用。
比如对于网络请求返回数据的一些通用处理。
class MyTransformer<T>(...) : MaybeTransformer<T,T>{
override fun apply(upstream: Maybe<T>): MaybeSource<T> {
return upstream
.subscribeOn(Schedulers.io())
.map { ... } //预处理请求返回
.observeOn(AndroidSchedulers.mainThread())
.onErrorReturn{...} //在异常时发送表示错误的数据
}
}
api.compose(MyTransformer(...)).subscribe(...)
复制代码
在Flow
的设计中,看得出来设计者在早期其实也有相同的想法,确实是有一个compose
操作符。不过已经是废弃状态,在Kotlin里也确实不需要再整这么麻烦的事情了。
回想一下Flow
里操作符是如何定义的呢?利用Kotlin的拓展函数,我们完全可以自定义一个操作符,内部调用这一系列操作。
fun <T> Flow<T>.preHandleHttpResponse(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
){
map {response->
if (it.code == 0) it
else throw MyHttpException(...) //抛出自定义异常,表示业务执行失败
}
.flowOn(dispatcher)
.catch{error->
//全局处理网络请求异常
emit(...) //在异常时发送表示错误的数据
}
}
flow{...}.preHandleHttpResponse().collect{...}
复制代码
取消
在RxJava
中subscribe
会返回Disposable
用于控制数据流取消,或者利用AutoDispose绑定Lifecycle
来自动管理数据流关闭。
而Flow
本身并没有提供取消功能,不过Flow
的消费是挂起函数,必须运行在Kotlin协程,直接利用外部协程启动时的Job
对象就能管理Flow
的取消。
背压
在响应式编程中,数据背压是必然会存在的问题。
在生产者-消费者模型中,如果生产者的生产速率远大于消费者的消费速率,也就产生了背压问题。
仅出现在生产者与消费者分别在不同线程的异步场景。
限制生产端
在RxJava
中,从RxJava2开始就专门推出了Flowable
来解决数据背压问题。
并为其设置有个默认固定为128的异步缓存池以及Backpressure
缓存策略:
- MISSING :不做缓存处理
- ERROR :缓存队列满后,抛出
MissingBackpressureException
异常- BUFFER :缓存队列满后,继续扩大缓存容量,知道OOM
- DROP :缓存队列满后,直接抛弃后续新的值。
- LAEST : 永远会将最新的一个值放入缓存队列,不论缓存池状态如何。
Flow
同样也提供了类似的背压缓存策略
-
buffer
基于
Channel
开启一个缓存区。Channel
可以理解为Kotlin协程的BlockingQueue
,接收和发送都是挂起函数。public fun <T> Flow<T>.buffer( capacity: Int = BUFFERED, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND ): Flow<T> 复制代码
参数
capacity
,表示缓存区容量,默认为BUFFERED
,是Channel
的常量。- RENDEZVOUS ,无缓存区,生产一个消费一个。
- BUFFERED,创建个默认缓存容量为64的缓存区
- CONFLATED ,相当于把缓存容量设置为1,且缓存策略强制为
DROP_OLDEST
- 自定义缓存区容量大小
参数
onBufferOverflow
,缓存策略方式,默认使用SUSPEND
,- SUSPEND :当缓存区满时,将生产者挂起,不继续发送后续值,直到缓冲区有空缺位置。
- DROP_OLDEST :缓存区满时,移除缓存区中最旧的值,并插入最新的值
- DROP_LATEST :缓存区满时,抛弃最新的值。
-
conflate
专门提供的快捷使用
buffer(CONFLATED)
的操作符 -
flowOn
实际上
flowOn
在修改了协程上下文后,内部也会开启默认缓冲区。相当于默认的
buffer()
,天然支持背压。flow{ (0..100).forEach { delay(50) val currTime = System.currentTimeMillis() println("emit $it on ${currTime - startTime} ms") emit(it) } } .flowOn(Dispatchers.IO) //等价于buffer(),并且指定调度器 .onEach { delay(300) } //消费端延迟 .collect { val endTime = System.currentTimeMillis() println("result : $it on ${endTime - startTime} ms") } emit 0 on 93 ms emit 1 on 156 ms emit 2 on 220 ms emit 3 on 282 ms emit 4 on 345 ms emit 5 on 409 ms result : 0 on 409 ms emit 6 on 471 ms ... emit 9 on 657 ms result : 1 on 719 ms emit 10 on 719 ms ... //超出默认64位缓存区,生产端就被挂起暂停了,速度减缓 //消费一个,从缓存队列中移除,然后生产一个填充队列。 emit 79 on 5050 ms result : 15 on 5081 ms emit 80 on 5112 ms emit 81 on 5174 ms result : 16 on 5390 ms emit 82 on 5451 ms result : 17 on 5700 ms ... //直到生产端完成任务 result : 34 on 11026 ms emit 100 on 11090 ms result : 35 on 11338 ms result : 36 on 11651 ms result : 37 on 11965 ms ... //直到消费结束 复制代码
注意上面emit运行的时间,
buffer
、flowOn
操作符还有将上游数据快速并发的附带功能。- 其实可以利用这一特性快速并发运行多个任务,但
buffer
无法指定调度器
如果将上面
flowOn
替换为conflate
,程序运行结果:emit 0 on 99 ms emit 1 on 162 ms emit 2 on 224 ms emit 3 on 287 ms emit 4 on 351 ms emit 5 on 413 ms result : 0 on 413 ms emit 6 on 474 ms ... emit 9 on 660 ms result : 5 on 720 ms emit 10 on 720 ms ... emit 14 on 970 ms result : 9 on 1034 ms emit 15 on 1034 ms ... emit 19 on 1286 ms result : 14 on 1350 ms emit 20 on 1350 ms ... 复制代码
此时,都只能接收上一个最新的值,毕竟缓存区容量只有一个,其他新值都被丢弃。
类似于
RxJava
的LAEST
策略。 - 其实可以利用这一特性快速并发运行多个任务,但
限制消费端
在消费端也提供了背压数据取舍的功能
-
collectLatest
按顺序收集上游数据流传递的每一个元素,如果当前元素还未处理完,又新元素传递过来后,取消当前元素的收集操作。
collectLatest
操作符对于上游数据流的耗时操作是不在意的,只有在collectLatest
函数内部的处理时间过长才会被算作还未完成处理。将上面的程序稍作修改:
//生产端不变 flow.collectLatest{ delay(300) //延迟移到消费端 ... } emit 0 on DefaultDispatcher-worker-1 @coroutine#3 (115 ms) emit 1 on DefaultDispatcher-worker-1 @coroutine#3 (177 ms) emit 2 on DefaultDispatcher-worker-1 @coroutine#3 (240 ms) emit 3 on DefaultDispatcher-worker-1 @coroutine#3 (304 ms) emit 4 on DefaultDispatcher-worker-1 @coroutine#3 (369 ms) emit 5 on DefaultDispatcher-worker-1 @coroutine#3 (433 ms) emit 6 on DefaultDispatcher-worker-1 @coroutine#3 (496 ms) ... //由于消费端一直在取消收集,只获取到最后一个值。 result : 100 on Test worker @coroutine#104 (6721 ms) 复制代码
总结
RxJava
虽说很好用,但相对复杂的实现,繁杂的操作符,导致上手难度偏大,甚至使用多年的老手也很少敢说完全了解的。
相较于RxJava
基于Java的复杂实现,背靠着Kotlin与Kotlin协程优秀基础设施的Flow
,简直就是亲儿子,本身API实现能做到相当精简,经过多个版本的迭代,Kotlin Flow
目前已经比较成熟。
虽然仍有部分处于实验阶段,可能在后续版本被修改,不过通常也只是废弃警告,替换成推荐等价函数即可。
对于日常使用而言,上述列举的基本都已经是正式操作符,基本能覆盖常用范围。相信Flow
的设计过程中也参考了RxJava
的设计,其在操作符的功能与命名方面都非常相似,对于用过RxJava
的人来说,非常容易上手。
总体而言,在Kotlin协程中,自然优先使用Flow
来进行异步数据操作。
原本使用RxJava
进行异步编程的项目也可以尝试逐步使用Kotlin协程+Flow的方案替代。
限于篇幅有限,更多Flow
的进阶功能,将在后续文章中继续探索。
参考资料
kotlin 协程官方文档(5)-异步流(Asynchronous Flow)