Coroutine 基础六 —— Flow

前面我们讲解的 Channel 的相关知识。Channel 是一个跨协程传递数据的工具,属于底层工具,不是拿来直接用的,尤其是在近些年 Flow API 被推出以及逐渐完善之后,Channel 都不太适合当做一个上层的功能性工具来用了。如果需要事件流或数据流,还是用 Flow 比较好。Channel 现在更多是作为 Flow API 的一个关键的底层支撑而存在,它给 Flow 提供了跨协程的能力。从功能上说,Flow 更完整,使用体验更顺畅。

这一篇我们就开始了解 Flow 的相关内容。

1、Flow 的功能定位

Flow API 具体可以分为三类:

  1. 普通的 Flow API:最早诞生,属于数据流
  2. SharedFlow:事件流
  3. StateFlow:特殊的 SharedFlow,提供状态订阅而不是事件订阅。但实际上,状态订阅本身也是事件订阅,因为状态的改变就是一种事件。但由于状态订阅这种订阅类型太过于常见与实用,所以协程单独把这种订阅给做了一份实现

数据流:关注的是同一个协程下的数据序列的持续发送和处理。

事件流:关注的是事件序列向多个订阅者的、一对多的、跨协程的通知。

由于 Flow 的功能与 Sequence 非常相似,或者说 Flow 可以看作协程版的 Sequence,因此我们对 Flow 的讲解,先从 Sequence 开始。

1.1 Sequence

Sequence 的简单使用

Sequence 是一种让我们可以像队列一样按顺序提供数据,并且按照提供的顺序来获取数据的 API。虽然说 Sequence 像队列,但它跟队列有着本质的不同 ------ Sequence 不是一种数据结构,而是一种机制。它让我们提供数据规则,而不是数据本身。这样用 Sequence 提供的数据,实际上是动态生产的,用完一条才生产下一条。而不是像队列那样,把所有数据都提前准备好,然后一条一条取出。

具体的使用方法,通过 sequence 函数,在 block 内通过 yield() 生产数据:

kotlin 复制代码
val nums = sequence {
    yield(1)
    yield(2)
}

注意,协程当中也有一个同名的 yield() 函数,用于在当前时间片未到时主动放弃时间片,定位是与 Thread.yield() 类似的。与 Sequence 的 yield() 不是同一个函数,Sequence 的 yield() 有参数而协程的没有,不要混淆。

通过 for 循环遍历取出元素:

kotlin 复制代码
for(num in nums) {
    println(num)
}

与 List 的类比

List 也有类似的用法:

kotlin 复制代码
val list = buildList {
    add(1)
    add(2)
}

for(num in list) {
    println(num)
}

你也可以将 List 通过 List.asSequence() 转换为一个生产 List 内数据的 Sequence。

Sequence 与 List 的最大区别是:Sequence 是惰性(Lazy)的,生产策略是拖延的。上面返回的 list 里面包含两个元素,而 nums 只包含代码块,即生产策略,直到被遍历时,才开始执行代码块生产数据。即用的时候才生产数据,而不是声明之后就生产。并且,是用到一条数据才生产一条,在执行 for(num in nums) 遍历时,遍历到第一个元素时,Sequence 生产第一个数据,在进行数据处理过程中时,Sequence 不会生产第二条数据,直到 for 循环开始遍历第二条元素时才开始生产。

Sequence 惰性生产数据的好处是,可能会因为满足生产过程中的某些停止条件(比如遍历到 i = 10 时就 break 结束生产过程),而节省生产数据的总时间。并且,这种生产一条使用一条的特性使得通过 Sequence 获得一条数据的时间要比 List 快,开始操作具体数据的时间比 List 快(当然,对于相同数量的数据,总的生产时间是相同的)。Sequence 适用于那种有一条数据就赶紧处理一条的业务场景,而不是把所有数据全处理完才有意义的场景。

Sequence "不支持"协程

sequence 的 block 参数是生产数据的规则:

kotlin 复制代码
@SinceKotlin("1.3")
@Suppress("DEPRECATION")
public fun <T> sequence(@BuilderInference block: suspend SequenceScope<T>.() -> Unit): Sequence<T> = Sequence { iterator(block) }

