4、Flow 的上下文:flowOn 操作符,指定发射数据的协程上下文

协程上下文这个东西,说白了就是协程运行时的「环境配置」。它决定了协程在哪个线程上跑、用什么异常处理器、叫什么名字等等。而 Flow 的上下文问题,是我在项目里踩过最多坑的地方之一。

先问一个问题:Flow 的 collectemit 是在同一个协程里执行的吗?

答案是:默认情况下,是的。但你可以用 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,必须在主线程。如果它们共享同一个上下文,你就没法同时满足这两个要求。

核心原则: Flow 的发射端和收集端默认运行在同一个协程上下文中。如果你需要它们运行在不同的线程上,就必须使用 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 放在尽量靠近数据源的位置,这样后面的操作符(如 mapfilter)默认就在收集端的上下文中执行,减少了不必要的线程切换。

4.6 flowOn 与 ChannelFlow 的区别

这里提一个进阶知识点。如果你用 channelFlow 而不是 flow 构建 Flow,那么 flowOn 的行为会略有不同。

构建方式 flowOn 行为
flow { ... } 发射代码在指定上下文中执行,收集端不变
channelFlow { ... } 整个 block 在指定上下文中执行,但 send 操作可能受缓冲区影响

简单来说,channelFlow 内部使用了 Channel,它的上下文切换更复杂一些。不过日常开发中,大部分场景用 flow 就够了。

4.7 小结

关于 flowOn,你只需要记住三件事:

  • 它改变的是发射端的上下文,不影响收集端
  • 多个 flowOn 会形成上下文分界线,每个只影响上游
  • 使用 flowOn 时要注意线程安全问题

你想想看,如果没有 flowOn,我们得手动用 withContext 在发射代码里切换线程,那代码得多难看。Kotlin 团队设计这个操作符,就是为了让我们能优雅地处理异步数据流中的线程问题。

小技巧: 如果你不确定当前 Flow 的发射端在哪个线程上执行,可以在 emit 前后打印 Thread.currentThread().name。调试阶段很有用。

下一章我们会聊到 Flow 的背压问题,以及 bufferconflate 这些操作符怎么帮你控制数据流速。到时候你会发现,flowOn 和它们配合起来,能玩出很多花样。