3、Flow 的取消行为:collect、launchIn 与取消的联动
3.1 为什么取消这事儿容易翻车?
Flow 本身是冷的,但一旦开始收集,它就变成一段活跃的协程任务。你想想看,如果协程被取消了,但 Flow 内部还在发射数据,或者资源没释放,这就泄漏了。我早期刚用 Flow 时,以为只要协程作用域一取消,所有 flow 都跟着结束——太天真了。
实际上,Flow 的取消行为完全取决于你用什么方式启动它。最常用的两个入口:collect 和 launchIn。别看只是 API 不同,背后的取消机制天差地别。
3.2 collect:跟调用协程同生共死
collect 是一个挂起函数,它会阻塞当前协程直到 flow 结束或被取消。说白了,你把它放在哪个协程里,它就听谁的。协程被取消了,collect 也会被取消——因为挂起点会响应取消。
// 场景:collect 在 viewModelScope 中运行
viewModelScope.launch {
flow {
emit(1)
delay(100) // 挂起点,可取消
emit(2)
}.collect { value ->
// 处理数据
}
}
// 当 viewModelScope 被取消时,collect 自动终止
但这里有个坑——如果你的 flow 内部没有挂起点(比如纯计算),协程取消无法立即生效。我记得有一次排查泄漏,发现一个 collect 块里做了大量循环计算,没有 delay 或 yield,结果协程取消后还在跑了好久。后来我强制加了一个 kotlinx.coroutines.yield() 才解决。
3.3 launchIn:返回 Job,你说了算
launchIn 的本质是在指定 CoroutineScope 中启动一个协程来收集 flow。它返回一个 Job,你可以手动取消这个 Job。很多新手以为用 launchIn 就自动绑定 scope 生命周期了——错!scope 取消确实会取消收集,但如果你需要更细粒度的控制(比如提前停止),就得操作 Job。
val job = flow {
emit("A")
emit("B")
emit("C")
}.launchIn(viewModelScope)
// 我想在某个条件满足时停止
if (condition) {
job.cancel()
}
我个人习惯把 Job 存起来,在 onCleared 或者重置状态时手动 cancel。你想想看,如果 flow 是一个不断轮询的定时任务,你只在页面关闭时靠 scope 取消,那中间切 fragment 重新创建时,旧的 flow 可能还没停,新的又启动了——泄漏就这么来的。
3.4 collect vs launchIn:一张表说清楚
| 维度 | collect | launchIn |
|---|---|---|
| 调用方式 | 挂起函数,嵌入协程内部 | 扩展函数,返回 Job |
| 取消机制 | 跟随外层协程取消 | 手动 cancel Job 或依赖 scope 取消 |
| 典型使用场景 | 一次性流,或在已有协程里处理 | 长期观察、UI 绑定、需手动控制生命周期 |
| 泄漏风险 | 低,scope 取消即停 | 中等,忘记 cancel Job 可能泄漏 |
| 挂起点要求 | 需要 flow 内部有挂起点 | 同样需要,但 Job 取消保证协程终止 |
3.5 避坑指南:我曾经踩过的三个坑
- 坑一:launchIn 后没存 Job
我曾经写过一个实时搜索的 flow,每次输入变化都重新launchIn,但没存 Job。结果旧的 flow 还在跑,新的又启动了,导致数据乱跳。后来每个请求前先job?.cancel(),再重新 launchIn,世界清净了。 - 坑二:collect 里写耗时操作
有个页面用collect收集列表数据,结果数据量大时 UI 卡死。因为collect在主线程跑,而 flow 内部没有使用flowOn切换调度器。记住:collect代码块本身也会被取消,但如果它不挂起,取消无法被检测。 - 坑三:flow 内无挂起点导致取消延迟
我见过一个 flow 里用while(true)轮询网络状态,但忘了加delay。一旦launchIn的 Job 被 cancel,协程确实被标记取消,但那个 while 循环还会继续跑,直到下一次emit触发挂起才意识到取消。解决方案:在每个循环里加ensureActive()。
3.6 取消联动的本质:挂起点检测
不管是 collect 还是 launchIn,Flow 的取消最终依赖的是 Kotlin 协程的取消协作机制。说白了,协程被取消时只是把标志位设为 true,真正的停止需要代码在挂起点检查这个标志。Flow 的 emit 函数就是一个挂起点,所以如果你的 flow 从来不 emit,取消就不会生效。
currentCoroutineContext().ensureActive()。3.7 一张图理解整个取消流程
下面这个 SVG 图展示了 collect 和 launchIn 两种方式的取消联动路径,以及它们共通的风险点——挂起点缺失。
3.8 小结:记住这两点就够了
- collect 模式:你不需要管取消,但必须保证外层协程的生命周期正确。如果 flow 里有死循环,记得加挂起点。
- launchIn 模式:每次调用都要保存 Job,在合适的时机 cancel。不然旧的 flow 变成幽灵任务。
- 终极原则:Flow 取消不是自动魔法,是协作式取消。你给了协程取消的机会,它才会停。