深入理解 Kotlin 协程 (十):引而不发,探秘 Flow 冷流机制与异常透明性

从 Sequence 到 Flow:受限挂起与完整协程

在了解 Flow 是什么之前,我们先来看看标准库中的序列生成器:

kotlin 复制代码
val numbers = sequence {
    Thread.sleep(3000L)
    (1..5).forEach {
        Thread.sleep(1000L)
        println("yield $it")
        yield(it)
    }
}

numbers.forEachIndexed { index, number ->
    println("[$index] $number")
}

其中 yield 是一个挂起函数,当外部访问 numbers 的下一个元素时,序列生成器就会开始执行,直到结束或遇到挂起点。

那它和我们的 Flow 有什么关系呢?

Sequence 的本质是基于标准库的基础协程 API 实现的,而 Flow 则是基于上层协程框架构建的冷数据流,两者的底层挂起和恢复模型是类似的。

你可以把 Flow 看作是具有完整协程能力的 Sequence。在 Flow 中,我们可以调用任意的挂起函数,例如 delaywithContext(切换调度器)。

但 Sequence 不行,根本原因在于它演变自最基础的协程底层,完全没有上层框架的相关概念,根本就不认识什么是调度器 。此外,为了防止滥用,它内部的作用域还特意加上了 @RestrictsSuspension 注解,限制了在它作用域中,只能调用它自己提供的同步挂起函数(如 yieldyieldAll),不允许调用 delay 这种涉及异步调度的挂起函数。

为什么要限制?

因为 Sequence 的基础协程会直接运行在调用者的同步调用栈上。如果放开限制,允许 delay 触发挂起,那么当 Sequence 恢复执行时,可能会发现后续代码跑在另一个线程上,这是外部同步调用者完全无法接受的。

而 Flow 就没有这个问题,因为每次收集时它都会通过上层框架创建拥有完整上下文的新协程。当内部的 delay 被唤醒后,后续代码在调度器的指引下,会安全回到挂起之前的运行环境,不会给外部代码带来线程切换的副作用。

kotlin 复制代码
fun main() = runBlocking {
    val intFlow = flow {
        for (i in 1..10) {
            delay(500.milliseconds)
            emit(i) // 发射元素
        }
    }.flowOn(Dispatchers.IO) // 在 IO 线程池中执行,flowOn 只影响上游的执行环境

    val myDispatcher = Executors.newSingleThreadExecutor { r ->
        return@newSingleThreadExecutor Thread(r, "SingleThread")
    }.asCoroutineDispatcher()

    launch(myDispatcher) {
        // 消费 intFlow
        intFlow.collect {
            println("[${Thread.currentThread().name}] $it")
        }
    }.join()
    // 输出: [SingleThread] 1, [SingleThread] 2 ... 
}

什么是冷数据流?

我们前面提到过 Flow 是冷数据流,这是什么意思?

其实就是指,Flow 创建后并不会开始生产,只有在被消费(收集)时才会生产,且多次消费会触发多次生产。而与此对应的 Channel 则是热数据流,不管是否有接收端,数据都在不断地产生着,发送端不依赖接收端。

kotlin 复制代码
fun main(): Unit = runBlocking {
    val intFlow = flow {
        for (i in 1..5) {
            delay(2000.milliseconds)
            emit(i)
        }
    }

    delay(1000.milliseconds)

    // 多次消费
    listOf(1, 2).map { index ->
        launch {
            intFlow.collect {
                println("[coroutine$index] collect $it")
            }
        }
    }.joinAll()
    // 输出: 
    // [coroutine1] collect 1
    // [coroutine2] collect 1
    // ...
}

Flow 的异常透明性与 catch 操作符

Flow 的异常处理可以使用 catch 操作符函数。如果没有调用 catch,异常会在消费时直接抛出。

kotlin 复制代码
flow {
    emit(1)
    throw Exception("unknown error")
}.catch { t -> // 捕获上游的异常
    println("flow occurred error: ${t.message}")
}.collect {
    println("flow collect: $it")
}
// 输出: 
// flow collect: 1
// flow occurred error: unknown error

这是怎么做到的呢?(包括前面的 flowOn)

我们首先要搞懂 collect 操作符,我们在 collect 中写的逻辑代码会封装为一个对象,当调用 emit 时,就会回调对应操作。

