4. 取消时机不当的根源:作用域未正确传递、忽视取消检查
说实话,很多Flow泄漏的案子,查到最后都指向两个老问题:作用域传丢了,或者压根没做取消检查。你可能会想:“不就是个协程作用域嘛,能有多大影响?”嗯,我当年也是这么觉得的,直到线上出现了一个隔三差五就内存飙升的bug,追了三天才发现是同事在ViewModel里直接用了GlobalScope.launch来收集flow。
这一章我们就来扒一扒这两个根源到底是怎么导致泄漏的,以及你在代码里该怎么一眼看穿它们。
4.1 作用域未正确传递——引用链的“断头路”
协程的取消机制依赖作用域的层次结构。当父作用域被取消时,所有子协程都应该自动取消。但如果你的flow是跑在一个脱离父作用域的协程里,那父作用域取消就跟它没关系了。
我见过最典型的例子是:在Activity里用lifecycleScope.launch启动一个协程,然后在里面又用GlobalScope.launch去收集flow。你想想看,Activity销毁时lifecycleScope会取消,但GlobalScope那个子协程根本不在它的层次结构里,所以继续跑,flow也继续发射,于是泄漏就来了。
还有个隐蔽的坑:有些同学喜欢在函数里临时创建CoroutineScope(),比如这样:
fun startCollecting() {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
someFlow.collect { /* 处理 */ }
}
// scope没有被保存到任何地方,也没人取消它
}
这里的scope创建后就没人管了。它既不是父作用域的一部分,又没有显式取消。等函数返回,你连它的引用都拿不到,自然没办法取消。这种“野作用域”是泄漏的温床。
4.2 忽视取消检查——阻塞操作把取消变成了空谈
作用域传对了,是不是就万事大吉?未必。还有一个更隐形的问题:即使协程收到了取消信号,如果它正在执行不可取消的操作,那取消就只是一声空喊。
Flow的collect本身是挂起函数,它是可以响应取消的。但如果你在collect里做了下面这些事:
- 调用了阻塞的IO操作(比如File.readBytes、Socket阻塞读)
- 执行了死循环且没有挂起点
- 使用的第三方库方法没有suspend支持,且内部不检查中断状态
- 自己写了while(true)循环,却没调用yield()或ensureActive()
那么即使作用域取消了,flow照样跑得欢——因为协程调度器没办法在非挂起点插入取消检查。
withContext(Dispatchers.IO) { dao.getSync() }并加一个ensureActive,问题就解决了。
4.3 根源关系图
下面这张图把两个根源及其影响串了一遍。你在定位泄漏时,可以直接对照这个结构来分析。
4.4 两种根源的对比速查表
为了让你在实际排查中能更快定位,我整理了一个对比表。你可以把这份表贴在开发环境里,遇到泄漏时就拿出来对照一下。
| 对比维度 | 作用域未正确传递 | 忽视取消检查 |
|---|---|---|
| 典型症状 | 页面销毁后 Flow 仍在发射;线程未停止;泄漏对象自然增长 | 页面销毁后代码继续执行;CPU 占用居高不下;阻塞操作不响应 InterruptedException |
| 排查切入点 | 查看 collect 所在的 CoroutineScope 来源;是否有 GlobalScope、self.createScope | 检查 collect 的 lambda 中是否有同步阻塞代码;是否有 while(true) 循环 |
| 快速验证方法 | 在 onDestroy 或 onCleared 中打断点,看协程状态是否为 Active | 在 collect 内加 currentCoroutineContext().isActive 日志,看是否 cancel 后仍为 true |
| 根治方案 | 始终使用生命周期绑定的作用域:lifecycleScope、viewModelScope、repeatOnLifecycle | 替换为挂起 API;在循环中加 ensureActive() 或 yield();使用 flowOn(Dispatchers.Default) |
4.5 个人排查套路与避坑指南
说实话,这两个根源往往还同时出现。我见过最恶心的Case是:用GlobalScope启动一个协程,在collect里又调用了Thread.sleep()来模拟延迟。那彻底没法取消了,睡下去的线程也不会被中断。
我的排查套路是这样:
- 先问「这个collect所在的协程是哪个scope?」——如果是你肉眼找不到scope的地方,直接改。
- 再问「collect的lambda里有没有可能阻塞的操作?」——有的话,改成挂起版或加上取消检查。
- 最后用Android Profiler的Live Memory看是否存在泄漏的Lifecycle对象或Fragment,结合线程dump看哪些线程还在跑。
1. 永远不要在collect内部使用Thread.sleep()、java.io.File读写、Socket阻塞读等非挂起阻塞API。
2. 如果必须用同步代码,用
withContext(Dispatchers.IO) { ... }包一层,并在包外或包内加currentCoroutineContext().ensureActive()。3. 随手创建一个自定义
CoroutineScope()又没有地方取消它?那你不如直接写GlobalScope——因为两者都会泄漏。
说白了,取消时机不当的根就在这两点。你只要在写Flow收集代码时多花30秒检查一下scope和取消检查,就能避免90%的泄漏问题。剩下的10%……会在接下来的章节里继续讲。