4. 图片解码与缓存机制:从 Bitmap 到三级缓存
好,咱们进入第四章。这一章我打算聊聊图片加载的「下半场」——解码和缓存。你想想看,网络请求再快,文件下载再顺,如果解码卡在主线程、缓存策略一塌糊涂,用户该卡还是卡。我在项目里见过太多「网络优化做得很极致,但列表滑动依然掉帧」的案例,最后查下来,十有八九是解码和缓存没处理好。
4.1 Bitmap 解码原理:一张图片是怎么变成像素的?
一张 JPEG 或 PNG 文件,说白了就是一串压缩后的二进制数据。要让屏幕显示它,必须经过解码——把压缩数据还原成每个像素的 ARGB 值。这个还原过程,就叫 Bitmap 解码。
解码的核心开销在哪?内存和 CPU。
- 内存开销:一张 1920×1080 的图片,解码成 ARGB_8888 格式,每个像素占 4 字节,算下来就是 1920 × 1080 × 4 ≈ 7.9 MB。你想想看,如果列表里同时存在十几张这样的图,内存瞬间就爆了。
- CPU 开销:解码本身是计算密集型操作。JPEG 解码涉及哈夫曼解码、反量化、IDCT 变换;PNG 解码涉及 LZ77 解压、过滤器还原。我曾在低端机上测过,一张 2MB 的 JPEG 解码耗时约 80ms,如果放在主线程,足够让界面卡掉好几帧。
核心结论:解码必须异步,且必须控制解码后的尺寸。不要直接解码原图,先用 BitmapFactory.Options 的 inSampleSize 做降采样。
// 推荐做法:先读尺寸,再降采样解码
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, options);
int imageWidth = options.outWidth;
int imageHeight = options.outHeight;
int sampleSize = calculateSampleSize(imageWidth, imageHeight, targetWidth, targetHeight);
options.inJustDecodeBounds = false;
options.inSampleSize = sampleSize;
Bitmap bitmap = BitmapFactory.decodeFile(path, options);
我的习惯:inPreferredConfig 我一般用 ARGB_8888,除非图片本身没有透明度且对色彩精度要求不高,才考虑 RGB_565。后者每个像素只占 2 字节,内存省一半,但颜色过渡会有肉眼可见的条纹。
4.2 三级缓存策略:内存、磁盘、网络
缓存的意义不用我多说——避免重复解码、避免重复网络请求。但缓存不是随便存就行的,得有个层次结构。我个人习惯把缓存分成三级,每一级管好自己的事。
| 层级 | 存储介质 | 访问速度 | 容量 | 生命周期 |
|---|---|---|---|---|
| L1 内存缓存 | Java Heap / Native Heap | 纳秒级 | 应用可用内存的 1/8 ~ 1/4 | 进程存活期间 |
| L2 磁盘缓存 | 闪存文件 | 毫秒级 | 几十 MB ~ 几百 MB | 应用卸载前 |
| L3 网络 | 远程服务器 / CDN | 百毫秒 ~ 秒级 | 不限 | 由服务端控制 |
读取流程很简单:先查 L1,命中直接返回;没命中查 L2,命中后回填 L1 再返回;L2 也没命中,走 L3 网络请求,下载后依次写入 L2 和 L1。嗯,这里要注意,L1 和 L2 的写入策略不一样——L1 写入要快,L2 写入可以异步。
我曾经踩过的坑:磁盘缓存写入放在主线程。有一次线上反馈列表滑动卡顿,查了半天发现是 DiskLruCache 的 edit() 和 commit() 在 UI 线程执行,每次写入几十毫秒,累积起来就卡了。解决方案很简单:磁盘写入全部丢到后台线程池。
4.3 LRU 缓存算法实现:淘汰谁,保留谁?
缓存空间是有限的,满了就得淘汰。淘汰策略有很多种,但 LRU(Least Recently Used)是业界最通用的选择。为什么?因为它假设「最近被访问过的数据,将来被再次访问的概率更高」。这个假设在图片浏览场景下非常合理——用户刚滑过的图,很可能马上滑回来。
LRU 的核心数据结构是 双向链表 + 哈希表。链表维护访问顺序,哈希表提供 O(1) 的查找。
- 访问一个节点:从哈希表找到节点,把它移到链表头部。
- 插入一个新节点:加到链表头部,同时写入哈希表。如果容量超限,移除链表尾部的节点,并从哈希表删除。
- 淘汰:永远淘汰链表尾部的节点——那就是「最久未被访问」的那个。
Android 官方提供了 LruCache 类,封装好了这套逻辑。但说实话,它的默认实现是基于 LinkedHashMap,线程安全需要自己加锁。我在项目中一般会做一层封装:
public class ImageLruCache extends LruCache<String, Bitmap> {
private static final int DEFAULT_CACHE_SIZE = (int) (Runtime.getRuntime().maxMemory() / 8);
public ImageLruCache() {
super(DEFAULT_CACHE_SIZE);
}
@Override
protected int sizeOf(String key, Bitmap bitmap) {
// 按实际占用内存计算,而不是按数量
return bitmap.getByteCount();
}
@Override
protected void entryRemoved(boolean evicted, String key, Bitmap oldValue, Bitmap newValue) {
// 淘汰时可以做回收提示,但不要主动 recycle()
// 因为 Bitmap 可能还被其他地方引用
}
}
一个容易被忽略的点:sizeOf 一定要按 getByteCount() 算,而不是按「张数」。一张 10MB 的图和一张 100KB 的图,占用的缓存空间天差地别。按张数算会导致大图撑爆内存。
4.4 整体流程:一张图片的完整旅程
把上面这些串起来,一张图片从请求到显示,大致走这么几步:
- Key 生成:根据 URL + 目标宽高 + 变换参数生成唯一缓存 Key。
- 查 L1 内存缓存:命中则直接返回 Bitmap,更新 LRU 顺序。
- 查 L2 磁盘缓存:命中则读取文件,解码成 Bitmap,写入 L1,返回。
- 发起网络请求:下载原始数据,写入 L2,解码成 Bitmap,写入 L1,返回。
- 显示:Bitmap 交给 ImageView 绘制。
下面这张图把整个流程画清楚了:
4.5 避坑指南:我踩过的几个坑
- Bitmap 复用:不要每次解码都创建新 Bitmap。用 BitmapFactory.Options.inBitmap 复用已废弃的 Bitmap 内存,能大幅减少 GC 压力。但要注意,inBitmap 只能复用尺寸相同或更大的 Bitmap,且格式必须兼容。
- 缓存 Key 的冲突:URL 相同但图片内容变了怎么办?我习惯在 Key 里加上图片的 ETag 或 Last-Modified,服务端返回 304 时更新缓存。
- 内存缓存不要用软引用:SoftReference 在 Android 2.3 之后就被 GC 频繁回收,缓存命中率极低。用 LruCache 才是正道。
- 磁盘缓存目录要隔离:不同业务模块的图片缓存最好分目录存放,方便排查问题。我曾经遇到过缓存目录被其他模块误删的惨案。
最后提醒一句:三级缓存不是银弹。如果图片总量远超缓存容量,缓存命中率会很低,这时候该考虑的是图片压缩、预加载、甚至服务端下推策略。缓存只是手段,不是目的。