Fragment生命周期与协程作用域

很多同学找我排查内存泄漏时,第一句话就是:“我在 Fragment 里 collect 了一个 Flow,然后回不来了”。
其实大部分情况,问题都出在「生命周期」和「协程作用域」没对齐。今天我就把这对冤家的关系彻底理清楚。

Fragment 生命周期的特殊之处

Fragment 的生命周期比 Activity 复杂得多。Activity 只有前后台切换和销毁重建,而 Fragment 多了 onCreateView / onDestroyView、onAttach / onDetach 等额外状态。

我强调一个关键点:视图层销毁不代表 Fragment 实例被销毁。比如用户按下返回键时,onDestroyView 会先调用,但 onDestroy 可能还没跑。如果你在 onCreateView 里启动了协程,且作用域绑定的是 viewLifecycleOwner,那视图销毁时协程就会自动取消。可如果你绑定的是 this(即 Fragment 本身),协程会一直活到 Fragment 彻底销毁——这中间就埋下了泄漏的种子。

生命周期事件 视图是否存活 Fragment 实例存活 典型泄漏场景
onCreateView → onViewCreated ✅ 有 ✅ 无
onDestroyView 之后 ❌ 无 ✅ 持有 View 引用、协程未取消
onDestroy 之后 ❌ ❌ 变为 destroyed 协程作用域未与 Fragment 绑定
铁律:对 UI 更新的 Flow 订阅,请始终使用 viewLifecycleOwner.lifecycleScope。别用 Fragment 本身的 lifecycleScope——它和 Fragment 实例共存亡,跨越了视图范围。

协程作用域的三种绑定方式

Kotlin 协程在 Fragment 里通常有三种启动方式。我按照「推荐程度」排了序:

  1. viewLifecycleOwner.lifecycleScope —— 最安全,视图销毁即取消。
  2. lifecycleScope —— 绑定 Fragment 本身,视图销毁不取消,需手动管理。
  3. GlobalScope / 自定义 CoroutineScope —— 极易泄漏,不推荐。

我曾经在一个电商项目里接手了一个崩溃单:用户反复切换 Tab,每个 Fragment 里的网络请求堆积起来,最后 OOM。查了半天,发现所有协程用的都是 GlobalScope.launch。嗯,这种写法比 `lifecycleScope` 更危险,因为它根本不会随任何组件销毁而取消。

小技巧:如果你不确定当前代码绑定了哪个作用域,在 onDestroyView 里打一个日志,打印 coroutineContext[Job]?.isActive。看到 true 且 Fragment 视图已销毁,那就要警惕了。

Flow 订阅时的常见错误

Flow 本身是冷流,但一旦你用 .collect 或 .launchIn 启动它,就会变成「活跃的协程任务」。下面这种写法,十有八九会引发泄漏:

// ❌ 错误示例
class MyFragment : Fragment() {

    private val viewModel: MyViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // 泄漏根源:协程绑定在 lifecycleScope,但 collect 操作未被主动取消
        lifecycleScope.launch {
            viewModel.someFlow.collect { data ->
                // 更新 UI —— 这里引用了 view
                textView.text = data.toString()
            }
        }
    }
}

为什么泄漏?因为 lifecycleScope 是 Fragment 实例级别的。当用户离开页面,视图被销毁,textView 被置为 null,但协程里还在持有 textView 的引用(lambda 捕获了外部变量)。虽然协程在 onDestroy 时会被取消,但如果 Fragment 被加回回退栈而没走到 onDestroy,那么这个协程会一直运行,导致内存泄漏。

正确做法是使用 viewLifecycleOwner.lifecycleScope,并且最好配合 repeatOnLifecycle:

// ✅ 正确示例
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    viewLifecycleOwner.lifecycleScope.launch {
        // 当视图处于至少 STARTED 状态时才 collect
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.someFlow.collect { data ->
                textView.text = data.toString()
            }
        }
    }
}

repeatOnLifecycle 的好处很直观:当生命周期低于指定状态时自动取消 collect,重新回到该状态时再重新订阅。这正好解决了「回退栈回来后,Flow 订阅状态不一致」的问题。

注意:如果用了 flowWithLifecycle 操作符,也要确保它运行在正确的 LifecycleCoroutineScope 里。我见过有人把 flowWithLifecycle 直接写在 ViewModel 里,那是没用的——它绑定了错误的生命周期。

一张图理清关系

Fragment 生命周期与协程作用域对照 Fragment 实例 onCreate ~ onDestroy 视图 (View) onCreateView ~ onDestroyView 协程作用域 lifecycleScope (Fragment 级别) viewLifecycleOwner .lifecycleScope ❌ 泄漏风险区 • lifecycleScope 在 onDestroyView 后仍存活 • collect 内持有 View 引用 → 无法 GC • 回退栈场景下不会执行 onDestroy ✅ 安全区 • viewLifecycleOwner.lifecycleScope • + repeatOnLifecycle(STARTED) • 离开视图自动取消协程,不会持有 View 核心原则:Flow 订阅的作用域 ≤ 视图生命周期

实战排查步骤

如果现在有一个泄漏的 Fragment,我建议按下面的顺序排查:

  1. 检查协程启动位置 —— 是在 onViewCreated 里吗?还是 onCreate?后者往往更危险。
  2. 确认使用的 scope —— 看是 lifecycleScope 还是 viewLifecycleOwner.lifecycleScope。肉眼看不出来的话,加断点走一遍。
  3. 查看 collect 内部是否有 View 引用 —— adapter、textView、甚至 requireView() 都算。用 LeakCanary 验证最直接。
  4. 验证是否使用了 repeatOnLifecycle —— 没有的话,加上基本就解决问题了。
避坑指南:我曾经在一个 Fragment 嵌套 ViewPager 的场景中,子 Fragment 用了 viewLifecycleOwner.lifecycleScope 但还是泄漏了。最后发现,是因为 ViewPager 在销毁子 Fragment 前先销毁了其视图,导致 viewLifecycleOwner 对象变成 detached 状态,协程虽然取消了但回调还在队列里。解决方案是配合 lifecycle.whenResumed 做二次保护。细节很多,遇到类似情况时可以往这个方向查。

小结

Fragment 的生命周期是 Android 里最复杂的组件之一,不当的协程作用域选择会直接导致内存泄漏。
记住一句话:Flow 订阅的「生」与「死」要对齐视图的「生」与「死」。
用 viewLifecycleOwner.lifecycleScope + repeatOnLifecycle 是经过行业验证的最佳实践。