编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

    • README
    • 公共方法论

    • 体系建设篇

    • 资源专项篇

    • 流水线专项

    • 业务专项篇

      • App冷启动优化
      • 网络性能分析优化
      • 图片性能解码优化
        • 01.阅读说明
        • 02.贯穿案例
          • 2.1 案例背景
          • 2.3 经验派 4 周折腾(典型反面教材)
          • 2.3 方法派 5 天闭环
          • 2.4 上线效果
          • 2.5 案例如何串起本文
        • 03.图片本质定义
          • 3.1 图片性能的物理本质
          • 3.2 图片性能的现象与代价
          • 3.3 度量准则
          • 3.4 行业基准与目标
          • 3.5 8 个反直觉问题
        • 04.三资源耦合原理
          • 4.1 三资源的数学模型
          • 4.2 资源耦合定律
          • 4.3 OOM 风险的数学
          • 4.4 跨平台同构原理
          • 4.5 平台差异点矩阵
        • 05.度量与采集
          • 5.1 三类捕获方案
          • 5.2 各方案的可见盲区
          • 5.3 跨平台采集对照表
          • 5.4 数据可信度评估
        • 06.归因决策树
          • 6.1 图片问题决策树
          • 6.2 内存峰值归因
          • 6.3 解码慢归因
          • 6.4 网络与缓存归因
        • 07.下载层全链路 ⭐
          • 7.1 下载阶段的物理本质
          • 7.2 CDN 的工程价值
          • 7.3 渐进式 JPEG 的视觉妙处
          • 7.4 预取与并发控制
        • 08.解码层全链路 ⭐
          • 8.1 解码阶段的物理本质
          • 8.2 inSampleSize 工作原理
          • 8.3 异步解码与线程池
          • 8.4 硬件解码
          • 8.5 Bitmap 复用池(inBitmap)
        • 09.缓存层全链路 ⭐
          • 9.1 三层缓存模型
          • 9.2 LRU 算法的工作原理
          • 9.3 按字节而非按数量算 LRU
          • 9.4 缓存大小的科学化
          • 9.5 磁盘缓存策略
          • 9.6 onTrimMemory 主动释放
        • 10.显示层全链路 ⭐
          • 10.1 显示阶段的物理本质
          • 10.2 Hardware Bitmap(Android 8+)
          • 10.3 不可见 view 的释放
          • 10.4 lifecycle-aware 自动释放
          • 10.5 占位与骨架屏
        • 11.格式选择全链路 ⭐
          • 11.1 主流格式对比
          • 11.2 Accept 协商机制
          • 11.3 格式选择的决策树
          • 11.4 现代格式的渐进采纳
        • 12.跨端图片对照
          • 12.1 端到端流程对照表
          • 12.2 加载库对照
          • 12.3 平台特异点
          • 12.4 统一启示
        • 13.治理一层下载
          • 13.1 一层命题
          • 13.2 策略 1.1:CDN 多分辨率 + 客户端按需请求
          • 13.3 策略 1.2:现代格式(WebP/HEIC/AVIF)+ Accept 协商
          • 13.4 策略 1.3:渐进式加载与占位策略
          • 13.5 策略 1.4:预取与并发控制
          • 13.6 一层反思
        • 14.治理二层解码
          • 14.1 二层命题
          • 14.2 策略 2.1:按目标 ImageView 尺寸下采样
          • 14.3 策略 2.2:异步解码 + 优先级调度
          • 14.4 策略 2.3:硬件解码格式优先
          • 14.5 策略 2.4:Bitmap 复用池(inBitmap)
          • 14.6 二层反思
        • 15.治理三层缓存
          • 15.1 三层命题
          • 15.2 策略 3.1:缓存大小动态计算
          • 15.3 策略 3.2:按字节而非按数量算 LRU
          • 15.4 策略 3.3:磁盘缓存补强
          • 15.5 策略 3.4:onTrimMemory 主动释放
          • 15.6 三层反思
        • 16.治理四层显示
          • 16.1 四层命题
          • 16.2 策略 4.1:滚出屏幕的 view 立即取消加载
          • 16.3 策略 4.2:lifecycle-aware 自动释放
          • 16.4 策略 4.3:占位与骨架屏
          • 16.5 策略 4.4:显示阶段 GPU 优化
          • 16.6 优先级判定(ROI)
          • 16.7 四层反思
        • 17.求证实验
          • 17.1 实验一:下采样收益
          • 17.2 实验二:格式对比
          • 17.3 实验三:缓存命中率
          • 17.4 实验四:异步解码与滚动卡顿的关系
          • 17.5 实验五:Bitmap 复用池(inBitmap)的内存峰值收益
          • 17.6 五大实验启示
        • 18.实战案例
          • 18.1 跨端同构案例:CDN 多分辨率
          • 18.2 平台特异案例:iOS UIImage 缓存陷阱
          • 18.3 异步解码案例:社交 App 朋友圈瀑布流
          • 18.4 案例统一启示
        • 19.防劣化体系
          • 19.1 三道防线总览
          • 19.2 编码期 Lint
          • 19.3 CI 卡口与线上 SLO
          • 19.4 监控数据闭环
        • 20.跨平台速查
          • 20.1 工具速查
          • 20.2 关键 API 速查
          • 20.3 通用 SLO 速查
        • 21.总结与延伸
          • 21.1 五条核心原则
          • 21.2 五个常见误区
          • 21.3 三个外延
          • 21.4 给团队的建议
          • 21.5 延伸阅读
        • 一句话总结
      • 列表与滚动性能
      • 功耗与电量优化
    • 交付防御篇

  • 程序编程原理

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 性能优化实践
  • 业务专项篇
