第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 整体成因图谱

把上面说的东西串起来,画了一张图:

Flow订阅泄漏主要成因分析 Fragment 生命周期结束 未正确取消协程 lifecycleScope 未关联 sharing 策略不当 Eagerly / WhileSubscribed Lambda 隐式持有 匿名内部类强引用 协程仍在后台运行 更新已销毁的 View Flow 上游持续发射 ViewMode 不销毁 Fragment 实例泄漏 无法被 GC 回收 内存泄漏 / ANR / 应用崩溃 💡 排查思路:从 Lambda 捕获链 → 作用域匹配 → ViewModel 生命周期,逐层追溯 最有效的预防:让 Flow 的 scope 与 Fragment 的 viewLifecycleOwner 严格同步

3.6 总结与排查清单

每次排查 Flow 泄漏时,我会按这个顺序走一遍:

  1. 确认协程作用域:是不是用了 viewLifecycleOwner.lifecycleScope?是不是用 flowWithLifecycle 绑定了视图状态?
  2. 查 sharing 操作符:stateIn / shareIn 的 started 参数是不是 Eagerly?如果是,改成 WhileSubscribed(0) 试试。
  3. 查 Lambda 捕获:在 collect 里是不是直接引用了 view、requireContext()、activity?把这些引用挪到外面,用临时变量存一下。
  4. 查 ViewModel 生命周期:如果 Flow 在 ViewModel 里,Fragment 被销毁后 ViewModel 是否还活着?如果活着,Flow 的上游是否在后台持续运行?
  5. 用 Profiler 验证:实在找不到,就用 Android Studio 的 Memory Profiler dump 一次 heap,搜索 Fragment 类的实例,看看被谁引用着。

我个人的黄金法则:
「Flow 的 scope 不要比它要更新的那个 UI 的寿命更长」
——如果一个 Flow 最终要更新 TextView,那就让它的协程和这个 TextView 所在的生命周期绑定。
别想着复用、共享、缓存什么的,先把泄漏防住再说。