这是一个非常简单、但几乎每个人都犯过的错误:页面打开时注册了一个监听器,页面销毁时忘了移除。代码能跑,功能正常,测试也过了。但应用用一段时间之后开始变卡,越用越卡,最后甚至可能 OOM 崩溃。
这篇文章把这个问题从头到尾讲清楚:现象是什么、怎么排查、引用链是怎么把内存扣住的、为什么卡的不只是内存,以及怎么写才不会犯。
先说结论
问题的本质一句话:监听者列表持有了监听者的强引用,注册进去而不移除,监听者就永远无法被垃圾回收。
在这个案例里:
- 每次打开页面,页面的 ViewModel 都会把自己
addListener到一个生命周期比页面长的对象上(比如单例的定位管理器、全局事件总线)。 - 页面销毁时没有
removeListener,这个"死掉"的 ViewModel 依然被监听列表引用着。 - ViewModel 里存着一份路线数据(可能有几 MB),它被 ViewModel 拽着,也回收不掉。
- 页面每打开一次就多泄漏一个 ViewModel、多一份路线数据、多一个还在干活的监听回调。
- 内存不断上涨触发频繁 GC,回调重复执行浪费 CPU —— 应用就这样越用越卡。
问题现象
这类泄漏最迷惑人的地方是:刚启动时一切正常。现象是随使用时间慢慢浮现的:
- 应用刚打开很流畅,用了半小时、反复进出某个页面之后,滑动开始发涩、动画掉帧。
- 卡顿不是偶发的,而是单调变差 —— 用得越久越卡,重启应用又恢复正常。
- 观察内存曲线,会看到一个只涨不落的阶梯:每进出一次那个页面,内存就抬高一截,退出页面后并不回落。
- 如果监听回调里有日志,会发现同一个事件的日志打印了很多遍 —— 打开过 10 次页面,一次定位更新就打 10 条日志。
- 极端情况下最终
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 ← 事故现场
}逐帧看发生了什么:
LocationManager是单例,被静态引用持有,它本身就挂在 GC Root 上,和进程活得一样久。addListener(this)这一行,本质上是把 ViewModel 的引用存进了一个和进程活得一样久的列表里。- 用户退出页面,Activity 销毁,框架调用
viewModel.onCleared(),框架层面放掉了对 ViewModel 的引用。框架以为它可以死了。 - 但 GC 从 Root 出发一走:
LocationManager活着 → 它的listeners列表活着 → 列表里第 3 项那个 RouteViewModel 活着 → ViewModel 的routeData活着。整条链一个都回收不掉。 - 用户再次打开页面,框架创建一个新的 RouteViewModel(旧的那个已经和页面解绑了,不会被复用),新的又
addListener一次。列表里现在有两个 ViewModel、两份路线数据。 - 循环往复。打开 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 只认引用不认逻辑,而那个列表里的引用,就是你亲手放进去的。
写监听相关代码时守住三条:
- 注册和移除必须成对出现,且在生命周期的镜像位置。
- 能用生命周期感知的订阅(LiveData / Flow + repeatOnLifecycle),就不手写监听。
- 开发阶段用 LeakCanary 和 Memory Profiler 主动验证,不要等"用久了变卡"来告诉你。