这个 block 虽然有 suspend 修饰,表面上看起来支持挂起函数,但是接收者 SequenceScope 被标记了 @RestrictsSuspension 注解:

kotlin 复制代码
@RestrictsSuspension
@SinceKotlin("1.3")
public abstract class SequenceScope<in T> internal constructor() {
    ...
}

这样就限制该 block 只能是 Sequence 自己的挂起函数,如 yield()、yieldAll(),其他挂起函数不行。因此,实际上,Sequence 相当于不支持协程。

Kotlin 团队使用协程完成 Sequence 的业务逻辑,但是希望 Sequence 的协程逻辑是独立的、与世隔绝的,不与使用者创建的协程产生任何的联系,因此使用 @RestrictsSuspension 注解,只能调用自己的挂起函数,调用别的就报错。

1.2 引入 Flow

如果想在 Sequence 内使用协程,调用挂起函数该如何处理?这时就轮到 Flow 登场了。Flow 相当于是一个协程版的 Sequence,可以在内部调用挂起函数:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val numFlow = flow {
        // 使用挂起函数 emit 发送数据
        emit(1)
        emit(2)
    }.map { "number $it" } // Flow 支持各种操作符

    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch {
        // 在指定协程内获取并遍历数据,Flow 不能用 for 循环遍历,而是用 collect
        numFlow.collect {
            println(it)
        }
    }

    delay(3000)
}

最后我们做一个小结:Sequence 的定位是提供一个边生产边消费的数据序列,而 Flow 的定位就是(支持)协程版的 Sequence。Flow 的应用场景是提供一个支持挂起函数的数据流,比如一个持续获取网络数据的数据流。

2、Flow 的工作原理和应用场景

2.1 Flow 的原理和本质

Flow 的原理非常简单,它相当于把 flow 函数内的代码挪到 collect() 调用的地方来执行。

来看个例子:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val numFlow = flow {
        emit(1)
        delay(100)
        // 延迟 100ms 发送第二条数据
        emit(2)
    }

    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch {
        numFlow.collect {
            println("A: $it")
        }
    }

    scope.launch {
        // 延迟 50ms 才开始接收数据,看能否收到第一条数据
        delay(50)
        numFlow.collect {
            println("B: $it")
        }
    }

    delay(3000)
}

第二个 launch 在 flow 发射完第一条数据之后才开始遍历数据,但也能收到所有的数据:

A: 1
B: 1
A: 2
B: 2

将示例代码进行等价替换就容易理解为什么会是这样的结果:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val numFlow = flow {
        emit(1)
        delay(100)
        emit(2)
    }

    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch {
        println("A: 1") //emit(1)
        delay(100)
        println("A: 2") //emit(2)
        /*numFlow.collect {
            println("A: $it")
        }*/
    }

    scope.launch {
        delay(50)
        println("B: 1") //emit(1)
        delay(100)
        println("B: 2") //emit(2)
        /*numFlow.collect {
            println("B: $it")
        }*/
    }

    delay(3000)
}

用 flow 内的代码块替换掉 collect() 的代码,同时将发射数据的代码替换为对数据进行具体处理的代码,形成上述的伪代码,自然容易看出得出上述结果的原因了。

Flow 的本质:Flow 设定好一套逻辑,在每个 collect() 调用处重复执行一遍这套逻辑。在逻辑里安插发送数据的节点,而 collect() 在执行这套逻辑时,对每一条数据,都会执行自己设定好的数据处理逻辑。

简言之,Flow 对象提供数据流的生产逻辑,在收集流程里执行这套生产逻辑并处理每条数据。Flow 是一个数据流工具指的就是这个。

2.2 Channel 和 Flow 的"冷"与"热"

Kotlin 官方文档中说 Channel 是热(hot)的,Flow 是冷(cold)的,指的是数据生产是独立的,还是跟数据收集有关的:

  • 热,指的是没收集数据的时候也可以生产数据
  • 冷,指的是开始收集数据的时候才开始生产数据

Channel 有它自己独立的生产线,调用一次 send() 就生产一条数据,与是否调用 receive() 来取数据是无关的。

Flow 则不然,flow() 存的只是生产规则,而真正的生产一定是在每次调用 collect() 时才开始的,每次 collect() 都会有一次完整的生产流程。

