回到笔记
2026.07.149 分钟

忘了移除的监听器:一个 ViewModel 引发的内存泄漏,以及应用为什么越用越卡

页面打开时注册监听、销毁时忘了移除——一个最简单也最常见的错误。从现象、排查到引用链,把这次内存泄漏为什么发生、为什么越用越卡讲给初学者听。

Android内存泄漏踩坑复盘Memory Profiler 排查GC 引用链分析生命周期配对

这是一个非常简单、但几乎每个人都犯过的错误:页面打开时注册了一个监听器,页面销毁时忘了移除。代码能跑,功能正常,测试也过了。但应用用一段时间之后开始变卡,越用越卡,最后甚至可能 OOM 崩溃。

这篇文章把这个问题从头到尾讲清楚:现象是什么、怎么排查、引用链是怎么把内存扣住的、为什么卡的不只是内存,以及怎么写才不会犯。

先说结论

问题的本质一句话:监听者列表持有了监听者的强引用,注册进去而不移除,监听者就永远无法被垃圾回收。

在这个案例里:

  1. 每次打开页面,页面的 ViewModel 都会把自己 addListener 到一个生命周期比页面长的对象上(比如单例的定位管理器、全局事件总线)。
  2. 页面销毁时没有 removeListener,这个"死掉"的 ViewModel 依然被监听列表引用着。
  3. ViewModel 里存着一份路线数据(可能有几 MB),它被 ViewModel 拽着,也回收不掉。
  4. 页面每打开一次就多泄漏一个 ViewModel、多一份路线数据、多一个还在干活的监听回调。
  5. 内存不断上涨触发频繁 GC,回调重复执行浪费 CPU —— 应用就这样越用越卡。

问题现象

这类泄漏最迷惑人的地方是:刚启动时一切正常。现象是随使用时间慢慢浮现的:

  1. 应用刚打开很流畅,用了半小时、反复进出某个页面之后,滑动开始发涩、动画掉帧。
  2. 卡顿不是偶发的,而是单调变差 —— 用得越久越卡,重启应用又恢复正常。
  3. 观察内存曲线,会看到一个只涨不落的阶梯:每进出一次那个页面,内存就抬高一截,退出页面后并不回落。
  4. 如果监听回调里有日志,会发现同一个事件的日志打印了很多遍 —— 打开过 10 次页面,一次定位更新就打 10 条日志。
  5. 极端情况下最终 OutOfMemoryError 崩溃。

第 3 和第 4 条是这类问题最典型的指纹。尤其是第 4 条:回调执行次数等于页面历史打开次数,这几乎可以直接锁定"注册了没移除"。

分析过程:怎么一步步找到它

假设你只知道"用久了会卡",标准的排查路径是这样的:

第一步:确认是不是内存问题

打开 Android Studio 的 Memory Profiler,一边操作应用一边看内存曲线。重点做一个动作:反复进出可疑页面,每次退出后手动触发一次 GC(Profiler 上的垃圾桶按钮)。

健康的应用,GC 之后内存应该回落到进入页面之前的水平。如果每进出一次,GC 后的"地板"都比上次高一截,就说明有东西退出页面后仍然活着 —— 这是泄漏的直接证据。

第二步:抓 Heap Dump,看谁没死

进出页面若干次后,触发 GC,然后 Capture heap dump。在堆转储里按类名搜索这个页面的 ViewModel。

正常情况下,页面已经销毁,它的 ViewModel 实例数应该是 0(或者只有当前存活的 1 个)。但这时你会看到触目惊心的一幕:RouteViewModel,实例数 12 —— 你刚才进出了 12 次页面。

第三步:顺着引用链找凶手

选中其中一个本不该存在的 ViewModel 实例,查看它的 GC Root 引用链("谁引用着它,导致它不能被回收")。Profiler 会给出类似这样的链条:

GC Root (static 单例)
  └─ LocationManager.INSTANCE
       └─ listeners (ArrayList)
            └─ [3] → RouteViewModel        ← 泄漏的实例
                       └─ routeData (List<RoutePoint>, 4.2 MB)

到这里真相大白:单例 LocationManager 的监听列表里存着这个 ViewModel。往回翻代码,找到 addListener 的调用处,再找对应的 removeListener —— 找不到。就是它了。

顺带一提,LeakCanary 可以把第二、三步自动化:接入之后页面一销毁它就检查 ViewModel 是否被回收,泄漏了会直接弹通知并给出上面这条引用链。开发阶段建议常驻。

为什么会发生:把引用链讲透

要理解泄漏,只需要理解垃圾回收的一条基本规则:

一个对象能不能被回收,不取决于你还用不用它,只取决于有没有人还引用着它。

垃圾回收器从一组"根"(GC Root,比如静态变量、活跃线程)出发,沿着引用一路标记,凡是能摸到的对象都算"活着",摸不到的才回收。GC 没有办法读懂你的业务逻辑,它不知道"这个页面已经关了、这个 ViewModel 已经没用了"—— 它只看引用。

现在看这段典型的出事代码:

// 一个生命周期和整个应用一样长的单例
object LocationManager {
    private val listeners = mutableListOf<LocationListener>()
 
    fun addListener(listener: LocationListener) {
        listeners.add(listener)          // 强引用,存进列表
    }
 
    fun removeListener(listener: LocationListener) {
        listeners.remove(listener)
    }
}
 
// 页面的 ViewModel,实现了监听接口
class RouteViewModel : ViewModel(), LocationListener {
 
    private val routeData = mutableListOf<RoutePoint>()   // 路线数据,可能几 MB
 
    init {
        LocationManager.addListener(this)   // 注册:把"我自己"交了出去
    }
 
    override fun onLocationChanged(location: Location) {
        routeData.add(location.toRoutePoint())
        // 更新界面状态……
    }
 
    // 忘了写 onCleared(),没有 removeListener ← 事故现场
}

逐帧看发生了什么:

  1. LocationManager 是单例,被静态引用持有,它本身就挂在 GC Root 上,和进程活得一样久。
  2. addListener(this) 这一行,本质上是把 ViewModel 的引用存进了一个和进程活得一样久的列表里。
  3. 用户退出页面,Activity 销毁,框架调用 viewModel.onCleared(),框架层面放掉了对 ViewModel 的引用。框架以为它可以死了。
  4. 但 GC 从 Root 出发一走:LocationManager 活着 → 它的 listeners 列表活着 → 列表里第 3 项那个 RouteViewModel 活着 → ViewModel 的 routeData 活着。整条链一个都回收不掉。
  5. 用户再次打开页面,框架创建一个新的 RouteViewModel(旧的那个已经和页面解绑了,不会被复用),新的又 addListener 一次。列表里现在有两个 ViewModel、两份路线数据。
  6. 循环往复。打开 N 次,泄漏 N-1 个。

注意一个关键点:泄漏的不只是 ViewModel 对象本身。ViewModel 只是链条的头,它身后拖着的一切 —— 路线数据、缓存的图片、持有的其他对象 —— 全部一起泄漏。一个 ViewModel 对象本身可能只有几十字节,但它拖着的 routeData 可能有几 MB。这就是为什么内存是一截一截往上跳的。

为什么卡的不只是内存

很多初学者以为泄漏只是"费内存",其实它从三个方向同时拖慢应用:

1. GC 压力。 堆里堆积的死对象越多,可用内存越少,分配新对象就越频繁地触发 GC。虽然现代 Android 的 GC 大部分工作是并发的,但堆越大、扫描越勤,GC 消耗的 CPU 就越多,也仍然有短暂的停顿窗口 —— 主线程只要在一帧的 16.6ms 预算里被挤占几毫秒,掉帧就来了。这就是"越用越卡"里"卡"的第一个来源。

