图片浏览器内存缓存实战:LruCache 原理、参数调优与 OOM 排查
1. 图片浏览器为什么非要用 LruCache 做内存缓存
做图片浏览器这个方向的朋友,大概率都经历过这样一个阶段:GridView 或 RecyclerView 一滑动就卡,图片一闪一闪,滑回去还得重新加载,流量哗哗地跑,用户体验直接崩盘。我最早做的一个本地相册应用,图片列表滑到第三屏就开始掉帧,用户反馈里十条有八条在骂"卡成 PPT"。后来把内存缓存这一层用 LruCache 重新撸了一遍,滑动流畅度肉眼可见地提升,OOM 崩溃率从百分之三降到了千分之二以下。这篇文章就把我在图片浏览器内存缓存这块踩过的坑、调过的参数、写过的工具类,完整地摊开讲一遍。
LruCache 是 Android 系统在 android.util 包下提供的一个泛型缓存类,从 API 12 开始就有了,它的核心逻辑是 Least Recently Used,最近最少使用。翻译成人话就是:缓存满了之后,把最久没被访问过的那个数据踢出去,给新数据腾位置。这个策略特别适合图片浏览器这种场景,因为用户看图片是有局部性的——他可能反复看最近几张,但三个月前看过的某张图,大概率不会再翻回去。LruCache 内部用 LinkedHashMap 以访问顺序维护数据,每次 get 或 put 都会把对应条目挪到队尾,队头就是最久未使用的,淘汰时直接从队头砍。
那为什么不用 SoftReference 或者 WeakReference 呢?这是很多新手会问的问题。早期 Android 确实流行用 SoftReference 做图片缓存,因为系统内存紧张时会自动回收软引用。但问题在于,Android 2.3 之后 GC 对软引用的回收策略变得非常激进,图片刚缓存进去可能下一秒就被回收了,缓存命中率极低,等于白缓存。而且软引用的回收时机完全不可控,你没法精确管理内存占用。LruCache 的好处是内存占用可控、淘汰策略明确、命中率稳定,你能精确算出当前缓存了多少 KB,什么时候该清理,主动权在自己手里。
还有一个常见误区是拿 LruCache 和 DiskLruCache 对比。这俩不是二选一的关系,而是配合使用。LruCache 管内存,速度快但容量小;DiskLruCache 管磁盘,速度慢但容量大。图片浏览器的标准三级缓存架构是:内存缓存 LruCache → 磁盘缓存 DiskLruCache → 网络/本地文件。用户滑动时优先从内存拿,内存没有就去磁盘找,磁盘也没有才去解码原图。内存缓存的作用就是把最近访问的图片留在 RAM 里,避免重复解码这个最耗时的操作。
至于热搜词里提到的"windows 传递优化缓存占了很多内存可以删吗",这其实反映了一个普遍现象:用户对缓存占内存这件事非常敏感。做图片浏览器也是同理,你的缓存策略如果太激进,用户看到应用占用几百 MB 内存会直接卸载;太保守又会导致频繁重新加载。LruCache 的价值就在于给你一个可量化、可调优的内存管理工具,让你在流畅度和内存占用之间找到那个平衡点。一般来说,图片浏览器的内存缓存大小控制在应用可用内存的八分之一到四分之一之间比较合适,具体数值后面会详细算。
这篇文章适合三类人看:一是正在做图片浏览器、相册、社交 feed 流这类图片密集应用的开发者;二是被 OOM 和滑动卡顿折磨过的 Android 工程师;三是对缓存策略感兴趣、想搞明白 LruCache 内部机制的学习者。我会从设计思路讲到参数计算,从完整代码实现讲到线上问题排查,尽量让不同基础的人都能拿走能直接用的东西。
2. LruCache 核心机制与图片缓存的设计取舍
2.1 LinkedHashMap 的访问顺序模式是 LruCache 的心脏
要真正用好 LruCache,得先搞明白它内部是怎么运转的。LruCache 的源码其实不长,核心就是一个 LinkedHashMap。关键点在于这个 LinkedHashMap 的构造函数第三个参数传了 true:
这个 true 就是 accessOrder 标志位。当它为 true 时,每次调用 get() 或 put() 都会把被访问的条目移动到链表的尾部。也就是说,链表尾部是最近访问的,链表头部是最久未访问的。当缓存大小超过 maxSize 时,LruCache 会调用 trimToSize(),从链表头部开始逐个移除条目,直到缓存大小降到 maxSize 以下。
这里有个细节很多人不知道:LruCache 的 sizeOf() 方法默认返回 1,也就是说默认情况下它统计的是"条目数量"而不是"字节数"。对于图片缓存来说,这显然不合理,因为一张缩略图可能只有 10KB,一张原图可能有 5MB,按条目数算根本控制不住内存。所以必须重写 sizeOf() 方法,返回每张图片实际占用的字节数。这是图片缓存和普通对象缓存最大的区别。
注意这里用的是 getByteCount() 而不是 getRowBytes() * getHeight(),虽然后者计算结果一样,但 getByteCount() 在 API 12 以上更规范,而且能正确处理不同像素格式的情况。另外,getByteCount() 返回的是图片在内存中的实际占用,对于 ARGB_8888 格式的图片,一个像素占 4 字节,一张 1080x1920 的图就是 1080 * 1920 * 4 ≈ 8.3MB,这个数字很吓人,所以缓存大小一定要算清楚。
2.2 缓存容量到底设多大才不 OOM
这是被问得最多的问题。我的经验是:不要拍脑袋定一个固定值,要根据设备的可用内存动态计算。具体做法是获取当前应用可用的最大堆内存,取其八分之一作为缓存上限。
为什么是八分之一?这是 Android 官方文档推荐的经验值,也是大量实践验证过的安全区间。假设一台中端手机给单个应用的堆内存是 192MB,八分之一就是 24MB。按一张缩略图 200KB 算,能缓存约 120 张;按一张中等图 1MB 算,能缓存 24 张。这个容量对于列表滑动场景基本够用,因为用户一屏通常也就显示 6 到 12 张图,缓存两三屏的量就能保证来回滑动不重新加载。
但八分之一不是死规矩。如果你的应用是纯图片浏览器,没有其他大内存开销,可以适当提高到六分之一甚至四分之一,换取更高的命中率。反过来,如果你的应用本身就有大量内存占用(比如同时跑着地图、视频播放),那就要降到十分之一甚至更低。我一般会在 Application 初始化时根据应用类型设一个基准值,然后在运行时监听 onTrimMemory 回调动态调整。
onTrimMemory 是 Android 提供的内存压力回调,当系统内存紧张时会通知应用释放资源。TRIM_MEMORY_BACKGROUND 表示应用退到后台,这时候可以把缓存砍一半;TRIM_MEMORY_MODERATE 表示内存已经比较紧张,直接清空缓存保命。这个机制配合 LruCache 使用,能大幅降低被系统杀进程的概率。
2.3 为什么 key 的设计比你想的重要
LruCache 的 key 设计是个容易被忽视但影响巨大的点。很多人直接用图片 URL 当 key,这在简单场景下没问题,但实际项目里往往不够用。因为同一张图片可能有不同的显示尺寸——列表里显示 200x200 的缩略图,详情页显示 1080x1920 的原图,如果都用 URL 当 key,就会互相覆盖,导致列表页拿到原图(浪费内存)或者详情页拿到缩略图(模糊)。
正确的做法是把图片标识 + 目标尺寸组合成 key:
这样同一张图的不同尺寸版本会分别缓存,各取所需。代价是内存占用会增加,但换来的是显示正确性和解码效率。如果内存实在紧张,可以只缓存一种尺寸(通常是列表缩略图尺寸),详情页单独处理。
另一个坑是 key 的字符串拼接开销。如果图片量大、滑动频繁,每次 buildKey 都做字符串拼接会产生大量临时对象,加重 GC 负担。优化方式是用 StringBuilder 复用,或者用自定义的 Key 对象重写 equals 和 hashCode。我实测下来,在千张图片级别的列表里,用自定义 Key 对象比字符串拼接能减少约 15% 的 GC 次数。
3. 从零实现一个可落地的图片内存缓存
3.1 完整工具类代码与逐行解析
下面这个 ImageCache 类是我在多个项目里迭代出来的版本,直接可以拿去用。它封装了 LruCache,处理了 key 生成、尺寸计算、内存回收等细节。
逐行说几个关键点。第一,sizeOf 里除以 1024 是为了和 maxSize 的单位保持一致,maxSize 是 KB,sizeOf 也返回 KB,这样比较才不会错。第二,entryRemoved 回调里我留了个口子,实际项目中可以在这里把被淘汰的 Bitmap 加入 BitmapPool 复用,减少内存分配。第三,putBitmap 里先 get 一次判断是否已存在,避免重复 put 覆盖导致 size 计算错乱。第四,单例用双重检查锁,保证多线程安全。
3.2 图片采样与缓存写入的配合
光有缓存还不够,如果每次解码都是全尺寸原图,那缓存再大也扛不住。必须配合 BitmapFactory 的 inSampleSize 做采样压缩。核心逻辑是:先读取图片的原始宽高(inJustDecodeBounds = true),然后根据目标显示尺寸计算采样率。
这个算法的逻辑是每次把采样率翻倍,直到采样后的尺寸刚好不小于目标尺寸。为什么用 2 的幂次?因为 BitmapFactory 对 2 的幂次采样有优化,解码速度更快,而且能避免图片失真。假设原图 4000x3000,目标显示 400x300,那么 inSampleSize 会算到 8,解码出来的图是 500x375,内存占用从 400030004=48MB 降到 5003754=0.75MB,效果立竿见影。
采样后的 Bitmap 再写入 LruCache,这样缓存里存的都是压缩过的图,同样的内存能缓存更多张。我实测过一个场景:不采样时缓存 24MB 只能存 3 张原图,采样后能存 120 张缩略图,滑动命中率从 40% 提升到 92%。
3.3 异步加载与缓存的线程安全
图片加载必须异步,否则主线程解码会直接 ANR。但异步加载和 LruCache 配合时有几个线程安全问题要注意。LruCache 本身的 get 和 put 方法是 synchronized 的,单次操作线程安全,但"检查缓存→没有则加载→写入缓存"这个复合操作不是原子的。如果两个线程同时加载同一张图,会重复解码两次,浪费资源。
解决方案是用一个正在加载的集合做去重:
这里有两个细节值得说。一是 mLoadingKeys 用 synchronizedSet 包装,保证多线程下的 add 和 remove 安全。二是 imageView.setTag(key) 配合加载完成后的 tag 比对,解决 RecyclerView 复用导致的图片错位问题——当 ImageView 被复用到另一张图时,tag 变了,旧的回调就不会再设置图片。这个 tag 机制是列表图片加载的标配,不加的话快速滑动时会出现图片乱跳。
4. 线上实战中的典型问题与排查手册
4.1 缓存命中率低的排查思路
缓存命中率是衡量内存缓存效果的核心指标。我一般会在 ImageCache 里埋点统计 get 的总次数和命中次数,算出命中率。正常情况下,列表滑动场景的命中率应该在 80% 以上,低于 60% 就说明有问题。
命中率低的常见原因有这么几个。第一是缓存容量太小,图片还没滑回来就被淘汰了,这时候要调大 maxSize 或者优化采样率。第二是 key 设计有问题,同一张图用了不同的 key,导致缓存分裂,比如列表页用 URL 当 key,详情页用 URL+尺寸当 key,两边对不上。第三是图片尺寸过大,单张图占用的内存太多,几张就把缓存撑满了,这时候要检查采样率计算是否正确。第四是缓存被频繁清空,比如 onTrimMemory 回调触发太频繁,或者代码里某处调用了 evictAll。
排查时我会用 Android Studio 的 Profiler 看内存曲线,如果内存一直很平稳但命中率低,多半是容量问题;如果内存曲线锯齿状剧烈波动,说明缓存在被频繁清空和重建,要查清空逻辑。
4.2 OOM 崩溃的定位与解决
即使有 LruCache,OOM 依然可能发生,因为 LruCache 只管住了缓存这部分内存,解码过程中的临时内存、Bitmap 复用池、其他业务对象都不在它的管辖范围内。我遇到过的 OOM 场景里,最常见的是在缓存之外还持有 Bitmap 引用,比如把 Bitmap 存到了某个静态 Map 里,或者 ImageView 的 tag 里存了 Bitmap,导致 LruCache 淘汰了但对象还被引用着,GC 回收不掉。
定位 OOM 的利器是 Android Studio 的 Heap Dump,抓一份内存快照,看 Bitmap 实例的数量和总大小。如果发现 Bitmap 数量远超缓存容量,那就是有泄漏。重点排查静态集合、单例、未取消的异步任务、未解绑的监听器这几类。
解决 OOM 的根本思路是控制峰值内存。除了 LruCache 限容,还要做这几件事:一是解码时用 inBitmap 复用内存,API 19 以上支持;二是大图用 RGB_565 格式代替 ARGB_8888,内存直接减半(代价是没有透明度);三是列表滑动时暂停加载,滑动停止后再加载,避免滑动过程中大量解码导致内存飙升。
这个滑动暂停机制看起来简单,但对降低峰值内存效果显著。我实测过一个图片流应用,加上这个机制后,滑动过程中的内存峰值下降了约 35%,OOM 基本绝迹。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 滑动卡顿掉帧 | 主线程解码图片 | 看 Profiler 主线程耗时 | 解码放到线程池,滑动时暂停加载 |
| 图片显示错位 | ImageView 复用未校验 | 检查 tag 机制 | 加载完成后比对 tag 再设置 |
| 缓存命中率低 | 容量小或 key 分裂 | 埋点统计命中率 | 调大 maxSize,统一 key 规则 |
| OOM 崩溃 | 缓存外持有 Bitmap | Heap Dump 看 Bitmap 数量 | 清理静态引用,用 inBitmap 复用 |
| 内存占用居高不下 | 采样率计算错误 | 打印解码后尺寸 | 修正 inSampleSize 计算逻辑 |
| 图片模糊 | 采样过度 | 对比原图与显示尺寸 | 调整目标尺寸,避免过度压缩 |
| 快速滑动白屏 | 加载任务被取消 | 检查取消逻辑 | 用 tag 机制而非直接取消 |
这张表是我从多个项目的 bug 记录里整理出来的,基本覆盖了图片内存缓存 90% 以上的问题。遇到问题时按表排查,能省不少时间。
4.4 几个只有踩过坑才知道的经验
第一个经验:不要在 getView 或 onBindViewHolder 里做任何耗时操作,包括计算 key、查缓存这些看起来很快的操作。因为这两个方法在滑动时会被高频调用,任何微小耗时都会被放大。我的做法是在数据绑定阶段就把 key 算好存到 ViewHolder 里,onBindViewHolder 里直接拿现成的 key 查缓存。
第二个经验:LruCache 的 maxSize 不要设成固定值,要根据设备动态调整。低端机和高端机的可用内存差好几倍,用同一个值必然出问题。我一般会读取 ActivityManager 的 memoryClass,然后按比例算。
第三个经验:缓存清理要分级。onTrimMemory 的不同 level 对应不同的清理力度,不要一上来就 evictAll,那样用户切回来还得重新加载。我的策略是 BACKGROUND 砍一半,MODERATE 砍四分之三,COMPLETE 才全清。
第四个经验:测试时要用真机,而且要用低端机。模拟器的内存行为和真机差别很大,尤其是低端机,内存紧张时系统回收非常激进。我习惯用一台两三年前的中低端机做基准测试,能在这台机器上跑流畅,高端机基本没问题。
第五个经验:监控线上缓存表现。上线后要埋点统计缓存命中率、平均加载耗时、OOM 率这几个指标,持续观察。我遇到过一次线上命中率突然从 85% 掉到 50%,排查发现是新版本改了图片 URL 规则,导致老缓存全部失效,这种问题不看监控根本发现不了。
5. 缓存策略的进阶优化方向
5.1 Bitmap 复用池减少内存抖动
LruCache 解决了"存多少"的问题,但没解决"频繁分配回收"的问题。每次解码都 new 一个 Bitmap,用完被 GC 回收,这个过程中内存会不断抖动,GC 频繁触发会导致卡顿。BitmapPool 的思路是把被淘汰的 Bitmap 存起来,下次解码时复用它的内存空间,避免重新分配。
Android 3.0 之后 Bitmap 的像素数据存在 native 堆里,复用需要设置 inBitmap 参数,而且要求新旧 Bitmap 的尺寸和格式兼容。实现一个简单的复用池:
配合 LruCache 的 entryRemoved 回调,把被淘汰的 Bitmap 放进复用池,解码时先从池里找可复用的,找不到再新建。这个优化能减少约 30% 的内存分配次数,滑动流畅度提升明显。
5.2 预加载与缓存预热
用户滑动是有方向的,往下滑说明他大概率会继续往下看。利用这个规律可以做预加载:当前可见区域之外,提前加载后面几张图到缓存里。这样用户滑过去时直接命中缓存,体验丝滑。
预加载的关键是控制数量,加载太多会浪费内存和流量,加载太少又起不到效果。我的经验是预加载当前可见区域后面 3 到 5 张,具体数量根据缓存剩余空间动态调整。实现上可以在 onScrollStateChanged 里计算当前可见位置,然后触发后面几张图的加载。
预加载的图片同样走 LruCache,所以不会额外占用不受控的内存。如果缓存满了,预加载的图会被优先淘汰(因为它们最久未被访问),不会挤占用户实际要看的图。
5.3 多级缓存的协同调度
完整的图片浏览器缓存架构是三级:内存 LruCache、磁盘 DiskLruCache、源文件。调度逻辑是:
- 先查内存缓存,命中直接返回,这是最快的路径。
- 内存没有,查磁盘缓存,命中则解码后写入内存缓存再返回。
- 磁盘也没有,从源文件或网络加载,解码后同时写入内存和磁盘缓存。
这个调度逻辑看起来简单,但有几个细节要注意。一是磁盘缓存的读写也要异步,不能阻塞主线程。二是写入内存缓存前要判断图片是否还在使用,避免写入后立即被淘汰。三是磁盘缓存的 key 要和内存缓存保持一致,否则会出现内存有磁盘没有、或者反过来导致重复加载的情况。
我一般会把三级缓存的调度封装成一个 ImageLoader 类,对外只暴露 loadImage 方法,内部处理所有缓存逻辑。这样业务代码不用关心缓存细节,调用方只需要传 URL 和 ImageView 就行。
5.4 缓存效果的量化和持续调优
缓存策略不是设完就一劳永逸的,需要持续监控和调优。我一般会统计这几个指标:内存缓存命中率、磁盘缓存命中率、平均加载耗时、OOM 率、内存峰值。这些指标通过埋点上报到监控平台,按版本、按机型、按场景维度分析。
调优的优先级是:先解决 OOM(这是底线),再提升命中率(影响体验),最后优化加载耗时(锦上添花)。命中率每提升 10 个百分点,平均加载耗时会下降约 20%,用户感知很明显。
调优时要注意 A/B 测试,不要一次性改太多参数,否则出了问题不知道是哪个改动导致的。我习惯一次只调一个参数,观察一周数据再决定是否保留。比如先把缓存容量从八分之一调到六分之一,看命中率和 OOM 率的变化,稳定后再调下一个参数。
6. 写在最后的一些个人体会
做图片浏览器这些年,我最大的感受是:缓存策略没有银弹,只有权衡。LruCache 是个好工具,但它解决的是"存什么、存多少、淘汰谁"的问题,解决不了"怎么解码更省内存""怎么加载更流畅"这些更底层的问题。真正要做好图片浏览器的内存管理,需要把采样、复用、缓存、异步加载这几块串起来看,任何一环掉链子都会影响整体效果。
我踩过最深的坑是早期迷信"缓存越大越好",把 maxSize 设到了可用内存的一半,结果在低端机上频繁 OOM,用户投诉不断。后来才明白,缓存大小的第一原则是保证应用不崩,第二原则才是提升命中率。宁可缓存小一点、多加载几次,也不能让应用因为内存问题挂掉。
另一个体会是监控比优化更重要。很多时候你以为的瓶颈和实际的瓶颈不是一回事。我遇到过一次命中率上不去,一开始以为是容量问题,加了一堆内存,结果发现是 key 生成逻辑有 bug,同一张图生成了两个 key。如果没有监控数据,这种问题靠猜是猜不出来的。
最后分享一个小技巧:如果你不确定缓存参数设多少合适,可以先设一个保守值(比如十分之一),然后逐步往上调,同时盯着 OOM 率和命中率两个指标。当命中率增长放缓、OOM 率开始抬头时,就是当前设备的最优值。不同设备的最优值不一样,所以动态计算比固定值靠谱得多。
这个方向后续还可以往 Bitmap 复用池的精细化、预加载策略的智能化、以及基于设备内存压力的自适应缓存这几个方向继续深挖。我现在正在试的是根据滑动速度动态调整预加载数量,滑得快就多预加载几张,滑得慢就少加载,实测下来对内存和体验的平衡效果不错,等数据稳定了再单独写一篇分享。