先把结论放在前面:图片加载框架解决的核心问题有三个——把网络/磁盘的阻塞 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-组件:生命周期感知的标准化
有任何问题欢迎在评论区留言交流。