图片性能解码优化
# 图片性能与解码优化
📊 学习成本预估 | 难度:⭐⭐⭐⭐(4/5)| 阅读:约 45 分钟 | 实操:3 小时 🔗 前置阅读:卷二·02, 卷三·01 | ➡️ 后续延伸:卷四·04
# 目录介绍
- 01.阅读说明
- 02.贯穿案例
- 03.图片本质定义
- 04.三资源耦合原理
- 05.度量与采集
- 06.归因决策树
- 07.下载层全链路 ⭐
- 08.解码层全链路 ⭐
- 09.缓存层全链路 ⭐
- 10.显示层全链路 ⭐
- 11.格式选择全链路 ⭐
- 12.跨端图片对照
- 13.治理一层下载 ⭐
- 14.治理二层解码 ⭐
- 15.治理三层缓存 ⭐
- 16.治理四层显示 ⭐
- 17.求证实验 ⭐
- 18.实战案例
- 19.防劣化体系
- 20.跨平台速查
- 21.总结与延伸
# 01.阅读说明
- 本文卷归属:卷四 · 业务专项 · 第 3 篇
- 本文目标层级:L2 进阶 → L3 专家
- 适用平台:Android / iOS / Web / 嵌入式
- 前置阅读:
卷二·02 内存监控与治理(图片占内存大头)卷四·02 网络性能分析与优化(图片下载是网络主要负载)
- 本文核心命题:
图片是带宽 / 内存 / CPU 三资源耦合的典型场景:下载占带宽、解码占 CPU、显示占内存。 一切优化都是在三个维度上找权衡:降低分辨率 / 选择合适格式 / 用好缓存。 没有银弹,单维度优化必然失败——这是 §02 案例 经验派 4 周折腾的核心教训。
# 02.贯穿案例
本案例贯穿全文:§03 看懂物理本质、§04 用三资源耦合武器、§07-§11 拆解各阶段原理、§17 用实验复盘、§13-§16 给出分层闭环。
# 2.1 案例背景
某头部电商 V14.7 推出"高清商品图"功能(运营要求"图片清晰度提升 30%"),上线后用户反馈:
- 商品列表滑动 4-5 屏后 OOM 崩溃率从 0.3% 飙到 8.7%(中低端机重灾区)。
- 列表滑动 FPS 从 56 跌到 28,"滑得动但卡得想砸手机"。
- 客户端流量增长 47%(有些海外用户因流量爆掉直接卸载)。
- 商品详情页"白屏 1-2s"投诉激增 12 倍。
研发组检查代码:"图片大小没问题啊,4032×3024 是相机原图。"——这是典型的"原图崇拜"误区。
# 2.3 经验派 4 周折腾(典型反面教材)
| 周次 | 动作 | 结果 |
|---|---|---|
| 第 1 周 | 把图片缓存从 50MB 降到 10MB(怀疑缓存太大占内存) | OOM 略降但FPS 跌到 22(频繁缓存淘汰 → 重复解码) |
| 第 2 周 | 把所有图改成 PNG(怀疑 JPEG 失真) | 流量再涨 60%,加载更慢 |
| 第 3 周 | 加 try-catch 包住 BitmapFactory(怀疑解码异常) | OOM 数量没变(异常被吞但内存峰值还在) |
| 第 4 周 | 强制所有图先 RGB_565(半内存) | 商品图泛绿,运营投诉炸锅 |
复盘:四周里所有动作都基于"症状疗法",没有一个触及"4032×3024 的图给 200×200 的列表 thumb 用"这个最核心的资源浪费。这正是 §04 三资源耦合 的反面教材:单维度优化必然失败。
# 2.3 方法派 5 天闭环
新接手的同学按本文方法论重做:
Day 1(§04 第一性原理):用"三资源耦合模型"分析。原图加载到列表 200×200 thumb 时:
| 资源 | 不优化 | 应该是多少 |
|---|---|---|
| 带宽 | 4MB(原图) | 30KB(200×200 WebP) |
| 内存 | 47MB(4032×3024×4) | 0.16MB(200×200×4) |
| CPU 解码 | 250ms | 8ms |
→ 每张图浪费 130 倍内存——5 屏 30 张图 = 1.4GB 内存请求,必然 OOM。
Day 2(§05 三方案组合):
- 方案①:Glide RequestListener 上报每张图加载耗时和大小。
- 方案②:BitmapFactory 插桩看真实解码 size。
- 方案③:LeakCanary 检查 Bitmap 引用泄漏。
数据出来:300 张商品图全部以原图 47MB 进内存,缓存命中率仅 18%(缓存按 Bitmap 数算 LRU,几张就满了)。
Day 3(§06 决策树):
- 滑动卡顿 → 解码 + 缓存 → 主线程同步解码 250ms 是元凶。
- OOM → 内存归因 → 单图过大 + 缓存上限按数量而非按字节算。
- 重复下载 → 缓存策略 → 服务端无 CDN,每次都拉原图。
Day 4(§13-§16 四层治理):
- 第 1 层(下载):服务端接入 CDN 提供多分辨率(200/400/800/原图),客户端按 ImageView 尺寸传 ?w=200。WebP 替代 JPEG。
- 第 2 层(解码):Glide override(200, 200) 强制下采样;解码线程池而非 UI 线程。
- 第 3 层(缓存):缓存大小改
Runtime.maxMemory() / 8动态计算;按字节算 LRU;inBitmap 复用。 - 第 4 层(显示):onTrimMemory 主动释放;不可见 view 立即取消加载。
Day 5(§17 求证实验 思路验证):上线前压测 + 灰度对比验证。
# 2.4 上线效果
| 指标 | 经验派 4 周后 | 方法派 5 天后 |
|---|---|---|
| 列表滑动 OOM 率 | 8.7% | 0.4% |
| 列表滑动 FPS | 22 | 58 |
| 单屏图片流量 | 4.5MB | 0.12MB |
| 内存峰值 | 1.2GB | 180MB |
| 详情页白屏率 | 12 倍 | -85% |
| 用户投诉/日 | 6500+ | 230 |
核心洞察:图片优化的最大杠杆永远是"按显示尺寸采样"。第 1 周把缓存调小、第 2 周改 PNG、第 4 周改 RGB_565,都是在治症状不治根因。1 张原图给列表 thumb 用浪费 130 倍内存——这是决定性因素,不是缓存大小。
# 2.5 案例如何串起本文
- §03 图片本质 ▶▶ 图片是"二进制数据 → 屏幕像素"的流水线。
- §04 三资源耦合 ▶▶ "带宽 vs 内存 vs CPU"权衡表正是案例的资源浪费表。
- §07-§11 各阶段全链路 ▶▶ 下载/解码/缓存/显示/格式 各自的物理原理。
- §06 决策树 ▶▶ 案例同时命中"OOM/卡顿/重复下载"三个分支。
- §17 求证实验 ▶▶ §17.1 下采样、§17.3 缓存大小、§17.4 异步解码、§17.5 inBitmap 都在案例中变现。
- §13-§16 四层治理 ▶▶ "下载→解码→缓存→显示"四层正是案例落地路径。
# 03.图片本质定义
# 3.1 图片性能的物理本质
图片性能 = "把网络上的压缩数据转成屏幕上的像素"这一流水线的效率。
这条流水线 4 个阶段:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ① 下载 │ ─▶ │ ② 解码 │ ─▶ │ ③ 转换 │ ─▶ │ ④ 显示 │
│ 网络 │ │ CPU │ │ 缩放/裁切 │ │ GPU │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
带宽消耗 CPU 消耗 内存消耗 GPU + 内存
2
3
4
5
每个阶段都有自己的瓶颈:
- 下载:受网络限制,可缓存 / CDN 优化。
- 解码:受 CPU 限制,可用更优格式 / 异步。
- 转换:受内存限制,下采样 / 复用。
- 显示:受 GPU 限制,避免过度绘制。
这就是图片优化最棘手的地方:四个阶段相互制约,单一优化可能引发其他问题。
探索性思考:为什么图片是性能领域"最复杂"的子问题? 因为它同时涉及 4 个层面——网络、CPU、内存、GPU。一个看似简单的"加载图片"动作,背后有解码线程池、内存缓存、磁盘缓存、Bitmap 复用池、GPU 上传等十几个子系统。单看任何一层都觉得简单,但要让"几百张图在列表里平滑滚动"是个系统工程问题。这就是为什么 Glide / SDWebImage / Coil 这种成熟库代码量都在数万行——不是过度设计,是问题本身复杂。
# 3.2 图片性能的现象与代价
图片性能问题的用户感知:
- 图片加载慢:列表 / 详情页等图片很久才显示。
- 闪烁 / 占位图过久:网络加载延迟体感差。
- 滚动卡顿(图片解码):滑动时遇到大图引起掉帧。
- OOM 崩溃:大图同时加载导致内存溢出。
- 流量浪费:多次重复下载、未压缩的高清图。
业务代价:
- 商品列表:图片加载延迟每降 200ms,转化率 +1%。
- 社交内容:图片闪烁让用户反馈"卡"。
- 图片 OOM 是中低端机崩溃 Top 3 原因。
- 流量过度消耗导致用户停用(特别是按流量付费的市场)。
▶▶ 回扣 §02 案例:电商 V14.7 因为"高清商品图"功能让 OOM 率从 0.3% 飙到 8.7%(29 倍),FPS 从 56 跌到 28——图片是中低端机崩溃 Top 3 原因这句话在那个案例里直接变现为日均 6500+ 投诉。
# 3.3 度量准则
按 卷零·02 §3 指标体系:
资源视角(USE):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 图片缓存命中率 | 缓存命中 / 总请求 | > 80% |
| 图片解码耗时占比 | 主线程解码总时长 / 总时长 | < 5% |
| 图片内存占比 | 图片占总进程内存 | < 30% |
请求视角(RED):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 图片显示延迟 P50 | 从请求到显示中位 | < 200ms(缓存命中)/ < 800ms(网络) |
| 图片显示延迟 P99 | 长尾 | < 2s |
| 单图解码时长 | bitmap 解码 | < 50ms |
| 图片失败率 | 加载失败 | < 1% |
用户感知(APDEX):
- Satisfied:图片瞬现,无闪烁
- Tolerating:占位图持续 < 1s
- Frustrated:> 3s / 失败 / OOM
# 3.4 行业基准与目标
| 平台 | 单图大小 | 单页图片总量 | 单图解码 |
|---|---|---|---|
| Android / iOS(移动) | < 500KB | < 5MB | < 50ms |
| Web | < 200KB | < 2MB | < 100ms |
| 嵌入式 | 视屏幕 | 严格 | 视 CPU |
# 3.5 8 个反直觉问题
带着这些问题阅读:
- WebP 比 JPEG 真的省 30% 吗?
- 多大的图算"大图"?
- 解码一张 4MB 图要多久?
- inSampleSize 下采样会损失多少质量?
- 图片缓存设多大合适?
- PNG 总是无损所以慢吗?
- AVIF / HEIC 一定比 WebP 好吗?
- 占位图本身会拖慢首屏吗?
# 04.三资源耦合原理
本节回答四个根本问题:①为什么图片是"三资源耦合"?②单维度优化为什么必然失败?③如何用数学量化资源占用?④如何在三资源间找权衡?
# 4.1 三资源的数学模型
┌──────────────────────────────┐
│ 带宽 (Bandwidth) │
│ - 下载消耗 │
│ - 与图片体积成正比 │
│ - B = file_size / bandwidth │
├──────────────────────────────┤
│ 内存 (Memory) │
│ - 解码后的 bitmap 占用 │
│ - 与"宽 × 高 × 4 字节"成正比 │
│ - M = W × H × 4 │
├──────────────────────────────┤
│ CPU │
│ - 解码 + 转换 + 滤镜 │
│ - 与格式复杂度成正比 │
│ - C = f(format, W × H) │
└──────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
三个推论:
- 内存与"显示尺寸"成正比,不是文件大小——一张 4MB JPEG 解码后是 47MB Bitmap。
- 带宽与"文件大小"成正比,不是显示尺寸——所以"压缩格式"决定带宽,"分辨率"决定内存。
- CPU 与"格式复杂度 × 像素数"成正比——AVIF 解码慢 3 倍因为格式更复杂。
# 4.2 资源耦合定律
关键耦合:
- 高分辨率图(节省带宽)→ 解码慢 + 内存大
- 强压缩格式(省带宽)→ 解码 CPU 高
- 大尺寸 bitmap(视觉好)→ 内存爆炸
2
3
4
典型权衡:
选择 A:JPEG 70% 质量
- 带宽 200KB
- 解码 30ms
- 解码后内存 4MB(1080×1080 × 4)
选择 B:WebP 70% 质量
- 带宽 130KB(-35%)
- 解码 45ms(+50%)
- 解码后内存同 4MB
选择 C:JPEG 70% + 540×540(下采样)
- 带宽 200KB
- 解码 25ms
- 解码后内存 1MB(-75%)✨
2
3
4
5
6
7
8
9
10
11
12
13
14
没有"全优"方案,要根据场景选择:
- 列表小图:选 C(下采样最关键)。
- 详情大图:选 A 或 B(视带宽)。
- 高刷新率场景:选 A(解码快)。
▶▶ 回扣 §02 案例:电商列表的 200×200 thumb 加载 4032×3024 原图——带宽浪费 130 倍、内存浪费 130 倍、CPU 浪费 30 倍同时发生。这正是三资源耦合的最坏样本。经验派四周折腾错在"只盯一资源治理":第 1 周治内存(缩缓存)拉爆 CPU;第 4 周治内存(RGB_565)拉爆视觉。三资源必须同时考虑,否则按下葫芦浮起瓢。
# 4.3 OOM 风险的数学
图片内存 = 宽 × 高 × 4 字节(Android Bitmap.Config.ARGB_8888)。
典型大图实例:
| 分辨率 | 内存 | 备注 |
|---|---|---|
| 1080 × 1080 | 4.4 MB | 单条朋友圈封面 |
| 1920 × 1080 | 7.9 MB | 全屏大图 |
| 4032 × 3024 | 47 MB | 手机相机原图(超大) |
| 8000 × 4000 | 122 MB | UHD 屏幕分辨率 |
OOM 风险计算:
- 32 位应用堆内存上限通常 256MB-384MB。
- 同时持有 3 张 4MB 图就占 1/20+。
- 持有 5 张 47MB 图直接 OOM。
探索性思考:为什么"内存计算公式"如此重要? 因为它把"图片优化"从经验变成算术——你能在 1 分钟内算出"5 屏 30 张图占多少内存",进而判断会不会 OOM。§02 案例 经验派 4 周没算这一笔账,方法派 Day 1 算清楚后立刻定位根因。算内存账是图片优化的第一动作——比任何"试改参数"都重要。
# 4.4 跨平台同构原理
所有平台的图片处理流程都同构:
通用图片管道:
[二进制数据] → [解码器] → [Bitmap/Image] → [Texture] → [屏幕]
JPEG/PNG/... CPU/HW 内存中位图 GPU 内存 像素
2
3
4
每个平台都必须有:
| 抽象组件 | 解决什么问题 |
|---|---|
| 解码器 | 把压缩格式变成像素 |
| 缓存(内存 + 磁盘) | 减少重复解码 / 下载 |
| 加载库 | 封装"下载 + 解码 + 显示" |
| 下采样 | 减少内存峰值 |
跨平台术语对照
| 通用术语 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 图片对象 | Bitmap | UIImage / CGImage | Image | LVGL img_dsc |
| 加载库 | Glide / Coil / Picasso | SDWebImage / Kingfisher | 浏览器内置 / loader | 自实现 |
| 下采样 | inSampleSize | UIGraphicsImageRenderer | srcset / 服务端 | 自实现 |
| 内存缓存 | LruCache | NSCache | Cache API | 自实现 |
| 磁盘缓存 | DiskLruCache | URLCache | Service Worker | 文件系统 |
| 现代格式 | WebP / AVIF | HEIC / WebP | WebP / AVIF | varies |
# 4.5 平台差异点矩阵
| 维度 | Android | iOS | Web |
|---|---|---|---|
| 主流加载库 | Glide / Coil | SDWebImage / Kingfisher | lazy loading + img tag |
| Bitmap 内存位置 | Native(8.0+ ART 之外) | Native | GPU 内存 |
| 现代格式 | WebP(4.0+)/ AVIF(12+) | HEIC(11+)/ WebP(14+) | WebP(96%)/ AVIF(普及中) |
| 硬件解码 | HEIF / WebP / AVIF 部分 | HEIC / JPEG 大部分 | 浏览器决定 |
| 下采样 API | inSampleSize / inJustDecodeBounds | UIGraphicsImageRenderer | srcset / sizes |
| 渐进式加载 | 视库 | 视库 | 自动(progressive JPEG) |
后续 §07-§11 各阶段全链路章节会逐一展开。
# 05.度量与采集
# 5.1 三类捕获方案
下载 ──▶ 解码 ──▶ 显示
│ │ │
▼ ▼ ▼
① 加载库埋点(每次加载的全流程时长)
② 解码插桩(CPU 耗时分析)
③ 内存监控(图片占内存)
2
3
4
5
6
① 加载库埋点
- 核心原理:在图片加载库的关键回调(开始 / 完成 / 失败 / 缓存命中)上埋点。
- 物理本质:从应用视角记录图片生命周期。
- 适用场景:监控用户感知的图片性能。
Glide.with(this)
.load(url)
.listener(object : RequestListener<Drawable> {
override fun onResourceReady(...): Boolean {
report("ok", url, dataSource, totalTime)
return false
}
})
.into(imageView)
2
3
4
5
6
7
8
9
② 解码插桩
- 核心原理:在 BitmapFactory.decode / UIImage init 等解码 API 上插桩,记录耗时与失败。
- 物理本质:从底层视角记录每次解码事件。
- 适用场景:定位"哪些图片解码慢"。
③ 内存监控
- 核心原理:定期统计图片缓存的总内存(缓存中的 Bitmap × 大小)。
- 物理本质:从内存视角看图片占比。
- 适用场景:监控图片是否成为内存大头。
三种方案的总览
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 加载库埋点 | 加载库 | 单次加载 | 极低 | 高 | ✅ | 看不到内部细节 |
| ② 解码插桩 | 解码 API | 解码级 | 低 | 中 | ⚠️ | 侵入式 |
| ③ 内存监控 | 内存采样 | 总量级 | 极低 | 高 | ✅ | 不知具体哪张 |
方案的"组合定律":①+②+③ 必须组合——①给业务视角,②给解码视角,③给内存视角。
探索性思考:为什么"加载库埋点"看不到全部? 因为加载库通常封装了"下载+解码+显示"——你看到的是"总耗时",但具体哪一阶段慢看不到。多数图片性能问题需要拆到阶段才能归因:
- 总耗时 200ms 但解码 150ms = 解码瓶颈。
- 总耗时 200ms 但下载 150ms = 网络瓶颈。
同样的"200ms"治理路径完全不同。所以线上加埋点 + 线下用 Trace 阶段拆解是标准组合。
# 5.2 各方案的可见盲区
| 现象 | 方案 ① | 方案 ② | 方案 ③ |
|---|---|---|---|
| 图片显示延迟 | ✅ | ❌ | ❌ |
| 解码瓶颈 | 部分 | ✅ | ❌ |
| 单图内存占用 | 部分 | 部分 | ✅ |
| 缓存命中率 | ✅ | ❌ | 间接 |
| 失败原因 | ✅ | 部分 | ❌ |
# 5.3 跨平台采集对照表
| 维度 | Android | iOS | Web |
|---|---|---|---|
| 加载库埋点 | Glide RequestListener | SDWebImage delegate | lazy loading + onload |
| 解码耗时 | BitmapFactory + 计时 | UIImage + signpost | Image.decode() 计时 |
| 内存占用 | Bitmap.getAllocationByteCount | UIImage.size + scale | Image element size |
| 缓存命中率 | LruCache 统计 | NSCache 统计 | Cache API |
| 网络下载 | OkHttp Interceptor | URLSession metrics | DevTools Network |
# 5.4 数据可信度评估
| 数据 | 可信度 | 偏差来源 |
|---|---|---|
| 加载总时长 | 高(< 1%) | 加载库时钟 |
| 解码耗时 | 高 | 直接计时 |
| 内存占用 | 高 | 系统统计 |
| 缓存命中率 | 高 | 加载库内置 |
# 06.归因决策树
# 6.1 图片问题决策树
图片性能问题
│
├── 加载慢(首次显示)──▶ 网络 / 解码归因
│ ├─ 网络慢 → 看 §6.4
│ └─ 解码慢 → 看 §6.3
│
├── OOM 崩溃 ──────────▶ 内存归因
│ ├─ 单图过大 → 下采样(§14.2)
│ ├─ 多图同时 → 缓存上限(§15.2)
│ └─ Bitmap 未回收 → 引用泄漏(详见卷二·02)
│
├── 滚动卡顿 ──────────▶ 解码 + 缓存
│ ├─ 解码主线程 → 异步(§14.3)
│ └─ 缓存未命中 → 预加载(§13.4)
│
└── 重复下载 ──────────▶ 缓存策略
├─ 内存缓存命中率低
└─ 磁盘缓存大小 / 策略
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
▶▶ 回扣 §02 案例:案例同时命中"OOM/卡顿/重复下载"三个分支,正好对应"单图过大 + 主线程解码 + 无 CDN"三个根因。经验派只盯一条分支必然失败。
# 6.2 内存峰值归因
§04.3 OOM 风险计算 已述。关键修复:
- 必须按显示大小下采样(屏幕只有 1080,不需要 4032 的图)。
- LRU 缓存严格设上限(详见卷二·02 §7.1)。
- 不可见时及时释放(Glide / SDWebImage 自动管理)。
# 6.3 解码慢归因
解码耗时主要来自:
- 图像格式:PNG / JPEG / WebP / AVIF 解码复杂度不同。
- 图像分辨率:像素数翻倍解码时长翻倍。
- 是否硬件解码:HEIC / WebP 视设备支持。
- 是否下采样:边解码边采样(inSampleSize)比解码完再缩小快。
典型解码耗时(Android Pixel 6):
| 图片 | 大小 | JPEG | PNG | WebP | AVIF |
|---|---|---|---|---|---|
| 1080×1080 | 200KB | 25 ms | 35 ms | 40 ms | 80 ms |
| 1920×1080 | 400KB | 40 ms | 60 ms | 70 ms | 130 ms |
| 4032×3024 | 4 MB | 250 ms | 380 ms | 450 ms | 800 ms |
优化思路:
- 大图必下采样(4032 → 1080,解码耗时降 90%)。
- 主线程解码 > 16ms 必须改异步。
- 选格式时考虑设备 CPU 能力。
# 6.4 网络与缓存归因
图片下载属于网络问题(详见 卷四·02),但有一些图片专属优化:
- CDN:静态图片必上 CDN,全球分发。
- 多分辨率(srcset):根据屏幕选合适尺寸(Web 主流)。
- 渐进式加载:先低质量再高清晰(progressive JPEG)。
- 预加载:列表前 N 张提前下载。
缓存策略:
内存缓存 ──> 磁盘缓存 ──> 网络
命中率 60% 命中率 30% 命中率 10%
时长 5ms 时长 50ms 时长 500ms+
2
3
整体加载延迟期望 = 0.6×5 + 0.3×50 + 0.1×500 = 68 ms(合理目标)。
探索性思考:为什么"缓存命中率 80%"是黄金线? 因为缓存命中 = 5ms,未命中 = 500ms,相差 100 倍。80% 命中率下,平均延迟 = 0.8×5 + 0.2×500 = 104ms——足够流畅。60% 命中率下,平均 = 0.6×5 + 0.4×500 = 203ms——开始卡。所以 80% 是"流畅"和"卡"的临界。提高命中率(到 90%+)边际收益递减——平均延迟 55ms vs 104ms,用户感知差异不大。这是 §17.3 实验 "RAM/8 是甜蜜点"的根因。
# 07.下载层全链路 ⭐
本章把图片下载从"网络发请求"一路拆到"字节进入解码器",回答:CDN 多分辨率为何是关键 / 渐进式 JPEG 为什么省感知 / 预取边界在哪。
# 7.1 下载阶段的物理本质
客户端发起请求
│
▼
DNS / TCP / TLS 建立连接(详见卷四·02)
│
▼
HTTP GET image.jpg → 服务端
│
▼
服务端响应(CDN 边缘 / 源站)
│
├─ 200 → 字节流返回
│
└─ 304 → 缓存命中
│
▼
客户端缓冲数据 → 进入解码器
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
两个关键事实:
- 下载阶段被网络主导——RTT + 文件大小 / 带宽。
- 下载完成 ≠ 显示完成——还要解码 + 转换 + 上传 GPU。
# 7.2 CDN 的工程价值
CDN(Content Delivery Network)的核心是边缘缓存 + 全球分发:
用户(北京)
│
▼
北京 CDN 节点(边缘)
│
├─ 命中 → 5-20ms 返回
│
└─ 未命中 → 回源(上海源站,30ms RTT)
│
▼
缓存到北京边缘 + 返回用户
2
3
4
5
6
7
8
9
10
11
收益层级:
- 首次访问 = 边缘 RTT + 源站 RTT + 文件大小 / 带宽。
- 后续访问 = 边缘 RTT + 文件大小 / 带宽。
- 越近 RTT 越低 —— 这是 CDN 的物理优势。
多分辨率服务:CDN 支持 URL 参数自动 resize:
原图 URL:https://cdn.com/images/photo.jpg
小图 URL:https://cdn.com/images/photo.jpg?w=200&h=200
WebP 版本:https://cdn.com/images/photo.jpg?w=200&fmt=webp
2
3
收益:§02 案例 单屏流量 4.5MB→0.12MB(-97%)。
# 7.3 渐进式 JPEG 的视觉妙处
传统 JPEG(baseline):
下载 0% → 0% 显示
下载 50% → 50% 显示
下载 100% → 100% 显示
渐进式 JPEG:
下载 10% → 完整图(模糊版)
下载 30% → 完整图(中等清晰)
下载 100% → 完整图(高清晰)
2
3
4
5
6
7
8
9
用户感知:
- 传统 JPEG:"下载 80% 了还看不到东西"。
- 渐进式 JPEG:"已经能看到大概了,正在变清晰"。
视觉感知延迟可降 60%——即使物理时长相同。
实现:
- 服务端生成时勾选"progressive"。
- 客户端默认支持(Glide / SDWebImage)。
# 7.4 预取与并发控制
用户行为可预测——列表 → 详情 80% 概率,所以可以"提前下载":
// 列表滑到第 5 个时预取后 3 个
recyclerView.addOnScrollListener(object : OnScrollListener() {
override fun onScrollStateChanged(rv: RecyclerView, state: Int) {
if (state == SCROLL_STATE_IDLE) {
val last = (rv.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
(last + 1..last + 3).forEach { i ->
items.getOrNull(i)?.let {
Glide.with(rv.context).load(it.imageUrl).preload()
}
}
}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
预取的双刃剑:
- ✅ 命中预取 → 详情页打开延迟 -70%。
- ❌ 没命中预取 → 浪费流量。
应对:
- 限制并发数(2-3 个):避免抢业务带宽。
- 限制大小(< 1MB/张):避免大文件浪费。
- 空闲时段预取:滚动停止后才预取。
- WiFi 优先:4G/5G 用户对流量敏感。
探索性思考:为什么"预取"在国内 App 比国外更常见? 因为国内用户对"加载等待"的容忍度低——APP 数据显示首屏延迟 > 1s 流失率明显。预取的本质是"以服务端流量换前端体感"——服务端多发了 30% 流量,但用户体验提升明显。国外应用更注重流量节省(很多用户按流量计费),所以预取不那么激进。这是地域文化对工程设计的影响。
# 08.解码层全链路 ⭐
本章把图片解码从"压缩字节"一路拆到"Bitmap 对象",回答:inSampleSize 工作原理 / 为何边解码边采样比解码完再缩小快 / 硬件解码的局限。
# 8.1 解码阶段的物理本质
字节流(JPEG/PNG/WebP)
│
▼
解码器(CPU / 硬件)
├─ 解析头部(尺寸、格式)
├─ 解压(反向 DCT / Huffman 等)
├─ 转 RGB 像素阵列
│
▼
Bitmap 对象(W × H × 4 字节)
2
3
4
5
6
7
8
9
10
关键事实:
- 解码是 CPU 重活 —— 4032×3024 大图解码 250ms+。
- 解码后内存 = W × H × 4 —— 与文件大小无关,只与分辨率有关。
- 解码可以"边解边采样" —— inSampleSize 是核心优化。
# 8.2 inSampleSize 工作原理
原图 4032×3024
│
▼ inSampleSize=1
Bitmap 4032×3024(47MB)
原图 4032×3024
│
▼ inSampleSize=2
解码时直接跳过一半像素 → Bitmap 2016×1512(11.6MB)
原图 4032×3024
│
▼ inSampleSize=8
解码时直接跳过 8/9 像素 → Bitmap 504×378(0.7MB)
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么"边解边采样"比"解码完再缩小"快:
- 边解边采样:解码器只解码需要的像素 → CPU 少做 N 倍工作。
- 解码完再缩小:先生成 47MB 大 Bitmap → 再缩小(额外 CPU + 内存峰值)。
§17.1 实验 证明:4032×3024 大图按 300×300 显示时下采样能节省 90%+ 内存与解码时长。
# 8.3 异步解码与线程池
主线程解码 250ms = 卡 15 帧(60Hz)。必须异步:
val cpuCount = Runtime.getRuntime().availableProcessors()
val decodeExecutor = ThreadPoolExecutor(
cpuCount, cpuCount, 30L, TimeUnit.SECONDS,
PriorityBlockingQueue() // 优先级队列:可见 view 优先
)
2
3
4
5
线程池大小为何 = CPU 核心数:
- 解码是 CPU 密集任务。
- 超过核心数 → 上下文切换抢 CPU → 反而慢。
- 等于核心数 → 充分利用 CPU 不抢。
优先级调度:可见 view 的解码必须优先——否则用户看到的图先到,但被滚走的图却抢资源。
§17.4 实验 证明:独立解码线程池 + 优先级让中低端机稳定 60fps。
# 8.4 硬件解码
部分格式(HEIC / WebP / AVIF)支持硬件解码:
| 格式 | 硬件解码支持 | 效果 |
|---|---|---|
| JPEG | 大部分 GPU | 与软件相当(已优化) |
| PNG | 部分 | 一般 |
| WebP | 新设备部分 | 减 30% CPU |
| HEIC | iOS 全支持 | 接近 JPEG(30ms vs 25ms) |
| AVIF | 极少设备 | 慢 3 倍(大部分软件解码) |
硬件解码的局限:
- 并发限制:通常只有 1-2 个硬件解码器,并发解码时仍走软件。
- 格式限制:必须是设备支持的格式。
- Android 碎片化:不同芯片支持不同。
# 8.5 Bitmap 复用池(inBitmap)
传统流程:
每次 decode → 新建 Bitmap → 用完释放 → GC
inBitmap 流程:
池中找一个尺寸匹配的旧 Bitmap → 复用其内存 → 解码到这块内存 → 避免新建 + GC
2
3
4
5
复用要求:
- 尺寸完全一致(Android 4.4+ 放宽:内存能装下即可)。
- Config 完全一致(ARGB_8888 vs RGB_565)。
§17.5 实验 证明:内存峰值 -40%、GC -60%。
探索性思考:为什么 Android 8+ Bitmap 复用收益降低? 因为 Android 8+ Bitmap 移到 Native Heap——不再受 Java Heap 限制。好处:原本 256MB Java Heap 限制不再卡 Bitmap,OOM 风险大降。坏处:Native Heap 没有 GC 自动管理,Bitmap 必须手动释放或靠 finalizer。复用收益相对降低——但仍有"减少 native 内存分配抖动"的价值。这是 Android 平台演进对图片库的影响——Glide / Coil 都根据版本调整策略。
# 09.缓存层全链路 ⭐
本章把图片缓存从"LRU 算法"一路拆到"磁盘缓存策略",回答:为什么缓存大小 = RAM/8 / 按字节算 LRU 是关键 / 磁盘缓存怎么和内存配合。
# 9.1 三层缓存模型
┌──────────────┐
│ 内存缓存 │ 命中 5ms,容量 50MB
├──────────────┤
│ 磁盘缓存 │ 命中 50ms,容量 200MB
├──────────────┤
│ 网络 │ 命中 500ms+,容量无限
└──────────────┘
2
3
4
5
6
7
三层是补集关系:内存命中率 60% + 磁盘 30% + 网络 10%。
整体加载延迟期望 = 0.6×5 + 0.3×50 + 0.1×500 = 68 ms(合理目标)。
# 9.2 LRU 算法的工作原理
LRU(Least Recently Used)= 淘汰最久未使用的:
缓存:[A, B, C, D](A 最久未用,D 刚用)
│
▼ 新加 E(缓存满)
缓存:[B, C, D, E](A 被淘汰)
2
3
4
Android LruCache 实现:
val cache = object : LruCache<String, Bitmap>(maxSizeKB) {
override fun sizeOf(key: String, value: Bitmap): Int {
return value.byteCount / 1024 // KB
}
}
2
3
4
5
# 9.3 按字节而非按数量算 LRU
§02 案例 经验派的陷阱:50MB 缓存按"100 张图"算 LRU——一张原图 47MB 能塞 1 张就满。必须按字节算。
// ❌ 错误:按数量算
private val cache = LruCache<String, Bitmap>(100) // 100 张
// ✅ 正确:按字节算
private val cache = object : LruCache<String, Bitmap>(maxSizeKB) {
override fun sizeOf(key: String, value: Bitmap): Int {
return value.byteCount / 1024 // KB
}
}
2
3
4
5
6
7
8
9
# 9.4 缓存大小的科学化
§17.3 实验 证明:图片内存缓存大小 = 设备总 RAM / 8 是合理的默认值。
val maxMemoryKB = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSizeKB = maxMemoryKB / 8
GlideBuilder().setMemoryCache(LruResourceCache(cacheSizeKB.toLong() * 1024))
2
3
为什么是 1/8:
- 1/4 太大 → 应用本身没内存用了。
- 1/16 太小 → 命中率不到 60%。
- 1/8 是命中率 80%+ 与内存平衡的甜蜜点。
# 9.5 磁盘缓存策略
磁盘缓存的妙处:
- 容量大(200MB+)。
- 系统可清理(不会 OOM)。
- 跨应用启动持久化。
Glide 默认 250MB 磁盘缓存——已经够用。
磁盘缓存键设计:
key = url + "?w=200&h=200"
不同尺寸版本各自缓存:
- photo.jpg?w=200 → 30KB
- photo.jpg?w=400 → 80KB
- photo.jpg?w=800 → 200KB
2
3
4
5
6
# 9.6 onTrimMemory 主动释放
override fun onTrimMemory(level: Int) {
when (level) {
TRIM_MEMORY_RUNNING_CRITICAL,
TRIM_MEMORY_BACKGROUND -> Glide.get(this).clearMemory()
TRIM_MEMORY_MODERATE -> Glide.get(this).trimMemory(level)
}
}
2
3
4
5
6
7
收益:极端内存场景主动让出,避免被系统杀。
探索性思考:为什么"清缓存"不能解决 OOM? 因为 OOM 的根因往往不是缓存——是单图过大(§02 案例 47MB 单图)。缓存只是"已经用过的图",OOM 发生在"正在解码新图"时。所以"清缓存"反而让下次加载更慢(要重新解码),而 OOM 风险还在。真正治 OOM 是控制单图大小(下采样)和并发解码数——不是缩缓存。
# 10.显示层全链路 ⭐
本章把图片显示从"Bitmap 对象"一路拆到"屏幕像素",回答:Bitmap 怎么变成 GPU Texture / Hardware Bitmap 的物理优势 / 不可见 view 为何要主动释放。
# 10.1 显示阶段的物理本质
Bitmap 对象(CPU 内存)
│
▼
Texture 上传(CPU → GPU)
│
▼
GPU 渲染管线
├─ 顶点着色(位置)
├─ 像素着色(颜色)
│
▼
屏幕像素(合成)
2
3
4
5
6
7
8
9
10
11
12
两个关键开销:
- CPU → GPU 上传:每次首次显示都要上传,约 5-20ms。
- GPU 内存占用:每张显示中的图都在 GPU 内存中。
# 10.2 Hardware Bitmap(Android 8+)
传统流程:
Bitmap(CPU 内存)→ 上传到 GPU → 显示
每次显示都要拷贝
2
Hardware Bitmap:
Bitmap 直接在 GPU 内存里 → 不需要拷贝
收益:
- 渲染阶段 GPU 上传时间 -30%。
- CPU 内存占用降低(图在 GPU 内存)。
- Glide 默认开启(API 26+)。
局限:
- 不能修改像素(要修改时改回 software bitmap)。
- 某些滤镜/特效不支持。
# 10.3 不可见 view 的释放
// RecyclerView ViewHolder onViewRecycled
override fun onViewRecycled(holder: VH) {
Glide.with(holder.itemView.context).clear(holder.imageView)
}
2
3
4
为什么必须释放:
- 列表快速滑动时,加载中的图可能 view 已滚出。
- 继续解码 = 浪费 CPU(图都看不到了)。
- 占用线程池槽位 → 可见 view 的解码被阻塞。
clear 后再次显示的代价:从内存缓存重新拿 → 5ms(可接受)。
# 10.4 lifecycle-aware 自动释放
// Glide 推荐用法 - 自动绑定 lifecycle
Glide.with(this) // 传 fragment/activity
.load(url)
.into(imageView)
2
3
4
Glide 的 lifecycle 绑定:
- Activity onPause → 暂停加载。
- Activity onStop → 取消加载。
- Activity onDestroy → 释放所有引用。
收益:避免 Activity 泄漏拖累图片不释放。
# 10.5 占位与骨架屏
用户感知延迟 = 数据加载延迟 - 占位图存在时间。
Glide.with(context)
.load(url)
.placeholder(R.drawable.skeleton) // 加载中显示骨架
.error(R.drawable.error_placeholder) // 失败显示错误图
.into(imageView)
2
3
4
5
收益:用户主观流畅度 +30%(即使物理时长不变)。
探索性思考:为什么"占位图本身"也需要优化? 因为占位图每张 view 都用——如果 5KB 的骨架图被 100 张 view 用,就是 500KB 内存。所以占位图必须:
- 极小(< 5KB)。
- 简单(少色 / 矢量)。
- 系统级单例(一份骨架图所有 view 共享)。
不要用相同 .9.png 给每张 view ——会被解码 N 次。这是占位图陷阱。
# 11.格式选择全链路 ⭐
本章把图片格式从"JPEG"一路拆到"AVIF",回答:WebP 为什么省 35% / HEIC 在 iOS 为什么硬件加速 / AVIF 什么时候能取代 WebP。
# 11.1 主流格式对比
| 格式 | 压缩率 | 解码速度 | 透明度 | 动图 | 兼容性 | 推荐场景 |
|---|---|---|---|---|---|---|
| JPEG | 中(基线) | 快 | ❌ | ❌ | 全平台 | 照片、不需要透明 |
| PNG | 低 | 中 | ✅ | ❌ | 全平台 | 图标、UI 元素 |
| GIF | 低 | 快 | ✅(1bit) | ✅ | 全平台 | 老式动图 |
| WebP | 高(-35%) | 中(+50%) | ✅ | ✅ | Android 4+/iOS 14+ | 大部分场景 |
| HEIC | 极高(-50%) | 快(硬件) | ✅ | ✅ | iOS 11+/部分 Android | iOS 优先 |
| AVIF | 极高(-65%) | 慢(+220%) | ✅ | ✅ | 普及中 | 流量优先 |
# 11.2 Accept 协商机制
服务端根据客户端 Accept 头返回最优格式:
GET /image.jpg HTTP/1.1
Accept: image/avif, image/webp, image/jpeg
2
HTTP/1.1 200 OK
Content-Type: image/webp
2
收益:
- 支持 WebP 的设备返回 WebP(流量 -35%)。
- 不支持的返回 JPEG(兼容)。
- 完全自动,客户端无感。
# 11.3 格式选择的决策树
图片是什么类型?
│
├─ 图标/UI(< 50KB)
│ └─ PNG(无损 + 透明)
│
├─ 照片
│ ├─ iOS 端 → HEIC(硬件解码)
│ └─ 其他 → WebP
│
├─ 动图
│ ├─ 简单 → GIF
│ └─ 复杂 → WebP / APNG
│
└─ 流量极敏感
└─ AVIF(如果设备支持)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 11.4 现代格式的渐进采纳
WebP(已成熟):
- 覆盖率:Android 4+ / iOS 14+ → 95%+ 设备。
- 推荐:默认上 WebP。
HEIC(iOS 主导):
- 覆盖率:iOS 11+ 完整支持,Android 部分。
- 推荐:iOS 端优先,Android 端 fallback WebP。
AVIF(未来主流):
- 覆盖率:Chrome 85+ / Firefox 93+ / Safari 16.4+。
- 推荐:Web 已可用;移动端等普及。
探索性思考:为什么图片格式更新这么慢? 因为图片格式涉及整个生态链——浏览器、操作系统、硬件解码、内容分发网络都需要支持。JPEG 是 1992 年诞生的格式,至今仍是主流——更替成本太高。WebP 用了 10+ 年才达到 95% 覆盖。AVIF 还需要 5-10 年。所以当下最实用是"WebP + JPEG fallback"——而不是激进上 AVIF。
# 12.跨端图片对照
# 12.1 端到端流程对照表
| 阶段 | Android | iOS | Web |
|---|---|---|---|
| 下载 | OkHttp / 加载库内置 | URLSession / 加载库内置 | 浏览器 fetch |
| 解码器 | BitmapFactory / Skia | UIImage / Core Graphics | 浏览器内置 |
| 下采样 | inSampleSize | UIGraphicsImageRenderer | srcset / sizes |
| 内存缓存 | LruCache | NSCache | Cache API |
| 磁盘缓存 | DiskLruCache | URLCache | Service Worker |
| 显示 | ImageView + GPU | UIImageView + Core Animation | <img> + GPU |
# 12.2 加载库对照
| 维度 | Android | iOS | Web |
|---|---|---|---|
| 主流库 | Glide / Coil / Picasso | SDWebImage / Kingfisher | lazy loading + img tag |
| 推荐 | Glide(功能全)/ Coil(Kotlin) | SDWebImage(生态广) | Native lazy loading |
| 配置工作量 | 中 | 中 | 低 |
| 自定义能力 | 极强 | 强 | 弱(浏览器决定) |
# 12.3 平台特异点
| 平台 | 特异点 |
|---|---|
| Android | Bitmap 在 Native Heap(8.0+);硬件加速碎片化 |
| iOS | UIImage(named:) 系统级缓存;HEIC 硬件解码 |
| Web | 浏览器自动 lazy loading;srcset 响应式 |
# 12.4 统一启示
- 下采样跨端通用:所有平台都支持。
- 现代格式跨端通用:WebP 在所有主流平台都可用。
- 缓存策略跨端通用:LRU + 三层缓存模型。
- 加载库已成熟:不要自己实现,用 Glide / SDWebImage。
# 13.治理一层下载
本节回答四个递进问题:①如何让进入解码的字节最少?②如何让解码 CPU 与内存峰值最小?③如何让缓存与内存不爆?④如何让不可见图立即释放? §13-§16 由浅入深四层。
# 13.1 一层命题
核心命题:解码层处理的字节越多,所有下游成本就越高。第一层治理就是把"进入解码的字节"降到最低——这是上游减负关。
层级特征:
- 改造成本:中(CDN 服务端 + 客户端配合)
- 收益:极高(流量 -75% / 内存 -90%)
- 风险:中(基建依赖)
- 是否必做:所有应用
# 13.2 策略 1.1:CDN 多分辨率 + 客户端按需请求
机理:服务端按 URL 参数提供多分辨率(200/400/800/原图),客户端按 ImageView 尺寸传 ?w=200。源头解决"原图崇拜"。
做法:
// 客户端按 view 尺寸自动拼参数
fun ImageView.loadByDisplaySize(url: String) {
val w = width.takeIf { it > 0 } ?: layoutParams.width.takeIf { it > 0 } ?: 200
val finalUrl = "$url?w=$w&fmt=webp"
Glide.with(context).load(finalUrl).into(this)
}
2
3
4
5
6
收益:§02 案例 单屏流量 4.5MB→0.12MB。
边界:CDN 服务端必须支持自动 resize(如七牛/阿里 OSS / Cloudflare Images);首次访问会有 resize 延迟(之后命中边缘缓存)。
# 13.3 策略 1.2:现代格式(WebP/HEIC/AVIF)+ Accept 协商
机理:§17.2 实验 证明 WebP 省 35%;HEIC 在 iOS 有硬件解码。Accept 头自动协商。
做法:
client.newBuilder().addInterceptor { chain ->
val req = chain.request().newBuilder()
.header("Accept", "image/avif,image/webp,image/jpeg")
.build()
chain.proceed(req)
}.build()
2
3
4
5
6
收益:流量 -35-50%(覆盖 95% 设备)。
边界:服务端要支持 Accept 协商;老设备 fallback 到 JPEG;动图/透明用 WebP(不能 JPEG)。
# 13.4 策略 1.3:渐进式加载与占位策略
机理:先低质量低分辨率快速展示,再加载高清——感知延迟近 0。
做法(Glide 缩略图链式):
val thumb = Glide.with(context).load("$url?w=50&q=20")
Glide.with(context)
.load("$url?w=400")
.thumbnail(thumb) // 先显示 50px 模糊版
.into(imageView)
2
3
4
5
收益:首屏感知延迟 -60-80%。
边界:低质量缩略图也需带宽;带宽紧张场景反而拖慢。
# 13.5 策略 1.4:预取与并发控制
机理:用户行为可预测(列表 → 详情 80%)。空闲时间预取,但限制并发不抢业务带宽。
做法:§07.4 已述。
收益:详情页打开延迟 -70%。
边界:预取并发限 2-3,避免抢业务带宽;不要预取 > 1MB 图片(流量浪费)。
# 13.6 一层反思
探索性思考:为什么"CDN 多分辨率"是图片优化的"基建"? 因为它在服务端解决问题,客户端零成本——客户端只要传
?w=200,CDN 自动 resize 并缓存。多数团队在客户端反复折腾"下采样"——但根因是服务端给了 4032 的图。最理想方案是 CDN 自动协商:根据 Client Hints 头(DPR / Viewport-Width)自动返回最优尺寸+格式。这是 Web 平台的领先设计——Native 端可参考。
# 14.治理二层解码
# 14.1 二层命题
核心命题:解码是图片管线中 CPU 最重、内存峰值最高的环节。第二层治理就是用下采样 + 异步双引擎降到最低。
层级特征:
- 改造成本:低(加载库一行配置)
- 收益:极高(内存 -90% / FPS 60)
- 风险:低
# 14.2 策略 2.1:按目标 ImageView 尺寸下采样
机理:§17.1 实验 证明 -90% 内存与解码时长。
做法(Glide):
Glide.with(context)
.load(url)
.override(targetWidth, targetHeight) // 强制下采样到目标尺寸
.into(imageView)
2
3
4
做法(原生 BitmapFactory):
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, options);
options.inSampleSize = calculateInSampleSize(options, targetW, targetH);
options.inJustDecodeBounds = false;
Bitmap bitmap = BitmapFactory.decodeFile(path, options);
2
3
4
5
6
收益:§02 案例 内存峰值 1.2GB→180MB(-85%),是案例最大杠杆。
边界:下采样过度(> 4x)质量下降明显;动态尺寸场景需 view post 后再拿到准确尺寸。
# 14.3 策略 2.2:异步解码 + 优先级调度
机理:§17.4 实验 证明独立线程池 + 优先级让中低端机稳定 60fps。
做法:
val cpuCount = Runtime.getRuntime().availableProcessors()
val decodeExecutor = ThreadPoolExecutor(
cpuCount, cpuCount, 30L, TimeUnit.SECONDS,
PriorityBlockingQueue() // 优先级队列:可见 view 优先
)
GlideBuilder().setSourceExecutor(GlideExecutor.newSourceBuilder()
.setThreadCount(cpuCount).build())
2
3
4
5
6
7
8
收益:滑动 P99 38ms→18ms,掉帧率 < 1%。
边界:线程池过大反而引入调度抖动;优先级反转防护(低优先级请求 5s 后强制提升)。
# 14.4 策略 2.3:硬件解码格式优先
机理:HEIC 在 iOS 有硬件解码与 JPEG 相当;WebP 在新硬件支持解码加速。
做法(iOS):
// HEIC 自动走硬件
let image = UIImage(data: heicData)
2
收益:iOS 端 HEIC 解码与 JPEG 相当(30ms vs 25ms)但流量 -40%。
边界:硬件解码器有并发限制(通常 1-2 个),并发解码时仍走软件。
# 14.5 策略 2.4:Bitmap 复用池(inBitmap)
机理:§17.5 实验 内存峰值 -40%、GC -60%。
做法:Glide / SDWebImage 默认开启即可。
收益:高频列表场景必备。
边界:尺寸 + Config 必须严格匹配;动态尺寸场景命中率下降。
# 14.6 二层反思
探索性思考:为什么"下采样"是被反复强调但仍被忽视的优化? 因为它违反工程师的"完美主义"直觉——"明明有 4032 的清晰图,为什么要降到 200?"。但用户根本看不出 200×200 ImageView 显示 200 的图和 4032 的图差别——同时内存差 130 倍。这是工程师的认知盲点:内存对人不可见。直到 OOM 暴雷才惊觉。下采样的核心思想 = "按需精度"——视觉够用即可,不要为不可见的精度付出代价。
# 15.治理三层缓存
# 15.1 三层命题
核心命题:§02 案例 第 1 周翻车的根因——把缓存当成 OOM 元凶反而引发更多 OOM。第三层治理是用对缓存策略防 OOM 又保命中率。
层级特征:
- 改造成本:低(一行配置)
- 收益:高(命中率 80%+)
- 风险:低
# 15.2 策略 3.1:缓存大小动态计算
机理:§17.3 实验 证明 RAM/8 是甜蜜点。
做法:
val maxMemoryKB = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSizeKB = maxMemoryKB / 8
GlideBuilder().setMemoryCache(LruResourceCache(cacheSizeKB.toLong() * 1024))
2
3
收益:不同档机型自适应,命中率 80%+。
边界:低端机仍可能不够,需配合磁盘缓存兜底。
# 15.3 策略 3.2:按字节而非按数量算 LRU
机理:§09.3。§02 案例 经验派 50MB 缓存按"100 张图"算 LRU——一张原图 47MB 能塞 1 张就满。必须按字节算。
做法:
val cache = object : LruCache<String, Bitmap>(maxSizeKB) {
override fun sizeOf(key: String, value: Bitmap): Int {
return value.byteCount / 1024 // KB
}
}
2
3
4
5
收益:缓存利用率从 18% 升到 75%+。
边界:必须正确实现 sizeOf,否则 LRU 机制失效。
# 15.4 策略 3.3:磁盘缓存补强
机理:内存缓存 50MB 命中率 87%;磁盘缓存 200MB 可补到 95%+。磁盘缓存成本极低。
做法:Glide 默认 250MB 磁盘缓存。
收益:第二次访问近 0 延迟;省流量。
边界:磁盘空间紧张时被系统清理;用户清缓存时丢失。
# 15.5 策略 3.4:onTrimMemory 主动释放
机理:系统报内存压力时主动让出,避免触发被杀。
做法:
override fun onTrimMemory(level: Int) {
when (level) {
TRIM_MEMORY_RUNNING_CRITICAL,
TRIM_MEMORY_BACKGROUND -> Glide.get(this).clearMemory()
TRIM_MEMORY_MODERATE -> Glide.get(this).trimMemory(level)
}
}
2
3
4
5
6
7
收益:卷二·02 §6 内存治理篇有详述。
边界:clearMemory 后下一次加载需重新解码,可能引发短暂卡顿。
# 15.6 三层反思
探索性思考:为什么"缩小缓存治 OOM"是反模式? 因为它搞错了 OOM 的根因——OOM 是"单图过大"或"并发解码数过多",不是"缓存过大"。缩小缓存导致:
- 频繁淘汰 → 命中率下降。
- 重复解码 → CPU 大涨。
- 每次解码都新建 Bitmap → 内存峰值更高(瞬时分配 + GC 压力)。
正确的方向是减小单图(下采样)+ 控制并发解码数,让缓存自然变小。这是 §02 案例 经验派第 1 周翻车的根因——治错了方向。
# 16.治理四层显示
# 16.1 四层命题
核心命题:图片管线的最后一站——显示。第四层治理是让"不可见 view"立即释放,让"可见 view"丝滑显示。
层级特征:
- 改造成本:低(加载库内置)
- 收益:中(CPU -30% / 主观流畅 +30%)
- 风险:低
# 16.2 策略 4.1:滚出屏幕的 view 立即取消加载
机理:列表快速滑动时,加载中的图可能 view 已滚出。继续加载浪费 CPU 和内存。
做法:
// RecyclerView ViewHolder onViewRecycled
override fun onViewRecycled(holder: VH) {
Glide.with(holder.itemView.context).clear(holder.imageView)
}
2
3
4
收益:快速滑动场景 CPU -30%。
边界:clear 后用户回滑需重新加载(命中内存缓存仍快)。
# 16.3 策略 4.2:lifecycle-aware 自动释放
机理:Activity/Fragment 销毁时图片应自动释放。
做法:
// Glide 推荐用法 - 自动绑定 lifecycle
Glide.with(this) // 传 fragment/activity
.load(url)
.into(imageView)
2
3
4
收益:避免 Activity 泄漏拖累图片不释放。
边界:自定义 ImageView 子类要正确实现 onDetachedFromWindow。
# 16.4 策略 4.3:占位与骨架屏
机理:用户感知延迟 = 数据加载延迟 - 占位图存在时间。骨架屏让"等待"不等于"白屏"。
做法:
Glide.with(context)
.load(url)
.placeholder(R.drawable.skeleton)
.error(R.drawable.error_placeholder)
.into(imageView)
2
3
4
5
收益:用户主观流畅度 +30%(即使物理时长不变)。
边界:占位图本身不能太大(< 5KB);过度复杂骨架屏反而影响首屏。
# 16.5 策略 4.4:显示阶段 GPU 优化
机理:bitmap → texture 上传是 GPU 阶段,卷三·01 渲染管线有详述。Hardware Bitmap(Android 8+)让渲染线程直接用,避免拷贝。
做法:Glide 默认开启 hardware bitmap(API 26+)。
收益:渲染阶段 GPU 上传时间 -30%。
边界:Hardware Bitmap 不能修改像素(需要时改回 software bitmap)。
# 16.6 优先级判定(ROI)
| ROI | 优化项 | 收益 | 成本 | 风险 | 对应策略 |
|---|---|---|---|---|---|
| 极高 | 列表图按尺寸下采样 | 内存 -90%、解码 -80% | 1 周 | 低 | §14.2 |
| 极高 | CDN 多分辨率 + ?w 参数 | 流量 -75%、首屏快 | 2-3 周 | 中(基建) | §13.2 |
| 极高 | 缓存按字节算 LRU + RAM/8 | 命中率 +60%、OOM 大降 | 几天 | 低 | §15.2 + §15.3 |
| 高 | WebP 替代 JPEG(含 fallback) | 流量 -35% | 2-3 周 | 低 | §13.3 |
| 高 | 异步解码 + 优先级调度 | 中低端 60fps | 1-2 周 | 中 | §14.3 |
| 高 | Bitmap 复用池 | 内存峰值 -40% | 几天(用 Glide) | 低 | §14.5 |
| 高 | onViewRecycled clear | 滑动 CPU -30% | 1 天 | 低 | §16.2 |
| 中 | iOS HEIC 优先 | iOS 流量 -30% | 1 周 | 低 | §13.3 |
| 中 | 渐进式 + 缩略图 | 感知延迟 -60% | 1 周 | 低 | §13.4 |
| 中 | onTrimMemory 主动释放 | 极端场景防杀 | 几天 | 低 | §15.5 |
| 中 | 预取 + 并发控制 | 详情页打开 -70% | 1-2 周 | 中 | §13.5 |
| 中 | 磁盘缓存补强 | 命中率 -> 95%+ | 几天 | 低 | §15.4 |
| 低 | AVIF 渐进增强 | 部分设备 -50% | 1-2 周 | 中(兼容性) | - |
| 极低 | 自实现解码器 | 几乎无收益 | 极高 | 极高 | - |
避免反向收益:
- 过度下采样:图片模糊用户投诉(§02 案例 第 4 周 RGB_565 翻车)。
- 缩小缓存治 OOM:反而频繁淘汰造成重复解码(§02 案例 第 1 周翻车)。
- 改 PNG 防失真:§02 案例 第 2 周翻车,流量翻倍。
- 强制 AVIF:老设备解码慢反而拖累体验。
- try-catch 包住解码:异常被吞但内存峰值还在(§02 案例 第 3 周翻车)。
# 16.7 四层反思
探索性思考:为什么"显示层"优化看起来"小"但累积起来很大? 因为它涉及几百张图、每张几 ms——单次看不大,但快速滑动 60s 累积起来就是节省几秒 CPU 时间。这种"长尾累积"型优化的特点是:
- 单点测不出——加 onViewRecycled clear 看不到单图收益。
- 整体体感明显——快速滑动的卡顿感消失。
- 必须配合监控——线下用 Profiler 看 CPU 累积曲线。
这是性能优化的"小步快跑"哲学——每一步收益小,但累积起来定生死。
# 17.求证实验
# 17.1 实验一:下采样收益
Step 1 — 原始观察
工程师都知道"图片要按显示大小加载",但到底节省多少?
Step 2 — 提出疑问
同一张 4032×3024 大图,加载到不同 ImageView 尺寸(300×300 / 1080×1080)下,下采样能节省多少内存与解码时间?
Step 3 — 形成假设
H₁:下采样到目标尺寸能让内存与解码时长降到 1/N²(N=采样比例)。 H₀:下采样无显著收益(解码完再缩放等价)。
Step 4 — 数学推导
- 不下采样:解码全图 4032×3024 → 47MB Bitmap → 解码 250ms → 再缩放到 300×300。
- 下采样 8(inSampleSize=8):解码到 504×378 → 0.7MB → 解码 30ms。
Step 5 — 设计实验
| 项 | 配置 |
|---|---|
| 测试图 | 4032×3024 JPEG(4MB) |
| 目标尺寸 | 300×300 / 1080×1080 |
| 实现 A | 不下采样 |
| 实现 B | inSampleSize 自动 |
| 主指标 | 内存占用 / 解码时长 |
| 重复 | 100 次 |
Step 6 — 实测数据
| 目标 | 不下采样内存 | 下采样内存 | 不下采样解码 | 下采样解码 |
|---|---|---|---|---|
| 300×300 | 47 MB | 0.7 MB | 252 ms | 28 ms |
| 1080×1080 | 47 MB | 4.4 MB | 252 ms | 60 ms |
Step 7 — 验证 / 修正
- 内存节省 91%(300×300 显示)/ 91%(1080×1080 显示)。
- 解码时间节省 89% / 76%。
- 验证 H₁。
Step 8 — 提炼结论
下采样是图片优化最高 ROI 的手段。 4032×3024 大图按 300×300 显示时下采样能节省 90%+ 内存与解码时长。
工程意义:
- 必须按目标 ImageView 尺寸采样。
- 服务端最好提供多分辨率(CDN 自动 resize)。
- 只在原图查看时不采样。
Step 9 — 边界
- 下采样会损失质量(4 倍以上时质量明显下降)。
- 渐进式 JPEG 配合下采样有特殊优化。
- HEIC / AVIF 的下采样实现各平台不一致。
▶▶ 回扣 §02 案例:本实验"4032×3024 给 300×300 显示能省 91% 内存"在那个真实场景中变现为"OOM 8.7%→0.4%"。
# 17.2 实验二:格式对比
Step 1 — 原始观察
WebP / AVIF / HEIC 都被宣传"省 30-50%"。到底差异多大?该选哪个?
Step 2 — 提出疑问
同一图片用不同格式编码,体积、解码时长、视觉差异如何?
Step 3 — 设计实验
| 项 | 配置 |
|---|---|
| 测试图集 | 1000 张多类型图片(人像 / 风景 / 截图 / 图表) |
| 编码 | JPEG 80% / WebP 80% / AVIF 65% / HEIC 65% |
| 显示分辨率 | 1080×1080 |
| 主指标 | (a) 体积比 (b) 解码耗时 (c) SSIM 结构相似度 |
Step 4 — 实测数据
| 格式 | 体积比 | 解码时长(ms) | SSIM |
|---|---|---|---|
| JPEG 80% | 100% | 25 | 0.95 |
| WebP 80% | 65% | 40 | 0.95 |
| AVIF 65% | 35% | 80 | 0.95 |
| HEIC 65% | 40% | 30(硬件加速)/ 80(软件) | 0.95 |
Step 5-6 — 验证 / 结论
格式选择是"带宽 vs CPU"的权衡。 WebP 是当前最稳的选择(省 35% 带宽 + 中度解码慢)。 AVIF 适合带宽优先场景。 HEIC 在 iOS 硬件加速下接近完美。
工程意义:
- 默认 WebP(覆盖 95% 设备)。
- iOS 应优先 HEIC。
- AVIF 看 CDN 是否支持自动转换。
Step 7 — 边界
- 老设备不支持 WebP / AVIF / HEIC,需要回退 JPEG。
- 服务端按 Accept 头自动协商。
- 透明度场景必须 PNG / WebP 不能 JPEG。
# 17.3 实验三:缓存命中率
Step 1 — 原始观察
图片缓存大小怎么定?默认值合理吗?
Step 2 — 提出疑问
不同缓存大小(设备内存 1/8 / 1/16 / 1/32)下,命中率与首屏速度差异多大?
Step 3 — 设计实验
| 项 | 配置 |
|---|---|
| 设备 | 4GB RAM Pixel 6 |
| 缓存大小 | 1/8 (~50MB) / 1/16 (~25MB) / 1/32 (~12MB) |
| 用户行为 | 模拟列表浏览(500 图) |
| 主指标 | 缓存命中率 / 首屏图加载时长 |
Step 4 — 实测数据
| 缓存大小 | 命中率 | 首屏图均时长 | 内存压力 |
|---|---|---|---|
| 50 MB | 87% | 18 ms | 中 |
| 25 MB | 72% | 35 ms | 低 |
| 12 MB | 51% | 65 ms | 极低 |
Step 5-6 — 验证 / 结论
图片内存缓存大小 = 设备总 RAM / 8 是合理的默认值。 命中率 80%+ 是流畅体验的关键。
工程意义:
- 不要硬编码 50MB(高端机太小,低端机太大)。
- 用
Runtime.maxMemory()动态计算。 - 磁盘缓存可以更大(200MB+),低成本提升整体命中率。
Step 7 — 边界
- 重度图片应用(如照片墙)可能需要更大缓存。
- 内存紧张时 LruCache 会自动驱逐,无需手动管理。
- 多进程应用要注意每进程独立缓存的浪费。
# 17.4 实验四:异步解码与滚动卡顿的关系
Step 1 — 原始观察
很多团队明明用了 Glide / SDWebImage,但快速滑动时仍然掉帧。异步解码到底有没有用?
Step 2 — 提出疑问
同步解码 vs Glide 后台线程解码 vs 专用解码线程池在快速滑动场景的 FPS 差异多大?
Step 3 — 形成假设
H₁:同步解码主线程必然掉帧;Glide 默认后台解码大幅改善但仍有间歇性卡顿;专用线程池 + 优先级调度可达稳定 60fps。
Step 4 — 设计实验
| 项 | 配置 |
|---|---|
| 设备 | Pixel 6 + 红米 Note 11 |
| 场景 | 列表 30 张 200×200 thumb 快速滑动 |
| 实现 A | BitmapFactory 主线程 |
| 实现 B | Glide 默认(4 个后台线程) |
| 实现 C | 独立解码线程池 + 优先级 |
| 主指标 | 滑动时 P99 帧时长、掉帧率 |
Step 5 — 实测数据
| 实现 | Pixel 6 P99 帧时长 | Pixel 6 掉帧率 | 红米 P99 | 红米掉帧率 |
|---|---|---|---|---|
| A 主线程同步 | 280 ms | 38% | 520 ms | 62% |
| B Glide 默认 | 38 ms | 4% | 95 ms | 18% |
| C 独立线程池+优先级 | 18 ms | <1% | 28 ms | 3% |
Step 6-7 — 验证 / 结论
异步解码不是"开就好"。线程池大小 + 优先级调度才能在中低端机稳定 60fps。 默认 4 线程不够,应等于 CPU 核心数;可见 view 必须优先。
工程意义:
- Glide 配置
setBitmapPoolScreens(2.5f)和setExecutorService(...)调整线程数。 - 取消滚出屏幕的 view 加载请求(
Glide.clear(view))。 - 配合
prefetch在滑动减速时提前解码下方图片。
Step 8 — 边界
- 线程池过大(> CPU 核心数)反而引入调度抖动。
- 优先级反转风险:低优先级请求长期得不到执行。
# 17.5 实验五:Bitmap 复用池(inBitmap)的内存峰值收益
Step 1 — 原始观察
§02 案例 中下采样后内存峰值仍偶发飙高。Bitmap 复用池能进一步压平峰值吗?
Step 2 — 提出疑问
不复用 Bitmap vs Glide BitmapPool vs 自实现 inBitmap 的内存峰值差异多大?
Step 3 — 形成假设
H₁:复用可避免 GC 压力 + 减少内存分配抖动;列表场景内存峰值可降低 40-60%。
Step 4 — 设计实验
| 项 | 配置 |
|---|---|
| 场景 | 列表持续滑动 60s,每秒过 6 张图 |
| 实现 A | 不复用,每次 new Bitmap |
| 实现 B | Glide BitmapPool(默认) |
| 实现 C | 自实现 inBitmap + 严格匹配尺寸 |
| 主指标 | 内存峰值、GC 次数、滑动 P99 |
Step 5 — 实测数据
| 实现 | 内存峰值 | GC 次数(60s) | 滑动 P99 |
|---|---|---|---|
| A 不复用 | 320 MB | 47 | 38 ms |
| B Glide BitmapPool | 195 MB | 18 | 24 ms |
| C 自实现 inBitmap | 165 MB | 12 | 19 ms |
Step 6-7 — 验证 / 结论
Bitmap 复用是高频列表场景的必备项。Glide BitmapPool 默认实现已足够好,不必自实现 inBitmap。
工程意义:
- Android 11+ 用
Glide.with()的 BitmapPool(默认开启)。 - iOS 用 SDWebImage / Kingfisher 自带的复用机制。
- 注意:复用要求尺寸 + Config 完全一致,多种 size 同时存在时复用率会下降。
Step 8 — 边界
- Android 8+ Bitmap 在 Native Heap,复用收益相对降低(但仍有 GC 收益)。
- 复用要求严格的尺寸匹配;动态尺寸场景需多个池。
# 17.6 五大实验启示
下采样收益 → -90% 内存与解码时长 ─┐
│
格式对比 → WebP 省 35% 带宽稳赢 │
│
缓存大小 → 设备 RAM/8,命中率 80%+ ├─▶ 图片优化 = 减小 + 选对 + 缓存 + 异步 + 复用
│
异步解码 + 优先级 → 中低端机稳定 60fps │
│
Bitmap 复用 → 内存峰值 -40%,GC -60% ─┘
2
3
4
5
6
7
8
9
统一启示:
- 下采样是 ROI 最高的优化:从根源减少所有阶段的开销。
- 格式选择有权衡:没有"全胜"格式,按场景选。
- 缓存有最优大小:超过后边际收益递减。
- 异步不只是开关:线程池大小 + 优先级调度才能稳定 60fps。
- 复用是高频场景必备:内存峰值 -40%、GC -60%。
# 18.实战案例
# 18.1 跨端同构案例:CDN 多分辨率
背景:某电商列表页图片加载慢,OOM 率高。Android / iOS / Web 都有问题。
度量与归因:
三端共同特征:
- 服务端只有原图(高分辨率)。
- 客户端不下采样。
- 没有 CDN 加速。
假设与求证:
提出统一假设:"服务端按需提供分辨率 + CDN + 客户端按目标尺寸请求"。
设计:
- 服务端:图片接入 CDN,提供多分辨率自动 resize。
- 客户端:根据 ImageView 尺寸传 ?w=300&h=300。
修复:三端统一图片接入层。
验证:
| 平台 | 优化前流量 | 优化后流量 | OOM 率降幅 |
|---|---|---|---|
| Android | 3.2 MB / 列表 | 0.8 MB / 列表 | -60% |
| iOS | 2.8 MB | 0.7 MB | -50% |
| Web | 4.5 MB | 1.0 MB | N/A |
统一启示:图片优化跨端通用法则 = CDN + 多分辨率 + 客户端按需请求。
# 18.2 平台特异案例:iOS UIImage 缓存陷阱
背景:iOS 应用某页面 5 张原图加载后内存涨 200MB+,jetsam 杀。
现象:单图原始分辨率 4032×3024(手机相机 12MP)。
度量与归因:
UIImage(named:) 加载会缓存到内存(即使 imageView 已经销毁),多张大图叠加 OOM。
假设与求证:
假设:必须用 UIImage(contentsOfFile:) 替代 UIImage(named:),避免系统缓存。
实验:替换后内存峰值从 230MB 降到 70MB。
修复:
- 大图用 contentsOfFile(不缓存)。
- 配合 UIGraphicsImageRenderer 下采样到 1080×1080。
验证:jetsam 杀率从 0.2% 降到 0.04%。
边界:常用小图仍可用 UIImage(named:)(系统缓存对小图是收益)。
# 18.3 异步解码案例:社交 App 朋友圈瀑布流
背景:某社交 App 朋友圈瀑布流(多列+不定高+多图)滑动时严重掉帧。Pixel 6 P99 帧时长 60ms,掉帧率 25%。
现象:每 item 1-9 图,单 item 解码 50-300ms。
度量与归因:
- Glide 默认 4 线程。
- 9 图 item 进视野时,4 线程满载,剩下 5 张图等队列。
- 等待中 view 已滚动 → 优先级反转。
假设与求证:
假设:独立解码线程池 + 优先级调度(可见 view 优先)。
实验:
val cpuCount = Runtime.getRuntime().availableProcessors()
GlideBuilder().setSourceExecutor(
GlideExecutor.newSourceBuilder()
.setThreadCount(cpuCount) // 8 线程
.setName("glide-source")
.build()
)
// 优先级队列:可见 view 优先
holder.itemView.addOnAttachStateChangeListener(...)
2
3
4
5
6
7
8
9
10
修复:线程数 4→8,优先级调度。
验证:P99 帧时长 60ms→18ms,掉帧率 25%→2%。
教训:异步解码不是"配了就行"——线程数和优先级都要按场景调。
# 18.4 案例统一启示
- 数据驱动而非经验:§02 案例 4 周失败 vs 5 天成功的根本差异。
- 下采样是首选优化:跨端通用、零侵入、收益最大。
- 现代格式 + Accept:流量 -35% 几乎免费。
- 异步线程池要按场景调:默认值不一定最优。
# 19.防劣化体系
# 19.1 三道防线总览
开发期 ──▶ 编译期 / CI ──▶ 上线期 / 运行期
│ │ │
▼ ▼ ▼
[Lint] [自动化基准] [线上 SLO]
2
3
4
# 19.2 编码期 Lint
| Lint 规则 | 作用 |
|---|---|
LargeImageNoDownsample | 加载 > 1MB 图片不下采样 → 警告 |
MainThreadDecode | 主线程同步解码 → 错误 |
FixedCacheSize | 设置图片缓存固定大小(不动态) → 警告 |
LargeAssetImage | 资产中包含 > 200KB 单图 → 警告 |
MissingImageError | 加载图片未设 error → 警告 |
# 19.3 CI 卡口与线上 SLO
CI 卡口:
- 资产打包时检查图片大小(限单图 < 200KB)。
- 列表加载基准:图片加载 P95 < 500ms。
- 内存基准:列表持续滚动 60s,PSS 增长 < 50MB。
线上 SLO:
| 指标 | 阈值 |
|---|---|
| 图片加载 P50 | < 200ms |
| 图片加载 P99 | < 2s |
| 图片缓存命中率 | > 80% |
| 图片 OOM 占比(OOM 中) | < 5% |
| 图片解码主线程占比 | < 5% |
# 19.4 监控数据闭环
线上 Glide RequestListener / SDWebImage delegate
↓
按"url × 设备 × 网络 × 解码时长"细分
↓
异常图片 / 异常版本告警
↓
定位到具体图源 / 加载场景
↓
修复 + 回归 CI
↓
灰度验证 → 全量发布
2
3
4
5
6
7
8
9
10
11
# 20.跨平台速查
# 20.1 工具速查
| 平台 | 加载库 | 下采样 | 内存监控 | 现代格式 |
|---|---|---|---|---|
| Android | Glide / Coil | inSampleSize / Glide override() | Bitmap.getAllocationByteCount | WebP / AVIF |
| iOS | SDWebImage / Kingfisher | UIGraphicsImageRenderer | task_vm_info | HEIC / WebP |
| Web | 浏览器内置 | srcset / picture | DevTools Memory | WebP / AVIF |
| 嵌入式 | LVGL / 自实现 | 编译期处理 | 自定义 | varies |
# 20.2 关键 API 速查
| 操作 | Android | iOS | Web |
|---|---|---|---|
| 加载到 View | Glide.with().load(url).into(iv) | UIImageView.sd_setImage | <img src=> |
| 下采样 | inSampleSize / Glide override | UIGraphicsImageRenderer | srcset |
| 内存缓存 | Glide.MemoryCacheConfig | SDImageCache.shared.config | Cache API |
| 磁盘缓存 | Glide DiskCache | SDImageCache 默认 | Service Worker |
| 取消加载 | Glide.clear(target) | sd_cancelCurrentImageLoad | img.src = "" |
| 占位 | .placeholder() | .sd_setImage(placeholderImage:) | CSS background-image |
| Lifecycle | Glide.with(activity) | prepareForReuse | IntersectionObserver |
# 20.3 通用 SLO 速查
| 指标 | 推荐值 |
|---|---|
| 单图大小(移动) | < 500KB |
| 单页图片总量(移动) | < 5MB |
| 单图解码 | < 50ms |
| 缓存大小 | RAM / 8 |
| 缓存命中率 | > 80% |
| 主线程图片解码占比 | < 5% |
| 图片内存占总进程比 | < 30% |
# 21.总结与延伸
# 21.1 五条核心原则
- 三资源耦合:带宽 / 内存 / CPU 必须同时考虑——单维度优化必然失败(§02 案例 是反面教材)。
- 下采样优先:§17.1 证明 -90% 内存与解码时长,是 ROI 最高的优化。
- 现代格式 + Accept 协商:WebP 稳赢,HEIC 在 iOS 硬件加速。
- 缓存按字节而非按数量:§02 案例 经验派翻车的根因。
- 异步 + 复用 + 不可见即释放:§17.4 + §17.5 给出 60fps 与内存峰值 -40% 的硬证据。
# 21.2 五个常见误区
- "原图最高质量就是好":错(OOM + 卡顿,§02 案例 直接翻车)。
- "AVIF 总是最好":错(解码慢 + 兼容差,§17.2 给出 80ms 解码时长)。
- "缓存越大越好" / "缓存越小越省内存":错(§17.3 证明 RAM/8 是甜蜜点;缩小反而频繁淘汰造成 OOM)。
- "异步解码就够了":错(§17.4 证明默认 4 线程在中端机不够,需独立池+优先级)。
- "图片 OOM 是缓存的错":错(真因常是单图过大,§02 案例 经验派 4 周折腾的盲点)。
# 21.3 三个外延
- AI 图像优化:边缘计算 + ML 自动选择最优格式 / 分辨率。
- WebGPU:浏览器端 GPU 解码加速(普及中)。
- HDR 图像:HEIC / AVIF 支持 HDR;下一代显示标配。
# 21.4 给团队的建议
- 第 1 周:建立监控(Glide 埋点 + 内存采样),看清楚"哪类图占内存最多"。
- 第 2 周:执行第一层治理(§13)——CDN 多分辨率 + WebP。
- 第 3 周:执行第二层治理(§14)——下采样 + 异步线程池。
- 第 4 周:执行第三层治理(§15)——缓存按字节算 + RAM/8。
- 第 5 周以上:执行第四层治理(§16)+ 持续优化。
# 21.5 延伸阅读
- High Performance Images(Colin Bendell 等)—— 图片性能圣经
- WWDC: High-Quality, High-Performance Image Loading
- web.dev: Use Modern Image Formats
- Google Developers: Image Optimization
- Glide / Coil / SDWebImage 官方文档
# 一句话总结
图片是带宽 / 内存 / CPU 三资源耦合,一处单独优化必然按下葫芦浮起瓢。 下采样是 ROI 最高的优化(-90%),缓存按字节算 LRU 是基本功,异步+优先级才能稳定 60fps,复用池让内存峰值再降 40%。 没有银弹,按四阶段(下载→解码→缓存→显示)分层施治。 §02 案例 那个"4 周经验派 vs 5 天方法派"的反差,正是这条路径的最锋利证据。