杨充
2026-05-27
目录

图片性能解码优化

# 图片性能与解码优化

📊 学习成本预估 | 难度:⭐⭐⭐⭐(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 + 内存
1
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 个反直觉问题

带着这些问题阅读:

  1. WebP 比 JPEG 真的省 30% 吗?
  2. 多大的图算"大图"?
  3. 解码一张 4MB 图要多久?
  4. inSampleSize 下采样会损失多少质量?
  5. 图片缓存设多大合适?
  6. PNG 总是无损所以慢吗?
  7. AVIF / HEIC 一定比 WebP 好吗?
  8. 占位图本身会拖慢首屏吗?

# 04.三资源耦合原理

本节回答四个根本问题:①为什么图片是"三资源耦合"?②单维度优化为什么必然失败?③如何用数学量化资源占用?④如何在三资源间找权衡?

# 4.1 三资源的数学模型

   ┌──────────────────────────────┐
   │ 带宽 (Bandwidth)              │
   │ - 下载消耗                    │
   │ - 与图片体积成正比             │
   │ - B = file_size / bandwidth   │
   ├──────────────────────────────┤
   │ 内存 (Memory)                 │
   │ - 解码后的 bitmap 占用         │
   │ - 与"宽 × 高 × 4 字节"成正比  │
   │ - M = W × H × 4               │
   ├──────────────────────────────┤
   │ CPU                          │
   │ - 解码 + 转换 + 滤镜           │
   │ - 与格式复杂度成正比           │
   │ - C = f(format, W × H)        │
   └──────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

三个推论:

  1. 内存与"显示尺寸"成正比,不是文件大小——一张 4MB JPEG 解码后是 47MB Bitmap。
  2. 带宽与"文件大小"成正比,不是显示尺寸——所以"压缩格式"决定带宽,"分辨率"决定内存。
  3. CPU 与"格式复杂度 × 像素数"成正比——AVIF 解码慢 3 倍因为格式更复杂。

# 4.2 资源耦合定律

   关键耦合:
   - 高分辨率图(节省带宽)→ 解码慢 + 内存大
   - 强压缩格式(省带宽)→ 解码 CPU 高
   - 大尺寸 bitmap(视觉好)→ 内存爆炸
1
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%)✨
1
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 内存     像素
1
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 耗时分析)
                    ③ 内存监控(图片占内存)
1
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)
1
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)
   │
   └── 重复下载 ──────────▶ 缓存策略
                          ├─ 内存缓存命中率低
                          └─ 磁盘缓存大小 / 策略
1
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+
1
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 → 缓存命中
       │
       ▼
   客户端缓冲数据 → 进入解码器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

两个关键事实:

  1. 下载阶段被网络主导——RTT + 文件大小 / 带宽。
  2. 下载完成 ≠ 显示完成——还要解码 + 转换 + 上传 GPU。

# 7.2 CDN 的工程价值

CDN(Content Delivery Network)的核心是边缘缓存 + 全球分发:

   用户(北京)
       │
       ▼
   北京 CDN 节点(边缘)
       │
       ├─ 命中 → 5-20ms 返回
       │
       └─ 未命中 → 回源(上海源站,30ms RTT)
                    │
                    ▼
                  缓存到北京边缘 + 返回用户
1
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
1
2
3

收益:§02 案例 单屏流量 4.5MB→0.12MB(-97%)。

# 7.3 渐进式 JPEG 的视觉妙处

   传统 JPEG(baseline):
   下载 0% → 0% 显示
   下载 50% → 50% 显示
   下载 100% → 100% 显示

   渐进式 JPEG:
   下载 10% → 完整图(模糊版)
   下载 30% → 完整图(中等清晰)
   下载 100% → 完整图(高清晰)
1
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()
                }
            }
        }
    }
})
1
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 字节)
1
2
3
4
5
6
7
8
9
10

关键事实:

  1. 解码是 CPU 重活 —— 4032×3024 大图解码 250ms+。
  2. 解码后内存 = W × H × 4 —— 与文件大小无关,只与分辨率有关。
  3. 解码可以"边解边采样" —— 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)
1
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 优先
)
1
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
1
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+,容量无限
   └──────────────┘
