4. ViewModel 泄漏的典型特征:Activity/Fragment 被 ViewModel 长生命周期持有

当 Activity / Fragment 因配置变更重建时,旧实例本应被 GC 回收,却因为被 ViewModel 中的某个长生命周期对象(如 LiveData 观察者、协程 Job、回调、有界 View 引用)牢牢持有,导致内存中残留大量“僵尸” Activity / Fragment。本章通过故障树、排查表、判断规则,系统拆解这一特征。

4.1 现象识别 — 故障树

故障树根节点: 配置变更后内存持续增长,来回切换 3 次以上,Heap dump 中旧 Activity 实例数 > 当前栈内 Activity 数。

  • 叶节点 1: LeakCanary 报告 Activity instance leaked,引用链尾部直指某个 ViewModel。
  • 叶节点 2: 手动触发 GC 后,在 profiler 中仍能看到存活的前一 Activity 对象。
  • 叶节点 3: 同一个 Fragment 被重复创建,但旧 Fragment 的 onDestroy() 日志未打出。

快速判断规则:

  • 规则 A:旋转屏幕后,Runtime.totalMemory() 上升量 > 当前 Activity 简单视图树的理论大小 × 2 → 很可能有实例残留。
  • 规则 B:用 adb shell dumpsys meminfo <package> 查看 Activities 计数,如果 > 1 且与旋转次数成正比 → 存在 ViewModel 持有泄漏。

4.2 数据采集 — 持有关键证据

采集以下证据,用于后续原因定位:

证据类型采集方法关键看什么
Heap dumpAndroid Studio Profiler → Dump Java Heap查找 Activity/Fragment 对象,检查其 GC Root 路径。
LeakCanary 报告集成 LeakCanary,触发泄漏后读取通知引用链倒数第二或第三节点是否为 ViewModel 实例。
Logcat 生命周期日志在 Activity/Fragment 的 onDestroy() 中打 Log.d(TAG, "onDestroy")对比重建次数与 onDestroy 打印次数是否一致。
Mat / PerfMate 分析MAT 打开 hprof,用 OQL 查 select * from instanceof android.app.Activity统计数量,看是否有多个不属于当前栈的 Activity。

4.3 原因定位 — 定位持有链

典型的“ViewModel → 长生命周期对象 → Activity/Fragment”持有链条有以下三种模式:

  1. LiveData 观察者未移除: ViewModel 中持有 LiveData.observe(lifecycleOwner),但传入的 lifecycleOwner 是一个 View 或旧 Activity,而 ViewModel 的 onCleared() 未取消注册。
  2. 协程 Job 隐式捕获: ViewModel 内启动 viewModelScope.launch,但在协程内部使用了 requireActivity() 或 view 的强引用。
  3. 回调/监听器注册: ViewModel 将 Activity / View 注册为回调(如 SystemService、SensorManager),未在 onCleared() 中注销。

· 易混淆点: 很多时候泄漏表面看是静态变量或单例导致,但实际根源是 ViewModel 内部将这些静态引用委托给了 Activity 实例。务必检查 ViewModel 中的所有成员变量和匿名内部类。

引用链分析表:

GC Root 路径片断常见泄漏源判断规则
ViewModel → LiveData → Observer → ActivityObserver 以 lambda/anonymous class 形式持有外部 ActivityObserver 内部是否使用了 activity 或 context?
ViewModel → Job → Continuation → Fragment协程挂起函数中保留了 Fragment 引用搜索协程块内是否出现 requireFragment() 或 view。
ViewModel → Listener → ActivityViewModel 中 eventBus.register(activity) 但未 unregister查看 onCleared() 中是否有对应的注销操作。
ViewModel 长生命周期持有 Activity 典型泄漏链 Activity 实例 Observer / Callback LiveData / Job / Listener ViewModel 泄漏场景 • 记性观察者(LiveData.observe) • 协程隐式持有 Activity 引用 • 未注销的系统回调监听 排查手段 1. 检查 onCleared() 中清理逻辑 2. 查找所有强引用 Activity 的字段 3. 使用 LeakCanary 分析引用链 4. 避免在 ViewModel 内持有 View 引用

4.4 修复动作 — 中断持有链

根据定位结果,执行以下修复:

  • LiveData 场景: 改用 viewLifecycleOwner 或 observe(lifecycleOwner) 后确保 listener 在 LifecycleOwner 销毁时自动移除;若手动注册,在 onCleared() 中调用 liveData.removeObserver(observer)。
  • 协程场景: 在 ViewModel 的 onCleared() 中取消 viewModelScope.coroutineContext[Job]?.children?.forEach { it.cancel() };确保协程内部避免直接使用 activity 或 fragment 强引用,改为传入独立的 callback 接口或使用 WeakReference。
  • 回调场景: 在 onCleared() 中统一注销所有注册的监听器;推荐使用 LifecycleObserver 配合 lifecycle.addObserver() 自动管理生命周期。

修复示例: 清理协程残留的 ViewModel 代码片段

class MyViewModel : ViewModel() {
    private var job: Job? = null

    fun startListening() {
        job = viewModelScope.launch {
            // 避免使用强引用,改用回调方式传递结果
            val result = repository.fetchData()
            callback?.onResult(result)  // callback 应为 WeakReference<ResponseHandler>
        }
    }

    override fun onCleared() {
        super.onCleared()
        job?.cancel()  // 主动取消协程
        // 若存在 LiveData observer,在此处移除
        myLiveData.removeObserver(observer)
    }
}

4.5 复测 — 验证修复有效性

执行以下复测步骤:

  1. 重现代码:连续快速旋转屏幕 5 次,每次间隔 500ms。
  2. 抓取 Heap dump(After GC)查看 Activity 实例总数,应 ≤ 1(当前前台 Activity)。
  3. 检查 Logcat:每旋转一次都应看到 onDestroy() 日志,且 onCreate() 次数与旋转次数相同。
  4. 使用 LeakCanary 确认为 No Leak 状态。

· 陷阱: 某些 ViewModel 中持有 SavedStateHandle 时,即使 ViewModel 被清除,SavedStateHandle 可能仍然持有旧 Activity 的 Bundle 引用。需检查 SavedStateHandle 内是否存储了自定义 Parcelable 对象,这些对象中是否又持有 Context。

4.6 预防 — 设计时避免长生命周期持有

预防措施做法原因
禁止强引用 ViewViewModel 内使用 LiveData<T> 传递数据,绝不用 View 类型字段View 持有 Activity 引用链
优先使用 observer(lifecycleOwner)传入 viewLifecycleOwner 而非 activity生命周期自动管理,避免手动清理遗漏
协程中传入接口定义 interface ResponseCallback,ViewModel 持有 WeakReference<ResponseCallback>避免隐式捕获 Activity
统一注册管理创建 LifecycleObserver 并在 onCreate 中注册ViewModel.onCleared 不再是唯一清理入口
代码审查规则禁止在 ViewModel 中添加 Activity、Fragment、View、Context 类型字段从根本上切断持有链

最佳实践: 在团队 GitLab MR 模板中要求“ViewModel 新增字段不得包含 android.app.Activity / android.support.v4.app.Fragment / android.view.View 类型”,并配置 SpotBugs 或 Detekt 规则自动检查。