简介:这份资源是面向Android开发者的LeakCanary入门实践Demo,聚焦内存优化中最关键的内存泄漏排查环节。相比Android Profiler、MAT等PC端工具,LeakCanary可直接在手机端运行,帮助开发者在真机上快速定位泄漏对象与引用链,适合刚接触性能优化、需要上手内存泄漏检测的中初级开发者参考。压缩包共54个文件,约596KB,以png截图、xml配置、java源码、properties与gradle构建脚本为主,另含jar依赖、lock缓存文件及proguard混淆规则,完整呈现一个可运行的Android工程结构。目前已有1918人学习下载。读者可从中获得一套可直接导入Android Studio的示例工程,对照源码理解LeakCanary的接入方式与基本使用流程,并借助截图与配置了解工程搭建细节,为后续在自有项目中排查内存泄漏提供可复用的参考模板。
1. LeakCanary 上手:从一次 OOM 崩溃说起
线上监控报了一个 OOM,日志里堆栈指向一个再普通不过的列表页,用户来回切了十几次之后进程被系统干掉。复现路径不复杂,但真正定位到「谁没被回收」花了整整两天——那两天里我把内存快照导出来又导进去,对着支配树一层层展开,最后发现是一个静态单例持有了 Activity 的 Context。如果当时项目里已经接了 LeakCanary,这个结论大概在第二次切页面时就会自己跳出来。LeakCanary 是 Square 开源的 Android 内存泄漏检测库,核心做的事就一件:在 Activity、Fragment 等对象应该被销毁后主动观察它们是否真的被 GC 回收,没回收就抓一份堆快照,用可达性分析算出引用链,直接告诉你「谁拽着它不放」。它适合所有做 Android 应用、又不想每次泄漏都靠人工 dump 的团队,尤其适合中大型项目在 debug 阶段常驻。下面按「它怎么判断泄漏 → 怎么接进工程 → 怎么读结果 → 坑在哪」的顺序拆开讲,参数和排错都给到能直接抄的程度。
2. LeakCanary 的检测原理与工程接入
2.1 它凭什么知道一个对象「泄漏」了
理解原理才能读懂它给出的报告,不然看到引用链也是一头雾水。LeakCanary 的判断逻辑建立在 Java 的弱引用和引用队列之上,整个链路可以拆成四步。
第一步是监听生命周期。它通过ActivityLifecycleCallbacks和 Fragment 的生命周期回调,在onDestroy被调用时拿到对应的 Activity 或 Fragment 实例。注意这里的关键词是「onDestroy 被调用」,不是「对象已经被回收」——这两件事之间隔着 GC 的不确定性,而泄漏恰恰发生在这段间隙里。
第二步是埋一个观察点。对每个待观察对象,它构造一个KeyedWeakReference(弱引用 + 一个唯一 key),把对象挂上去。弱引用的特性是:只要对象没有强引用可达,下一次 GC 就会把它清掉,同时把弱引用本身塞进关联的ReferenceQueue。所以「弱引用进了队列」等价于「对象已经被回收」。
第三步是触发 GC 并检查。等一段时间后,它主动调用一次 GC(debug 环境下用Runtime.getRuntime().gc()配合System.runFinalization()),然后去ReferenceQueue里找对应的 key。找到了,说明对象正常回收,删掉记录;没找到,说明对象还活着,进入可疑状态。
第四步是确认与取证。可疑不等于泄漏,可能只是 GC 还没轮到。所以它会再等一轮、再触发一次 GC,仍然没回收才判定为泄漏,此时 dump 一份 hprof 堆快照,用内置的 Shark 分析引擎做可达性分析,从 GC Roots 出发找到通往那个对象的引用链,剔除掉软引用、弱引用这些不算「强持有」的路径,剩下的最短强引用链就是报告里那条「Leak trace」。
这套机制决定了两个使用上的事实:一是它只在 debug 构建里工作,release 包里相关代码会被裁掉,不用担心线上开销;二是它的检测有延迟,不是onDestroy一调用就立刻报警,中间隔着 GC 和等待窗口,所以看到报告的时间点会比泄漏发生的时刻晚几秒到几十秒,这是正常的。
2.2 依赖接入:两行配置和版本选择
接入本身极简,但版本选择有讲究。LeakCanary 2.x 之后把入口收敛成一个debugImplementation,不需要在 Application 里写任何初始化代码,靠 ContentProvider 自动装配。
// app/build.gradle dependencies { // 只在 debug 变体引入,release 包不会包含任何检测代码 debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14' }这段配置的逻辑说明:用debugImplementation而不是implementation,是为了让依赖只进入 debug 变体的 classpath。LeakCanary 2.x 通过一个声明在库 manifest 里的LeakCanaryProcess和AppWatcherInstaller(一个 ContentProvider)完成自动初始化,ContentProvider 会在 Application 的onCreate之前被系统创建,所以你在自己代码里拿到的AppWatcher已经是可用状态。参数上唯一要改的是版本号,2.14 是较稳定的一个点,团队里统一版本即可,不要一个模块 2.9 一个模块 2.14,否则可能出现重复的 watcher 注册。
如果你的工程还在用 LeakCanary 1.x 的写法(LeakCanary.install(this)),升级到 2.x 时要把那行删掉,否则会重复初始化。另外多进程应用要注意:默认只有主进程会装配,如果你的某个业务跑在独立进程里,需要在那个进程的 manifest 里显式声明 provider 或者手动调用安装,否则那个进程的 Activity 泄漏不会被观察到。
2.3 自定义观察对象:不止 Activity 和 Fragment
默认它只盯 Activity、Fragment、Fragment View、ViewModel、Service 这几类。实际项目里泄漏大户往往是自定义的控制器、Presenter 或者某个持有 Context 的管理类,这些需要手动注册。
// 在自定义对象的销毁回调里调用,告诉 LeakCanary 开始观察它 class PlayerController(context: Context) { init { // 注册观察:objectToWatch 传 this,description 用于报告里标识来源 AppWatcher.objectWatcher.expectWeaklyReachable( this, "PlayerController received onRelease" ) } fun release() { // 真正的资源释放逻辑 } }逻辑说明:expectWeaklyReachable是 2.x 暴露的核心 API,第一个参数是要观察的对象,第二个参数是一段描述,会原样出现在泄漏报告的标题里,所以描述要写清楚「谁在什么时机应该被回收」,方便日后排查时一眼定位。调用时机必须是「这个对象理论上不再被需要」的那一刻,比如release()、onDetach()、onCleared()里,而不是构造函数里随便调——在对象还活着的时候注册,等于给自己制造误报。
参数上还有一个容易忽略的点:这个方法内部会做一次isEnabled判断,如果当前不是 debug 构建或者用户手动关了检测,调用是空操作,所以不用担心在共享代码里调用带来线上开销。但反过来,如果你在 release 包里也调了它,行为是静默跳过,不会报错也不会生效,排查「为什么没检测到」时先确认构建类型。
3. 读懂泄漏报告:从 Leak trace 到引用链定位
3.1 报告的三段结构和关键字段
LeakCanary 的通知点开之后是一份结构化报告,信息密度很高,但新手容易被那一长串├─吓退。它其实就三段:泄漏对象、引用链、结论。
泄漏对象段告诉你「谁没被回收」,通常是一个 Activity 或自定义类,附带它的类名和实例的 hash。引用链段是从 GC Roots 一路往下到泄漏对象的路径,每一层是一个持有关系,箭头方向是「持有者 → 被持有者」。结论段会给出泄漏的字节数和一句人话总结,比如「This is a leak」或者「Not a leak, just a delay」。
读引用链有个技巧:从下往上读,先看紧挨着泄漏对象的那一层是谁在持有它,再看这个持有者又是被谁持有,一层层往上追到 GC Roots。真正的问题往往不在最底层,而在链条中间某个「本不该长期存活」的对象上。比如链条是MainActivity → MyApplication → SomeManager → Listener → MainActivity,问题出在SomeManager是单例、Listener注册后没反注册,而不是 Activity 本身。
报告里还有几个字段值得盯:retained heap size是这个对象及其支配的所有对象占用的内存,数值大说明泄漏影响大;Leaking: YES/NO/UNKNOWN是它对每一层的判定,UNKNOWN通常意味着这一层是系统类或者被混淆过,需要结合代码看;GC Root那一行会标明根的类型,常见的是Java static field、Thread、JNI global,不同类型对应不同的修法。
3.2 用 Shark 命令行做离线分析
UI 报告适合快速定位,但有些场景需要把 hprof 拿到本地慢慢啃,比如线上用户反馈的泄漏、或者需要对比多个版本的堆。LeakCanary 内置的 Shark 引擎提供了命令行工具,可以直接分析任意 hprof 文件。
# 下载 shark-cli 后,分析指定 hprof,输出泄漏对象和引用链 shark-cli analyze-process \ --hprof-file ./dump/heap.hprof \ --leak-dir ./leak-output \ --obfuscation-mapping ./mapping.txt逻辑说明:analyze-process是 Shark 的主命令,它会模拟 LeakCanary 的判定逻辑,把 hprof 里所有「应该被回收但还活着」的对象找出来。--hprof-file指向堆快照,--leak-dir是输出目录,里面会生成每个泄漏对象的引用链文本。--obfuscation-mapping是关键参数,如果你的包做了混淆,不传 mapping 文件,报告里的类名全是a.b.c,根本没法看;传了之后 Shark 会把混淆名还原成原始类名和方法名。
这个工具的价值在于可批量、可脚本化。我一般会在 CI 里加一个任务:跑完 monkey 测试后自动 dump 一份 hprof,用 shark-cli 分析,如果泄漏对象数量超过阈值就让构建失败。这样泄漏不会攒到发版前才集中爆发。参数上要注意 hprof 文件可能很大(几百 MB 很常见),分析时给 JVM 留够堆,-Xmx4g起步,否则容易 OOM 在分析工具自己身上。
3.3 把报告翻译成修复动作
拿到引用链只是第一步,真正的工作是判断「这条链里哪一段是不该存在的」。常见的有三类,对应三种修法。
第一类是静态持有。链条里出现static field指向某个 Manager,Manager 又持有 Activity 或 Context。修法是把这个 Context 换成applicationContext,或者在 Manager 里用弱引用持有,再或者在合适的生命周期里显式置空。注意换成applicationContext只对「不需要 UI 的 Context」有效,如果 Manager 要弹 Dialog、要拿 WindowManager,就不能这么换。
第二类是回调未注销。链条里出现 Listener、Callback、Observer 这类对象,且注册方是长生命周期的(单例、系统服务)。修法是在对应的销毁回调里反注册,并且保证注册和反注册成对出现。血泪经验是:注册写在onCreate、反注册写在onDestroy是最稳的配对,写在onStart/onStop就要考虑配置变更时的重复注册问题。
第三类是线程和 Handler。链条里出现Thread或MessageQueue,通常是后台线程持有了 Activity 的匿名内部类,或者 Handler 的 Message 还在队列里没处理完。修法是线程任务里不要直接引用外部 Activity,用静态内部类 + 弱引用,Handler 用removeCallbacksAndMessages(null)在销毁时清空队列。
4. 避坑与排查:五个真实翻车现场
4.1 报告说泄漏,但代码看着没问题
现象:报告明确指出某个 Activity 泄漏,引用链指向一个第三方 SDK 的类,翻遍自己的代码找不到哪里持有它。
原因:第三方 SDK 内部注册了生命周期回调或者全局监听,但没有在合适时机反注册,属于 SDK 自身的泄漏。LeakCanary 只能告诉你「谁持有」,没法替你改 SDK 的代码。
解决:先确认是不是 SDK 的已知问题,查它的 issue 列表或者更新到最新版本;如果短期没法升级,就在自己的代码里做兜底,比如在 Activity 销毁时反射调用 SDK 的清理方法,或者用LeakCanary的AppWatcher忽略列表把这个已知泄漏屏蔽掉,避免它淹没真正的问题。屏蔽用AppWatcher.config = AppWatcher.config.copy(watchActivities = ...)这类配置,或者更精细地在报告里标记为 known leak。
4.2 检测不到任何泄漏,但线上确实在涨内存
现象:本地跑了一下午,LeakCanary 一条报告都没有,但线上内存曲线持续上升。
原因:最常见的是构建类型不对——release 包里 LeakCanary 完全不工作,如果你测的是 release 包,自然什么都看不到。其次是泄漏发生在非主进程,而默认只装配主进程。还有一种可能是泄漏对象不是 Activity/Fragment,而是某个没注册观察的自定义类。
解决:先确认BuildConfig.DEBUG为 true 且依赖是debugImplementation;再确认泄漏发生的进程,多进程应用要在目标进程手动安装;最后把可疑的自定义类用expectWeaklyReachable注册上。排查顺序建议从构建类型开始,这是最高频的乌龙。
4.3 报告里全是系统类,引用链看不懂
现象:引用链里大量android.os.*、java.lang.*,中间夹着几个混淆后的短类名,完全无法对应到业务代码。
原因:一是没传混淆映射,二是泄漏确实发生在系统层(比如 InputMethodManager、WindowManager 持有 View),这类泄漏在旧版本 Android 上很常见。
解决:debug 包一般没混淆,如果报告来自混淆包,务必带上 mapping 文件用 shark-cli 分析。对于系统层泄漏,重点看链条里第一个「你自己的类」,问题通常出在它和系统服务之间的交互上,比如在onDestroy之后还调用了某个需要 Window 的方法。这类问题往往和特定 ROM 相关,需要在目标机型上复现验证。
4.4 检测本身拖慢了应用
现象:接了 LeakCanary 之后,debug 包切页面明显卡顿,有时还会 ANR。
原因:每次onDestroy都可能触发 GC 和堆快照,堆快照的 dump 是 stop-the-world 的,堆越大停顿越久。如果页面切换频繁,检测频率会很高。
解决:调整AppWatcher的配置,降低观察频率或者只在特定场景开启。比如把watchActivities设为 false,只在需要排查的模块手动注册;或者调大AppWatcher.config.watchDurationMillis,让它在对象销毁后等更久再判定,减少误报和无效 dump。另外 dump 目录要定期清理,hprof 文件堆积会占满存储,间接影响性能。
4.5 同一个泄漏反复报,修了还在
现象:按报告改完代码,重新跑,同样的引用链又出现了。
原因:可能是改的地方不对——报告给的是「当前这条链」,但同一个对象可能有多条持有路径,你切断了一条,另一条还在。也可能是修复没生效,比如反注册写在了错误的生命周期回调里,或者被其他代码重新注册了。
解决:修完之后不要只看「还有没有报告」,要对比修复前后的引用链是否变化。如果链条变短了但没消失,说明切断了一部分但还有残留;如果链条完全没变,说明改的代码根本没被执行到,加日志确认回调是否触发。我一般会在修复点加一行Log.d,确认执行顺序,比盯着报告猜快得多。
5. 进阶:把泄漏检测接进 CI 与自定义规则
真正让 LeakCanary 发挥价值的,不是手动点开报告看,而是把它变成流水线里的一道闸。我的做法是分三层:本地开发时靠 UI 报告即时反馈,提测前靠 monkey 跑一轮自动 dump,发版前靠 shark-cli 做基线对比。
自动 dump 这一层,核心是用Debug.dumpHprofData在测试结束时抓快照。下面这段是跑在 instrumentation 测试里的写法:
// 在测试 teardown 阶段抓取堆快照,供后续离线分析 fun dumpHeapOnFinish(targetDir: File) { val hprof = File(targetDir, "heap-${System.currentTimeMillis()}.hprof") try { // 先触发一次 GC,减少临时对象干扰 Runtime.getRuntime().gc() System.runFinalization() Debug.dumpHprofData(hprof.absolutePath) } catch (e: IOException) { // dump 失败通常是存储不足或权限问题,记录后跳过,不阻断测试 Log.e("LeakCI", "dump failed", e) } }逻辑说明:Debug.dumpHprofData是 Android 提供的原生 dump 接口,路径必须可写,通常放应用私有目录。dump 前手动 GC 一次是为了让临时对象先被清掉,减少快照体积和分析噪音。异常要吞掉而不是抛出,因为 dump 失败不该让整个测试挂掉,记录日志后继续即可。参数上唯一要控制的是频率——不要每个测试方法都 dump,放在整个测试套件的tearDown里跑一次就够,否则 IO 开销会拖垮流水线。
拿到 hprof 之后,用 shark-cli 分析并和基线对比:
# 分析本次 dump,输出泄漏列表 shark-cli analyze-process --hprof-file ./heap.hprof --leak-dir ./leaks --obfuscation-mapping ./mapping.txt # 统计泄漏对象数量,超过阈值则让 CI 失败 LEAK_COUNT=$(grep -c "Leaking: YES" ./leaks/*.txt || true) if [ "$LEAK_COUNT" -gt 5 ]; then echo "leak count $LEAK_COUNT exceeds threshold" exit 1 fi这段脚本的逻辑是:先分析出泄漏报告,再用grep数一下标记为Leaking: YES的行数,超过 5 个就退出非零码让流水线红掉。阈值不要设成 0,因为系统层和第三方 SDK 的已知泄漏很难清零,设一个容忍度更现实。基线对比的思路是:把上一个稳定版本的泄漏列表存下来,本次新增的才阻断,历史遗留的只告警。这样团队不会被存量问题卡住,又能防止新泄漏混进来。
自定义规则这块,LeakCanary 允许你通过ObjectWatcher的配置控制观察范围,也允许在报告生成后做二次处理。我一般会写一个简单的过滤器,把包名属于已知第三方 SDK 的泄漏单独归类,避免它们和业务代码的泄漏混在一起。判断依据就是引用链里出现的关键类名,匹配到就标记为external,在 CI 统计时单独计数。
从那以后我每次接新项目,第一件事就是把 LeakCanary 的 debug 依赖和 CI 里的 shark-cli 任务一起配上,宁可前期多花半小时,也不想再经历一次对着支配树手动展开两天的夜晚。希望帮到你。
本文还有配套的精品资源,点击获取