1
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 被淘汰)
1
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
    }
}
1
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
    }
}
1
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))
1
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
1
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)
    }
}
1
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 渲染管线
       ├─ 顶点着色(位置)
       ├─ 像素着色(颜色)
       │
       ▼
   屏幕像素(合成)
1
2
3
4
5
6
7
8
9
10
11
12

两个关键开销:

  1. CPU → GPU 上传:每次首次显示都要上传,约 5-20ms。
  2. GPU 内存占用:每张显示中的图都在 GPU 内存中。

# 10.2 Hardware Bitmap(Android 8+)

传统流程:

   Bitmap(CPU 内存)→ 上传到 GPU → 显示
   每次显示都要拷贝
1
2

Hardware Bitmap:

   Bitmap 直接在 GPU 内存里 → 不需要拷贝
1

收益:

  • 渲染阶段 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)
}
1
2
3
4

为什么必须释放:

  • 列表快速滑动时,加载中的图可能 view 已滚出。
  • 继续解码 = 浪费 CPU(图都看不到了)。
  • 占用线程池槽位 → 可见 view 的解码被阻塞。

clear 后再次显示的代价:从内存缓存重新拿 → 5ms(可接受)。

# 10.4 lifecycle-aware 自动释放

// Glide 推荐用法 - 自动绑定 lifecycle
Glide.with(this)  // 传 fragment/activity
    .load(url)
    .into(imageView)
1
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)
1
2
3
4
5

收益:用户主观流畅度 +30%(即使物理时长不变)。

探索性思考:为什么"占位图本身"也需要优化? 因为占位图每张 view 都用——如果 5KB 的骨架图被 100 张 view 用,就是 500KB 内存。所以占位图必须:

  1. 极小(< 5KB)。
  2. 简单(少色 / 矢量)。
  3. 系统级单例(一份骨架图所有 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
1
2
HTTP/1.1 200 OK
Content-Type: image/webp
1
2

收益:

  • 支持 WebP 的设备返回 WebP(流量 -35%)。
  • 不支持的返回 JPEG(兼容)。
  • 完全自动,客户端无感。

# 11.3 格式选择的决策树

图片是什么类型?
   │
   ├─ 图标/UI(< 50KB)
   │      └─ PNG(无损 + 透明)
   │
   ├─ 照片
   │   ├─ iOS 端 → HEIC(硬件解码)
   │   └─ 其他 → WebP
   │
   ├─ 动图
   │   ├─ 简单 → GIF
   │   └─ 复杂 → WebP / APNG
   │
   └─ 流量极敏感
       └─ AVIF(如果设备支持)
1
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)
}
1
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()
1
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)
1
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)
1
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);
1
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())
1
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)
1
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))
1
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
    }
}
1
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)
    }
}
1
2
3
4
5
6
7

收益:卷二·02 §6 内存治理篇有详述。

边界:clearMemory 后下一次加载需重新解码,可能引发短暂卡顿。

# 15.6 三层反思

探索性思考:为什么"缩小缓存治 OOM"是反模式? 因为它搞错了 OOM 的根因——OOM 是"单图过大"或"并发解码数过多",不是"缓存过大"。缩小缓存导致:

  1. 频繁淘汰 → 命中率下降。
  2. 重复解码 → CPU 大涨。
  3. 每次解码都新建 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)
}
1
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)
1
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)
1
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 时间。这种"长尾累积"型优化的特点是:

  1. 单点测不出——加 onViewRecycled clear 看不到单图收益。
  2. 整体体感明显——快速滑动的卡顿感消失。
  3. 必须配合监控——线下用 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%            ─┘
1
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(...)
1
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]
1
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
          ↓
   灰度验证 → 全量发布
1
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 五条核心原则

  1. 三资源耦合:带宽 / 内存 / CPU 必须同时考虑——单维度优化必然失败(§02 案例 是反面教材)。
  2. 下采样优先:§17.1 证明 -90% 内存与解码时长,是 ROI 最高的优化。
  3. 现代格式 + Accept 协商:WebP 稳赢,HEIC 在 iOS 硬件加速。
  4. 缓存按字节而非按数量:§02 案例 经验派翻车的根因。
  5. 异步 + 复用 + 不可见即释放:§17.4 + §17.5 给出 60fps 与内存峰值 -40% 的硬证据。

# 21.2 五个常见误区

  1. "原图最高质量就是好":错(OOM + 卡顿,§02 案例 直接翻车)。
  2. "AVIF 总是最好":错(解码慢 + 兼容差,§17.2 给出 80ms 解码时长)。
  3. "缓存越大越好" / "缓存越小越省内存":错(§17.3 证明 RAM/8 是甜蜜点;缩小反而频繁淘汰造成 OOM)。
  4. "异步解码就够了":错(§17.4 证明默认 4 线程在中端机不够,需独立池+优先级)。
  5. "图片 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 天方法派"的反差,正是这条路径的最锋利证据。

上次更新: 2026/06/07, 10:26:12
网络性能分析优化
列表与滚动性能

← 网络性能分析优化 列表与滚动性能→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式