4. 泄漏可疑特征分类:无限collect、未取消的Scope、View层持有、静态引用链、匿名内部类

4.1 现象识别:五类泄漏特征的日志与堆栈签名

Flow 泄漏的源头往往隐藏在不同类型的订阅代码中。本节将五类最常出现的泄漏特征拆解为可快速识别的“签名”,帮助你在 Logcat 或 MAT 中一眼锁定嫌疑模式。

可疑特征 堆栈签名 / 日志模式 典型泄漏速率
无限 collect .collect { } 出现在 Activity/Fragment 的 onCreate 或 init 块中,无取消逻辑 每次页面重建产生 1 个协程泄漏
未取消的 Scope viewModelScope.launch 或自定义 CoroutineScope 在页面销毁后仍输出日志 常伴随 JobCancellationException 缺失
View 层持有 View 对象被 ViewModel 或 UseCase 通过 lambda 间接引用,无法被 GC 单次泄漏 50~200 KB
静态引用链 Companion object 或 饿汉单例 持有 FlowCollector,形成 GC Root → Activity 路径 极高,常导致 OOM
匿名内部类 object : FlowCollector 或 lambda 隐式持有外部 this 与生命周期组件耦合,等比例泄漏
排查起点:在 Dump 的 hprof 中搜索 kotlinx.coroutines.flow.FlowCollector 子类实例,若发现属于 View 或 Activity 的匿名类且数量多于预期,则为泄漏。

4.2 数据采集:建立泄漏特征的量化检测点

使用字节码插桩或 AOP(AspectJ / ASM)在 Flow 的 collect 函数入口植入监测点,采集以下指标:

// 伪代码:采集监测点
aspect FlowLeakAspect {
    pointcut flowCollect() : call(* kotlinx.coroutines.flow.Flow.collect(..));

    after() returning(runnable: Continuation) : flowCollect() {
        val caller = thisEnclosingJoinPoint.thisJoinPoint.sig.getSourceLocation()
        val thread = Thread.currentThread().name
        LeakTracker.recordCollect(
            caller = caller,
            thread = thread,
            scope = findCurrentScope()  // 通过协程上下文判断
        )
    }
}

将上述数据记录到 LeakTracker 内存环形缓冲区中,每 30 秒输出一次汇总报告:

=== Flow Leak Summary [elapsed: 30s] ===
Feature: INFINITE_COLLECT        count=12    topCaller=MainActivity$onCreate$1
Feature: UNCANCELLED_SCOPE       count=8     topCaller=ViewModel.loadData
Feature: VIEW_HOLDING            count=3     topCaller=BindingAdapter.bind
Feature: STATIC_REF_CHAIN        count=1     topCaller=SingletonHolder.flow
Feature: ANONYMOUS_INNER_CLASS   count=7     topCaller=Fragment$1.collect

4.3 原因定位:故障树与判定矩阵

将五类特征组织为故障树,从根节点“是否存在未完成 collect”向下展开。每个子节点的判定规则见下表:

  1. 是否在 View 层调用 collect? → 进入“View 层持有”分支;否则进入 Scope 分析。
  2. Scope 是否绑定生命周期? → 如果绑定但未取消,进入“未取消的 Scope”;如果未绑定,进入“无限 collect”。
  3. 是否存在静态变量指向 collector? → 进入“静态引用链”。
  4. collect 实现是否为匿名对象? → 进入“匿名内部类”。
快速判断口诀:
“View collect 看取消,Scope 未绑是无限;静态持有断链难,匿名隐式存外 this。”
Flow 泄漏特征故障树 Flow泄漏 1. 是否在View层调用collect? View层持有 2. Scope是否绑定生命周期? 未取消的Scope 无限collect 3. 静态变量指向collector? 静态引用链 匿名内部类 故障树判定总结 • 根节点泄漏 → 检查 collect 调用处是否在 View 层 • View 层 collect → 优先定位 View 持有和静态引用 • 非 View 层 → 分析 Scope 绑定状态,判断是无限 collect 还是未取消 Scope • 最终所有路径都要确认匿名内部类是否隐式持有外部引用

