70、性能优化之内存优化:内存泄漏检测(LeakCanary)、大对象回收、Bitmap优化

内存优化,说白了就是让你的App别那么「吃内存」。

我见过太多项目,功能做得花里胡哨,结果一跑就OOM。用户手机发烫、卡顿,最后直接卸载。你说冤不冤?

这一讲,咱们就聊聊内存优化的三个核心点:内存泄漏检测大对象回收Bitmap优化。这三板斧砍下去,你的App内存至少能瘦一圈。

核心观点:内存优化不是「省内存」,而是「用好内存」。该回收的及时回收,该复用的别重复创建。

内存优化三大核心 内存优化 LeakCanary 检测 • 自动检测内存泄漏 • 生成泄漏引用链 • 通知栏提醒 + dump 大对象回收 • 避免静态集合持有 • 使用弱引用/软引用 • 及时释放资源 Bitmap 优化 • 按需采样加载 • 内存复用(inBitmap) • 缓存策略(LRU) 目标:减少GC压力,避免OOM

一、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内存基本就稳了。剩下的,就是养成好习惯——写代码的时候多想想「这个对象什么时候被回收?」

公众号:蓝海资料掘金营,微信deep3321