第149篇图片加载原理:三级缓存与解码采样

简介: 图片加载框架核心解决三大问题:IO异步化、内存可控解码、复用场景下的请求-View精准绑定。其中第三点最易被忽视,却最易引发线上错图、闪白、崩溃等事故。关键在于理解完整链路与双重校验机制。

先把结论放在前面:图片加载框架解决的核心问题有三个——把网络/磁盘的阻塞 IO 挪出主线程、把解码后的位图按目标尺寸管理以控制内存、把"哪个请求对应哪个 View"这件事在视图复用场景下管住。 三者里,第三条最容易被忽略,也最容易出线上事故。

这道题的分水岭从来不在会不会背三级缓存,而在能不能把"从请求发起到图显示出来"的完整链路讲顺。能讲清"为什么一个 View 复用了位置后旧请求不能直接设置结果",说明真的处理过线上问题。

一条请求的完整路径

以常见的 Glide/Coil 类架构为例,一次加载的完整链路是:

load(url, targetView, size)
  → 解析/规范化请求(URL、宽高、配置、签名)
  → 计算 cacheKey(关键:必须含所有影响结果的参数)
  → 查内存缓存(ActiveResource 活跃引用 → ResourceCache 弱引用 LruCache)
      命中 → 立即设置(主线程)
  → 未命中 → 生成 EngineJob,调度到线程池
      → 查内存缓存(二次,可能刚被别处加载完成)
      → 解码(BitmapFactory 采样 / Drawable 解码 / 视频帧)
      → 变换(centerCrop、圆角、模糊)
      → 存内存缓存 + 写磁盘缓存
  → 回调主线程 → 设置到 target
  → 活跃期间进入 ActiveResource,引用计数 +1
  → View 销毁 / 请求被清 → 引用计数 -1
  → 引用计数归零 → 从活跃表移出,位图可被 LRU 回收

这条链里有三处设计意图必须讲出来:

  • 内存缓存分"活跃"和"非活跃"两层。活跃位图被强引用(正在显示中,不能被回收),非活跃位图放在 LRU 里(可被回收)。这就是为什么框架要维护"活跃引用表"——否则正在显示的图会被 LRU 挤掉,表现为"图片闪一下变白"。
  • 磁盘缓存存的是原始数据(未解码),不是位图。因为位图无法高效序列化到磁盘(体积大、格式复杂),而原始数据可以按 key 直接读写。 解码放到内存侧按需进行。
  • 请求被"取消"不是中断线程,而是标记"结果不再需要"。线程池任务继续跑完,但回调被丢弃。这是异步库的标准设计(协作式取消),因为中断正在解码的线程可能留下半成品状态。

最常见的坑是

第一层坑:缓存 key 漏掉变换参数。

如果 key 只有 URL,那么"原图"和"按 200×200 裁剪的圆角图"会互相覆盖。表现是:列表里的小图和详情页的大图来回切换时显示错乱,或者取到尺寸不匹配的位图导致拉伸。规范是key 必须包含所有影响结果的参数:URL、目标宽高、Transformation、配置(是否允许硬件位图)、解码配置。主流框架是把这些拼成一个 hash 字符串,这是"框架帮你做了"的典型例子——但自己实现时漏掉就是线上 bug。

第二层坑:页面销毁未取消请求,回调操作已回收的 View。

列表滑出 50 条,后 30 条的请求还在跑;用户返回,Adapter 被销毁,但请求完成时回调仍会执行 holder.imageView.setImageBitmap(...),此时 imageView 属于已回收的 item。后果有两种:轻则显示错位(数据串行),重则崩溃(某些自定义 View 已释放资源)。规范有两条:① 用框架提供的"target 自动解绑"机制(请求与 target 弱关联,target 被回收时自动取消);② 显式 clear() 整个请求管理器。判断有没有解绑的实操方法:日志里如果出现"设置图到已回收 View"的告警,就是漏了。

第三层坑:把"缓存命中"当成"不用采样"。

很多自研实现把原图按 URL 存进内存缓存,结果列表页为了显示 100×100 的小图也把 2000×3000 的原图解进了内存。规范是内存缓存的粒度应该与"目标尺寸"绑定——同一个 URL 在不同尺寸下是两个不同的缓存条目。这会让缓存占用变大,但换来的是内存峰值可控——这是有意的取舍,不是设计缺陷。

