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(),二者都用来对接回调函数。

相关推荐
行墨12 分钟前
Kotlin 主构造函数
android
巷北夜未央14 分钟前
Python每日一题(14)
开发语言·python·算法
前行的小黑炭14 分钟前
Android从传统的XML转到Compose的变化:mutableStateOf、MutableStateFlow;有的使用by有的使用by remember
android·kotlin
_一条咸鱼_18 分钟前
Android Compose 框架尺寸与密度深入剖析(五十五)
android
在狂风暴雨中奔跑31 分钟前
使用AI开发Android界面
android·人工智能
行墨32 分钟前
Kotlin 定义类与field关键
android
雾月5542 分钟前
LeetCode 914 卡牌分组
java·开发语言·算法·leetcode·职场和发展
Y.O.U..1 小时前
今日八股——C++
开发语言·c++·面试
weixin_307779131 小时前
使用C#实现从Hive的CREATE TABLE语句中提取分区字段名和数据类型
开发语言·数据仓库·hive·c#
Xiaok10181 小时前
解决 Hugging Face SentenceTransformer 下载失败的完整指南:ProxyError、SSLError与手动下载方案
开发语言·神经网络·php