「最后一次,彻底搞懂kotlin Flow」(一) | 五项全能 🤺 🏊 🔫 🏃🏇:Flow

2,580 阅读9分钟


引言

  大家知道 Flow 是基于 Kotlin 协程设计的,要深入理解 Flow,对于 Kotlin 协程的理解非常必要,前面我们已经用了6篇讲述了 Kotlin 协程的全部重要技术细节,还没有看过的同学可以先回去看看:

  1. 「最后一次,彻底搞懂kotlin协程」(一) | 先回到线程

  2. 「最后一次,彻底搞懂kotlin协程」(二) | 线程池,Handler,Coroutine

  3. 「最后一次,彻底搞懂kotlin协程」(三) | CoroutineScope,CoroutineContext,Job: 结构化并发

  4. 「最后一次,彻底搞懂kotlin协程」(四) | suspend挂起,EventLoop恢复:异步变同步的秘密

  5. 「最后一次,彻底搞懂kotlin协程」(五) | Dispatcher 与 Kotlin 协程中的线程池

  6. 「最后一次,彻底搞懂kotlin协程」(六) | 全网唯一,手撸协程!

  7. 「最后一次,彻底搞懂kotlin Flow」(一) | 五项全能 🤺 🏊 🔫 🏃🏇:Flow

  8. 「最后一次,彻底搞懂kotlin Flow」(二) | 深入理解 Flow

  9. 「最后一次,彻底搞懂kotlin Flow」(三) | 冷暖自知:Flow 与 SharedFlow 的冷和热

  现在我们就正式进入 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,有 ObservableSingleFlowableMaybe 等一大堆不同的抽象。并且 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。接下来要讲什么,读者可以在评论区讨论互动,笔者会根据互动的情况来安排后面的内容。

示例源码github.com/chdhy/kotli…