2.3 Flow 的适用场景

其实 Sequence 跟 Flow 的应用场景几乎是一样的,只不过一个是同步的,一个是挂起式的。

这里我们直接说 Flow。什么时候会需要 Flow 呢?当需要连续地处理同类型的数据时,可以......先直接处理。比如有一个获取天气信息的挂起函数:

kotlin 复制代码
suspend fun getWeather() = withContext(Dispatchers.IO) {
    // 用 delay 模拟通过网络获取天气数据的过程,最后返回一个结果字符串
    delay(100)
    "Sunny"
}

想要每分钟获取天气信息再打印出来:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch {
        while (true) {
            println("Weather: ${getWeather()}")
            delay(60_000)
        }
    }
    
    job.join()
}

这样看似乎不要 Flow,但假如我需要对功能进行拆分,把数据获取和数据处理的功能拆开,Flow 就派上用场了。

假设在界面模块,有一个显示天气数据的函数。该函数希望外界提供天气信息的持续获取的功能。外界怎么获取天气、多久获取一次我都不想管,只想持续地拿到最新的天气数据然后更新到界面中。这时要用 Flow 作为函数参数:

kotlin 复制代码
suspend fun showWeather(flow: Flow<String>) = flow.collect {
    println("Weather: $it")
}

创建参数所需的 Flow 对象:

kotlin 复制代码
val weatherFlow = flow {
    while (true) {
        emit(getWeather())
        delay(60_000)
    }
}

调用 showWeather() 显示天气:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch {
        showWeather()
    }

    job.join()
}

通过例子可以看到,持续提供数据这种形式,就是所谓的数据流。而需要把数据流的生产和消费功能拆开的业务场景就是 Flow 的用途所在。

2.4 collect() 会"把协程卡死"

回看上一节的 showWeather(),它的 collect() 是一个挂起函数,在其执行完之前,协程内的后续代码不会被执行。如果 showWeather() 在 collect() 后面再加一些其他代码,它们在 collect() 收集完全部数据之前就得不到执行。

这里要注意,严格来讲,collect() 是数据流中的收集,而不是事件流中的订阅。因此,collect() 在收集完所有数据之后再执行后续代码是合理的。

此外,由于数据流与事件流并没有严格的界限划分,因此你将 collect() 就当作一个事件订阅函数,也没什么问题。只不过,从事件订阅的角度来看 collect(),在事件订阅完成后,理应继续执行后续代码,而不是卡在这里,让后续代码无法执行。这是 collect() 作为事件订阅的一个不足,但我们可以通过合理的安排代码结构来消除这种不足,毕竟业务代码是由我们程序员自己掌控的。如果真的出现业务需要在 collect() 之后还有其他代码,你可以将 collect() 放到一个单独的协程中,这样就不会卡住后续代码了。

3、Flow 的创建

本节主要介绍创建 Flow 会使用的 API。

前面我们只是通过 flow() 来创建 Flow 对象,自己定制生产逻辑与发送数据。而 Flow 其实还提供了其它创建 Flow 的函数,可以方便地把其他类型的对象直接转换成 Flow。

3.1 flowOf()

提供一串数据给 flowOf(),进而生成一个 Flow 对象,Flow 在生产时会直接把这些数据依次发送出来:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val flow = flowOf(1, 2, 3)

    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch {
        flow.collect {
            println("Flow: $it")
        }
    }

    job.join()
}

查看 flowOf() 的源码可以发现,它其实就是一个便捷函数,创建 Flow 对象并逐个发射提供的数据:

kotlin 复制代码
public fun <T> flowOf(vararg elements: T): Flow<T> = flow {
    for (element in elements) {
        emit(element)
    }
}

3.2 asFlow()

与 flowOf() 类似,也是提供数据给函数,函数帮你创建 Flow 对象。区别在于,asFlow() 是一个扩展函数,可以把各种类型的对象转换为 Flow 对象,比如 List、Set 和 Sequence 都可以通过 asFlow() 生成对应的 Flow 对象:

