Android 图片加载缓存一致性:从错图、旧图到可验证的缓存策略
图片列表里的错图、旧图和闪烁,通常不是 ImageView 本身的问题,而是请求生命周期、复用组件和多级缓存共同作用的结果。本文从一个可复现的列表场景出发,拆解内存缓存、磁盘缓存、网络缓存和请求取消之间的关系,并给出一套能够验证、监控和逐步演进的实现策略。
先把问题变成可观察的现象
列表滚动时,常见症状有三种:
- 占位图短暂显示后,出现了另一条数据的图片。
- 用户修改头像后,个人页仍然显示旧图片。
- 同一张图片在弱网下反复闪烁,甚至触发多次下载。
这三个现象要分开排查。错图通常和 View 复用、请求回调晚到有关;旧图通常和缓存键、响应头或失效策略有关;闪烁则可能是占位图设置时机、请求取消和缓存命中路径不一致造成的。
排查时先记录四个字段:业务图片地址、展示组件标识、缓存命中层级、请求开始和结束时间。没有这些信息,只看最终画面很难判断是数据错误还是渲染时序错误。
View 复用要求每次绑定都重置状态
RecyclerView 的 ViewHolder 会被反复绑定。绑定新数据时,如果旧请求还没有结束,旧回调就可能把结果写进已经展示新数据的 ImageView。
class AvatarViewHolder(
private val imageView: ImageView
) : RecyclerView.ViewHolder(imageView) {
fun bind(item: UserItem, loader: ImageLoader) {
imageView.setTag(R.id.bound_image_url, item.avatarUrl)
imageView.setImageResource(R.drawable.avatar_placeholder)
loader.cancel(imageView)
loader.load(item.avatarUrl, imageView)
}
}
关键不是“先设置占位图”这么简单,而是让加载器拥有明确的绑定关系。回调真正落地前,应再次确认目标组件仍绑定同一个地址:
fun displayIfStillBound(view: ImageView, url: String, bitmap: Bitmap) {
if (view.getTag(R.id.bound_image_url) == url) {
view.setImageBitmap(bitmap)
}
}
实际项目中应优先使用成熟图片库提供的生命周期、复用和取消机制。自定义加载器时,至少要同时处理 onViewRecycled、请求取消、失败占位和回调校验,不能只在成功回调里设置图片。
缓存键必须包含真正影响内容的维度
把完整 URL 直接当作缓存键,在最简单的场景里可以工作,但很多接口会通过请求头、尺寸参数、鉴权上下文或图片版本影响返回内容。缓存键缺少这些维度,就会出现“地址相同但内容不应该相同”的污染。
常见做法是先规范化地址,再把尺寸和版本纳入键:
data class ImageCacheKey(
val url: String,
val width: Int,
val height: Int,
val version: String?
) {
fun value(): String = listOf(url, width, height, version.orEmpty())
.joinToString(separator = "|")
}
如果服务端支持稳定的内容版本,优先使用版本字段或内容摘要。不要把用户 token 直接拼进磁盘文件名,也不要用完整响应内容作为日志字段。缓存键既要稳定,也要避免泄露敏感信息。
多级缓存要有清晰的读取顺序
一个可维护的图片读取链路通常是:内存缓存、磁盘缓存、网络请求。内存缓存负责快速复用,磁盘缓存负责跨进程生命周期保留,网络请求负责取得最新内容。
suspend fun getImage(key: ImageCacheKey): Bitmap {
memory[key.value()]?.let { return it }
disk.read(key.value())?.let { bitmap ->
memory[key.value()] = bitmap
return bitmap
}
val bitmap = api.download(key.url)
disk.write(key.value(), bitmap)
memory[key.value()] = bitmap
return bitmap
}
这个顺序并不代表永远相信缓存。对头像、商品主图等内容,服务端应提供 ETag、Last-Modified 或显式版本号,让客户端能够使用条件请求验证缓存,而不是每次都强制下载,也不是无限期使用旧文件。
失效策略要和业务更新方式匹配
缓存失效有三种常见策略:
- 时间失效:适合变化频率可接受的内容,例如资讯缩略图。
- 版本失效:上传或更新后生成新版本号,适合头像、商品图片。
- 主动清理:业务完成更新后删除指定键,适合对实时性要求高的内容。
最稳妥的做法通常是版本失效加容量淘汰。上传成功后由服务端返回新的图片地址或版本,客户端把它作为新缓存键;旧键可以等待 LRU 淘汰,不必在主线程同步清理大量文件。
不要只依赖“清空全部缓存”解决一致性问题。全量清理会造成下一次进入页面时大量网络请求,可能放大服务器压力和首屏耗时,也会让问题难以复现。
请求合并避免同一资源重复下载
快速滚动或多个页面同时显示同一图片时,多个协程可能同时发现缓存未命中并发起重复请求。可以按缓存键维护正在执行的任务:
private val inFlight = mutableMapOf<String, Deferred<Bitmap>>()
suspend fun getOrJoin(key: ImageCacheKey): Bitmap {
val cacheKey = key.value()
val task = synchronized(inFlight) {
inFlight[cacheKey] ?: scope.async {
getImage(key)
}.also { inFlight[cacheKey] = it }
}
return try {
task.await()
} finally {
synchronized(inFlight) { inFlight.remove(cacheKey, task) }
}
}
生产代码还要处理取消传播、异常清理和最大并发数。请求合并只解决同一键的重复工作,不能替代磁盘缓存,也不能掩盖缓存键设计错误。
用测试验证一致性,而不是只看手感
至少覆盖以下场景:
- 同一个 ViewHolder 先绑定 A 再绑定 B,A 的延迟回调不能覆盖 B。
- 同一缓存键并发请求多次,网络层只执行一次。
- 图片版本变化后,旧版本不能命中新内容的缓存。
- 磁盘缓存损坏或读取失败时,能够删除坏文件并回源网络。
- 取消列表请求后,不应继续向已回收的 View 写入结果。
测试中可以使用可控的假图片服务和延迟栅栏,主动让旧请求晚于新请求返回。线上则记录命中层级、下载耗时、解码耗时、失败原因和取消数量,但要过滤用户隐私和鉴权信息。
小结
图片加载一致性不是单个 API 的技巧,而是“绑定关系、缓存键、失效策略、请求并发和可观测性”的组合设计。先保证回调不会写错组件,再保证缓存键能准确描述内容,最后用版本和条件请求控制新旧数据,问题才能从偶发画面变成可验证的工程行为。