4、Flow 的上下文:flowOn 操作符,指定发射数据的协程上下文
协程上下文这个东西,说白了就是协程运行时的「环境配置」。它决定了协程在哪个线程上跑、用什么异常处理器、叫什么名字等等。而 Flow 的上下文问题,是我在项目里踩过最多坑的地方之一。
先问一个问题:Flow 的 collect 和 emit 是在同一个协程里执行的吗?
答案是:默认情况下,是的。但你可以用 flowOn 把它们拆开。
4.1 默认上下文:谁收集,谁发射
我们先看一个最简单的例子:
lifecycleScope.launch {
flow {
emit(1)
emit(2)
emit(3)
}.collect { value ->
Log.d("TAG", "collect: $value")
}
}
这段代码里,flow { ... } 里的代码和 collect { ... } 里的代码,都在 lifecycleScope 启动的那个协程里执行。也就是说,发射和收集共享同一个上下文。
这有什么问题呢?
我举个例子。假设你的 emit 里要做网络请求或者数据库读取,这些操作通常需要放在 IO 线程。而 collect 里要更新 UI,必须在主线程。如果它们共享同一个上下文,你就没法同时满足这两个要求。
flowOn。
4.2 flowOn 操作符:给发射端换个环境
flowOn 的作用很简单:它改变了上游 Flow 的协程上下文。注意我说的是「上游」,不是整个 Flow。
看代码:
flow {
for (i in 1..5) {
delay(100) // 模拟耗时操作
emit(i)
}
}.flowOn(Dispatchers.IO) // 发射端在 IO 线程
.collect { value ->
// 收集端在主线程
textView.text = value.toString()
}
这里 flowOn(Dispatchers.IO) 把发射代码移到了 IO 线程池里执行,而 collect 仍然在调用它的协程上下文中运行。这样,网络请求和 UI 更新就各司其职了。
嗯,这里要注意一个细节:flowOn 只影响它前面的代码。如果你在 flowOn 后面再加操作符,那些操作符的上下文取决于它们相对于 flowOn 的位置。
4.3 多个 flowOn 的叠加效果
我曾经在代码里看到过这样的写法:
flow {
emit(1)
emit(2)
}.flowOn(Dispatchers.IO)
.map { it * 2 }
.flowOn(Dispatchers.Default)
.collect { ... }
这个 Flow 的执行顺序是什么样的?
记住一条规则:每个 flowOn 只影响它上游的代码,直到遇到下一个 flowOn。
所以上面的代码中:
flow { ... }在Dispatchers.IO上执行map { ... }在Dispatchers.Default上执行collect { ... }在调用协程的上下文中执行
说白了,flowOn 就像一道分水岭,把 Flow 的上下游切成了不同的执行环境。
4.4 避坑指南:flowOn 与线程安全
flowOn 切换的 Flow 中,共享了一个可变对象。结果数据出现了竞态条件,排查了好久才发现是线程安全问题。
记住:flowOn 改变了执行线程,所以你的发射代码必须是线程安全的。不要在 emit 里修改外部可变状态,除非你做了同步处理。
另外,flowOn 不能改变 collect 的上下文。如果你想改变收集端的上下文,需要用 launchIn 配合 withContext,或者直接在启动协程时指定上下文。
4.5 实战场景:网络请求 + UI 更新
这是我在实际项目中最常用的模式:
viewModelScope.launch {
repository.fetchUserList()
.flowOn(Dispatchers.IO) // 网络请求在 IO 线程
.catch { e ->
_uiState.value = UiState.Error(e.message)
}
.collect { users ->
_uiState.value = UiState.Success(users)
}
}
这里 fetchUserList() 返回一个 Flow,内部会发射网络请求的结果。通过 flowOn(Dispatchers.IO),我们把网络请求放到了 IO 线程,而 collect 里的 UI 状态更新仍然在主线程。
我个人习惯把 flowOn 放在尽量靠近数据源的位置,这样后面的操作符(如 map、filter)默认就在收集端的上下文中执行,减少了不必要的线程切换。
4.6 flowOn 与 ChannelFlow 的区别
这里提一个进阶知识点。如果你用 channelFlow 而不是 flow 构建 Flow,那么 flowOn 的行为会略有不同。
| 构建方式 | flowOn 行为 |
|---|---|
flow { ... } |
发射代码在指定上下文中执行,收集端不变 |
channelFlow { ... } |
整个 block 在指定上下文中执行,但 send 操作可能受缓冲区影响 |
简单来说,channelFlow 内部使用了 Channel,它的上下文切换更复杂一些。不过日常开发中,大部分场景用 flow 就够了。
4.7 小结
关于 flowOn,你只需要记住三件事:
- 它改变的是发射端的上下文,不影响收集端
- 多个
flowOn会形成上下文分界线,每个只影响上游 - 使用
flowOn时要注意线程安全问题
你想想看,如果没有 flowOn,我们得手动用 withContext 在发射代码里切换线程,那代码得多难看。Kotlin 团队设计这个操作符,就是为了让我们能优雅地处理异步数据流中的线程问题。
emit 前后打印 Thread.currentThread().name。调试阶段很有用。
下一章我们会聊到 Flow 的背压问题,以及 buffer、conflate 这些操作符怎么帮你控制数据流速。到时候你会发现,flowOn 和它们配合起来,能玩出很多花样。