4、Flow上下文:flowOn 与 withContext

Flow 在协程里跑,但你有没有想过——它到底跑在哪个线程上?

说白了,Flow 默认跑在 收集(collect) 的那个协程上下文里。也就是说,如果你在 lifecycleScope.launch 里 collect,那默认就是主线程。嗯,这里就有问题了:网络请求、数据库读写这些耗时操作,你肯定不想堵住主线程。

我刚开始用 Flow 时犯过一个低级错误:在 flow builder 里直接调了一个 OkHttp 的挂起函数,结果 collect 时 UI 卡住了。后来才意识到,Flow 不会自动帮你切线程。你需要手动控制上下文。

4.1 flowOn:只影响上游

flowOn 是 Flow 的“上游调度器”。它只切换 flow builder 里的代码和它之前的所有操作符。下游(collect 那边的操作)不受影响。

flow {
    // 这段代码会跑在 IO 线程
    emit(1)
    emit(2)
}
.flowOn(Dispatchers.IO)   // 上游切到 IO
.collect { value ->
    // 这里还在主线程
}

我记得有个同事问我:“为什么我在 flowOn(Default) 里调了网络请求还是 ANR?”一看代码,他把 flowOn 写在 collect 后面了——没用,flowOn 只管它前面的链。

技巧:你可以多次调用 flowOn,每个 flowOn 会影响它前面的所有操作符。但别滥用,太多上下文切换会带来性能开销。

4.2 withContext:协程内的“硬切换”

withContext 是 Kotlin 协程提供的标准切换工具。它可以把一段代码强制扔到另一个调度器上,执行完再回来。但在 Flow 里用它要小心。

flow {
    withContext(Dispatchers.IO) {
        // 这里确实在 IO 线程
        emit(fetchData())
    }
    // 但这行代码又回到了原来的上下文
    emit(anotherValue)
}
.collect { ... }

在 Flow builder 里用 withContext 的问题是:整个 flow builder 共享同一个协程。你切进去之后,如果不切回来,后续 emit 可能还在那个线程上;如果切回来,又可能污染你期望的线程模型。个人习惯:能用 flowOn 就别用 withContext,除非你明确需要局部切换。

4.3 关键区别一张表说清楚

特性flowOnwithContext
作用范围上游所有操作符(它之前的链)代码块内的局部范围
对下游影响不改变 collect 上下文不改变 collect,但影响 builder 内后续代码
推荐场景整个数据生产阶段需要切换到指定线程数据生产过程中某一段需要特殊调度
可重复调用可以,多个 flowOn 叠加可以,嵌套使用
常见陷阱放在 collect 后无效忘记切回原上下文导致线程泄漏

4.4 实战避坑:OkHttp 泄漏与错误上下文

我在项目中遇到过最典型的场景:用 Flow + OkHttp 做网络轮询。一开始写的代码是这样的:

flow {
    while (true) {
        val response = httpClient.newCall(request).await()  // 挂起函数
        emit(response)
        delay(5000)
    }
}.flowOn(Dispatchers.IO)
.collect { ... }

看上去没问题?但仔细想想:await() 是挂起函数,它内部其实跑了 OkHttp 的异步回调。如果你在 flowOn(IO) 里调它,确实不会阻塞主线程。但有个隐患:每次 collect 时,OKHttp 的 Call 对象会持有当前协程的引用。如果你取消了 collect,但 OkHttp 请求还在进行,就容易造成泄漏。

我曾经遇到过 App 退出后还不断收到网络回掉,就是因为没及时清理 Call。解决办法:使用 withContext 包裹挂起调用,并配合 cancel() 手动释放。

flow {
    var call: Call? = null
    try {
        while (isActive) {
            call = httpClient.newCall(request)
            val response = withContext(Dispatchers.IO) {
                call!!.await()   // 明确切到 IO
            }
            emit(response)
            delay(5000)
        }
    } finally {
        call?.cancel()   // 确保取消时清理
    }
}.collect { ... }
警告:不要以为 flowOn(IO) 就能解决一切。如果 Flow 内部持有外部资源(比如 OkHttp Call、数据库连接),一定要在 finally 或 onCompletion 中释放,否则泄漏等着你。

4.5 理解背后机制:Flow 的上下文是怎么传递的

Flow 本身是一个冷流,它不持有协程。当你调用 collect 时,才会在调用者的协程中启动。如果你不指定 flowOn,整个 Flow 链都跑在调用者的协程上下文里。一旦调用了 flowOn,它就会在链中插入一个“上下文切换点”,把上游切换到新调度器,但下游仍然待在原处。

Flow 上下文切换流程 主线程(collect) flowOn(Dispatchers.IO) IO 线程 上游:map / filter 等 flow builder 下游:collect ⚠ flowOn 只切换它之前的操作(上游),collect 始终在调用时的上下文跑

4.6 什么时候用 flowOn,什么时候用 withContext

  • 整个上游都要切线程:无脑用 flowOn,干净清爽。
  • 只在某一段代码需要特殊调度:比如在 flowOn(IO) 的基础上,又想临时切到 Main 更新 UI(虽然不推荐在 flow builder 里更新 UI),可以用 withContext。
  • 资源生命周期管理:如果你在 flow 内部有需要手动释放的对象(比如 OkHttp Call、Cursor),不要只用 flowOn,配合 try/finally 或 .onCompletion 才是正解。

一句话总结:让 flowOn 负责线程调度,让 withContext 负责局部适配。两者不冲突,但别搞反了作用范围。

另外,我记得有一次调试一个诡异的内存泄漏,最后发现是 withContext 里嵌套了 async,结果 scope 没传好,子协程逃逸了。在 Flow 里用 withContext 时,务必确认你清楚当前 CoroutineScope 是被谁 cancel 的。你想想看,Flow 取消了,但 withContext 里的协程还在跑,那不就炸了?

目前为止,Flow 的上下文管理你已经掌握了八成。剩下的就是多练,在实战中体会哪些场景该用哪个工具。嗯,这一节内容就这样,拿起键盘试试看吧。