还有一个更隐蔽的坑:忽略 inSampleSize 与目标尺寸的匹配,导致模糊或浪费。 采样系数按 2 的幂次取,可能出现"实际解码尺寸是目标尺寸的 2 倍"的情况——不模糊,但内存多 4 倍;反过来"采样过头"就是模糊。规范是采样后尺寸落在 [目标尺寸, 2×目标尺寸] 区间内最优。能主动说出这个区间,说明真的调过图片质量与内存的平衡。

代码里见真章

先看一个缓存 key 的规范构造方式,这是最容易被漏掉的一环:

// 规范:key 由所有影响结果的参数共同决定
data class ImageRequest(
    val url: String,
    val targetWidth: Int,
    val targetHeight: Int,
    val transform: String,       // "centerCrop" / "rounded8" / "circle"
    val allowHardware: Boolean,
    val config: Bitmap.Config
) {
   
    // 注意:不能用 data class 的 toString 做 key(不稳定、不紧凑)
    // 规范:显式拼一个短且无歧义的 key
    fun cacheKey(): String = buildString {
   
        append(url)
        append('|').append(targetWidth).append('x').append(targetHeight)
        append('|').append(transform)
        append('|').append(if (allowHardware) "hw" else "sw")
        append('|').append(config.name)
    }

    // 内存 key 与磁盘 key 分开:磁盘不需要尺寸与配置
    fun diskKey(): String = "$url|$transform"
}

// 使用:同一个 URL 在不同尺寸下是不同条目
val listKey = ImageRequest(url, 200, 200, "centerCrop", true, Bitmap.Config.ARGB_8888)
val detailKey = ImageRequest(url, 1080, 1920, "centerCrop", true, Bitmap.Config.ARGB_8888)
assert(listKey.cacheKey() != detailKey.cacheKey())    // 正确:不互相覆盖
assert(listKey.diskKey() == detailKey.diskKey())      // 正确:磁盘只存一份原始数据

这段代码值得盯两处:第一处,内存 key 与磁盘 key 分开设计——磁盘存原始数据不需要尺寸,内存存解码结果必须带尺寸;第二处,transform 参与 key,这正是"圆角图和原图互相覆盖"那个 bug 的根治点。

再看一个视图复用安全的目标解绑实现:

// 自研加载器必须处理的两件事:请求与 View 的解绑、复用后的结果校验
class ImageLoader {
   

    private val main = Handler(Looper.getMainLooper())
    private val pool = Executors.newFixedThreadPool(4)

    // key -> 已取消标记。复用同一个 key 的请求时递增版本
    private val versions = ConcurrentHashMap<String, Int>()

    fun load(
        request: ImageRequest,
        imageView: ImageView,
        onDone: ((Boolean) -> Unit)? = null
    ) {
   
        val key = request.cacheKey()
        // 每次新请求版本号 +1,旧版本的回调据此失效
        val version = versions.merge(key, 1, Int::plus)!!

        pool.execute {
   
            val bmp = decode(request)          // 采样 + 解码(后台线程)
            main.post {
   
                // 关键一:版本校验——期间发起过新请求则丢弃本次结果
                if (versions[key] != version) return@post
                // 关键二:tag 校验——View 已被复用给别的 key 则丢弃
                val bound = imageView.getTag(R.id.tag_image_key) as? String
                if (bound != null && bound != key) return@post
                // 双重校验通过才设置
                imageView.setTag(R.id.tag_image_key, key)
                imageView.setImageBitmap(bmp)
                onDone?.invoke(bmp != null)
            }
        }
    }

    fun cancel(imageView: ImageView) {
   
        // 规范:清 tag + 置灰图,阻止迟到的回调污染
        imageView.setTag(R.id.tag_image_key, null)
        imageView.setImageDrawable(null)
    }
}

这段代码的双重校验是这题的核心答案:版本号校验解决"同一 key 的新请求",tag 校验解决"View 被复用给别的 key"。只做其中一个,"快速滑动列表"很容易出现图错位。

