图片加载与缓存
图片问题要区分泄漏, 瞬时解码峰值, Java heap, native/graphics 内存, 磁盘缓存和网络获取, 不能只用 “压缩图片” 概括.
一, Bitmap 内存模型与大小计算
Bitmap 像素内存可近似按 宽 × 高 × 每像素字节数 估算, 还要考虑 density 缩放, row bytes, 颜色空间, 硬件 Bitmap 和解码中间缓冲.
ARGB_8888通常每像素 4 字节.RGB_565通常每像素 2 字节, 但色彩精度和透明度能力受限, 不能为了省内存无条件使用.- Android 8.0 以后像素数据主要位于 native heap, 但仍计入进程内存压力; 这不等于 “移出 Java heap 后就不会 OOM”.
- density 缩放推演: 解码尺寸会被
scale = inTargetDensity / inDensity放大,inDensity是资源所在 density bucket (如drawable-mdpi= 160),inTargetDensity是设备 density (如 xxhdpi = 480). 一张 100x100 的 mdpi 资源在 xxhdpi 设备上放大 3 倍 (480/160) 解码为 300x300, 内存按放大后尺寸计算: 300 x 300 x 4 = 360KB, 而非原资源的 40KB: 面积放大 scale² 倍 (此例 9 倍), 这正是误放 mdpi 图到高密度设备导致内存暴涨的原因.
二, 按目标尺寸解码
可运行示例 (工程内): 下列函数与调用放在 Activity, Fragment 或 Repository 的资源解码路径中; 输入是存在的 R.drawable.my_image 和目标像素尺寸, 输出是采样后的 Bitmap?. 原图 4000x3000, 目标 1000x750 时选取 sample 4, 实际解码尺寸约为目标尺寸, 最终仍以解码结果为准.
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
var sample = 1
while (options.outHeight / (sample * 2) >= reqHeight && options.outWidth / (sample * 2) >= reqWidth) sample *= 2
return sample
}
fun decodeSampledResource(resources: Resources, @DrawableRes id: Int, reqWidth: Int, reqHeight: Int): Bitmap? {
require(reqWidth > 0 && reqHeight > 0)
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeResource(resources, id, options)
if (options.outWidth <= 0 || options.outHeight <= 0) return null
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeResource(resources, id, options)
}
inSampleSize 表示解码采样比例. 不同解码器和历史 Android 版本对非 2 次幂取值的处理存在差异, 因此实现应以目标尺寸, 实际解码结果和当前平台文档为准, 而不是写成 “永远必须是 2 的幂”.生产代码通常优先使用成熟图片库完成尺寸协商, 解码复用和线程调度.
- inBitmap 复用: 把
BitmapFactory.Options.inBitmap设为一块可复用的旧 Bitmap, 解码直接复用其像素内存, 减少分配与 GC; 约束是目标尺寸与配置需能被旧对象容纳 (Android 4.4+ 允许尺寸不更大即可) 且解码器兼容, 不满足时需回退普通分配并严格校验. - ImageDecoder (API 28+): 替代
BitmapFactory的现代解码入口, 支持setTargetSize()目标尺寸, 缩放, 裁剪, 并自动按 EXIF 方向校正; 新代码优先考虑, 解码复用与后处理语义也更明确. - Hardware Bitmap (
Bitmap.Config.HARDWARE, API 26+): 像素存于 graphics 内存, 只能用于渲染不能读像素, 适合列表与 GPU 合成, 但取样, 序列化等需要读像素的 API 不可用.
三, 缓存与数据获取层次
通用图片管线可以分为:
- 活跃资源 / 内存缓存: 最快, 但受进程生命周期和内存压力影响.
- 转换后资源磁盘缓存: 缓存裁剪, 缩放或变换结果.
- 原始数据磁盘缓存: 缓存下载或读取到的原始数据.
- 数据源: 网络, 文件, ContentProvider 或应用资源, 不属于缓存层.
缓存 key 必须包含尺寸, 变换, 签名, 数据版本和必要的请求参数, 否则会出现错图或旧图.
四, Glide 缓存与生命周期边界
以 Glide 4 的公开行为模型说明, 请将内部类名视为版本相关实现细节. 典型查找顺序是:
- Active resources;
- Memory cache;
- Resource disk cache;
- Data disk cache;
- Source.
网络只是可能的数据源之一, 不是 “第三级缓存”.Glide 会根据传入的 Activity, Fragment, View 或 Application Context 选择请求管理和生命周期作用域;“偷偷注入隐藏 Fragment” 只适合解释部分历史实现, 不能作为所有版本和所有 Context 的稳定 API 契约.
上下文片段 (Glide 4): 在 Fragment 的 onViewCreated 中, imageView 是该 Fragment 的 ViewBinding 成员, URL 是可信业务数据. 普通 Glide.with().load() 只需 Glide 依赖; 只有使用生成 API 或 AppGlideModule 时才需要相应注解处理器. 预期: 按 320x180 请求, 加载期间显示占位, 失败显示错误图; 离开 Fragment View 生命周期后请求会按宿主生命周期管理.
Glide.with(this)
.load(imageUrl)
.override(320, 180)
.placeholder(R.drawable.placeholder)
.error(R.drawable.image_error)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(binding.imageView)
缓存层级图:
请求(key = model + size + transform + signature)
-> Active resource -> Memory LRU -> Resource disk -> Data disk -> source(network/file/provider)
数字示例: 可给自建 LruCache 的预算为运行时 maxMemory / 8, 若 maxMemory=256MiB, 容量是 32MiB; 一张 1080x720 ARGB_8888 约为 1080 x 720 x 4 = 2.97MiB, 理论仅约容纳 10 张, 未计对象与其他内存. 应通过低内存设备, 命中率和 OOM/GC 证据调整比例, 不把 1/8 当固定标准.
五, 列表, 长图与预取
- 不在主线程做大图 decode, 磁盘 I/O 或同步网络请求.
- 给列表项提供稳定尺寸, 避免用原始大图填充很小的 ImageView.
- 是否在滚动时暂停请求, 应通过 trace, 首屏命中率, 滑动帧时间和网络浪费测量; 现代图片库通常可以调度, 取消和复用请求, 不能把 “滑动必暂停” 写成通用最佳实践.
- 对可预测列表使用适量预取, 并同时约束并发数, 解码尺寸和缓存预算.
- 超长图可使用区域解码, 分块渲染或专用大图组件, 避免一次创建完整像素缓冲.
六, OOM 排查流程
- 先判断是 Java heap, native heap, graphics, mmap 还是总体进程限制.
- 区分持续增长的泄漏和瞬时解码 / 并发峰值.
- 记录图片来源, 原始尺寸, 目标尺寸, 配置, 并发请求数和页面生命周期.
- 线下结合 Memory Profiler, heap dump, LeakCanary, Perfetto/heapprofd; 线上按版本, 机型, 页面和内存档位聚合.
- 检查缓存预算, 动画帧, 硬件 Bitmap, 列表预取和第三方库版本, 而不是看到 Bitmap 就直接调用
recycle().
七, WebP, SVG 与 GIF 的格式边界
格式选型不能只比较文件体积, 还要看透明度, 动画, 解码峰值, 目标 Android 版本和图片库的实际解码器.
| 格式 | 能力与原理 | 适用场景 | 不适合的情况 |
|---|---|---|---|
| WebP | 支持有损和无损编码, 可携带透明通道; 动画 WebP 由多帧组成 | 可控客户端范围内的照片, 图标或短动画资源 | 未验证目标版本和解码器的动画资源, 或解码 CPU 峰值已是瓶颈的列表 |
| SVG | 用路径, 形状和文本描述矢量图, 缩放时不直接按位图像素放大 | 简单图标, 单色或少量路径的插画 | 照片, 复杂滤镜, 大量路径或频繁动画的图形 |
| GIF | 由多帧索引色图像和帧时间组成, 解码器需持续读取和合成帧 | 兼容性要求高且动画较短的内容 | 长时间自动播放, 大尺寸或高帧率动画 |
WebP: 能力不等于无成本
WebP 的有损模式通常适合照片类资源, 无损模式适合需要精确边缘或透明度的图形. Android 平台从 Android 4.0 (API 14) 支持有损 WebP, 从 Android 4.2.1 (API 17) 支持无损和透明 WebP; 基于 AnimatedImageDrawable/ImageDecoder 的动画 WebP 原生支持从 Android 9 (API 28) 开始. 第三方图片库可能自带解码器, 其能力不能等同于平台能力, 必须按库版本和最低支持设备验证. 发布前应在目标设备验证静态, 透明和动画资源的显示与降级路径.
文件更小不必然加载更快. 编码格式会改变网络传输量, 也会改变解码 CPU 时间和瞬时内存. 仍应按目标视图尺寸请求, 控制并发解码, 并用 trace 或 profiler 观察滚动期间的解码与帧时间.
SVG 与 VectorDrawable: 转换有子集边界
SVG 在 Web 或专用解析器中可按视口缩放, 但 Android 的 VectorDrawable 不是任意 SVG 的完整运行时实现. 将 SVG 转为 VectorDrawable 前要检查路径, 渐变, mask, filter, text 和动画等特性是否被目标工具链支持. 对复杂 SVG, 可在构建阶段栅格化为多个目标密度资源, 或简化路径后再转矢量; 不应在列表滚动时反复解析复杂 XML / 路径.
VectorDrawable 适合尺寸变化明显的简单图标, 但路径数量过多仍会增加测量, 绘制和动画成本. 需要频繁变换或复杂动画时, 应依据目标设备上的渲染 trace 选择位图, 简化的矢量资源或其他动画方案.
GIF: 帧解码与生命周期管理
GIF 播放的成本不仅在于压缩文件体积, 还会产生当前帧, 合成缓冲和解码工作. 大尺寸, 多帧或同时播放的 GIF 容易造成内存峰值, CPU 竞争和掉帧. 将请求绑定到 Activity/Fragment/View 生命周期, 在不可见, 滚动离屏或页面停止时暂停或清理; 是否释放由所用图片库的公开 API 和版本决定, 不直接操作内部帧缓冲.
若内容需要透明, 更平滑的颜色或更高效的现代动画编码, 可评估动画 WebP 或视频; 若只是状态反馈, 优先使用短小的属性动画或 Lottie 等矢量动画方案. 替代方案都要结合最低版本, 包体, 解码成本和无障碍降动效设置验证.
高频面试题
Q1: 如何设计图片加载框架?
拆分请求建模, 生命周期, 调度与取消, 缓存 key, 多级缓存, 数据源, 解码 / 变换和观测模块. 说明去重, 优先级, 并发限制, 失败重试和内存压力下的降级策略.
Q2: Bitmap 在 native heap 后为什么仍可能 OOM?
像素内存仍属于进程内存预算, 且可能与 Java 对象, GPU 资源, 解码临时缓冲和并发请求叠加. 只观察 Java heap 会漏掉 native/graphics 峰值.
Q3: Glide 为什么能跟随页面生命周期?
RequestManager 会绑定合适的 Lifecycle / 宿主作用域, 在宿主停止或销毁时暂停或清理请求. 具体内部实现随版本和传入 Context 类型变化, 不应把某个隐藏 Fragment 实现当成永久契约.
易错点 / 追问
- 资源 ID 应使用
R.drawable.*等可解码资源, 而不是R.id.*. drawable-mdpi等目录会参与 density 缩放; 不希望缩放的资源应评估drawable-nodpi.LruCache的容量应按字节成本计算, 而不是只按图片张数.inBitmap复用受尺寸, 配置和解码器条件约束, 应交给成熟图片库或严格校验.- 不把
Bitmap.recycle()当现代 Android 的常规泄漏治理方法. - EXIF 方向与采样顺序: 应先按方向校正再计算采样尺寸, 否则旋转后宽高互换会让目标尺寸算错, 解码出与预期相反的图;
ImageDecoder默认自动应用 EXIF, 手写BitmapFactory采样要自行处理方向. - 异步加载竞态: 复用 ImageView 时若未在请求开始前
setImageDrawable(null)或按请求校验, 旧请求晚返回会覆盖新位置的内容, 造成错图; 应在复用前取消旧请求或用请求 id / target 校验回写. - Coil / AsyncImage: 基于 Kotlin 协程的图片库,
AsyncImage(model = ...)是 Compose 入口, 2024-2025 年在 Kotlin / Compose 项目中高频使用;ImageLoader负责缓存与解码, 请求随组合作用域生命周期管理.
版本与参考资料
- 最后核验: 2026-08-07
- 适用基线: 现代 Android Bitmap API 与 Glide 4 公开行为模型
- Managing Bitmap Memory
- Loading Large Bitmaps Efficiently
- Glide caching