kotlin 复制代码
    val flow1 = listOf(4, 5, 6).asFlow()
    val flow2 = setOf(1, 2, 3).asFlow()
    val flow3 = sequenceOf(1, 2, 3).asFlow()

    val channel = Channel<Int>()
    val flow4 = channel.consumeAsFlow()
    val flow5 = channel.receiveAsFlow()

    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch {
        flow2.collect {
            println("Flow: $it")
        }
    }

    job.join()

这里 asFlow() 是作为 Iterable 的扩展函数:

kotlin 复制代码
public fun <T> Iterable<T>.asFlow(): Flow<T> = flow {
    forEach { value ->
        emit(value)
    }
}

3.3 consumeAsFlow() 与 receiveAsFlow()

通过 Channel 也可以生成 Flow 对象:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val channel = Channel<Int>()
    val flow1 = channel.consumeAsFlow()
    val flow2 = channel.receiveAsFlow()

    val scope = CoroutineScope(EmptyCoroutineContext)
    val job1 = scope.launch {
        flow2.collect {
            println("Flow2-1: $it")
        }
    }

    val job2 = scope.launch {
        flow2.collect {
            println("Flow2-2: $it")
        }
    }

    channel.send(1)
    channel.send(2)
    channel.send(3)
    channel.send(4)

    job1.join()
    job2.join()
}

由 Channel 提供数据的 Flow,从上游看,它是由 Channel 提供热流的数据,交给下游的 Flow,仍是直到调用 collect() 的时候才开始生产数据。整体是一个双阶段的生产流程,上游生产自己的数据,下游从上游拿到数据后,会先 hold 住这些数据,步继续往下释放,直到下游的下游索要数据时再去释放。

上述代码还需注意,两个协程中的 flow2 会瓜分 Channel 发出的数据:

Flow2-1: 1
Flow2-2: 2
Flow2-1: 3
Flow2-2: 4

这与 Channel 的性质是一致的,Channel 发出的数据只能被接收一次。

通过 consumeAsFlow() 生成的 Flow 对象有一个 consume 的概念,这个流只能被消费一次,如果在这个流上多次调用 collect(),就会抛出异常:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val channel = Channel<Int>()
    val flow1 = channel.consumeAsFlow()
    val flow2 = channel.receiveAsFlow()

    val scope = CoroutineScope(EmptyCoroutineContext)
    val job1 = scope.launch {
        flow1.collect {
            println("Flow2-1: $it")
        }
    }

    val job2 = scope.launch {
        flow1.collect {
            println("Flow2-2: $it")
        }
    }

    channel.send(1)
    channel.send(2)
    channel.send(3)
    channel.send(4)

    job1.join()
    job2.join()
}

flow1 调用了两次 collect(),抛出异常:

Exception in thread "main" kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=JobImpl{Cancelling}@4ec6a292
Caused by: java.lang.IllegalStateException: ReceiveChannel.consumeAsFlow can be collected just once
...

如果是进行应用层开发,几乎用不到 Channel,也就用不到这对函数。

3.4 channelFlow() 与 callbackFlow()

channelFlow() 是使用 Channel 作为生产工具的 Flow,它创建的 Flow 直到 collect() 的时候才会创建 Channel,开始生产。

channelFlow() 的 block 内可以使用 send() 发送数据,更重要的是,可以在这里开启协程:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val channelFlow = channelFlow {
        // channelFlow 内开启协程发送数据
        launch {
            delay(2000)
            send(2)
        }
        delay(1000)
        send(1)
    }

    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch {
        channelFlow.collect {
            println("channelFlow: $it")
        }
    }

    delay(3000)
}

普通的 Flow 允许开启子协程,但是不允许在这个子协程内发送数据:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val flow = flow {
        launch {
            delay(2000)
            emit(2)
        }
        delay(1000)
        emit(1)
    }

    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch {
        flow.collect {
            println("channelFlow: $it")
        }
    }

    delay(3000)
}

运行会抛出异常:

channelFlow: 1
Exception in thread "main" java.lang.IllegalStateException: Flow invariant is violated:
		Emission from another coroutine is detected.
...

意思是你不能从另一个协程中发射数据。flow 的代码块是在哪个协程中运行,emit() 就要在哪个协程中发射数据。因此 launch() 内的 emit() 由于更换了协程,所以导致异常抛出。