4.4 修复动作:针对不同特征的标准化修改

根据故障树分支选择对应的修复模板:

特征 修复动作 关键代码变更
无限 collect 将 collect 移入 lifecycleScope 或 repeatOnLifecycle 块
// 之前
lifecycleScope.launch {
    viewModel.flow.collect { }
}
// 之后
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.flow.collect { }
    }
}
未取消的 Scope 显式调用 cancel() 或使用 SupervisorJob 配合 onCleared
class MyViewModel : ViewModel() {
    private val job = SupervisorJob()
    val scope = CoroutineScope(Dispatchers.Main + job)

    fun load() {
        scope.launch {
            flow.collect { }
        }
    }

    override fun onCleared() {
        job.cancel()
    }
}
View 层持有 使用 weakReference 或通过 LifecycleOwner 回调解耦
// 之前
binding.button.setOnClickListener {
    viewModel.flow.collect { }
}
// 之后
lifecycleScope.launch {
    viewModel.flow
        .flowWithLifecycle(lifecycle, STARTED)
        .collect { }
}
静态引用链 移除静态 FlowCollector 引用,改为每次 collect 时重新创建
// 之前
object Singleton {
    val collector = FlowCollector { }
}
// 之后
object Singleton {
    fun createCollector() = FlowCollector { }
}
匿名内部类 显式使用 val listener = WeakReference(this) 或顶层 lambda
// 之前
flow.collect(object : FlowCollector {
    override suspend fun emit(value: String) {
        updateUI(value) // 隐式持有 this@Activity
    }
})
// 之后
flow.collect { value ->
    updateUI(value) // lambda 不持有 Activity 引用
}
特别注意: 修复“匿名内部类”时,若 lambda 中仍调用 Activity 方法(如 updateUI),编译器可能生成一个 this$0 字段。此时应改用 lifecycleScope 或 viewLifecycleOwner.lifecycleScope 确保作用域安全。

4.5 复测:验证泄漏特征是否消除

  1. 重复执行进入→退出→进入页面场景 20 次。
  2. 执行 adb shell dumpsys meminfo 观察 PSS 趋势。
  3. 使用 LeakCanary 捕获堆转储,确认五类特征实例数量不再增长。
# 复测脚本片段
for i in {1..20}; do
    adb shell am start -n com.example/.MainActivity
    sleep 2
    adb shell input keyevent 4  # 返回键
    sleep 1
done
adb shell dumpsys meminfo com.example | grep "TOTAL PSS"

若 PSS 增量为 0~2 MB(考虑图片缓存等正常波动),则认为泄漏特征已消除。

4.6 预防:代码审查与 CI 门禁规则

在 GitLab CI / GitHub Actions 中添加以下检查:

  • 禁止在 onCreate / onViewCreated 中直接调用 .collect{} → 自动标记为 INFINITE_COLLECT 风险。
  • 要求每个 coroutineScope.launch 必须配套 cancel() 或绑定生命周期 → 使用 detekt 规则 UncancelledScope。
  • 禁止将 View 引用传递到 ViewModel 或 UseCase → 通过 lint 检查 android.view.View 参数。
  • 禁止在 object 或 companion object 中持有 FlowCollector → 静态分析工具查找。
  • 匿名内部类必须使用 viewLifecycleOwner.lifecycleScope 包裹 → 代码审查时重点标记。
# detekt 自定义规则示例:UncancelledScope
# 在 detekt.yml 中配置
uncancelled_scope:
  active: true
  excludes: ["**/test/**"]
  severity: ERROR
  pattern: "coroutineScope\\.launch"  # 检查未用 lifecycleScope 的场景

将上述规则加入 Merge Request 门禁,当扫描到可疑特征时自动阻塞合并,并输出定位章节索引。