采样与内存控制的完整实现:

fun decode(request: ImageRequest): Bitmap? {
   
    val bounds = BitmapFactory.Options().apply {
    inJustDecodeBounds = true }
    decodeSource(request, bounds)                  // 第一遍:只读尺寸
    if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null

    val sample = calculateSample(bounds.outWidth, bounds.outHeight,
                                 request.targetWidth, request.targetHeight)

    val opts = BitmapFactory.Options().apply {
   
        inSampleSize = sample
        inPreferredConfig = if (request.allowHardware) {
   
            Bitmap.Config.HARDWARE      // API 26+:CPU 不可读,绘制更快
        } else {
   
            Bitmap.Config.ARGB_8888
        }
        inMutable = false               // 不可变位图可被渲染管线优化
    }
    return decodeSource(request, opts)            // 第二遍:按采样解码
}

private fun calculateSample(srcW: Int, srcH: Int, reqW: Int, reqH: Int): Int {
   
    if (reqW <= 0 || reqH <= 0) return 1
    var sample = 1
    // 关键:采样后落在 [req, 2*req) 区间,避免过度采样导致模糊
    while (srcW / (sample * 2) >= reqW && srcH / (sample * 2) >= reqH) {
   
        sample *= 2
    }
    return sample
}

这段代码里 inMutable = false 是一个容易被忽略的优化——不可变位图允许渲染管线做内部拷贝优化,而在需要频繁修改像素的场景才设 true(代价是每次绘制都可能触发拷贝)。

磁盘缓存的实现要点:

class DiskCache(private val dir: File, private val maxBytes: Long) {
   

    // 规范:key 做哈希映射成文件名,避免 URL 里的非法字符与超长路径
    private fun fileFor(key: String) = File(dir, hashKey(key))

    private fun hashKey(key: String): String {
   
        val md = MessageDigest.getInstance("MD5")
        val bytes = md.digest(key.toByteArray(Charsets.UTF_8))
        return bytes.joinToString("") {
    "%02x".format(it) }
    }

    fun get(key: String): ByteArray? {
   
        val f = fileFor(key)
        if (!f.exists()) return null
        // 规范:命中后 touch 修改时间,供 LRU 淘汰时判断新鲜度
        f.setLastModified(System.currentTimeMillis())
        return f.readBytes()
    }

    fun put(key: String, data: ByteArray) {
   
        val f = fileFor(key)
        // 规范:先写临时文件再 rename,避免写入中断产生半截文件
        val tmp = File(f.parentFile, f.name + ".tmp")
        tmp.writeBytes(data)
        if (!tmp.renameTo(f)) tmp.delete()
        trimIfNeeded()
    }

    private fun trimIfNeeded() {
   
        val files = dir.listFiles()?.filter {
    it.isFile && !it.name.endsWith(".tmp") } ?: return
        var total = files.sumOf {
    it.length() }
        if (total <= maxBytes) return
        // LRU:按最后修改时间升序淘汰
        files.sortedBy {
    it.lastModified() }.forEach {
    f ->
            if (total <= maxBytes) return@forEach
            total -= f.length()
            f.delete()
        }
    }
}

这段代码的三处规范值得盯:key 哈希成文件名(避免非法字符)、先写 .tmp 再 renameTo(原子替换,避免半截文件被当成有效缓存读进来)、LRU 按 lastModified 淘汰。"半截文件被读出来导致解码失败"是自研磁盘缓存最经典的线上问题。

面试中的经典考点

问:三级缓存各存什么?key 一样吗?

答:内存缓存存解码后的位图/可绘制对象,磁盘缓存存原始数据(未解码)。key 不一样:内存 key 必须包含目标尺寸、变换、配置;磁盘 key 只需 URL 与变换标识。 能主动指出"key 不一样"这一点,说明真设计过而不是抄的。

问:为什么正在显示的图片不会被 LRU 回收?

答:框架维护活跃资源表(ActiveResources),正在被 View 引用的位图以强引用存在表中,引用计数归零后才移入 LRU 弱引用缓存。所以"活跃/非活跃"是两层不同的存储与引用强度。这是"图片闪一下变白"问题的正解——引用计数实现有误就会提前回收正在显示的图。

