1. Bitmap吃内存的真相:一张图到底占多大
做Android开发的人,几乎都被OOM咬过,而OOM的头号嫌疑人十有八九是Bitmap。之前帮一个朋友排查应用崩溃,他一脸困惑地跟我说:“原图才3MB,怎么加载到内存就直接崩了?”这个困惑很典型——很多人把磁盘上的文件大小和内存占用混为一谈了。Bitmap内存优化的第一步,不是急着写代码,而是先把这笔账算清楚。
1.1 内存计算的基准公式
一张Bitmap在内存里占多大,跟文件多大没关系,只跟三个因素有关:像素宽、像素高、单位像素占用的字节数。公式就是:
内存占用 = 宽度 × 高度 × 每像素字节数每像素字节数由Bitmap的像素格式决定。Android里最常见的两种格式:
- ARGB_8888:每个像素4个字节,支持透明通道,色彩还原好,也是默认格式
- RGB_565:每个像素2个字节,不支持透明度,有轻微的色彩损失
我当时的排查过程是这样的:一张相机拍的照片,分辨率4032×3024,用默认的ARGB_8888解码。套公式算一下:
4032 × 3024 × 4 = 48,775,680字节大约是46.5MB。磁盘上那张JPEG可能只有3MB,但这完全不影响它在内存里占掉你46.5MB的空间。JPEG是压缩存储格式,而Bitmap是原始像素数据,两者维度根本不同。
1.2 容易被忽略的屏幕密度缩放
除了宽高和像素格式,还有一个暗坑:资源目录导致的密度缩放。
如果你把图片放在res/drawable或res/drawable-xhdpi这类目录下,系统在解码时会根据设备屏幕密度和目标资源目录的密度做一次隐式缩放。直白点说,一张为xhdpi设计的图,放到hdpi的设备上,系统会认为它“物理尺寸变大了”,于是自动按比例把像素宽高放大,内存也就跟着翻倍。
我见过一个线上案例:图片实际加载后宽高比原始资源大了快两倍,内存占用几乎变成预期值的三到四倍。排查了半天,最后发现问题就出在资源放错了目录。这类问题很难靠直觉发现,所以第一原则是:资源文件按密度放对目录,或者干脆用代码指定inDensity和inTargetDensity手动控制,比如从网络加载的图片,不该受影响,那就把inDensity和inTargetDensity设为一致。
现在再看开头那个问题,为什么原图3MB还能崩?其实答案已经很明显了,一张4032×3024的照片加载进来就是46.5MB,如果应用内存水位本来就在150MB左右起伏,加上图片列表里再滑几张,OOM几乎是必然。
2. 采样压缩:小图大用的正确姿势
既然Bitmap的内存占用跟宽高成平方关系,那么最直接的优化思路就是降低解码后的像素宽高。比如一张4032×3024的图,实际显示区域只有96×96的缩略图,你把它全尺寸解出来,那真是杀鸡用牛刀。
2.1 inSampleSize的功率换算逻辑
降低解码尺寸的方法很简单,解码时设置BitmapFactory.Options.inSampleSize。这个值的含义是:宽高各自除以多少。采样率2,宽高各自减半,内存直接变为原来的四分之一;采样率4,内存变为原来的十六分之一。
原理不算复杂,就是让解码器跳过部分像素,而不是先完整解码再缩小,这样可以避免创建一个巨大的中间Bitmap。
计算采样率的核心逻辑不是拍脑袋定个2或4,而是先算出目标尺寸和原始尺寸的比值,再取合适的2的幂。
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { final int width = options.outWidth; final int height = options.outHeight; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { final int halfHeight = height / 2; final int halfWidth = width / 2; // 不断翻倍,直到宽高都小于目标尺寸 while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2; } } return inSampleSize; }这里有个细节要注意:inSampleSize只能取2的幂,系统会向下取整,比如你填3,实际按2处理;填5,实际按4处理。每一项值都对应内存的等比缩减,所以尽量用上面这种翻倍循环,别自己乱给奇数。
2.2 先读边界再采样的两段式解码
解码的时候,还有一个关键动作:先通过inJustDecodeBounds把原始的宽高信息读出来,不需要真正解码像素数据,然后算出采样率,再执行真正的解码。这是标准套路了,但也经常有人忘。
BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFile(filePath, options); int sampleSize = calculateInSampleSize(options, targetWidth, targetHeight); options.inSampleSize = sampleSize; options.inJustDecodeBounds = false; Bitmap bitmap = BitmapFactory.decodeFile(filePath, options);inJustDecodeBounds = true时只解析图片文件头信息,几乎不占内存,所以双次解码的开销很小,但收益巨大。配合这个机制,我们可以为不同的UI场景设计不同尺寸的解码入口,比如头像列表用64×64目标尺寸,详情页大图用1080宽度,而不是统一全尺寸加载,再让ImageView去缩放。那等于内存已经浪费了,缩放只是视觉上的事,解决不了内存问题。
2.3 采样压缩的使用边界
采样率也不是越大越好,采样过猛会导致图片模糊。图片最终都是要显示在屏幕上的,所以目标尺寸应以控件的实际尺寸为基准,而不是盲目压缩。
经验法则:给ImageView设置目标尺寸时,可以略大于控件实际dp值转px的值,比如控件宽高约为800px,你就按800px当目标尺寸算。再大就纯粹浪费,再小就看着发虚,名为“收益边界”。
3. 像素格式瘦身:用RGB_565还是保留ARGB_8888
这又是一个不花一分钱却能省一半内存的方式:换像素格式。
3.1 常见像素格式参数对比
| 像素格式 | 每像素字节 | 是否支持透明 | 色彩表现 | 内存对比(以1000×1000为例) |
|---|---|---|---|---|
| ARGB_8888 | 4字节 | 支持 | 好,但实际有过度保留 | 约4MB |
| RGB_565 | 2字节 | 不支持 | 可接受,少量色阶损失 | 约2MB |
| ALPHA_8 | 1字节 | 只为透明服务 | 只能存透明度通道 | 约1MB |
在不支持透明通道、对色彩精度不太敏感的场景下,把ARGB_8888换成RGB_565,内存直接省50%。比如很多列表里的图片、加载进度图、引导页背景,这些不太需要渐变细腻过渡的图,完全可以用RGB_565。但如果是人物摄影、风景壁纸这类色彩层次丰富的图,RGB_565可能出现绿色或红色通道的渐变条纹,尤其是天空、皮肤这类区域,肉眼比较容易察觉。
3.2 兼容性问题与Android高版本的隐藏变化
用代码指定格式时要留意版本差异。
BitmapFactory.Options options = new BitmapFactory.Options(); options.inPreferredConfig = Bitmap.Config.RGB_565; Bitmap bitmap = BitmapFactory.decodeFile(filePath, options);在API 26之前,这个配置对绝大多数格式都生效。API 26之后,某些图片格式在解码时可能会忽略这个配置,特别是带Alpha通道的PNG,系统可能仍然返回ARGB_8888。我实际测试下来,JPEG转RGB_565基本稳定,PNG要看具体编码,所以别在代码里写死“一定省了一半”。
如果想强制验证,解码后打一行日志就行:
Log.d("BitmapConfig", "width=" + bitmap.getWidth() + ", config=" + bitmap.getConfig());如果返回的是ARGB_8888,说明系统没采纳你的配置。这时候要么换图片格式,要么接受现实,继续用采样压缩打底。
3.3 一个手机相册场景的取舍
举个我自己做相册类应用时的方案:缩略图网格用RGB_565,内存占用比ARGB_8888少一半,预览图观感几乎不受影响,因为网格里的图太小,色阶差异肉眼根本分不出来。点开大图再单独用ARGB_8888解码加载,这时内存压力只集中在一张图上,风险可控。
这个方案比全局无脑用ARGB_8888要理性得多。关键思路是:不同清晰度需求匹配不同像素格式,而不是一个配置打天下。
4. 内存复用与缓存:把已经分配的内存变成循环资源
采样压缩和格式调整解决了“每张图太大”的问题,但列表滚动时频繁创建和销毁Bitmap依然会引发频繁的GC和内存峰值。这里要引出两个进阶手段:inBitmap复用和LruCache策略。
4.1 inBitmap机制的原理
inBitmap是Android提供的一个解码复用机制。简单说,如果解码一张新图时,有一块旧Bitmap的内存还没被回收且尺寸足够大,就可以直接“借”这块内存来用,而不是重新申请一块。
这有点像一个仓库里反复用的箱子:箱子洗干净了就能装新的东西,不需要每次进货都买新箱子。
BitmapFactory.Options options = new BitmapFactory.Options(); options.inMutable = true; // 必须是可变的 options.inBitmap = reusableBitmap; // 复用这块内存 Bitmap newBitmap = BitmapFactory.decodeFile(filePath, options);需要满足几个硬性条件,否则复用会失败:
- inMutable必须设为true:被复用的Bitmap本身要可变
- 新老Bitmap的尺寸关系:API 19之前,新Bitmap的宽高必须和旧Bitmap完全一样;API 19之后放宽为:新的宽度、高度分别不大于旧的宽高,且解码后总字节数不超过旧图可容纳的字节数
- 像素格式限制:如果新图是ARGB_8888,旧图是RGB_565,大概率复用失败
实际工程里,最容易踩的坑是:网上拿来的示例代码只设置了inBitmap,但忘了inMutable,结果根本没有任何复用效果。Android的复用逻辑要求被复用的目标必须是可变Bitmap,这一点不满足就直接走普通解码路径,表面看没报错,内存却照常暴增。
为了让它真正生效,通常会提前创建一批“候选池”Bitmap,专门用来被复用。这个池子的大小往往和列表的可见Item数挂钩,而不是无限创建。
4.2 LruCache策略与容量计算
LruCache本身不直接减少Bitmap占用的内存,但它能通过“近期最少使用”的淘汰策略,严格控制缓存中的Bitmap总量,避免缓存无上限膨胀。
容量设置很讲究,设置得太大等于没限制,设置得太小照片会频繁被淘汰、频繁重新解码,反而更卡。我的做法是:
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); int cacheSize = maxMemory / 8; LruCache<String, Bitmap> memoryCache = new LruCache<>(cacheSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { return bitmap.getByteCount() / 1024; } };“分配应用可用内存的八分之一给图片缓存”,这是业界比较稳妥的默认方案。拿到这个总量后,我们还要乘以“当前列表希望缓存多少屏数据”这一设计指标来微调。
记住一个原则:LruCache用的是getByteCount(),不是getRowBytes()乘以高度。两者在某些场景下数值可能不同,而getByteCount()是官方推荐的统计方式。
4.3 复用与缓存怎么配合
复用和缓存其实是两个独立层:缓存决定“哪些Bitmap要留在内存里”;复用决定“Bitmap被淘汰后,它的内存能不能拿给别人用”。常见的组合是:当某个Bitmap从LruCache中被淘汰时,不直接丢掉,而是加入一个复用池,等下一次解码时通过inBitmap把这块内存接手过去。
这个合作模式能极大降低列表快速滑动时的内存波动。实测场景中,配合采样压缩,列表页的内存曲线可以从“锯齿状大幅抖动”变成“低位平缓直线”。
提示:复用池里的Bitmap数量需要人工控制,通常保留2-3个就够用。放太多反而把原本要省的内存变成了新的内存占用,得不偿失。
5. 图片加载库的深度配置:Glide的尺寸压缩还能再省多少
很多人问:我用Glide已经能加载图片了,还需要做上面这些吗?答案是——Glide内部已经默认做了大量Bitmap优化,比如根据View尺寸自动采样、默认用RGB_565、使用缓存和复用池等。但前提是你要知道它怎么配,配错了照样白搭。
5.1 Glide默认行为的理解
Glide 4.x默认会根据ImageView的尺寸自动降采样。比如控件只有200×200,它会自动按这个目标去解码,不会全尺寸加载。同时Glide默认使用的配置(某些版本中)是RGB_565,这已经是一种内存友好的方案。
但这里有个很多人不知道的细节:Glide自动降采样的前提是你能正确拿到控件的尺寸。如果ImageView还没有被布局系统测量(比如在RecyclerView里还没布局完),或者你在非UI线程创建了一个Target,Glide可能拿不到目标宽高,这时它会退回全尺寸加载。
这也是为什么有时同一个页面,某些Item加载快、内存正常,某些Item却异常吃内存的原因之一。
5.2 显式指定尺寸的做法
与其把尺寸寄托给“自动测量”,不如在加载时明确大小:
Glide.with(context) .load(url) .override(800, 800) // 直接指定解码目标宽高 .format(DecodeFormat.PREFER_RGB_565) .into(imageView);override(width, height)会强制Glide按这个目标尺寸采样,绕开测量时机问题。format(PREFER_RGB_565)则显式要求使用RGB_565。这两行配置可以让你在不改图片文件的情况下,把单张Bitmap的内存再压低一大截。
5.3 Downsampling一个真实计算案例
我拿一张2456×1842的JPEG做过对比测试,这是模拟项目里某个商品头图,加载区域约720×540:
- 默认加载:2456×1842,ARGB或RGB_565按版本策略,内存基数取决于像素格式
- 设置override(720, 540)后:解码目标接近720×540,以RGB_565计算,内存仅为原来的约十分之一
视觉上几乎看不出差别,因为图片本来就显示成720宽的区域,多余的像素本来就被View裁剪掉了,内存却省下来90%以上。
5.4 Glide适用情况要会用
如果项目里没有引入Glide,或者只加载少数几张大图,手动使用BitmapFactory + inSampleSize反而更轻量,但如果你在做一个图片密集的Feed流,那Glide的高效内存管理、生命周期感知和缓存策略几乎不可替代。不要只盯着“某张图省了多少”,还要看整个列表页的内存水位是否平稳。
有一个很现实的建议:不管用哪个库,最终在上线前抓一下内存水位线,比依赖逐个API的预期更可靠。工具和方法下一节讲。
6. 排查工具与实测经验:让优化效果看得见
技术方案讲了一堆,但最大的坑其实是“不知道怎么验证”。没有数据支撑的优化,都是玄学优化。我的经验是:先搭一套可复现的排查流程,每次改动后都能快速看到内存变化,再回头调参数,效率会高很多。
6.1 内存检查的常用工具链
- Android Studio自带的Memory Profiler:可以看到Java堆内存的实时走势、当前各类型对象的占用比例,是日常调试的主力
- Memory Profiler的Heap Dump:抓取堆快照后,可以在“Bitmaps”类别里按Retained Size排序,直接列出哪些Bitmap最大、各自占了多大。这是定位“某个页面为何内存暴涨”最直观的方式
- 命令行工具:真机调试不方便时,看一眼整体内存走势也能给到大致判断。日常开发中,配合Profile的GC按钮,可以比较不同操作后的内存水位
- Debug.startAllocationTracking:这是老一代的API,在Android 8.0之前好用,现在不如直接用Memory Profiler
6.2 一个对比案例
我在一个模拟项目里做过一轮Bitmap优化,前后变化可以从几个维度来看:
优化前:详情页加载原图(4000×3000,ARGB_8888),内存峰值接近48MB,滑动列表时LruCache淘汰频繁,GC次数多,偶发闪退。
优化后:详情页用inSampleSize把目标宽限制到1080;列表页统一override到控件尺寸并切RGB_565;引入复用池;LruCache容量按1/8内存设置。落地后内存峰值降到了原来的15%左右,列表滚动的内存曲线也基本稳定。
这几项优化本身都不难,合在一起效果才会放量级,因为每一条都是乘数关系——采样减四倍,格式减一半,再到复用削减波动,共享同一张图的引用计数还能进一步减少加载数。
6.3 几个实际踩过的坑
坑一:Bitmap被回调持有导致复用失败。如果你的Bitmap被某个回调或ViewBackground持有,系统无法判定它是可回收的,复用池里的Bitmap越攒越多,内存就悄悄涨上去。解决方法:复用池里的Bitmap用完后显式释放引用,或者限制池子的数量。
坑二:inSampleSize取2的幂后尺寸仍偏大。如果你需要的目标尺寸是500,原始是1600,采样率算出来会被强制到2,结果解出来是800宽,内存比你预期翻了一倍多。这不算bug,但提醒我们:预期目标尺寸不能定得太“卡线”,要给采样率取整留点余量,或者直接选择更接近的采样率组合做精细等比裁剪。
坑三:PNG带Alpha导致RGB_565配置失效。之前提过,系统对某些PNG在RGB_565下会出现透明部分异常或完全忽略配置的现象。如果业务上必须用PNG,又必须省内存,可以先把PNG转成WebP不带透明度,再走RGB_565;或者干脆接受ARGB_8888,把内存省在采样上,不要在格式上死磕。
7. 把几种方式拧成同一根绳
写到这里,Bitmap内存优化的主线其实已经很清晰了,它不是某一种技术的独角戏,而是一条组合拳链路:
第一步,先用计算和排查工具搞清楚内存到底被谁吃掉了,优先处理“占大头”的那几张图;第二步,用采样压缩把解码尺寸压到真正需要的像素级,粗调;第三步,按图片场景选择像素格式,细调;第四步,用inBitmap和LruCache把内存峰值削平,让内存动态循环起来;第五步,配合Glide这类加载库把降采样逻辑沉淀到日常开发中。
这套流程在大多数Android项目里都能落地,不管是三方图库还是原生BitmapFactory,底层逻辑都是一样的。如果你正被Bitmap的OOM问题折磨,建议不要急着搜“一行代码解决内存崩溃”,先照着上面的排查流程走一遍,把每一张图的内存账算清楚,优化自然知道该从哪里下手。
最后再分享一个小技巧:每次改动完,不要只看“模拟器上不崩了”,要拿真机在低端机上测试,2GB内存的手机和8GB内存的手机对Bitmap的耐受度完全是两个世界。真正稳的优化,是在最差设备上也不抖、不崩、不卡顿的方案。