第3章:Flow订阅泄漏的成因分析
Flow 在 Fragment 里泄漏,说白了就是协程该停的时候没停。
你可能觉得用了 lifecycleScope 就万事大吉,但现实往往打脸。
我自己排查过不下二十个泄漏案例,越追越发现——很多坑都在细节里。
核心观察:绝大多数 Flow 订阅泄漏,根源都是生命周期边界与协程作用域不匹配。
别急着写代码,先想清楚:这个 Flow 到底该和谁同生共死。
3.1 生命周期作用域错位
很多同学喜欢这样写:
class MyFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// 注意:这里用的是 Fragment 的 lifecycleScope
viewLifecycleOwner.lifecycleScope.launch {
someFlow.collect { data ->
updateUI(data)
}
}
}
}
表面上没问题:lifecycleScope 会在 onDestroyView 时取消。但 泄漏往往发生在 View 被销毁,Fragment 实例还活着的时候。
举个我遇到的真实场景:
- 用户进入 Fragment 订阅了一个网络状态 Flow
- 然后切换到其他 Fragment,当前 Fragment 的 View 被销毁
- 但因为 Fragment 被加入回退栈,实例仍然存活
- 如果这个 Flow 的协程用了
Dispatchers.IO并且没有正确关联 View 的生命周期…… - 协程可能还在后台执行,更新已经销毁的 UI
为什么会这样?
你想想看,lifecycleScope 只认 LifecycleOwner 的 onDestroy 事件。但 Fragment 的 View 销毁后,lifecycleOwner(即 viewLifecycleOwner)会经历 onDestroy,所以按理说协程应该被取消。问题出在哪里?
关键点:如果你把 Flow 绑定到viewLifecycleOwner.lifecycleScope,并且 Fragment 的实例还在后台,那么 Flow 本身可能仍然被其他持有者引用着。
例如:使用 stateIn 共享的 Flow 在 ViewModel 中存活,Fragment 重新创建时重新订阅,但旧的 Flow 收集器没有正确清理。
3.2 不正确的 sharing 策略
好多人喜欢用 stateIn 或 shareIn 来共享数据流。
但 started 参数如果选错了,就是一个隐形雷。
| Started 策略 | 行为 | 泄漏风险 |
|---|---|---|
Eagerly |
立即启动上游 Flow | 高:即使没有订阅者也在运行 |
Lazily |
第一个订阅者到来时启动 | 中:订阅者取消后,上游可能仍运行一段时间 |
WhileSubscribed(5000) |
最后一个订阅者消失后延迟5秒再停止 | 中低:延迟时间内可能泄漏 |
我在项目中遇到过这样一个案例:
一个 Fragment 使用 Eagerly 启动了一个传感器数据 Flow。Fragment 被销毁后,因为 ViewModel 还在后台,这个 Flow 继续发射数据,并且旧 Fragment 的 collect lambda 被匿名内部类持有,导致 Fragment 无法被 GC 回收。
破案的关键就是:只用 WhileSubscribed(0) 或者干脆不用 stateIn,而是直接在 Fragment 里用 lifecycleScope 走一次性的 collect,反而更安全。
3.3 ViewModel 中 Flow 的隐式引用链
这个坑很隐蔽。你写了一个 Flow 在 ViewModel 里:
class MyViewModel : ViewModel() {
val dataFlow: Flow<Data> = repository.observe()
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = Data()
)
}
Fragment 订阅它:
viewModel.dataFlow.observe(viewLifecycleOwner) { data ->
updateUI(data)
}
看起来没问题。但我告诉你,当 Fragment 的 View 被销毁时,observe 方法会取消,但 ViewModel 不会被销毁。
只要 viewModelScope 还活着,stateIn 的 Flow 就会继续运行。如果这个 Flow 里持有 Fragment 的引用(比如在收集 lambda 中用到了 view),那泄漏就稳稳的。
警告:在 collect lambda 里不要直接引用 Fragment 的 view 或 context。
我曾经排查过一个泄漏,发现是 lambda 里调用了 requireContext(),导致 Fragment 实例被协程的 continuation 持有。
用 viewLifecycleOwner.lifecycleScope 代替 GlobalScope 或 viewModelScope 才是正道。
3.4 匿名内部类 & Lambda 捕获
Kotlin 的 lambda 编译成匿名内部类时会隐含 this$0 引用。如果你在 lambda 里访问了 Fragment 的成员,那么这个 Fragment 实例就被协程捕获了。
// 危险写法
lifecycleScope.launch {
someFlow.collect {
textView.text = it // 捕获了 fragment 的 textView,间接持有 fragment
}
}
即使协程被 cancel,lambda 对象本身可能还被其他地方引用着。比如 flowWithLifecycle 内部使用的 Callback 对象。
我自己常用的避坑方法:
- 在
collect里尽量只传数据,不做 UI 操作 - UI 操作放到
viewLifecycleOwner.lifecycleScope的另一个协程里,或者用LiveData桥接 - 实在没办法时,在
onDestroyView里显式取消自定义的Job
3.5 整体成因图谱
把上面说的东西串起来,画了一张图:
3.6 总结与排查清单
每次排查 Flow 泄漏时,我会按这个顺序走一遍:
- 确认协程作用域:是不是用了
viewLifecycleOwner.lifecycleScope?是不是用flowWithLifecycle绑定了视图状态? - 查 sharing 操作符:
stateIn / shareIn的started参数是不是Eagerly?如果是,改成WhileSubscribed(0)试试。 - 查 Lambda 捕获:在
collect里是不是直接引用了view、requireContext()、activity?把这些引用挪到外面,用临时变量存一下。 - 查 ViewModel 生命周期:如果 Flow 在 ViewModel 里,Fragment 被销毁后 ViewModel 是否还活着?如果活着,Flow 的上游是否在后台持续运行?
- 用 Profiler 验证:实在找不到,就用 Android Studio 的 Memory Profiler dump 一次 heap,搜索 Fragment 类的实例,看看被谁引用着。
我个人的黄金法则:
「Flow 的 scope 不要比它要更新的那个 UI 的寿命更长」
——如果一个 Flow 最终要更新 TextView,那就让它的协程和这个 TextView 所在的生命周期绑定。
别想着复用、共享、缓存什么的,先把泄漏防住再说。