其他中间操作符内部的逻辑也是如此:调用 collect 收集上游的数据,然后调用 emit 构建一个新的 Flow 供给下游。

上述的代码其实可以简化理解为:

kotlin 复制代码
fun main() = runBlocking {
    val flow2 = flow { // catch 操作符的内部模拟逻辑
        val flow1 = flow { // 原始的 flow
            emit(1)
            throw Exception("unknown error") // 上游产生异常
        }

        var fromDownstream: Throwable? = null
        try {
            // 包住中间层的 collect 调用,捕获上游异常
            flow1.collect { value ->
                try {
                    // 包住中间层的逻辑,捕获下游异常
                    emit(value)
                } catch (e: Exception) {
                    // 拦截到下游异常,直接重新抛出
                    fromDownstream = e
                    throw e
                }
            }
        } catch (e: Exception) {
            if (fromDownstream != null && e == fromDownstream) {
                // 简单过滤下游异常
                throw e
            }
            // 处理上游异常
            println("flow occurred error: ${e.message}")
        }
    }

    launch {
        flow2.collect { // 最外层的 collect
            println("flow collect: $it")
//            throw Exception("unknown error") // 下游产生异常
        }
    }.join()
}

所以整个调用链为:

rust 复制代码
最外层的 collect --> 中间层的 collect --> 最内层的 emit --> (经过中间层的逻辑) --> 中间层的 emit --> (经过最外层的逻辑)

因此,使用 try...catch 包住中间层的 collect 调用,就能捕获其上游的异常。

而在官方的实现逻辑中,还包住了中间层的逻辑 ,这样能获取来自下游的异常,不过它会将下游处理数据的异常和取消异常重新抛出,不经过该 catch 操作符。更多细节可以查看 catch 操作符的内部源码。

此外,onCompletion 操作符可以添加 Flow 完成时的执行逻辑,获取到的也是上游未捕获的异常:

kotlin 复制代码
fun main(): Unit = runBlocking {
    try {
        flow {
            emit(1)
            throw Exception("flow error")
        }.onCompletion { t ->
            val message = if (t != null) "flow completed with error: $t" else "flow completed"
            println(message)
        }.collect {
            println("flow collect: $it")
        }
    } catch (e: Exception) {
        println("catch error: $e")
    }
}

这套操作符的意义在于保证 Flow 异常的透明性:我们不应该在 Flow 构建器内部直接使用 try-catch,因为这会将下游的异常也一并捕获,一旦上游没有将异常重新抛出,会导致下游的 try-catch 流程断裂。

另外,上游的生产者本身就不应该关心下游的异常。因此,使用 catchonCompletion 操作符才是 Flow 正确的异常处理姿势,它们相当于响应式流中的 try-catch-finally。

使用 onEach 与 launchIn 收集流

除了在末端直接调用 collect 消费 Flow 的数据外,还可以通过 onEach 操作符来完成,每产生一个元素都会经过它。它还常常配合 launchIn 来指定消费端的协程作用域:

kotlin 复制代码
fun main(): Unit = runBlocking {
    val coroutineScope = CoroutineScope(Dispatchers.Default)
    flow {
        for (i in 1..5) {
            delay(2000.milliseconds)
            emit(i)
        }
    }.onEach {
        println("onEach received $it")
    }.launchIn(coroutineScope)
}

collect 是一个末端操作符,调用它将不会生产新的 Flow,同时它会挂起当前协程,所以我们一般都会新开一个协程去执行。

其实 launchIn 的内部仅仅是触发了 Flow 的收集,并将这个收集动作放在了指定作用域发射的协程中。

kotlin 复制代码
public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch {
    collect() // tail-call
}

Flow 的协作式取消机制

Flow 没有提供专门的取消函数,原因在于 Flow 需要外部调用 collect 才能开始执行。所以,只要外部末端操作符所在的协程取消了,Flow 自然就会取消:

kotlin 复制代码
fun main(): Unit = runBlocking {
    val job = launch {
        flow {
            for (i in 1..5) {
                delay(1000.milliseconds) // 挂起点
                emit(i)
            }
        }.collect {
            println("collect: $it")
        }
    }

    delay(3600.milliseconds) 
    job.cancelAndJoin() // 取消协程
}

