4、Bitmap内存优化:Bitmap加载策略、inSampleSize采样、Bitmap复用与缓存
说到Bitmap优化,我脑子里第一个蹦出来的画面,就是几年前那个让人头皮发麻的OOM崩溃统计图。那时候我们团队做的是一个图片社交App,用户上传的图片动辄几兆甚至十几兆。你想想看,一张1920x1080的图片,在Android里解码成ARGB_8888格式,光这一张图就要吃掉将近8MB的内存。要是列表里同时加载十几张,那画面太美我不敢看。
所以今天咱们就来聊聊,Bitmap到底该怎么管。说白了,就是三件事:怎么加载、怎么采样、怎么复用和缓存。
4.1 Bitmap加载策略:别一股脑全塞进来
我见过不少新手,拿到图片URL就直接用BitmapFactory.decodeStream()去加载。结果呢?图片原图多大,内存里就占多大。一张4000x3000的照片,在手机上显示可能只需要一个200x200的缩略图,你非要把原图塞进来,这不是跟自己过不去吗?
正确的做法是:按需加载。你需要多大的显示区域,就加载多大尺寸的Bitmap。
我个人习惯的做法是,先通过BitmapFactory.Options读取图片的原始宽高,然后结合目标控件的尺寸,计算出合适的采样比例。这里有个关键点——inJustDecodeBounds这个属性。把它设为true,解码时不会分配内存,只读取图片的边界信息。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
int imageHeight = options.outHeight;
int imageWidth = options.outWidth;
String imageType = options.outMimeType;
拿到宽高后,再根据目标尺寸计算采样率。嗯,这里要注意,inSampleSize的值必须是2的幂次方,比如1、2、4、8。如果不是,系统会向下取整到最近的2的幂次方。我曾经踩过这个坑,设了个3,结果系统给我按2处理了,图片比预期大了一倍。
核心原则:Bitmap占用的内存 = 图片宽度 × 图片高度 × 每个像素占用的字节数。控制好宽高,就控制住了内存。
4.2 inSampleSize采样:降采样不是缩略图
很多人把inSampleSize和缩略图搞混了。缩略图是加载原图后再缩小,内存峰值依然很高。而inSampleSize是降采样,它在解码阶段就直接跳过部分像素,内存占用从一开始就是小的。
举个例子,一张1024x1024的图片,inSampleSize设为2,解码出来的Bitmap就是512x512,内存直接降到原来的四分之一。这个过程中,大图的内存从未被分配过。
我在项目中遇到过这样一个场景:用户上传的照片是4000x3000,但列表里的ImageView只有100x75。我当时的计算逻辑是这样的:
public static int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
// 原始图片的宽高
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// 不断除以2,直到小于目标尺寸
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
然后配合inJustDecodeBounds,先算采样率,再真正解码:
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
options.inSampleSize = calculateInSampleSize(options, 100, 75);
options.inJustDecodeBounds = false;
Bitmap sampledBitmap = BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
imageView.setImageBitmap(sampledBitmap);
小技巧:如果目标尺寸是固定的,比如头像都是100x100,我建议直接把采样率写死成常量。省去每次计算的性能开销。当然,前提是图片源尺寸相对稳定。
4.3 Bitmap复用:别频繁创建和回收
频繁创建Bitmap,频繁GC,这是性能杀手。Android 3.0之后引入了BitmapFactory.Options.inBitmap,允许你复用已有的Bitmap内存。说白了,就是告诉系统:别给我重新分配内存了,用这块旧的就行。
使用inBitmap有几个硬性条件:
- 复用Bitmap的尺寸必须大于等于新图片的尺寸
- Android 4.4之前,要求新旧Bitmap的尺寸必须完全一致
- Android 4.4之后,只要新图片不大于复用Bitmap的尺寸即可
- 新旧Bitmap的编码格式必须一致(比如都是ARGB_8888)
我曾经在一个图片编辑功能里用过这个技巧。用户连续裁剪多张图片,每次裁剪都会生成新的Bitmap。如果不复用,内存会像坐过山车一样忽高忽低。用了inBitmap之后,内存曲线平稳多了。
// 准备一个可复用的Bitmap池
private static Set<WeakReference<Bitmap>> reusableBitmaps;
public static Bitmap decodeSampledBitmapFromFile(
String filename, int reqWidth, int reqHeight) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(filename, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 尝试从复用池中找一个合适的Bitmap
options.inMutable = true;
options.inBitmap = getReusableBitmap(options);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFile(filename, options);
}
private static Bitmap getReusableBitmap(BitmapFactory.Options options) {
if (reusableBitmaps == null) return null;
for (WeakReference<Bitmap> ref : reusableBitmaps) {
Bitmap bitmap = ref.get();
if (bitmap != null && bitmap.isMutable()) {
// 检查尺寸是否满足复用条件
if (canUseForInBitmap(bitmap, options)) {
return bitmap;
}
}
}
return null;
}
避坑指南:我曾经在Android 4.3的设备上踩过inBitmap的坑。当时没注意编码格式,复用的Bitmap是RGB_565,新加载的是ARGB_8888,结果解码出来的图片颜色完全不对。所以一定要确保编码格式一致。
4.4 Bitmap缓存:内存缓存与磁盘缓存
缓存这块,我推荐用LruCache做内存缓存,用DiskLruCache做磁盘缓存。LruCache的原理是最近最少使用算法,当缓存满了,会优先淘汰最久没被访问的条目。
内存缓存的大小怎么定?我一般取应用最大可用内存的八分之一。太大容易OOM,太小缓存命中率低。
// 获取应用最大可用内存
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8;
LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
// 返回Bitmap占用的内存大小(单位:KB)
return bitmap.getByteCount() / 1024;
}
};
磁盘缓存我习惯用DiskLruCache,它把缓存文件存储在SD卡或应用私有目录。加载图片时,先查内存缓存,命中就直接用;没命中就查磁盘缓存,读出来再放进内存;磁盘也没有,才去网络下载。
这个三级缓存策略,说白了就是拿空间换时间。内存快但容量小,磁盘慢但容量大,网络最慢但数据源无限。我参与过的一个电商App,商品图片列表滑动流畅,靠的就是这套缓存机制。
总结一下:Bitmap优化的核心就四个字——按需、复用。按需加载控制内存峰值,复用减少GC压力,缓存提升加载速度。这三板斧用好了,Bitmap相关的OOM基本就能跟你say goodbye了。
嗯,最后再啰嗦一句。优化不是一蹴而就的,我建议你在开发阶段就开启Android Studio的Profiler,盯着内存分配曲线看。哪里冒尖了,哪里频繁GC了,一目了然。别等到线上OOM了才想起来优化,那时候就晚了。