引言
大家知道 Flow 是基于 Kotlin 协程设计的,要深入理解 Flow,对于 Kotlin 协程的理解非常必要,前面我们已经用了6篇讲述了 Kotlin 协程的全部重要技术细节,还没有看过的同学可以先回去看看:
现在我们就正式进入 Flow 的部分,同样的,本文不会直接讲 Flow 与 Kotlin 协程的关系或者用法,而是先看看 Flow 的设计理念。Flow 是什么?大家可以先停下来问问自己这个问题,然后揣着自己的答案,跟随笔者一起往下探索。下面,我会通过把 Flow 在放在一众与之相关的概念里做比较,来让大家更好的认识 Flow,了解 Flow 的优势,也可以避免在学习了强大的 Flow 之后滥用 Flow。
Flow 的生态位
Flow 与 Collection
用过 Flow 的同学应该知道 Flow 的很多操作符(借用 Rxjava 的术语) 跟 Collection 下面的容器的 API 如出一辙,下面我们用一个例子来横向对比一下:
// FlowCompare1.kt
fun main() {
listSample()
flowSample()
}
private fun listSample() {
val list = listOf(1, 2, 3, 4, 5)
.map { it * it }
.filter { it > 20 }
.map { "*$it" }
println(list)
}
private fun flowSample() {
val flow = flowOf(1, 2, 3, 4, 5)
.map { it * it }
.filter { it > 20 }
.map { "*$it" }
runBlocking { println(flow.toList()) }
}
// log
[*25]
[*25]
可以看到 listSample 方法和 flowSample 方法中两者的代码相似度极高,只是在 print 时,flow 需要调用 toList 方法,并且必须在 coroutine scope 中调用,这里使用为了简便直接使用 runBlocking。最终的打印结果也相同,看来 flow 跟 list 也没多大区别嘛,而且使用方式好像还更麻烦了。
稍等,我们加一点日志再来对比看看,差别就出来了:
// FlowCompare2.kt
fun main() {
listSample()
println()
flowSample()
}
private fun listSample() {
val list = listOf(1, 2, 3, 4, 5)
.map { it * it }
.filter { it > 20 }
.map {
println("map list $it")
"*$it"
}
println("before print list")
println(list)
}
private fun flowSample() {
val flow = flowOf(1, 2, 3, 4, 5)
.map { it * it }
.filter { it > 20 }
.map {
println("map flow $it")
"*$it"
}
println("before print flow.toList")
runBlocking { println(flow.toList()) }
}
// log
map list 25
before print list
[*25]
before print flow.toList
map flow 25
[*25]
我们在 print list 之前加入了一句 before print log,在最后一个 map 里面加入了一行 print map log。从 log 结果中,我们可以看到 map list 在 before print 之前就打印出来了,而 map flow 则是在 before print 之后才打印的。
我们可以推断,flow 中的 map 是在调用 flow.toList 时才打印的,而 list 中的 map 则是当场就调用了
,这就是 flow 与 list 不同的一个特性,懒计算,或者惰性计算。有的同学会说,懒不懒计算看起来也没多大的差别嘛。那么下面我们就来看看懒计算的好处。
我们把上面的代码做一下拆分,再来仔细看看两者的区别:
// FlowCompare3.kt
private fun listSample() {
val multiplyMap = listOf(1, 2, 3, 4, 5)
.map { it * it }
val filter20 = multiplyMap
.filter { it > 20 }
val starMap = filter20
.map {
println("map list $it")
"*$it"
}
println("before print list")
println(starMap)
}
// log
map list 25
before print list
[*25]
这个 listSample 和 上面 FlowCompare2.kt 中的 listSample 输出的 log 是相同的,不过这里的 listSample 把每个操作符分开了,这有什么影响呢?看起来我们增加了几个中间变量,没错,不过更麻烦的是生成了中间的 list,每一个变量都是一个 list,当处理较大的数据,或者处理方式较复杂时,中间变量会比较大,且比较多,这样就会增加内存的消耗。
那我使用 FlowCompare2.kt 写法就行了嘛,连在一起,码坚不拆。想法很好,不过,答案是:不行。multiplyMap,filter20,starMap 都只是对于数组对象的引用,引用本身占用的内存很少,内存消耗的大头在生成的中间 list 对象本身上。即使连在一起写,还是会生成中间 list,因为 list 的 api 是 eager 的,每一次调用对应 api,都会立即计算,并返回新的 list,所以上面两种 listSample 的写法除了用了对象引用,其余部分是等价的。
除了懒计算外,还有一个重要的特性,使得 Flow 能够完成 List 无法完成的事,流式特性。说到流式,很多同学会马上想到 Java IO 的 stream,没错,这确实是与流式相关,有经验的同学知道在移动开发平台 - 比如 Android 中,下载文件不能一次性把 io 流读到内存当中,而应该通过流式的方式写到文件中,那么到底什么是流式呢?下面我们还是用 List 与 Flow 的对比来看看:
// FlowCompare4.kt
fun main() {
listSample()
println()
flowSample()
}
private fun listSample() {
val list = listOf(1, 2, 3, 4, 5)
.map {
println("x map list $it")
it * it
}
.filter {
println("filter list $it")
it > 20
}
.map {
println("* map list $it")
"*$it"
}
}
private fun flowSample() {
val flow = flowOf(1, 2, 3, 4, 5)
.map {
println("x map flow $it")
it * it
}
.filter {
println("filter flow $it")
it > 20
}
.map {
println("* map flow $it")
"*$it"
}
runBlocking { flow.toList() }
}
// log
x map list 1
x map list 2
x map list 3
x map list 4
x map list 5
filter list 1
filter list 4
filter list 9
filter list 16
filter list 25
* map list 25
x map flow 1
filter flow 1
x map flow 2
filter flow 4
x map flow 3
filter flow 9
x map flow 4
filter flow 16
x map flow 5
filter flow 25
* map flow 25
我们在每一步都增加了 log 的打印,现在我们分析一下上面的 log 输出。可以看到在 listSample 中,是先执行了所有的 x map ,然后是 filter, 最后是 * map。而在 flowSample 中,可以看到 x map,filter,* map 是交替执行的,也就是按照数据一个一个执行的,从 1- 5(因为filter,只有 5 * 5 = 25 才会执行 * map),这一个个数据就汇集成了流,这就是流式。
就只是各个操作符之间的顺序的不同?当然不只是顺序的问题,List 不是流式的,当对 List 进行操作时,list 中所有的数据已经全部加载到内存中了。这里的 flow 也是,不过,如果我们把数据源换成网络 IO,本地大型文件呢?我们当然不可能把整个数据全部加载到内存中再做处理,这样会跟 OOM 亲密接触,所以我们需要一边读取数据,一边处理数据,如果我们把上面的 1-5 看成是非常占用内存的对象,并且把这个数量增大,那么 List 处理数据就需要把所有大对象加载到内存后再做处理,而 Flow 则可以加载一个,处理一个,这个过程像一个单个数据单位大小的滑动窗口,不停的往后走,这样我们就能够处理无限大的数据集了。
流式处理
那么,代价是什么呢?在获得了处理无限数据的能力的同时,流式 API 也失去了逆向和任意的能力,Flow 不能像 List 那样通过下标任意访问一个数据,也不能访问处理过的数据,就像真正的水流一样,长江黄河不会倒流。这也是为什么 Java IO stream的数据只能使用一次的原因,一旦 read 过后就不能再次 read 了。
在这回合中 Flow 展现了懒计算和流式 API 的能力,获得了 List(等 Collection接口) 的认可,这个时候有两位前辈 Stream,Sequence 跳了出来,表示自己可以一战,Stream 和 Sequence 相似度较高,接下来我们就排 Sequence 出来与 Flow 对比一下。
Flow 与 Sequence
Sequence 接着 List 继续与 Flow 进行对比:
// FlowCompare5.kt
fun main() {
sequenceSample()
println()
flowSample()
}
private fun sequenceSample() {
val sequence = sequenceOf(1, 2, 3, 4, 5)
.map {
println("x map sequence $it")
it * it
}
.filter {
println("filter sequence $it")
it > 20
}
.map {
println("* map sequence $it")
"*$it"
}
println(sequence.toList())
}
private fun flowSample() {
val flow = flowOf(1, 2, 3, 4, 5)
.map {
println("x map flow $it")
it * it
}
.filter {
println("filter flow $it")
it > 20
}
.map {
println("* map flow $it")
"*$it"
}
runBlocking { println(flow.toList()) }
}
// log
x map sequence 1
filter sequence 1
x map sequence 2
filter sequence 4
x map sequence 3
filter sequence 9
x map sequence 4
filter sequence 16
x map sequence 5
filter sequence 25
* map sequence 25
[*25]
x map flow 1
filter flow 1
x map flow 2
filter flow 4
x map flow 3
filter flow 9
x map flow 4
filter flow 16
x map flow 5
filter flow 25
* map flow 25
[*25]
好家伙,直接来了个五五开。从 log 来看,两者直接等价了,sequence 也是懒计算和流式 API 特性都齐活了,甚至从使用方便程度看,sequence 还略胜一筹。
那么 Flow 的意义在哪里呢?没错,就是异步能力,当数据的产生和处理和使用在不同的线程时,就该轮到 Flow 来 show 了:
// FlowCompare6.kt
private fun flowSample() {
val flow = flowOf(1, 2, 3, 4, 5)
.map {
printlnWithThread("x map flow $it")
it * it
}
.filter {
printlnWithThread("filter flow $it")
it > 20
}
.map {
printlnWithThread("* map flow $it")
"*$it"
}
runBlocking {
flow.flowOn(Dispatchers.IO)
.collect { printlnWithThread("collect $it") }
}
}
// log
main: x map sequence 1
main: filter sequence 1
main: x map sequence 2
main: filter sequence 4
main: x map sequence 3
main: filter sequence 9
main: x map sequence 4
main: filter sequence 16
main: x map sequence 5
main: filter sequence 25
main: * map sequence 25
[*25]
DefaultDispatcher-worker-1: x map flow 1
DefaultDispatcher-worker-1: filter flow 1
DefaultDispatcher-worker-1: x map flow 2
DefaultDispatcher-worker-1: filter flow 4
DefaultDispatcher-worker-1: x map flow 3
DefaultDispatcher-worker-1: filter flow 9
DefaultDispatcher-worker-1: x map flow 4
DefaultDispatcher-worker-1: filter flow 16
DefaultDispatcher-worker-1: x map flow 5
DefaultDispatcher-worker-1: filter flow 25
DefaultDispatcher-worker-1: * map flow 25
main: collect *25
我们把 sequence 的代码省略了,改动就是打印出了执行的线程。跟 flowSample 的 log 对比一下,会发现 sequence 从数据处理到收集,都在 main 线程。而 flow 可以做到在 worker 线程处理数据,再在 main 线程 collect 数据。这个特性可以允许我们在后台线程完成耗时的数据处理,再在主线程收集数据,完成界面的更新,熟悉 Android 的小伙伴会知道这个特性有多么的重要。流 + 异步,就是异步流。
在这个回合的对比中,Flow 展示出了异步流的特性,解决了数据的产生,处理,和使用必须在同一个线程带来的限制,获得了 Stream 和 Sequence 的认可。“异步流,那是我的强项”:Rxjava 在角落里悠悠的说道。于是比较进入下一个回合。
Flow 与 Rxjava
相信不少用过 Rxjava 的同学都知道,复杂的 Rxjava,在其官网上却仅有一句不明觉厉的介绍:An API for asynchronous programming with observable streams
。不过,经过上面的介绍,大家应该能够能够理解这句话了:一个具有可观察流的异步编程 API。现在你知道了,可以说这句描述就是对 Rxjava 最为凝练的定义了。下面我们就来对比一下 Flow 与 Rxjava:
// FlowCompare7.kt
fun main() {
rxjavaSample()
Thread.sleep(100)
println()
flowSample()
}
private fun rxjavaSample() {
val observable = Observable.fromArray(1, 2, 3, 4, 5)
.map {
printlnWithThread("x map sequence $it")
it * it
}
.filter {
printlnWithThread("filter sequence $it")
it > 20
}
.map {
printlnWithThread("* map sequence $it")
"*$it"
}
// 模拟 Scheduler.main()
val mainScheduler = Schedulers.from(Executors.newSingleThreadExecutor { Thread(it, "main") })
observable
.subscribeOn(Schedulers.io())
.observeOn(mainScheduler)
.subscribe { printlnWithThread("consume $it") }
}
private fun flowSample() {
val flow = flowOf(1, 2, 3, 4, 5)
.map {
printlnWithThread("x map flow $it")
it * it
}
.filter {
printlnWithThread("filter flow $it")
it > 20
}
.map {
printlnWithThread("* map flow $it")
"*$it"
}
runBlocking {
flow.flowOn(Dispatchers.IO)
.collect { printlnWithThread("collect $it") }
}
}
// log
RxCachedThreadScheduler-1: x map sequence 1
RxCachedThreadScheduler-1: filter sequence 1
RxCachedThreadScheduler-1: x map sequence 2
RxCachedThreadScheduler-1: filter sequence 4
RxCachedThreadScheduler-1: x map sequence 3
RxCachedThreadScheduler-1: filter sequence 9
RxCachedThreadScheduler-1: x map sequence 4
RxCachedThreadScheduler-1: filter sequence 16
RxCachedThreadScheduler-1: x map sequence 5
RxCachedThreadScheduler-1: filter sequence 25
RxCachedThreadScheduler-1: * map sequence 25
main: consume *25
DefaultDispatcher-worker-1: x map flow 1
DefaultDispatcher-worker-1: filter flow 1
DefaultDispatcher-worker-1: x map flow 2
DefaultDispatcher-worker-1: filter flow 4
DefaultDispatcher-worker-1: x map flow 3
DefaultDispatcher-worker-1: filter flow 9
DefaultDispatcher-worker-1: x map flow 4
DefaultDispatcher-worker-1: filter flow 16
DefaultDispatcher-worker-1: x map flow 5
DefaultDispatcher-worker-1: filter flow 25
DefaultDispatcher-worker-1: * map flow 25
main: collect *25
不愧是异步流界的老大哥,上来直接就化 Flow 的优势于无形。不过从使用层面上看还是稍微有些差异,rxjava 除了使用 subscribeOn 把数据的处理放在 io 线程之外,还需要再显示调用一次 observeOn(mainScheduler),把数据的最终消费放回 "main" 线程,而 Flow 则不需要,但是 Flow 必须在 CoroutineScope 中调用。collect 发生在 CoroutineScope 的 coroutineContext 中,所以不需要显示指定,这就是 Flow 只有一个 flowOn
操作符用来把任务流转到不同的线程上下文的原因。
接下来轮到 Flow 了,Flow 相对于 Rxjava 的优势之一就是其简洁
的 API,其对于流这个概念封装的唯一接口就是 Flow,不像 Rxjava,有 Observable
,Single
, Flowable
,Maybe
等一大堆不同的抽象。并且 Flow 弱化了 Observer,Subscriber 的概念,把其分别化为 onStart,onCompletion 等简单的操作符(Rxjava 也支持,但 Observer 中仍然包含相关概念),让数据的使用更加流畅简洁。对于同时可收可发的概念,Flow 用了 SharedFlow 这样与 Flow 联系紧密的概念,Rxjava 则又使用了一个叫做 Subject 的抽象,事实证明让人繁杂的概念会大大提升使用的难度。
Flow 一波输出之后逼得老前辈 Rxjava 连连后退,不过这些 API 层级上的区别却无法完全撼动已经非常成熟又强大的 Rxjava 的地位。于是 Flow 联手协程放出了大招:
// FlowCompare8.kt
fun main() {
flowSample()
rxjavaSample()
Thread.sleep(10000)
}
private fun rxjavaSample() {
val observable = Observable.fromArray(1, 2, 3, 4, 5)
.doOnSubscribe { printlnWithTime("rxjava start") }
.doOnComplete { printlnWithTime("rxjava complete") }
.map {
// mock 耗时IO
Thread.sleep(1000)
it * it
}
.filter { it > 20 }
.map { "*$it" }
// 模拟 Scheduler.main()
val mainScheduler = Schedulers.from(Executors.newSingleThreadExecutor { Thread(it, "main") })
repeat(10000) {
observable
.subscribeOn(Schedulers.io())
.observeOn(mainScheduler)
.subscribe {}
}
}
private fun flowSample() {
val flow = flowOf(1, 2, 3, 4, 5)
.onStart { printlnWithTime("flow start") }
.onCompletion { printlnWithTime("flow complete") }
.map {
// mock 耗时IO
delay(1000)
it * it
}
.filter { it > 20 }
.map { "*$it" }
runBlocking {
repeat(10000) {
launch {
flow.flowOn(Dispatchers.IO)
.collect { }
}
}
}
}
上面的 sample 把 log 做了一些改动,然后在收集数据时都重复执行了 10000 次,猜一猜会打印什么?先是快速打印10000次 flow start
,5s 后快速打印 10000 次 flow complete
。紧接着,快速打印数千次 rxjava start
后,程序 crash 了:
...
[5.710s][warning][os,thread] Failed to start the native thread for java.lang.Thread "RxCachedThreadScheduler-3977"
java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
...
具体这个数字多大结果根据运行环境与 java 配置有关。而协程是基于 EventLoop 的非阻塞式的,所以当进行 io 操作等待时,flow 的处理不会被阻塞,所以 flow 不会像 Rxjava 一样,在 io 操作的等待时会被 suspend,线程不会被占用,rxjavaSample 在循环中疯狂创建线程,直至 OOM。
至此,全能选手 Flow 出现了。对于 Flow 是什么,现在可以借用 Rxjava 官网上的定义方式,来为 Flow 下一个定义:A non-blocking API for asynchronous programming with observable streams。
总结
我们借用 Collection 认识了 Flow 的懒计算特性和流式 API
我们借用 Sequence 认知了 Flow 的异步特性
我们借用 Rxjava 认识了 Flow 的非阻塞性
所以为 Flow 下一个凝练的定义:一个具有可观察流的非阻塞式异步编程API
本篇是 Flow 部分的开篇,我们通过把 Flow 与类似概念的对比为 Flow 下了一个定义来认识了 Flow。接下来要讲什么,读者可以在评论区讨论互动,笔者会根据互动的情况来安排后面的内容。