输出结果:

makefile 复制代码
collect: 1
collect: 2
collect: 3

不过需要注意的是,上述代码能够顺利取消,得益于其内部的 delay 挂起点。

如果 flow 块里没有任何挂起点,外部取消是无法自动打断它的。对于这种情况,我们需要调用 currentCoroutineContext().ensureActive() 来检查协程取消状态,或者直接使用 .cancellable() 操作符让 Flow 具备响应取消的能力。

上下文保留与调度器切换

在官方的建议下,我们不允许在 flow 块内部随意切换调度器。

下面的代码是错误示范:

kotlin 复制代码
fun main(): Unit = runBlocking {
    flow {
        emit(1)
        withContext(Dispatchers.IO) {
            emit(2) // 违规操作!
        }
    }.collect()
}

问题的关键在于 Flow 中有一个严格的约束(上下文保留):所有的 emit 调用必须在同一个协程或同一个协程作用域中执行,中途不能擅自切换调度器。

为什么呢?

如果不进行约束,会导致冷流的上下文环境不可控。例如你在 Dispatchers.Main 中执行 collect,此时 flow 的上下文会继承主线程,emit(1) 会顺理成章地在主线程中执行并被消费。

如果此时内部调用 withContext(IO) 切到了 IO 线程,emit(2) 就会尝试在 IO 线程发送数据。这就导致下游 collect 的代码块也会跟着跑到 IO 线程中执行。当我们以为当前仍在 Main 线程准备操作 UI 时,就会抛出异常。

所以想要切换线程,不要在 flow 内部直接切,而是使用前面提到的 flowOn 操作符,它只会改变它上游的执行环境。

如果有部分逻辑在主线程、部分逻辑在 IO 线程执行,可以通过多个流的合并来实现:

kotlin 复制代码
fun main(): Unit = runBlocking {
    val myDispatcher = Executors.newSingleThreadExecutor {
        Thread(it).apply {
            name = "MyIODispatcher"
        }
    }.asCoroutineDispatcher()

    launch(Dispatchers.Default) {
        val concatFlow = flow {
            emitAll(
                flow {
                    println("flow1 emit 1, current thread: ${Thread.currentThread().name}")
                    emit(1)
                }
            )
            emitAll(
                flow {
                    println("flow2 emit 2, current thread: ${Thread.currentThread().name}")
                    emit(2)
                }.flowOn(myDispatcher)
            )
        }
        concatFlow.collect {
            println("[${Thread.currentThread().name}] $it")
        }
    }.join()
}

或者调用 flattenConcat,它会同步收集每个数据流,其实只是上面这种代码的另一种形式。

补充:flattenMerge 则是并发收集,不保证元素顺序。

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class)
fun main(): Unit = runBlocking {
    val myDispatcher = Executors.newSingleThreadExecutor {
        Thread(it).apply {
            name = "MyIODispatcher"
        }
    }.asCoroutineDispatcher()

    val flattenConcatedFlow = flowOf(
        flow {
            println("flow1 emit 1, current thread: ${Thread.currentThread().name}")
            emit(1)
        },
        flow {
            println("flow2 emit 2, current thread: ${Thread.currentThread().name}")
            emit(2)
        }.flowOn(myDispatcher)
    ).flattenConcat()

    launch {
        flattenConcatedFlow.collect {
            println(it)
        }
    }.join()
}

如果就是要在内部跨线程发射数据,可以使用 channelFlow 构建器。它的底层使用的是 Channel,天然支持在任何线程上发射数据:

kotlin 复制代码
fun main(): Unit = runBlocking {
    launch {
        channelFlow {
            send(1)
            withContext(Dispatchers.IO) {
                send(2)
            }
        }.collect { println(it) }
    }
}

如果要构建简单 Flow,直接使用各个集合的 asFlow 扩展函数,或是 flowOf 函数即可:

kotlin 复制代码
fun main(): Unit = runBlocking {
    val listFlow = listOf(1, 2, 3).asFlow()
    val setFlow = setOf(1, 2, 3).asFlow()
    val arrayFlow = arrayOf(1, 2, 3).asFlow()

    flowOf(1, 2, 3).collect()
    val flows: Flow<Flow<Int>> = flowOf(listFlow, setFlow, arrayFlow)
    flows.collect()
}

