第3章 Kotlin特有泄漏模式:匿名内部类 / Coroutine作用域 / Composable闭包

3.1 现象识别

Kotlin 编译器为语言特性生成隐式引用,使以下三类场景成为内存泄漏的“温床”:

泄漏模式典型现象MAT 对象名特征
匿名内部类(对象表达式)Activity/Fragment 销毁后堆中仍存在其实例,GC Root → Kotlin 匿名类对象 → 外部类OuterClass$1 或 OuterClass$object$...
Coroutine 作用域泄漏协程未取消,持有 CoroutineScope → ViewModel/Fragment,导致无法回收kotlinx.coroutines.Job、Continuation 链
Composable 闭包捕获重组期间 lambda 捕获 Activity/View 引用,导致 Composable 无法释放Composer 内部 closure、ComposableLambda
判断规则:打开 MAT 的 Histogram,筛选你的 Activity/ViewModel 类名,若存在多个实例且 GC Root 路径包含上述特征对象,则判定为 Kotlin 特有泄漏。

3.2 数据采集

使用 Android Studio 的 Memory Profiler 捕获 Dump,导出为 .hprof 后转换为 MAT 可读格式:

# 转换命令(Android SDK 自带)
hprof-conv -z dump.hprof dump_standard.hprof

在 MAT 中打开后,运行 Leak Suspects 报告,重点关注以下三类 Suspect:

  • Suspect 1:One instance of XxxActivity loaded by … 且 shortest path 包含 kotlin.jvm.internal.Reflection 或匿名类对象 → 对应第1种模式。
  • Suspect 2:Thread … has a stack frame … 且持有 kotlinx.coroutines.CoroutineDispatcher 或 ContinuationImpl → 对应第2种模式。
  • Suspect 3:… retained size large but no GC root? 路径中出现 ComposableLambda 或 Recomposer → 对应第3种模式。

若 Leak Suspects 未直接命中,手工执行 Path to GC Roots → exclude weak/soft/phantom references 遍历。

3.3 原因定位

用 故障树 逐层排查:

  1. 是否匿名内部类:检查 MAT 中目标对象的 outgoing references。若存在 this$0 字段指向外部类,则确认。
  2. 是否协程作用域:查看 CoroutineScope 对象的 coroutineContext[Job] 是否还处于 Active 状态(MAT Attributes 面板可看 state)。
  3. 是否 Composable 闭包:展开 ComposableLambda 的 _capturedArgs,若包含 Activity/Context 引用,则闭包泄漏。
排查表:常见 MAT 字段与对应模式
MAT 字段名模式解释
this$0匿名内部类Kotlin 编译器自动生成,指向外部类实例
dispatcher协程泄漏持有 HandlerDispatcher 或 Executors 的引用
composerComposableComposable 重建时保留旧 lambda 捕获的引用
Kotlin 特有泄漏模式决策流 发现对象残留 有 this$0 字段? 匿名内部类泄漏 修复:用静态类
或 WeakReference Job/Continuation 存活? 协程作用域泄漏 修复:取消 Job
使用 viewModelScope
ComposableLambda 捕获? Composable 闭包泄漏 修复:remember
/derivedStateOf
MAT 中观察 GC Root 路径 选择最短路径 → 匹配字段名 → 进入对应修复

3.4 修复动作

根据上述定位结果选择修复方案:

  • 匿名内部类:将对象表达式改为静态内部类,或使用 WeakReference 包装外部引用。例:
  • // 泄漏写法
    val listener = object : OnClickListener {
        override fun onClick(v: View) {
            // 隐式持有 this@Activity
            doSomething()
        }
    }
    // 修复:提取为顶层 lambda 或使用弱引用
    val listener = WeakReference(this).let { ref ->
        OnClickListener { ref.get()?.doSomething() }
    }
  • 协程作用域:确保在 onDestroy() 或 onCleared() 中取消 Job:
  • // 在 ViewModel 中应使用 viewModelScope
    viewModelScope.launch { ... }
    // 如使用自定义 Scope,务必手动取消
    private val myScope = CoroutineScope(Dispatchers.IO)
    fun destroy() {
        myScope.cancel()
    }
  • Composable 闭包:避免在闭包外直接捕获可变引用,使用 remember 和 derivedStateOf:
  • @Composable
    fun SafeComposable(activity: Activity) {
        // 泄漏:lambda 捕获 activity
        // 修复:使用 remember 提取不持引用状态
        val safeAction = remember { { activity.finish() } }
        Button(onClick = safeAction) { Text("退出") }
    }
注意:Compse 中 remember 本身也会在重组时重新计算捕获,若捕获对象生命周期长于 Composable,则仍需使用 remember { mutableStateOf } 或 LaunchedEffect 处理。

3.5 复测

修复后按以下步骤验证:

  1. 触发场景(如旋转屏幕、返回退出),重复三次以产生老化对象。
  2. 抓取 Dump,在 MAT 中打开 Histogram,过滤修复前的目标类名,确认实例数 ≤ 1。
  3. 如果仍有残留,重新执行 Path to GC Roots 检查是否还有其他隐式引用链。
判断规则:复测通过的标准是 Dump 中目标类实例数不大于正常运行时的存活数(通常为1),且 GC Root 路径不再包含 Kotlin 编译生成的匿名类或 Continuation。

3.6 预防

将以下检查项纳入 CI 门禁或代码审查:

预防项检测方式执行时机
对象表达式未被 object : 替代Lint 规则 KotlinAnonymousFunction编译时
自定义 CoroutineScope 缺少 cancel()静态代码分析或使用 LeakCanary 2.10+ 的协程检测编译时 / 运行时
Composable lambda 捕获 Activity人工审查 + MAT 快照对比Code Review

此外,推荐在基类中统一管理协程生命周期:

abstract class BaseViewModel : ViewModel() {
    fun launchCatching(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch { block() }
    }
}

避免在 Composable 中直接传入 Activity 引用,改用 LocalContext.current 结合 DisposableEffect 释放。养成在 onPause 或 onStop 中取消协程的习惯。