问:请求取消是怎么实现的?中断线程吗?

答:不中断。取消的语义是"标记本次结果不再需要",任务仍会跑完,但回调被丢弃。原因是中断正在解码的线程可能留下不一致状态,且线程池的任务本来就不该被业务逻辑随意打断。 主流框架的实现是给请求打标记(Request 的 cancel 回调 + 目标解绑),结果回来时发现"已取消"就丢弃。这是协作式取消 vs 抢占式取消的经典对比。

问:怎么防止 View 复用导致的图错位?

答:两层校验。① 目标绑定校验——把请求 key 打在 View 的 tag 上,回调时比对,不一致就丢弃;② 版本号校验——同一个 key 发起新请求时版本 +1,旧请求回调发现版本落后即丢弃。只做第一层解决"复用给别的数据",只做第二层解决"同 key 重新请求",两层都做才完整。

问:低端机上图片加载卡顿怎么优化?

答:按收益排序四条。① 降低并发——线程池大小从 4 降到 2 或 1,直接减少 CPU 与内存竞争;② 降低解码尺寸——把 targetWidth 收紧到实际显示尺寸(列表项用 getWidth 而不是屏幕宽);③ 避免硬件位图与软件位图混用带来的转换;④ 禁用动画帧解码(inSampleSize 配 inScaled)。关键是先用指标定位——是"主线程卡"还是"图片不出现",两者优化方向不同。

问:怎么度量图片加载的性能?

答:四个可量化指标。① 首次加载耗时(从请求到显示,P50/P95);② 缓存命中率(内存/磁盘分别统计);③ 内存占用(缓存中的位图总字节数);④ 错误率与降级率(失败后是否走了占位图)。"命中率"这一项最能说明缓存设计是否合理——如果磁盘命中率长期偏低,说明 key 设计或写盘时机有问题。主动提出指标的候选人在面试里明显更少。

落到项目里怎么做

一条能写进规范的红线:业务代码禁止直接调用 BitmapFactory 解码网络/asset 图片,一律走统一图片层。 理由不是封装洁癖,而是采样策略、缓存 key、生命周期解绑这三件事必须集中管理,分散实现势必会漏。

配套实践三条:① 图片层的接口只暴露"url + 目标尺寸 + 变换",把尺寸参数设为必填——从接口上杜绝"传 0 让框架自己猜"导致的全尺寸解码;② 埋点上报命中率与 P95 耗时,命中率低于阈值就告警,这比等用户反馈有效得多;③ 内存监控接 onTrimMemory,在 TRIM_MEMORY_RUNNING_CRITICAL 时清空非活跃缓存并降并发,这是低端机不 OOM 的关键兜底。

给正在准备面试的你

这题的答法要落到"三段链路 + 两个防错校验 + 三个缓存坑"。推荐这条线:先把链路画出来(内存活跃表 → LRU → 磁盘 → 采样解码 → 变换 → 回调)→ 讲两个校验(tag 校验解决 View 复用、版本号解决同 key 重发)→ 讲三个坑:key 漏变换参数、忘取消请求、内存缓存粒度不绑尺寸 → 落到"统一图片层 + 命中率埋点 + onTrimMemory 兜底"这套工程做法。能被追问到"为什么正在显示的图不会被回收"并答出活跃引用表,说明真读过框架;能讲清"取消为什么不中断线程",说明理解了协作式取消的设计意图。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:Canvas-与-Bitmap:绘制资源的内存账

下一篇预告:Lifecycle-组件:生命周期感知的标准化

有任何问题欢迎在评论区留言交流。

相关文章
|
20天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8885 26
|
19天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3828 16
|
18天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2245 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
411 1
|
13天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
1001 2
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
6天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
7天前
|
人工智能 Linux Windows
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
千问办公(QwenWork)是阿里云推出的AI智能办公平台,支持网页端直接使用及Windows/Mac/Linux客户端下载。提供PPT生成、财报分析、网页搭建等AI功能,个人版免费,企业版198元/席/月。详情见官网qwenwork.cn或阿里云产品页。
955 0
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
|
19天前
|
云安全 人工智能 安全