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 关键区别一张表说清楚
| 特性 | flowOn | withContext |
|---|---|---|
| 作用范围 | 上游所有操作符(它之前的链) | 代码块内的局部范围 |
| 对下游影响 | 不改变 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,它就会在链中插入一个“上下文切换点”,把上游切换到新调度器,但下游仍然待在原处。
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 的上下文管理你已经掌握了八成。剩下的就是多练,在实战中体会哪些场景该用哪个工具。嗯,这一节内容就这样,拿起键盘试试看吧。