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 自动终止

核心要点: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 可能还没停,新的又启动了——泄漏就这么来的。

重点警告:launchIn 虽然方便,但你别指望它能自动帮你在每个挂起点检测取消。它只是把 flow 放到一个独立协程里跑,Job 取消只影响那个协程。flow 内部的每个 emit 仍然需要挂起点才能响应取消。

3.4 collect vs launchIn:一张表说清楚

维度 collect launchIn
调用方式 挂起函数,嵌入协程内部 扩展函数,返回 Job
取消机制 跟随外层协程取消 手动 cancel Job 或依赖 scope 取消
典型使用场景 一次性流,或在已有协程里处理 长期观察、UI 绑定、需手动控制生命周期
泄漏风险 低,scope 取消即停 中等,忘记 cancel Job 可能泄漏
挂起点要求 需要 flow 内部有挂起点 同样需要,但 Job 取消保证协程终止

3.5 避坑指南:我曾经踩过的三个坑

  1. 坑一:launchIn 后没存 Job
    我曾经写过一个实时搜索的 flow,每次输入变化都重新 launchIn,但没存 Job。结果旧的 flow 还在跑,新的又启动了,导致数据乱跳。后来每个请求前先 job?.cancel(),再重新 launchIn,世界清净了。
  2. 坑二:collect 里写耗时操作
    有个页面用 collect 收集列表数据,结果数据量大时 UI 卡死。因为 collect 在主线程跑,而 flow 内部没有使用 flowOn 切换调度器。记住:collect 代码块本身也会被取消,但如果它不挂起,取消无法被检测。
  3. 坑三:flow 内无挂起点导致取消延迟
    我见过一个 flow 里用 while(true) 轮询网络状态,但忘了加 delay。一旦 launchIn 的 Job 被 cancel,协程确实被标记取消,但那个 while 循环还会继续跑,直到下一次 emit 触发挂起才意识到取消。解决方案:在每个循环里加 ensureActive()。

3.6 取消联动的本质:挂起点检测

不管是 collect 还是 launchIn,Flow 的取消最终依赖的是 Kotlin 协程的取消协作机制。说白了,协程被取消时只是把标志位设为 true,真正的停止需要代码在挂起点检查这个标志。Flow 的 emit 函数就是一个挂起点,所以如果你的 flow 从来不 emit,取消就不会生效。

技巧:如果需要取消尽快生效,在 flow 构建块里定期调用 currentCoroutineContext().ensureActive()。

3.7 一张图理解整个取消流程

下面这个 SVG 图展示了 collect 和 launchIn 两种方式的取消联动路径,以及它们共通的风险点——挂起点缺失。

Flow 取消联动流程 Flow 启动 collect 挂起收集 launchIn 启动协程 外层协程取消 → 挂起取消 Flow 自动终止 返回 Job → 手动取消或 scope 取消 Job.cancel() → 协程取消 风险:flow 内部无挂起点 → 取消不生效 解决方案:定期 ensureActive() collect:跟随外部协程,无需手动取消 launchIn:返回 Job,需主动 cancel 都依赖挂起点检测取消标志 无挂起点 → 泄漏风险

3.8 小结:记住这两点就够了

  • collect 模式:你不需要管取消,但必须保证外层协程的生命周期正确。如果 flow 里有死循环,记得加挂起点。
  • launchIn 模式:每次调用都要保存 Job,在合适的时机 cancel。不然旧的 flow 变成幽灵任务。
  • 终极原则:Flow 取消不是自动魔法,是协作式取消。你给了协程取消的机会,它才会停。