两相比较,可以跨协程才是 channelFlow() 真正的用途。除此之外,channelFlow() 还可与传统的回调式代码进行对接:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val channelFlow = channelFlow { // this:ProducerScope
        gitHub.contributionsCall("square", "retrofit")
            .enqueue(object : Callback<List<Contributor>> {
                override fun onResponse(
                    call: Call<List<Contributor>>,
                    response: Response<List<Contributor>>
                ) {
                    // 没在协程里,不能用 send
                    trySend(response.body()!!)
                }

                override fun onFailure(call: Call<List<Contributor>>, t: Throwable) {
                    // 遇到异常就取消
                    cancel(CancellationException(t.message))
                }
            })
    }

    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch {
        channelFlow.collect {
            println("channelFlow: $it")
        }
    }

    job.join()
}

但是这样写,有一个问题:channelFlow 的代码块内部本质上是在执行 produce(),也就是一个协程,在执行完 enqueue() 之后协程也就结束了。因此等 Callback 的回调函数被调用时,协程早已结束,trySend() 就会失败,运行上述代码就不会打印任何东西。

解决方案就是想办法让协程不结束,在协程内使用 awaitClose() 会一直掐住协程让其挂起,直到你手动调用 cancel() 或 close() 关闭协程:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val channelFlow = channelFlow {
        gitHub.contributionsCall("square", "retrofit")
            .enqueue(object : Callback<List<Contributor>> {
                override fun onResponse(
                    call: Call<List<Contributor>>,
                    response: Response<List<Contributor>>
                ) {
                    // 没在协程里,不能用 send
                    trySend(response.body()!!)
                    // 回调函数执行完毕,关闭协程
                    close()
                }

                override fun onFailure(call: Call<List<Contributor>>, t: Throwable) {
                    // 遇到异常就取消
                    cancel(CancellationException(t.message))
                }
            })
        // 让协程挂起等待回调函数执行
        awaitClose()
    }

    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch {
        channelFlow.collect {
            println("channelFlow: $it")
        }
    }

    job.join()
}

这样即可成功打印数据:

channelFlow: [Contributor(login=JakeWharton, contributions=1350), Contributor(login=swankjesse, contributions=232), Contributor(login=renovate[bot], contributions=212), Contributor(login=squarejesse, contributions=49), Contributor(login=pforhan, contributions=48), ...

实际上,当需要与回调 API 进行交互时,更合适的是使用 callbackFlow()。callbackFlow() 内部其实就是一个 channelFlow(),只不过它会强制调用 awaitClose(),如果忘记调用会抛异常提醒你,其余逻辑与 channelFlow() 完全相同。

如果只进行单次回调函数的调用,那么完全可以使用前面介绍过的 suspendCancellableCoroutine() 这个挂起函数来做,要比 callbackFlow() 更方便。但如果需要的是连续的回调数据,即数据流,那么还是要使用 callbackFlow()。

callbackFlow() 可以看作是 Flow 版的 suspendCancellableCoroutine(),二者都用来对接回调函数。

相关推荐
代码驿站52027 分钟前
PHP语言的并发编程
开发语言·后端·golang
老大白菜29 分钟前
第1章:Go语言入门
开发语言·后端·golang
DevOpsDojo33 分钟前
MATLAB语言的正则表达式
开发语言·后端·golang
等一场春雨1 小时前
Java 23 集合框架详解:ArrayList、LinkedList、Vector
java·开发语言
dzj20211 小时前
Unity发布android Pico报错——CommandInvokationFailure: Gradle build failed踩坑记录
android·unity·gradle·报错·pico
qincjun1 小时前
Qt仿音乐播放器:媒体类
开发语言·qt
蔗理苦2 小时前
2025-01-06 Unity 使用 Tip2 —— Windows、Android、WebGL 打包记录
android·windows·unity·游戏引擎·webgl
小白编程95272 小时前
matlab离线安装硬件支持包
开发语言·matlab
桂月二二2 小时前
深入探索 Rust 中的异步编程:从基础到实际案例
开发语言·后端·rust
早上好啊! 树哥4 小时前
JavaScript Math(算数) 对象的用法详解
开发语言·javascript·ecmascript