70、性能优化之内存优化:内存泄漏检测(LeakCanary)、大对象回收、Bitmap优化
内存优化,说白了就是让你的App别那么「吃内存」。
我见过太多项目,功能做得花里胡哨,结果一跑就OOM。用户手机发烫、卡顿,最后直接卸载。你说冤不冤?
这一讲,咱们就聊聊内存优化的三个核心点:内存泄漏检测、大对象回收、Bitmap优化。这三板斧砍下去,你的App内存至少能瘦一圈。
核心观点:内存优化不是「省内存」,而是「用好内存」。该回收的及时回收,该复用的别重复创建。
一、LeakCanary:内存泄漏的「照妖镜」
内存泄漏这东西,看不见摸不着。你写代码的时候感觉一切正常,跑起来也流畅。但用户用着用着,手机就越来越卡,最后直接闪退。
为什么会这样?因为泄漏的内存像沙子一样,一点一点漏掉了。GC回收不了,堆内存越占越多,最终触发OOM。
我个人习惯,新项目一开始就把LeakCanary集成进去。别等到上线了再查,那时候就晚了。
1.1 集成方式
在build.gradle里加一行依赖就行:
dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
}
嗯,就这么简单。debug模式下自动生效,release包不会有任何影响。
1.2 它能检测什么?
- Activity泄漏:最常见的问题。比如你关闭了一个Activity,但它的引用还被某个静态变量持有。
- Fragment泄漏:和Activity类似,Fragment销毁后如果还被引用,也会泄漏。
- ViewModel泄漏:如果你在ViewModel里持有Activity的Context,那基本就凉了。
- 全局静态集合:比如一个静态的ArrayList,往里面塞对象但不清理。
小技巧:LeakCanary检测到泄漏后,会生成一个引用链。你点进去看,就能找到「谁持有了本该被回收的对象」。我一般先看最短路径,那个往往就是问题所在。
1.3 实战案例
我曾经接手过一个项目,用户反馈App用久了就卡。我打开LeakCanary一看,好家伙,泄漏列表拉了三屏。
排查下来,发现是某个单例里持有了所有Activity的引用。代码大概是这样的:
class AppManager {
companion object {
val activities = mutableListOf<Activity>()
}
fun addActivity(activity: Activity) {
activities.add(activity)
}
}
你想想看,每个Activity启动时都往这个列表里塞一个引用,但从来没人清理。Activity销毁了,引用还在,GC怎么回收?
解决方案很简单:用弱引用代替强引用,或者在Activity销毁时主动移除。
注意:LeakCanary不是万能的。它只能检测「本该被回收但没被回收」的对象。如果你代码里大量使用静态变量,它检测出来的结果可能会让你怀疑人生。
二、大对象回收:别让「胖子」赖着不走
大对象,说白了就是那些占用内存特别多的家伙。比如一个几兆的Bitmap,或者一个巨大的JSON解析结果。
大对象有个特点:晋升快。它们很容易直接进入老年代,而老年代的GC频率比新生代低得多。这就导致一个问题——大对象一旦进入老年代,就很难被回收。
2.1 常见的大对象场景
| 场景 | 问题 | 建议 |
|---|---|---|
| 加载大图 | 直接加载原图,内存爆炸 | 采样加载,压缩到合适尺寸 |
| 网络请求返回大JSON | 一次性解析整个JSON | 流式解析,分页加载 |
| 数据库查询结果集 | 一次性加载所有数据 | 分页查询,Cursor及时关闭 |
| 动画/视频帧 | 缓存大量帧数据 | 复用帧缓冲区 |
2.2 如何让大对象及时回收?
说白了就一句话:用完就扔,别留着过年。
- 及时置null:大对象用完后,手动把引用置为null。虽然GC不是立即执行,但至少给了它一个信号。
- 使用弱引用:如果你不确定什么时候该释放,用WeakReference。GC在下次回收时,看到弱引用就直接收走了。
- 避免静态集合:静态的ArrayList、HashMap,里面放了大对象,基本等于「永久居住权」。
我的经验:有一次我在一个工具类里用静态HashMap缓存了一些图片处理结果。结果用户反馈App内存占用越来越高。排查后发现,这个HashMap只增不减,缓存了几百张图片的Bitmap。后来改成LRU缓存,问题就解决了。
三、Bitmap优化:图片是内存杀手
说到内存优化,Bitmap绝对是绕不开的话题。一张1920x1080的图片,加载到内存里占多大?
算一下:1920 × 1080 × 4字节(ARGB_8888)= 约8MB。就一张图,8MB没了。你想想看,一个列表页加载20张图,160MB就出去了。手机内存才多大?
3.1 按需采样加载
你不需要把原图完整加载进来。ImageView就那么大,你加载一个400x300的缩略图就够了。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.drawable.big_image, options);
int imageWidth = options.outWidth;
int imageHeight = options.outHeight;
int targetWidth = 400; // ImageView的宽度
int sampleSize = 1;
while (imageWidth / sampleSize > targetWidth) {
sampleSize *= 2;
}
options.inJustDecodeBounds = false;
options.inSampleSize = sampleSize;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.big_image, options);
这段代码的逻辑很简单:先读图片的尺寸,然后计算采样率,最后按采样率加载。这样加载进来的Bitmap,内存占用直接降到原来的1/4甚至1/16。
3.2 内存复用(inBitmap)
Android 3.0之后,BitmapFactory.Options里多了个inBitmap参数。它的作用是:复用已有的Bitmap内存,而不是重新分配。
Bitmap reusedBitmap = Bitmap.createBitmap(400, 300, Bitmap.Config.ARGB_8888);
BitmapFactory.Options options = new BitmapFactory.Options();
options.inBitmap = reusedBitmap;
options.inMutable = true;
options.inSampleSize = 2;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.image1, options);
注意:inBitmap要求新图片的尺寸不能大于复用Bitmap的尺寸。说白了就是「大坑不能装小萝卜」,但「小坑可以装大萝卜」——不对,是复用Bitmap的尺寸要大于等于新图片的尺寸。
建议:在列表滑动场景下,inBitmap特别有用。你可以维护一个Bitmap池,滑出屏幕的Bitmap放回池子里,新加载的图片从池子里取内存。这样能大幅减少GC次数。
3.3 缓存策略
图片缓存,我推荐用LRU(最近最少使用)算法。Glide、Picasso这些图片加载库内部用的就是LRU。
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8; // 分配1/8的内存做缓存
LruCache<String, Bitmap> lruCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getByteCount() / 1024;
}
};
这里有个细节:缓存大小怎么定?我一般取应用最大内存的1/8。太大容易OOM,太小缓存命中率低,等于没缓存。
3.4 图片格式的选择
| 格式 | 每像素字节 | 适用场景 |
|---|---|---|
| ARGB_8888 | 4字节 | 需要透明度的高质量图片 |
| RGB_565 | 2字节 | 不需要透明度的图片,内存减半 |
| ALPHA_8 | 1字节 | 只有透明度信息的图片 |
我个人习惯,能不透明就不透明。很多图片其实不需要透明度,用RGB_565能省一半内存。你想想看,一张8MB的图,换成RGB_565就变成4MB了,效果还看不出来区别。
避坑指南:我曾经在项目里用RGB_565加载一张渐变色图片,结果出现了明显的色带。后来发现RGB_565对渐变色的支持不好。所以,图片质量要求高的场景,还是用ARGB_8888吧。
总结一下
内存优化这件事,说白了就是三个字:控、检、优。
- 控:控制大对象的生命周期,用完就释放。
- 检:用LeakCanary定期检查,把泄漏扼杀在摇篮里。
- 优:Bitmap按需加载,复用内存,选对格式。
做到这三点,你的App内存基本就稳了。剩下的,就是养成好习惯——写代码的时候多想想「这个对象什么时候被回收?」