2. 僵尸回调在干活。 这一点常常比 GC 更致命:泄漏的 ViewModel 不是安静地占着内存 —— 它还在列表里,每次定位更新它的 onLocationChanged 都会被调用。打开过 10 次页面,就有 10 个 ViewModel 在同时处理每一次定位:10 次数据解析、10 次列表追加、10 份日志。CPU 在给 9 个早已没有界面的死对象打工。如果回调里还会更新数据库或发网络请求,还会引发重复写入、重复请求这类功能性 bug。

3. 雪崩式恶化。 僵尸回调本身还在往 routeData 里 add 数据,泄漏的内存不是静止的,还在持续增长。内存越少 → GC 越勤 → 越卡 → 内存继续涨 → 最终 OOM。

怎么修:注册和移除必须成对出现

修复本身只有一行 —— 补上移除。ViewModel 的正确位置是 onCleared():

class RouteViewModel : ViewModel(), LocationListener {
 
    init {
        LocationManager.addListener(this)
    }
 
    override fun onCleared() {
        LocationManager.removeListener(this)   // 就是这一行
        super.onCleared()
    }
}

onCleared() 是框架保证在 ViewModel 真正不再被使用时(页面彻底销毁、不是旋转屏幕那种临时重建)回调的钩子,和 init 里的注册正好构成一对。

如果注册发生在 Activity/Fragment 里,则用对应的生命周期配对:onStart 注册就在 onStop 移除,onResume 注册就在 onPause 移除。在哪一层注册,就在同一层的镜像位置移除 —— 错层配对(比如 onCreate 注册、onPause 移除)迟早会在某条生命周期路径上漏掉。

怎么从根上避免:比"记得移除"更好的办法

"记得移除"靠的是记性,而记性不可靠。更好的思路是让结构本身保证配对:

1. 优先用生命周期感知的订阅方式。 这是 Android 官方给这类问题的系统性答案:LiveData.observe(lifecycleOwner) { } 和 Flow + repeatOnLifecycle,都会在生命周期结束时自动取消订阅,根本不存在"忘了移除"的机会。能用它们就不要手写 addListener/removeListener:

// Flow 写法:页面停止自动断开,恢复自动重连,无需手动移除
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        locationRepository.locationFlow.collect { location ->
            // 更新 UI
        }
    }
}

2. 数据在下层收集,ViewModel 只做转发。 这个案例还有个架构层面的问题:路线数据的收集放在了 ViewModel 里,导致数据和页面生命周期绑死。更合理的分层是让 Repository(单例)负责收集和持有数据、对外暴露 Flow,ViewModel 只订阅它。这样即使页面反复进出,数据始终只有一份。

3. 写 addListener 的当下就写 removeListener。 如果确实要手动注册,养成一个肌肉记忆:写下注册那一行的同一分钟,先跳到 onCleared/onDestroy 把移除写上,再回来写业务逻辑。把"配对"变成一个不过夜的动作。

4. 提供 API 的一方可以防御。 如果你是那个管理监听列表的人,可以考虑用 WeakReference 持有监听者,或者提供返回"注销句柄"的接口(fun addListener(l): Closeable),从设计上降低调用方犯错的概率。

5. 开发阶段接入 LeakCanary。 让泄漏在开发时就弹到脸上,而不是等用户反馈"用久了卡"。

最后再压缩成一句话

把自己注册给一个活得比自己久的对象,就等于把自己的命交给了它 —— 不主动注销,你就永远死不掉,你身上背着的所有数据也一起死不掉;GC 只认引用不认逻辑,而那个列表里的引用,就是你亲手放进去的。

写监听相关代码时守住三条:

  1. 注册和移除必须成对出现,且在生命周期的镜像位置。
  2. 能用生命周期感知的订阅(LiveData / Flow + repeatOnLifecycle),就不手写监听。
  3. 开发阶段用 LeakCanary 和 Memory Profiler 主动验证,不要等"用久了变卡"来告诉你。

参考资料

评论
0 条

还没有评论,可以留下你的回应。