Flow 的背压处理策略

再来看看响应式编程中必然会遇到的"背压"问题,首先它会在"生产者的生产速率高于消费者的处理速率"的情况下出现------简单来说,生产太快了,消费来不及。

为了保证数据不丢失,我们可以添加缓冲来缓解,只需调用 buffer 操作符即可:

kotlin 复制代码
fun main(): Unit = runBlocking {
    flow {
        for (i in 1..300) {
            delay(50.milliseconds)
            emit(i)
        }
    }.buffer(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND).collect {
        delay(55.milliseconds) // 模拟消费慢
        println(it)
    }
}

这样数据一个都不会丢,但这只是用空间换时间缓解了问题。如果要彻底解决,只能舍弃一部分数据,我们可以指定 onBufferOverflowDROP_OLDESTDROP_LATEST

我们也可以使用更方便的 conflate 操作符,它等同于 buffer(CONFLATED);或者使用 collectLatest

虽然两者都能解决背压,但本质不同:

  • conflate: 丢弃数据。会直接丢弃来不及处理的中间发射值,不会打断当前正在执行的 collect 逻辑。
  • collectLatest: 取消并重启。它只会处理最新的数据,当新数据到来时,如果上一个数据还没处理完,会直接取消上一次的执行逻辑(协程),重新开始处理新数据。

collectLatest 示例:

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class)
fun main(): Unit = runBlocking {
    val job = launch {
        flow {
            for (i in 1..300) {
                delay(50.milliseconds)
                emit(i)
            }
        }.collectLatest {
            delay(60.milliseconds) // 处理时间长于生产时间,导致不断被取消
            println("collectLatest: $it")
        }
    }

    job.join()
}

运行结果:

makefile 复制代码
collectLatest: 300

(前面 1-299 的打印任务都在 delay 期间被新来的数据打断取消了)

Flow 的数据变换

最后是 Flow 的变换。这其实和集合的变换差不多,最基础的就是 map

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class)
fun main(): Unit = runBlocking {
    flowOf(1, 2, 3)
        .map { number ->
            number * number
        }.collect {
            println(it)
        }

    flow {
        for (i in 1..9) {
            emit(i)
        }
    }.map { i ->
        flow {
            for (j in 1..i) {
                val result = if (j == i) {
                    "$j * $i = ${j * i}\n"
                } else {
                    "$j * $i = ${j * i}\t"
                }
                emit(result)
            }
        }
    }.flattenConcat().collect {
        print(it)
    }
}

还有一个更加自由的 transform 操作符。它虽然需要我们手动发射元素,但它可以对一个元素发射多次数据map 只能一进一出)。例如:

kotlin 复制代码
fun main(): Unit = runBlocking {
    launch {
        flowOf("A", "B").transform { value ->
            emit("Start: $value")
            delay(1000.milliseconds)
            emit("End: $value")
        }.collect {
            println(it)
        }
    }.join()
}

关于 Flow 的操作符,可以看我的这两篇博客:玩转 Flow 操作符(一):数据转换与过滤玩转 Flow 操作符(二):时间控制、聚合与组合,里面详细记录了各个操作符。

相关推荐
plainGeekDev2 小时前
Application 单例 → Hilt Singleton
android·java·kotlin
程序员码歌3 小时前
我全程用 AI开发了一款微信小游戏,上线了
android·前端·游戏开发
黄林晴4 小时前
你敢信吗?同样是跨端,KMP 跟 RN 居然差这么多!
android·前端
zbmwa5 小时前
jetpack compose 副作用 produceState
android
hunterandroid6 小时前
Android 后台任务可靠性排查:从 WorkManager 观测到失败重试闭环
android·前端
GitLqr8 小时前
Flutter FocusNode 实战指南:玩转键盘焦点与用户输入体验
android·flutter·ios
zzq77978 小时前
Android 17 升级后 System.load 报错排查与加固兼容指南
android·安全·app加固·apk加固·御盾安全·免费加固·御盾加固
酷在前行9 小时前
【R绘图】Nature Communications 半眼图复刻:分布、区间与多面板排版(保姆级教程)
android·开发语言·r语言
2501_932750269 小时前
Android registerForActivityResult 详解
android