ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LeakCanary实战:从OOM崩溃到内存泄漏排查与CI集成

LeakCanary实战:从OOM崩溃到内存泄漏排查与CI集成

简介:这是一份面向Android开发者的LeakCanary入门实践Demo,聚焦内存泄漏检测这一性能优化核心场景。当Android Profiler、MAT等PC端工具不便随时使用时,LeakCanary可在手机端直接捕获泄漏引用链,帮助初中级开发者快速定位Activity、Fragment等对象的泄漏问题。资源包共54个文件,约596KB,以png截图、xml布局与配置、java源码、gradle构建脚本及properties属性文件为主,另含jar、bat、pro等辅助文件,完整呈现一个可运行的Android工程结构。目前已有1909人学习下载。通过这份Demo,读者可以了解LeakCanary的接入方式与依赖配置,观察泄漏日志与引用链输出,并对照工程目录理解Application初始化、Activity生命周期与泄漏触发场景的对应关系,为后续在真实项目中排查内存问题打下基础。

1. 从一次 OOM 崩溃说起:LeakCanary 到底在手机里装了什么

线上包跑了一周,后台崩溃列表里突然冒出一堆OutOfMemoryError,堆栈指向一个早该销毁的 Activity。日志里 GC 次数正常,内存曲线却像台阶一样只涨不跌——这就是典型的内存泄漏。很多同行第一次遇到这种问题,靠adb shell dumpsys meminfo和 MAT 翻 hprof 文件,一轮下来半小时起步。LeakCanary 干的事很直接:把「怀疑泄漏 → 抓堆 → 找引用链」这套流程塞进 App 里自动跑,在泄漏对象被 GC 回收失败时弹一条通知,把最短强引用路径摆在你面前。它适合所有还在用 Java/Kotlin 写 Android、且被内存问题拖过后腿的开发者,尤其是接手老项目、Activity 和 Fragment 满天飞的那种场景。这一篇不讲空泛概念,只讲它怎么接、怎么读结果、哪些坑会让你误判。

2. 接入与初始化:从依赖到第一次泄漏通知

2.1 为什么用 debugImplementation 而不是 implementation

LeakCanary 的核心是ObjectWatcher加一个后台HeapAnalyzer,它会持有对象引用、定时触发 GC、dump hprof 文件。这些动作在 release 包里既没必要也影响性能,所以官方推荐只在 debug 变体引入。常见做法是在模块级build.gradle里写:

