什么是 LruCache?如何在自定义图片加载器中实现一个高效的内存缓存?

解读

国内面试中,这道题既考察候选人对“内存缓存”这一高频优化点的理解深度,也暗含对“图片加载闭环”——下载→解码→缓存→复用→回收——的整体把控。面试官希望听到:

  1. 对 LruCache 数据结构的精确描述(LinkedHashMap 双向链表 + 访问顺序排序);
  2. 容量设定策略(可用内存比例、屏幕像素密度、业务场景);
  3. 与 Bitmap 强引用、软引用、弱引用的取舍;
  4. 线程安全与并发访问;
  5. 与磁盘缓存、复用池(BitmapPool)的协同;
  6. 版本差异(API 12 之前无 LruCache 时如何手写,Android 4.4 前后 Bitmap 内存模型差异);
  7. 线上灰度指标(缓存命中率、GC 次数、PSS 增长曲线)。

回答时切忌只背“最近最少使用”六个字,而要给出“可落地的数值 + 代码骨架 + 性能验证”。

知识点

  1. LruCache 源码级原理:LinkedHashMap 三参构造 accessOrder=true,每次 get/put 把节点移至队尾,trimToSize 从队头循环移除。
  2. Bitmap 内存占用计算:width * height * 每像素字节数(ARGB_8888 为 4 字节);在 Android 7.0 之前 Bitmap 像素存 native 层,7.0 之后移回 Java 堆,影响 GC 策略。
  3. 缓存容量设定:国内主流方案为“运行时最大内存的 1/8~1/6”,再按屏幕分辨率做系数修正,例如 1080P 手机取 1/6,2K 屏取 1/8,防止高分辨率图片瞬间打满。
  4. 并发安全:LruCache 自带 synchronized,但自定义加载器内部仍需把“内存缓存命中→UI 线程回调”与“磁盘/网络加载→线程池解码”两条路径拆成独立锁域,避免 UI 阻塞。
  5. Bitmap 复用池:inBitmap 参数要求新 Bitmap 的内存 <= 旧 Bitmap 的内存,且 inSampleSize=1 时必须尺寸完全一致;国内厂商 ROM 对 inBitmap 校验策略略有差异,需灰度兼容。
  6. 缓存命中率监控:利用美团 Raptor、腾讯 Matrix 或字节 ByteHook 插桩,统计 LruCache.hitCount / missCount,线上低于 85% 即触发告警。
  7. 国内合规:若图片含用户头像,需遵循《个人信息保护法》加密缓存,禁用全局可读目录;若集成第三方 SDK,需在隐私清单中声明“SDCard 缓存”用途。

答案

  1. LruCache 定义
    Android 提供的泛型哈希链表缓存,内部用 LinkedHashMap 按访问顺序排序,当元素超过 maxSize 时,从队头移除“最近最少使用”项,时间复杂度 O(1)。

  2. 容量计算与初始化

    val maxMemory = Runtime.getRuntime().maxMemory()
    val cacheSize = (maxMemory / 1024 / 6).toInt() // 单位 KB,约 1/6
    val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            return bitmap.byteCount / 1024 // KB
        }
    }
    
  3. 线程安全封装
    对内存缓存做一层代理,所有读写走主锁,但把“解码”任务放到线程池,解码完成后再一次性提交到主线程,避免持有锁做耗时 IO。

  4. 与复用池协同
    解码前从 BitmapPool 查找可复用对象,设置 Options.inBitmap;若复用失败则回退到普通解码。解码成功后将旧 Bitmap 回收到池中。

  5. 生命周期对齐
    在 Activity.onLowMemory() 与 ComponentCallbacks2.onTrimMemory() 中分别调用 memoryCache.evictAll() 与 memoryCache.trimToSize(newSize),防止后台被系统杀死。

  6. 命中率验证
    线下用 Systrace + Perfetto 观察:连续滑动 RecyclerView 200 张图片,目标帧率 ≥ 55 fps,缓存命中率 ≥ 90%,PSS 增长 ≤ 15 MB。

拓展思考

  1. 分层缓存架构:把“内存缓存→磁盘缓存→网络”做成责任链,国内弱网场景下磁盘缓存 TTL 可拉长至 7 天,但需在 HTTP 响应头加入 Cache-Control: max-age=604800,并兼容 CDN 劫持导致的 404 替换图。
  2. 大图片分片缓存:针对电商长图,先按 256 px 高切片,再对每片做 LruCache 缓存,滑动时按需解码,降低峰值内存。
  3. 零拷贝渲染:Android 10 以上引入 ImageDecoder,可把解码后像素直接映射到 HardwareBuffer,结合 SurfaceFlinger 的 GPU 纹理缓存,实现“解码→渲染”零次 memcpy,适合 4K 视频封面。
  4. 隐私沙盒适配:Android 13 细化 READ_MEDIA_IMAGES 权限,自定义加载器需把磁盘缓存目录迁移到 app-specific 路径,避免触发新权限弹窗;同时把缓存索引加密存储在 JetSec 提供的 EncryptedFile,防止 root 设备被拷走。
  5. 动态调参:利用 MVI 架构把“缓存命中率、GC 次数、电量消耗”作为状态流,后台下发实验参数,实时调整 cacheSize 与池大小,实现“千人千面”的内存策略。