内存问题在Android上分两种样子。第一种干脆,日志里直接甩出java.lang.OutOfMemoryError,后面跟着Failed to allocate、申请了多少字节、还剩多少字节。第二种难缠:页面来回进出几十次之后越来越卡,内存曲线一路上扬不回落,跑上半小时就慢得像起了雾,但从头到尾一次崩溃都没有,摊手也没用。
还有一种是容易被误判的,日志长这样:
android.view.WindowLeaked: Activity com.xxx.MainActivity has leaked window
com.android.internal.policy.PhoneWindow$DecorView{...} that was originally added here
这跟渲染没关系。MainActivity都已经finish了,顶上还挂着一个Dialog或PopupWindow没关:窗口持有DecorView,DecorView反过来持有Activity,整条链一起留在内存里。
先确认涨的是哪一块内存
第一件事是分清Java堆和native堆。Bitmap的像素数据从Android 8.0(API 26)起挪到了native堆,在那之前是放在Java堆里的。所以老机型上图片相关的问题表现为Java堆涨得飞快,新机型上则是native一路涨而Java堆看着正常。Profiler里这两块分开显示,别只盯着一边。
Java堆上限各家ROM都不一样,别靠猜,代码取到的是真实值:
ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
Log.d("mem", "上限=" + am.getMemoryClass() + "MB,largeHeap=" + am.getLargeMemoryClass() + "MB");
常见返回值落在128到512之间,中低端机多数是192或256。超过这个数就崩,和设备还剩多少内存无关。manifest里加android:largeHeap="true" 能把上限抬高一截,代价是系统回收别的进程时更积极,能不用就别用。
用LeakCanary抓引用链
接一行依赖就够了:
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
2.x之后不需要写任何install代码,ContentProvider会自己初始化;release构建因为用的是debugImplementation,一行相关的代码都不会打进包里。当前2.x的稳定版是2.14(2024年发布),3.0到2026年仍处于alpha,正式项目别急着往上升。
复现的动作要刻意做慢:对一个页面反复进出十几次,或者连续旋转屏幕若干次,再按返回退出。真有泄漏的话,通知栏会弹出分析结果,点进去就是一条引用链。
引用链要从上往下读。每行末尾标着Leaking状态,NO表示这个对象本该被回收掉,YES表示判定为泄漏,UNKNOWN是LeakCanary自己也判断不了。要改的位置通常在链条上从Leaking: YES转向下一个Leaking: NO的那一段之间,那里才是真正握着引用的成员变量。现场遇到过报告里最后一行指到一个framework的类,改半天没用,往回退两行才看到静态Map攥着Activity的key。
哪些写法最容易惹出来
非静态内部类的Handler排在第一位。Handler默认持有外部类的引用,在MainActivity里写一个成员内部类,再postDelayed一个几十秒的消息,这段时间Activity就算finish也释放不掉。改成static内部类加WeakReference,退页面时在onDestroy里调removeCallbacksAndMessages(null),把队列连根清掉。
单例传了Activity进去排第二。各种Manager喜欢在构造里收一个Context,传this进去就完了,应该传getApplicationContext()。之前接手的一个项目里,一处Toast工具类把Activity存进了静态字段,所有页面都被它拖着。
注册了没反注册的一批:BroadcastReceiver、传感器监听、LocationListener、EventBus。这类泄漏的特点是引用链最后停在某个系统服务的数组上,看着不像自身代码写错,其实是注册时传了Activity实例而不是applicationContext。
Kotlin协程与RxJava同一个道理。viewModelScope与lifecycleScope会跟着生命周期自动取消,但某个地方图省事用了GlobalScope,或者在Fragment里手动起了Job却没在onDestroyView里cancel,异步任务还在跑就会拖住整棵视图树。RxJava那侧的CompositeDisposable同理,记得在同样的位置clear。
WebView属于特殊情况,它内部握着Activity的Context,即便调了destroy也不一定立刻放开。稳妥的做法是把WebView单独放进一个进程;退一步的办法是在退页面时先从父布局removeAllViews,再调destroy。
有一点要说清楚:LeakCanary报出来的一部分内容可以先放着不动,那是ROM自身的问题,报告里会标成例外。判断办法简单,引用链里全是android.* 的类、一个自己的包名都没有,就不用往自己代码上找。
自己抓堆看大对象
LeakCanary只回答「哪个Activity泄漏了」,要看是谁占着几十MB就得抓堆。三条路:
# 命令行抓下来再拉回本机
adb shell am dumpheap <pid> /data/local/tmp/heap.hprof
adb pull /data/local/tmp/heap.hprof .
# 或者在代码里触发
Debug.dumpHprofData("/sdcard/leak.hprof");
第三条路是Android Studio的Profiler里点Capture heap dump,最省事,也最适合边操作边看。
导出来的hprof是Android格式,MAT这类Java工具读不了,得先用SDK里的工具过一遍,它在platform-tools目录下:
hprof-conv heap.hprof heap_converted.hprof
转完之后丢进MAT,按Dominator Tree排Retained Size,重点看Bitmap与byte[]:Bitmap的像素数据就是byte[],泄漏的大图会以几十MB的体积排在前面。右键选Path to GC Roots,完整的持有路径就出来了。MAT自动生成的Leak Suspects可以看,但经常给出不相干的结论,顺着Retained Size自己查反而更快。
Android Studio自带的堆分析其实够用:选View app heap,按包名过滤,勾上Show activity/fragment leaks,它会直接列出还活着的Activity与Fragment实例数。同一个Activity同时出现两三个实例,泄漏就没跑了。
日常盯的两个命令
adb shell dumpsys meminfo加上包名,能看到按类别分的明细,其中Views与Activities这两行值得单独盯,来回切页面之后它们不该持续上升。
StrictMode里打开detectActivityLeaks,泄漏发生时会立刻打出一段警告日志,虽然只报「有泄漏」不说「谁持有」,胜在开发阶段随时能看到,比等到OOM再查强。
版本信息核对到2026年10月:LeakCanary 2.10、2.12、2.13、2.14,这几版改的主要是分析器与高版本Android的兼容;Android Studio 2024.2.1、2024.3.1、2025.1.1,三个版本的Profiler界面几乎没动,本机在2025.1.1上实测还是同一套操作。
工具会持续更新,照着动手之前先看一眼对应版本的官方说明。