dependencies { // 只在 debug 构建中引入,release 包不会打进 LeakCanary 代码 debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14' }

这里2.14是当前较稳定的版本线,2.x 之后不需要在 Application 里手动LeakCanary.install(this),它靠ContentProvider自动完成初始化。逻辑说明:debugImplementation保证依赖只进 debug 变体;自动安装依赖leakcanary-android里的AppWatcherInstaller,它在 Application 创建前通过 ContentProvider 的onCreate完成注册。参数上唯一要留意的是版本号,2.x 各小版本 API 基本兼容,但 1.x 的RefWatcher写法已经废弃,别照抄老博客。

如果你是多模块项目,且某个 library 模块也想被监控,不需要在每个模块重复加依赖,LeakCanary 监控的是整个进程的对象,只要 app 模块引入即可。这一点常被误解成「每个模块都要加」,结果依赖冲突。

2.2 手动 watch 一个对象:什么时候需要自己调用

自动监控覆盖了 Activity、Fragment、Fragment View、ViewModel、Service 这些常见生命周期组件。但如果你怀疑的是某个自定义缓存、单例里的 Listener,就得手动埋点:

// 在对象应该被销毁的位置调用,比如自定义 View 的 onDetachedFromWindow val watcher = AppWatcher.objectWatcher watcher.watch( watchedObject = myCustomCache, // 被观察对象 description = "MyCustomCache should be destroyed" // 通知里显示的描述 )

逻辑说明:watch会把对象包进一个KeyedWeakReference,等 5 秒后触发 GC,如果引用还在就判定为 retained。参数description会直接出现在泄漏通知的标题里,写清楚「谁应该被销毁」,否则通知列表里一堆ObjectWatcher你根本分不清。注意watch本身不阻塞主线程,但频繁调用会拖慢 debug 包,别在onDraw这种高频回调里埋。

2.3 第一次泄漏通知长什么样

装好后跑 App,退出一个 Activity,如果它泄漏了,几秒后状态栏会出现一只金丝雀图标。点开通知,你会看到三段信息:Leaking 对象、GC Roots 到它的引用链、以及每个节点的类型。引用链是重点,它按「最短强引用路径」排序,通常第一行就是罪魁祸首。比如链里出现static field → MySingleton.instance → listener → MainActivity,那基本就是单例持有了 Activity 的匿名内部类。读这条链的能力,比记住任何 API 都重要。

3. 读懂引用链:从 retained 对象定位到那一行代码

3.1 引用链的三种典型形态

LeakCanary 给出的引用链不是随便排的,它按引用类型和长度做了裁剪。实际排查中你会反复遇到三类:

第一类是静态字段持有,链里出现static关键字,通常是单例、工具类缓存了 Context 或 Listener。第二类是线程或 Handler,链里出现ThreadLocal、MessageQueue、HandlerThread,多半是匿名 Runnable 或未移除的 Message 持有了外部类。第三类是资源未关闭,链里出现Cursor、InputStream、Bitmap,属于注册了监听或持有 native 内存没释放。

判断方法很简单:看链的末端是不是你的业务类,再看中间节点有没有static、Thread、Message这些关键字。定位到具体类后,去搜这个类里所有赋值给静态变量、或者传给线程的地方。

3.2 用 leakcanary 的 Logcat 输出做快速筛选

通知适合单次排查,但如果你要批量看,Logcat 更高效。LeakCanary 会把分析结果打到LeakCanarytag 下:

# 过滤 LeakCanary 的分析日志,只看 retained 对象和引用链 adb logcat -s LeakCanary:D | grep -A 20 "retained"

逻辑说明:-s LeakCanary:D只显示该 tag 的 debug 及以上日志,grep -A 20把 retained 后面的引用链一起带出来。参数上D是 debug 级别,如果你只想看泄漏结论可以换成E。这条命令在连续操作多个页面时特别有用,能一次性收集多条泄漏记录,再对照代码逐个改。

3.3 一个真实误判:为什么它说泄漏但其实是正常的

有一种情况会让新手翻车:LeakCanary 报告某个对象 retained,但你检查代码发现它确实还被合法持有。常见原因是对象生命周期比预期长,比如一个全局缓存故意持有 Activity 的 Context 用于预加载。这时候要看引用链里是不是有static且这个 static 是你自己设计的缓存。如果是,要么改成ApplicationContext,要么在description里标注这是预期行为,避免团队里其他人反复排查同一个「假泄漏」。

提示:LeakCanary 判定的是「GC 后仍被强引用」,不是「一定泄漏」。业务上合法的长生命周期持有会被误报,读链时先确认持有者是不是你主动设计的。

4. 避坑与排查:五条血泪经验

4.1 通知不弹,先查这三个开关

现象:依赖加了,跑起来却没有任何通知。原因通常有三个:一是构建变体选成了 release,debugImplementation不生效;二是设备关闭了通知权限,LeakCanary 的通知被系统拦了;三是 App 处于后台,部分 ROM 会限制后台弹通知。解决:确认当前是 debug 变体,去系统设置里给 App 打开通知权限,排查时把 App 切到前台再退出页面。

4.2 引用链指向系统类,别急着改系统代码

现象:链的末端是android.os.Handler或android.view.inputmethod.InputMethodManager。原因:这些系统类内部持有了你的 Activity,但根因往往是你没在onDestroy里移除回调或解绑监听。解决:顺着链往上看,找到你自己的类,检查removeCallbacks、unregisterReceiver、setListener(null)是否成对出现。系统类只是「最后一根稻草」,不是问题本身。

4.3 频繁 dump 导致 debug 包卡顿

现象:接入后 debug 包滑动明显掉帧,甚至 ANR。原因:LeakCanary 每次判定 retained 都会 dump hprof,大内存 App 一次 dump 可能几百毫秒到数秒,且会暂停部分线程。解决:排查阶段集中操作,别一边跑性能测试一边开着它;如果只需要看引用链不想要完整堆,可以在AppWatcher.config里调大watchDurationMillis,减少触发频率。

4.4 混淆后引用链变成 a.b.c

现象:debug 包里引用链全是a.b.c,完全看不懂。原因:debug 变体默认不混淆,但如果你给 debug 也开了 minify,类名就被改了。解决:检查buildTypes.debug是否误开了minifyEnabled true,排查内存问题时把它关掉。release 包本来就不该带 LeakCanary,所以不存在线上混淆问题。

4.5 多进程 App 只监控到主进程

现象:某个后台进程里的 Service 泄漏了,但通知里看不到。原因:LeakCanary 默认只安装在主进程,其他进程的ContentProvider初始化不会触发。解决:如果确实要监控多进程,需要在每个进程的 Application 里手动调用安装逻辑,或者用AppWatcher手动 watch。多数项目只关心主进程,这条按需处理。

5. 进阶:把泄漏检测接进 CI 与自定义 retained 判定

5.1 用 Instrumentation 测试自动断言泄漏

手动点页面终究会漏,成熟团队会把泄漏检测写进 UI 测试。思路是:在测试结束时主动触发 GC,然后检查ObjectWatcher里是否还有 retained 对象。常见做法是封装一个LeakAssertion:

// 在 Espresso 测试的 teardown 阶段调用 fun assertNoRetainedObjects() { // 触发一次 GC,等待 LeakCanary 完成判定 Runtime.getRuntime().gc() Thread.sleep(2000) val retained = AppWatcher.objectWatcher.retainedObjectCount // retainedObjectCount 大于 0 说明还有对象没被回收 if (retained > 0) { throw AssertionError("检测到 $retained 个 retained 对象,疑似内存泄漏") } }

逻辑说明:retainedObjectCount是 LeakCanary 暴露的计数,表示当前仍被强引用、判定为疑似泄漏的对象数量。参数Thread.sleep(2000)是给 GC 和判定留时间,实际项目里可以换成轮询直到计数稳定。这样每次 CI 跑 UI 测试,泄漏会直接让构建失败,比人工点页面可靠得多。

5.2 自定义 retained 判定阈值

默认情况下 LeakCanary 对任何 retained 对象都报警,但有些场景你只关心数量超过阈值的泄漏。可以通过AppWatcher.config调整:

// 在 Application 中配置,减少误报 AppWatcher.config = AppWatcher.config.copy( watchDurationMillis = 10_000, // 延长观察时间,减少瞬时误判 retainedDelayMillis = 5_000 // 延迟判定,给 GC 更多机会 )

逻辑说明:watchDurationMillis控制对象被 watch 后多久开始判定,retainedDelayMillis是判定前的等待时间。调大这两个值能过滤掉「刚好在 GC 边缘」的假阳性,代价是泄漏发现变慢。参数没有万能值,我一般先保持默认,误报多了再往上加。

5.3 一个我坚持了很久的习惯

引用链读多了会发现,90% 的泄漏就那几种:静态持有、线程未停、监听未解绑、资源未关。但剩下 10% 往往藏在第三方 SDK 里,链会指向某个你根本没写过的类。这时候别急着甩锅,先看这个 SDK 的初始化是不是传了 Activity 的 Context。从那以后我每次接入新 SDK,都会强制走一遍「传 ApplicationContext 还是 ActivityContext」的检查,并在退出页面后手动 watch 一次相关对象。这个习惯帮我省掉了至少三次线上 OOM 排查。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进