页面UI与布局优化
# 页面 UI 与布局优化
本文核心命题:UI 优化的本质是减少"产帧成本"——把 Layout / Draw / Inflate 三大主线程开销压缩到帧预算之内。一切 UI 优化都是在层级 / 重绘 / Inflate 三个维度上"减"或"延"。
# 01.阅读说明
- 本文卷归属:卷三 · 流水线篇 · 第 5 篇
- 本文目标层级:L2 进阶 → L3 专家
- 适用平台:Android(主) / iOS / Web / 跨端框架 / 嵌入式
- 前置阅读:
卷三·01 渲染管线与原理(UI 优化是渲染优化的应用层落地)卷三·03 卡顿捕获与归因(UI 卡顿的归因)
- 本文核心命题:
UI 优化的本质是减少"产帧成本" —— 把 Layout / Draw / Inflate 这三大主线程开销压缩到帧预算之内。
一切 UI 优化都是在层级 / 重绘 / Inflate 三个维度上"减"或"延"。
全文 21 章地图:
§01 阅读说明 §02 贯穿案例 §03 UI 性能本质 §04 三阶段成本原理
§05 度量与采集 §06 归因决策树
§07 Inflate 全链路 ⭐ §08 Layout 全链路 ⭐ §09 Draw 全链路 ⭐
§10 列表复用全链路 ⭐ §11 声明式 UI 全链路 ⭐ §12 跨端 UI 对照
§13 治理一层减节点 ⭐ §14 治理二层延 Inflate ⭐ §15 治理三层缩重绘 ⭐ §16 治理四层 ROI 排序 ⭐
§17 求证实验 ⭐ §18 实战案例 §19 防劣化体系 §20 跨平台速查
§21 总结与延伸
2
3
4
5
6
7
阅读建议:先读 §02 案例建立直觉 → §03/§04 拿到原理 → §05/§06 学会度量归因 → §07-§11 五个全链路构成核心 → §13-§16 四层治理是落地路径 → §17 求证 → §18-§20 工程闭环。
# 02.贯穿案例
本案例贯穿全文:§03 看懂代价、§04 拿到三阶段成本模型、§05/§06 用三方案+决策树定位、§07-§11 五个全链路对应每一类问题、§17 用实验复盘、§13-§16 给出分层策略闭环。
# 2.1 案例背景
某新闻 App V6.8 重构详情页(产品要求"功能更丰富"),上线后用户体验崩盘:
- 详情页打开 P95 = 1.8s(之前 0.6s),用户大量"白屏"投诉。
- 评论列表滑动 FPS 仅 22(之前 56),P99 帧时长 110ms。
- 详情页 cell 总创建时间长达 580ms(10 个 cell)。
- DAU 流失 6%、文章阅读完成率 -15%。
- 公司高层强力施压"必须立刻恢复"。
研发组初步反应:"功能加多了肯定慢点,是产品需求。"——这是典型的"功能 vs 性能"误区。
# 2.2 经验派的 4 周折腾(典型反面教材)
| 周次 | 动作 | 结果 |
|---|---|---|
| 第 1 周 | 把 LinearLayout 都改 ConstraintLayout(认为扁平就快) | 简单 cell 反而慢 10%(CL 自身 overhead),详情页快 5% |
| 第 2 周 | 全量异步 Inflate(认为异步就好) | 简单 cell 也异步,Attach 闪烁 + 总耗时反增 |
| 第 3 周 | 用 setLayerType(LAYER_TYPE_HARDWARE) 加速所有 View | GPU 内存涨 80MB,低端机 OOM 率升 5× |
| 第 4 周 | 把列表 cell 高度固定(怀疑动态高度) | FPS 升到 26,但内容显示不全 |
复盘:四周折腾错在"听经验,没数据"。ConstraintLayout 不是万能(简单场景反而慢);异步 Inflate 不是万能(简单 cell 没收益还闪烁);setLayerType 不是万能(吃显存);固定高度不是万能(牺牲业务)。每个所谓"经验"都需场景化验证——这正是案例反面教材的核心。
# 2.3 方法派的 6 天闭环
新接手的同学按本文方法论重做:
Day 1(§04 三阶段成本模型 + §05 三方案):
- 方案②(FrameMetrics):详情页首帧 INPUT=8ms / ANIM=12ms / LAYOUT=520ms / DRAW=80ms / SWAP=8ms。
- 方案①(自定义基类计时):详情页 setContentView 耗时 380ms(XML 层级达 9 层);评论 cell 层级 6 层。
- 方案③(FPS):列表稳态 P99=110ms,远超 16.67ms 帧预算 6.6×。
→ 三阶段都有问题:Inflate(380ms)+ Layout 每帧(45ms)+ Draw 每帧(22ms)。
Day 2(§06 决策树 + 分类):
- 首屏慢 → Inflate 分支 → 9 层深 + 大量未必要 View(社交/评论/相关推荐都同步 inflate)。
- 滚动卡 → Layout 分支 → 6 层 cell + RelativeLayout 双测量 + 动态高度。
- 局部卡 → Draw 分支 → 评论头像 + 表情用 SVG 实时解析。
Day 3-4(§13-§16 分层策略):
- 第 1 层(节点收敛):详情页根布局扁平化(9 层→4 层);评论 cell ConstraintLayout(6 层→2 层);相关推荐改 ViewStub 延迟。
- 第 2 层(Inflate 时机):评论/广告/相关推荐改 AsyncLayoutInflater;详情页主体保持同步(用户感知)。
- 第 3 层(重绘):固定背景区域 setLayerType;SVG 改 WebP;只对动画区域 invalidate(Rect)。
- 第 4 层(列表):onBindViewHolder 减负(图加载/格式化全异步);prefetchDataSource 提前测量。
Day 5(§17 实验思路验证):构造 mock 数据用 Macrobenchmark 跑前后对比。
Day 6(上线灰度):
# 2.4 上线效果
| 指标 | 经验派 4 周后 | 方法派 6 天后 |
|---|---|---|
| 详情页打开 P95 | 1.7s | 0.55s |
| 评论列表稳态 FPS | 26 | 58 |
| 列表 P99 帧时长 | 100 ms | 18 ms |
| 单 cell 创建时间 | 50 ms | 8 ms |
| 内存峰值 | 380 MB | 220 MB(setLayerType 滥用解除) |
| 详情页 DAU 流失 | -6% | +1%(恢复) |
| 文章阅读完成率 | -15% | +3% |
核心洞察:UI 优化不存在"银弹"——每个"经验招"都有场景边界。方法派的胜利不是用了什么神奇技术,而是用数据驱动按场景选择:复杂层级才扁平化,复杂页面才异步 Inflate,动画区才用 setLayerType。经验招 + 错误场景 = 反向收益。
# 2.5 案例如何串起本文
- §03 现象与代价 ▶▶ 业务损失映射:DAU -6%、阅读完成率 -15%。
- §04 三阶段模型 ▶▶ 案例 Inflate/Layout/Draw 三阶段同时有问题,正是模型存在的理由。
- §05 三方案组合 ▶▶ 单一方案看不到全貌,必须 ①②③ 配合。
- §06 决策树 ▶▶ 三个症状走三条不同分支,对应三种治法。
- §07-§11 五大全链路 ▶▶ Inflate/Layout/Draw/列表复用/声明式 UI 五个链路对应案例每一类问题。
- §17 求证实验 ▶▶ 扁平化(场景边界)+ 异步 Inflate(场景边界)+ 列表复用 + 声明式 UI 都在案例中变现。
- §13-§16 分层策略 ▶▶ "节点→Inflate→重绘→列表"四层正是案例落地路径。
探索性思考:为什么"经验派"会失败?不是经验本身错,而是经验缺少场景边界。"ConstraintLayout 比 LinearLayout 快"是在"嵌套 4 层以上"才成立;"异步 Inflate"是在"创建 > 50ms"才成立;"setLayerType"是在"高频重绘 + 内容稳定"才成立。没有场景的经验等于偏见——这是 UI 优化最容易踩的坑。
# 03.UI 性能本质
# 3.1 一句话定义
UI 优化 = 减少"主线程产出一帧"所需的工作量,把 Layout / Draw / Inflate 三大开销压到帧预算之内。
这句话隐含三个不可商量的物理约束:
约束一:UI 工作量与"节点数"直接相关
无论 View / UIView / DOM / LVGL Object,UI 都是一棵树。Measure / Layout / Draw / Compose 都是树形递归算法。节点数越多,单帧成本越高(详见 卷三·01 §5.1 实验)。
约束二:UI 工作分摊在三个时间窗口
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ ① Inflate 阶段 │ │ ② 帧产出阶段 │ │ ③ 重绘阶段 │
│ 仅创建时一次 │ │ 每帧执行 │ │ 局部触发 │
│ 影响首屏时长 │ │ 影响 FPS │ │ 影响交互流畅 │
└────────────────┘ └────────────────┘ └────────────────┘
2
3
4
5
这三个阶段的优化手段完全不同,不能混为一谈:
- Inflate 慢 → 异步 inflate / 缓存 / ViewStub
- 帧产出慢 → 减少层级 / 简化 onDraw
- 重绘慢 → 局部 invalidate / 合理的更新粒度
约束三:UI 工作在主线程上执行
UI 操作必须在主线程(详见 卷三·01 §2.1)。这意味着:
- UI 优化的每一毫秒都在和"帧预算"赛跑。
- 无法靠增加 CPU 核数加速。
- 必须通过"减"或"延",而不是"并"。
# 3.2 反直觉问题清单
带着这些问题阅读:
- 减少 View 层级一定能让渲染更快吗?
- ConstraintLayout 一定比 LinearLayout 嵌套快吗?
- 异步 inflate 是不是总比同步快?
- wrap_content 真的慢吗?
- RelativeLayout 真的会"测量两次"吗?
- View 复用一定能加速滚动吗?
- 软件渲染(disable hardware acceleration)什么时候比 GPU 快?
- 页面层级 5 vs 10 实测差多少?
探索性思考:为什么"主线程"是 UI 设计的根本约束?因为 UI 状态需要串行一致性——如果 ListView 一边滑动一边异步改高度,渲染线程就要做协调,开销远大于"全部主线程"的简单方案。主线程是工程师不愿放弃的"宇宙秩序"——所有跨平台框架(Flutter / React Native / Compose)都没敢挑战这一点。
# 3.3 现象与代价
UI 性能问题的用户感知很直接:
- 页面打开慢:点击后白屏 / 等待,启动期 Inflate 慢。
- 滚动不流畅:列表滑动卡顿,每个 cell 创建 / 测量耗时长。
- 切换迟钝:Activity / ViewController 之间切换有卡顿。
- 键盘弹出卡顿:弹键盘时整个布局重新测量。
- 旋转 / 折叠后慢:屏幕配置变化触发整树重新 Layout。
业务代价(行业实测数据):
- 头部 App:列表 P99 帧时长降 20ms,滑动留存 +1%。
- 启动期 inflate 优化能让首屏可见时间 -200-500ms。
- UI 卡顿是用户主观投诉最直接的来源("卡"占投诉 30%+)。
▶▶ 回扣 §02 案例:新闻 App 详情页 P95 1.8s + 列表 22fps 直接导致 DAU -6% + 阅读完成率 -15%——UI 性能 = 业务 KPI 不是夸张说法。
# 3.4 度量准则与基准
按 卷零·02 §3 指标体系:
请求视角(RED):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 首屏 inflate 时长 | 从 setContentView 到 measure 完成 | < 100ms |
| 单 cell 创建耗时 | onCreateViewHolder | < 5ms |
| 单 cell 绑定耗时 | onBindViewHolder | < 8ms |
| Layout 单次耗时 | measure + layout | < 帧预算 60% |
| Draw 单次耗时 | onDraw | < 帧预算 30% |
行业基准:
| 平台 | 首屏 inflate | 单 cell 创建 | Layout 复杂度 |
|---|---|---|---|
| Android | < 100ms | < 5ms | 树深 ≤ 8 |
| iOS | < 80ms | < 5ms | 嵌套 ≤ 10 |
| Web | < 200ms(含 CSS) | < 5ms | DOM 节点 ≤ 1500 |
| 嵌入式 | 视设备 | varies | 严格规划 |
# 04.三阶段成本原理
# 4.1 三阶段成本公式
单帧主线程总成本 = Inflate(首次) + Layout × N + Draw × M
N = 当前帧需要重测量的节点数
M = 当前帧需要重绘的节点数
2
3
4
关键洞察:
- Inflate 是一次性成本(创建时一次),但成本最高(每个节点要解析 XML / 反射 / 创建对象)。
- Layout 是每帧成本,与树深度指数相关(详见渲染篇 §5.1)。
- Draw 是每帧成本,与重绘范围 + Shader 复杂度相关。
# 4.2 优化优先级公式
按"成本 × 频次":
优先级 = 单次成本 × 触发频次
① Layout per 帧:成本 5-50ms × 60 帧/秒 = 300-3000 ms/秒(最高)
② Draw per 帧:成本 1-20ms × 60 帧/秒 = 60-1200 ms/秒
③ Inflate:成本 50-500ms × 1 次 = 50-500 ms(一次性,但首屏关键)
2
3
4
5
三类问题的物理来源:
┌────────────────────────────────────────────┐
│ A. 层级过深:树形递归成本指数级 │
│ 根因:嵌套布局 / RelativeLayout 误用 │
├────────────────────────────────────────────┤
│ B. 重绘范围过大:onDraw 被调用太多 / 太大 │
│ 根因:父布局 invalidate 全量;Shader 复杂 │
├────────────────────────────────────────────┤
│ C. Inflate 慢:XML 解析 / 反射 / 资源加载 │
│ 根因:层级深 + 复杂控件 + 大量资源 │
└────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
▶▶ 回扣 §02 案例:新闻 App 同时命中三类——A(详情页 9 层 + 评论 6 层 RelativeLayout)+ B(SVG 实时解析 + 大背景 invalidate)+ C(首屏全量同步 inflate)。经验派的失败正是因为不分类——只治一类问题不可能解决三类共存的真实场景。三阶段模型存在的价值正是"分而治之"。
# 4.3 跨平台同构原理
所有平台的 UI 都是"声明式 / 命令式 + 树 + 渲染管线":
通用 UI 模型:
[声明 UI 树] → [Inflate 创建实例] → [Layout] → [Draw] → [Compose]
声明文件 实例化对象 位置确定 画到 canvas 上屏
XML/JSX/SwiftUI Java 对象/JS DOM/UIView Measure+Layout onDraw GPU 合成
2
3
4
5
跨平台术语对照
| 通用术语 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 树根 | View | UIView | DOM | LVGL Obj |
| 节点 | View / ViewGroup | UIView / Layer | Element | Obj |
| 测量 | onMeasure | sizeThatFits / layoutSubviews | reflow | 自定义 |
| 布局 | onLayout | layoutSubviews | reflow | 自定义 |
| 绘制 | onDraw | drawRect / Layer.draw | paint | 自定义 |
| 重绘 | invalidate | setNeedsDisplay | requestAnimationFrame + DOM 修改 | invalidate |
| 异步实例化 | AsyncLayoutInflater | dispatchqueue 创建 | Web Worker | 不常见 |
# 4.4 平台差异点矩阵
| 维度 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 声明方式 | XML / Compose | XIB / Storyboard / SwiftUI | HTML / CSS / JSX | C 代码 / LVGL |
| 实例化成本 | 高(XML 解析 + 反射) | 中(XIB 高,代码低) | 中(DOM 创建) | 低 |
| 树深限制 | 经验 ≤ 8 | 经验 ≤ 10 | 浏览器有上限(巨深会卡) | 严格 |
| 重绘粒度 | View 级 | Layer 级 | Element 级 | Obj 级 |
| 异步 inflate | AsyncLayoutInflater | 不常用 | 自动(浏览器) | 罕见 |
| 主流模式 | RecyclerView 复用 | UITableView 复用 | 虚拟滚动 | 列表组件 |
探索性思考:为什么"减层级"是跨平台共通的优化?因为递归遍历的成本本质上是 O(N) ~ O(N·D)(D 为深度)。在简单平台(嵌入式)这是常识;在复杂平台(Web)也成立——浏览器 reflow 一旦触发就要遍历相关 DOM 树。跨平台共通的优化通常都来自"算法第一性",平台特性带来的优化反而是"末梢"。
# 05.度量与采集
# 5.1 三类采集方案
所有平台的 UI 性能采集方案,本质上只有 3 类:
① 节点级采集(每个 View 的测量 / 布局耗时)
② 阶段级采集(FrameMetrics / DevTools 阶段拆解)
③ 全局级采集(FPS、INP 等用户感知)
2
3
① 节点级采集(精细到单个 View)
核心原理:在 onMeasure / onLayout / onDraw 等关键回调上插桩,记录每个节点的耗时。
// 自定义 View 基类做计时
public class TimedView extends View {
@Override
protected void onMeasure(int wMs, int hMs) {
long t = System.nanoTime();
super.onMeasure(wMs, hMs);
Tracer.record("Measure", getClass().getSimpleName(), System.nanoTime() - t);
}
}
2
3
4
5
6
7
8
9
或者 ASM / Aspect 编译期注入。
适用边界:性能分析阶段、定向排查特定页面。线下用为主。
② 阶段级采集
使用系统提供的"阶段级"数据,看 Layout / Draw / Sync 各阶段耗时(详见 卷三·01 §3.1)。所有应用线上监控的核心方案。
③ 全局级采集
用 FPS / INP / TTI 等用户感知指标。监控仪表盘、SLO 跟踪。
# 5.2 三种方案的总览与可见盲区
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 节点级 | onMeasure/Layout/Draw | 单 View 级 | 高(5-15%) | 需各平台插桩 | 否 | 侵入式 |
| ② 阶段级 | FrameMetrics/PerfHUD | 阶段级 | 低(1-2%) | 跨平台支持 | 是 | 不知具体 View |
| ③ 全局级 | FPS/INP/TTI | 帧/页面级 | 极低 | 全平台 | 是 | 不知阶段 |
可见盲区:
- 方案①看得到具体哪个 View 慢,但开销大;
- 方案②看得到哪个阶段慢(Layout/Draw),但不知是哪个 View;
- 方案③看得到"哪个页面"卡,但不知"哪个阶段"。
实战建议:线上用 ② + ③;线下问题排查用 ①。
# 5.3 跨平台采集对照表
| 平台 | 节点级 | 阶段级 | 全局级 |
|---|---|---|---|
| Android | 自定义基类 / ASM | FrameMetrics / Perfetto | Choreographer / FPS |
| iOS | 重写 layoutSubviews 计时 | Instruments Time Profiler | CADisplayLink / FPS |
| Web | Performance.measure() | Performance Long Tasks API | INP / FPS |
| Compose | Layout Inspector | composition trace | Choreographer |
| SwiftUI | _printChanges() | Instruments | CADisplayLink |
# 5.4 数据可信度评估
- 节点级采集:可信度最高,但开销影响数据本身(heisenberg 效应)。
- 阶段级采集:可信度高(系统级),是线上首选。
- 全局级采集:可信度高,但延迟感知(已经发生才能知道)。
探索性思考:为什么 UI 性能采集没有"完美方案"?因为采集本身就是 UI 工作——你想精确测量,就要插桩,插桩就增加开销,增加开销就改变测量值。Heisenberg 不确定性原理在工程上的应用:你只能在"颗粒度"和"开销"之间二选一。
# 06.归因决策树
# 6.1 UI 问题决策树
症状 = ?
│
├─ 首屏打开慢(白屏 200-1500ms)
│ │
│ └─ 走 Inflate 分支:检查层级深度、ViewStub 是否使用、
│ 复杂控件是否同步初始化、资源解码
│
├─ 滚动卡顿(FPS < 50 / 帧时长 > 20ms)
│ │
│ └─ 走 Layout 分支:检查 cell 层级、动态高度、
│ RelativeLayout 双测量、过度嵌套
│
├─ 局部卡顿(点击/动画时卡)
│ │
│ └─ 走 Draw 分支:检查重绘范围、Shader 复杂度、
│ setLayerType、SVG 实时解析
│
└─ 内存暴涨(GPU/RAM)
│
└─ 走资源分支:检查 setLayerType 滥用、Bitmap 缓存策略
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 6.2 布局复杂度归因
症状:滚动卡顿、Layout 阶段长。
典型根因:
- 层级过深:树深 > 8 层(Android)或 > 10 层(iOS)
- RelativeLayout 双测量:含 layout_above / layout_below 时会触发两次 onMeasure
- 嵌套 wrap_content:每一层都需要先测量子节点再确定自己尺寸
- 动态高度列表:列表滚动时不断重新测量
- 过度使用 weight:LinearLayout weight 也会触发两次测量
归因方法:
# Android
adb shell dumpsys gfxinfo <package> framestats | grep -A 5 "Layout"
# 看 LAYOUT 列耗时占比
2
3
# 6.3 重绘范围归因
症状:动画/交互卡顿、Draw 阶段长。
典型根因:
- invalidate 范围过大:调用无参数 invalidate() 而非 invalidate(Rect)
- 父布局重绘传递:父 View 重绘导致整棵子树重绘
- 复杂 onDraw:自定义 View 中调用复杂 Path / Shader
- OffscreenBuffer:setLayerType(LAYER_TYPE_HARDWARE) 滥用
- 过度透明合成:多层半透明叠加导致 GPU overdraw
归因方法:
- Android:开发者选项打开"GPU 过度绘制"
- iOS:Instruments → Color Blended Layers / Color Off-screen Rendered
- Web:Chrome DevTools → Rendering → Paint Flashing
# 6.4 Inflate 性能归因
症状:页面打开慢。
典型根因:
- XML 层级深:每一层都要解析 XML / 反射创建
- 同步 Inflate:所有 View 都在主线程创建
- 复杂资源:大图 / SVG 在 Inflate 时解析
- 未用 ViewStub:低频显示的 View 也立即创建
- 未用 RecyclerView 复用:列表 cell 全部新建
归因方法:
- Android:Profile GPU Rendering 配合 Trace
- iOS:Time Profiler 看 -[UINib instantiateWithOwner:options:]
- Web:Performance Panel 看 Layout / Recalculate Style
探索性思考:为什么"决策树"是 UI 归因的最佳模式?因为 UI 问题的症状-病因映射通常是 1 对 1 而非多对多——首屏慢几乎等同于 Inflate 问题,滚动卡几乎等同于 Layout 问题。强分类的领域适合用决策树,弱分类的领域才需要机器学习。UI 优化领域是前者。
# 07.Inflate 全链路
Inflate 是 UI 创建的"启动开销":XML/XIB 解析 → 反射创建对象 → 资源加载 → attach 到 ViewTree。首屏慢的 70% 都来自 Inflate。
# 7.1 Android Inflate 全链路
setContentView(R.layout.xxx)
↓
① LayoutInflater.inflate() → 解析 XML(从 R.layout 资源)
- 资源解析(bytecode 形式的 XML)
- 创建 XmlPullParser
↓
② createViewFromTag()
- 反射 Class.forName("android.widget.LinearLayout")
- 反射 Constructor.newInstance(context, attrs)
↓
③ generateLayoutParams() → 解析 layout_width / layout_height
↓
④ rInflateChildren() 递归解析子节点
↓
⑤ attach 到 root ViewGroup → setContentView 完成
↓
⑥ ViewRootImpl.requestLayout() → 后续 measure / layout / draw
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键瓶颈:
- 反射创建 View:每个 Tag 都要走
Class.forName + newInstance - 属性解析:每个 attr 都要做类型转换
- 嵌套递归:深度 N 的树需要 N 次递归
- 资源加载:drawable / dimen 立即加载
优化机会点:
// ① AsyncLayoutInflater:把 ① ② ③ ④ 都丢到子线程
AsyncLayoutInflater(context).inflate(R.layout.xxx, parent) { view, _, _ ->
parent.addView(view) // 主线程仅做 attach
}
// ② Compose / 编译期生成:跳过 ① 和 ②(无反射)
// ③ X2C / 注解处理:编译期生成 Java 代码替代 XML 反射
// ④ ViewStub:延迟 ①-④ 到真正显示时
2
3
4
5
6
7
8
9
10
# 7.2 iOS Inflate 全链路
loadView() / loadFromNib()
↓
① UINib.instantiateWithOwner:
- XIB 文件解析(已编译为 NIB 二进制)
- Archive 反序列化
↓
② [view setValue: forKey:] // KVC 设置属性
↓
③ awakeFromNib // 子类回调
↓
④ viewDidLoad
↓
⑤ viewWillAppear / viewDidAppear
2
3
4
5
6
7
8
9
10
11
12
13
与 Android 的差异:
- iOS 没有"反射"问题(NIB 已编译)
- 但 KVC 设置属性也有开销
- 代码创建 View 比 XIB 快 30-50%(实测)
优化建议:
- 性能关键 View:用代码创建(避免 XIB)
- 复用:UITableView / UICollectionView 的 dequeueReusableCell
# 7.3 Web Inflate 全链路
document.createElement("div")
↓
① 创建 DOM 节点(C++ 内核中分配)
↓
② parent.appendChild(child)
↓
③ 触发 Style Recalculation(CSS 匹配)
↓
④ 触发 Layout(reflow)
↓
⑤ 触发 Paint
↓
⑥ 触发 Composite
2
3
4
5
6
7
8
9
10
11
12
13
关键瓶颈:
- CSS 匹配:选择器越复杂越慢(嵌套选择器 O(N²))
- layout thrashing:
element.offsetHeight; element.style.width = "100px"; element.offsetTop强制三次 layout - Shadow DOM 创建:Web Components 比原生 div 慢
优化机会:
// ① documentFragment 批量插入
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
frag.appendChild(createNode());
}
parent.appendChild(frag); // 一次 reflow
// ② 虚拟 DOM(React/Vue):内存中构造完整树后一次性 mount
2
3
4
5
6
7
8
# 7.4 跨端框架 Inflate 全链路
Flutter:
Widget tree(声明)→ Element tree(实例)→ RenderObject tree(渲染)
build() 每次都创建新 Widget(cheap)
Element 复用(mount/unmount 才动)
RenderObject 极少重建
2
3
4
React Native:
JSX → React Element → ReactShadowNode → 原生 View
Bridge 传递(异步序列化)→ 原生侧创建 View
2
Compose / SwiftUI:
@Composable 函数运行 → Slot table → LayoutNode → Skia Canvas
声明式 UI 的"重建"是廉价的(diff + 复用)
2
探索性思考:为什么"声明式 UI"反而能更快?因为它把"实例化"从运行时移到了"框架内部" —— Compose 的 LayoutNode、Flutter 的 RenderObject 都是为复用而设计,运行时只是函数调用 + diff,没有反射、没有 XML 解析。声明式 UI 的胜利不是 API 优雅,而是把昂贵的工作做了一次架构性的"位移"。
# 7.5 Inflate 全链路性能数据
| 阶段 | Android(实测) | iOS(实测) | Web(实测) |
|---|---|---|---|
| 解析 + 反射创建 | 50-200ms(10 节点) | 30-80ms | 5-30ms |
| KVC 属性设置 | 包含在反射中 | 10-30ms | CSS 匹配 5-50ms |
| Attach + 测量 | 20-100ms | 10-50ms | reflow 10-100ms |
▶▶ 回扣 §02 案例:详情页 setContentView 380ms,正是这条链路的真实代价。优化路径:① 减节点数(9→4 层)② 异步 Inflate(评论/广告)③ ViewStub 延迟(相关推荐)。
# 08.Layout 全链路
# 8.1 Android Layout 全链路
ViewRootImpl.performLayout()
↓
① performMeasure(widthSpec, heightSpec)
→ ViewGroup.measure → onMeasure → 子 View.measure → ...
(递归测量)
↓
② performLayout(left, top, right, bottom)
→ ViewGroup.layout → onLayout → child.layout → ...
(递归布局)
↓
③ measureChildWithMargins → resolveSizeAndState
MeasureSpec 三种模式:EXACTLY / AT_MOST / UNSPECIFIED
2
3
4
5
6
7
8
9
10
11
12
双测量原因:
- RelativeLayout:含 layout_above / layout_below / layout_alignBaseline 时
- LinearLayout + weight:先一次测量得到剩余空间,再一次分配
- wrap_content + match_parent 嵌套:子节点需要先确定,父节点需要等子节点
优化机会:
- ConstraintLayout:扁平化 + 一次性测量(避免双测量)
- 避免 wrap_content + 嵌套 weight
- 固定尺寸 cell:如果业务允许
# 8.2 iOS Layout 全链路
setNeedsLayout
↓
① layoutIfNeeded // 立即布局
② layoutSubviews // 子类重写
↓
③ Auto Layout:
- 添加 NSLayoutConstraint
- Cassowary 算法求解
- 应用 frame
④ 手动布局:
- 直接 view.frame = CGRect(...)
2
3
4
5
6
7
8
9
10
11
Auto Layout 的代价:
- Cassowary 算法时间复杂度 O(N²) 至 O(N³)
- 100 + 约束的 cell 测量 ~5ms
- 手动 frame 计算 < 0.5ms
优化建议:
- 列表 cell:手动 frame > Auto Layout(约 3-5×)
- 静态页面:Auto Layout 可接受
# 8.3 Web Layout 全链路
修改 DOM / Style
↓
① Invalidate Layout(标脏)
↓
② Recalculate Style(CSS 计算)
↓
③ Layout / Reflow
- block / inline 流计算
- flex / grid 求解
- 100% / auto 尺寸求解
↓
④ Paint
↓
⑤ Composite
2
3
4
5
6
7
8
9
10
11
12
13
14
Layout Thrashing 反模式:
// 错误:强制同步 layout 三次
for (let el of items) {
el.style.width = el.offsetWidth + "px"; // 写后立即读 → 强制 layout
}
// 正确:批量读 + 批量写
const widths = items.map(el => el.offsetWidth); // 批量读
items.forEach((el, i) => el.style.width = widths[i] + "px"); // 批量写
2
3
4
5
6
7
8
# 8.4 跨端 Layout 性能差异
| 场景 | Android Layout | iOS AutoLayout | Web Reflow | Compose / SwiftUI |
|---|---|---|---|---|
| 静态 100 节点 | 5-15ms | 5-30ms | 10-30ms | 3-10ms |
| 动态 100 节点 | 5-50ms | 10-100ms | 10-100ms | 5-30ms |
| 1000 节点列表 | 不可接受(必复用) | 不可接受 | 不可接受 | 不可接受 |
探索性思考:为什么"双测量"在 Android RelativeLayout 上是必要恶?因为相对布局需要"对齐其他 View",而被对齐的 View 必须先确定位置。这是数学上的依赖图问题 —— ConstraintLayout 通过求解器(不是双测量)解决了这个问题,代价是引入求解器的算法常数(简单场景反而更慢)。没有银弹。
# 09.Draw 全链路
# 9.1 Android Draw 全链路
View.invalidate()
↓
① ViewRootImpl 标脏
↓
② doFrame → performTraversals → performDraw
↓
③ View.draw(canvas)
→ onDraw // 自定义绘制
→ dispatchDraw // 子 View 绘制
↓
④ DisplayList(HWUI)记录绘制命令
↓
⑤ RenderThread → OpenGL ES → GPU
↓
⑥ SurfaceFlinger Composite
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键开销:
- invalidate 范围:无参 invalidate 重绘整个 View
- Shader 复杂度:复杂 Path / 渐变 / 模糊
- OffscreenBuffer:setLayerType(HARDWARE) 创建独立缓冲区
- OverDraw:多层叠加,相同像素被绘制多次
# 9.2 iOS Draw 全链路
setNeedsDisplay
↓
① CALayer 标脏
↓
② Core Animation transaction
↓
③ drawRect: / Layer.draw // 自定义
↓
④ CGContextRef 上的 Quartz 2D 绘制
↓
⑤ 上传到 GPU
↓
⑥ Compositor 合成(带圆角/阴影时)
2
3
4
5
6
7
8
9
10
11
12
13
iOS 关键性能点:
- Off-screen Rendering:圆角 + maskToBounds、阴影、shouldRasterize 都触发
- 每像素采样:模糊 / 阴影 cost 与像素数线性相关
- Layer 合并:Core Animation 自动合并相邻 Layer
# 9.3 Web Paint 全链路
修改样式(color/background/transform)
↓
① 标记 paint dirty
↓
② 浏览器内部 Display List
↓
③ 栅格化(Rasterization)
↓
④ Compositor 合成
2
3
4
5
6
7
8
9
优化路径:
- transform / opacity 动画:跳过 layout & paint,直接 composite
- will-change:提示浏览器为该元素创建独立合成层
- 避免 box-shadow / filter 频繁变化:触发 paint
# 9.4 重绘范围最小化原则
修改 → 标脏 → 重绘
↑ ↑
最小化 最小化
2
3
最小化标脏:
- Android:调用 invalidate(Rect rect) 而非 invalidate()
- iOS:setNeedsDisplayInRect:
- Web:避免操作影响 layout 的属性
最小化重绘:
- 拆分独立 layer / 独立 View
- 把"频繁变化"的部分独立成一个 View
- 静态部分用 setLayerType(HARDWARE) 缓存(注意 GPU 内存代价)
探索性思考:为什么 Web 的 transform 动画"零重绘"?因为浏览器把它放在 Compositor 线程上执行——transform 矩阵只是改变 Layer 的合成参数,不需要重新栅格化。这是 GPU 加速的正确用法 —— 大多数移动端的"丝滑动画"都基于这一原理(iOS Core Animation、Android RenderThread 都同此设计)。
# 10.列表复用全链路
列表是 UI 性能的"重灾区"——一个 1000 项的列表如果没有复用,要创建 1000 个 cell,Inflate 成本极高且内存爆掉。复用是列表的"灵魂"。
# 10.1 Android RecyclerView 复用全链路
RecyclerView.onLayout()
↓
① LayoutManager 决定可见范围
↓
② 对每个可见位置:tryGetViewHolderForPositionByDeadline()
→ ① Scrap(屏幕内已有的)
→ ② Cache(recyclePool 缓存的)
→ ③ ViewCacheExtension(自定义)
→ ④ RecycledViewPool(复用池)
→ ⑤ getViewForPosition → onCreateViewHolder(最贵)
↓
③ onBindViewHolder(绑定数据)
↓
④ 加入 ViewGroup
2
3
4
5
6
7
8
9
10
11
12
13
14
关键瓶颈:
- onCreateViewHolder:每次未命中复用都要 inflate
- onBindViewHolder:每滚动一行触发,里面绝不能 IO / 解码 / 复杂计算
优化机会:
// ① 预创建 ViewHolder(PrefetchPool)
recyclerView.recycledViewPool.setMaxRecycledViews(TYPE_NORMAL, 20)
// ② onBindViewHolder 减负:图片/格式化全异步
override fun onBindViewHolder(holder: VH, position: Int) {
holder.title.text = data[position].title // 简单赋值
Glide.with(...).into(holder.image) // 异步加载
// ❌ 禁止 holder.text = formatComplexDate(data[position].time)
}
// ③ DiffUtil:只更新变化的 item
DiffUtil.calculateDiff(callback).dispatchUpdatesTo(adapter)
// ④ stableIds:避免重复创建
adapter.setHasStableIds(true)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 10.2 iOS UITableView / UICollectionView 复用全链路
tableView.reloadData() / 滚动
↓
① cellForRowAtIndexPath // 询问 cell
↓
② dequeueReusableCellWithIdentifier
→ 命中:复用
→ 未命中:实例化 cell
↓
③ 配置 cell 属性
↓
④ heightForRowAtIndexPath(如果不是 estimatedRowHeight)
2
3
4
5
6
7
8
9
10
11
iOS 优化要点:
- estimatedRowHeight:异步算高度,避免一开始就遍历所有 cell
- prefetchDataSource (iOS 10+):预加载即将出现的 cell 数据
- cell 用代码创建 > XIB(性能)
- cellForRowAtIndexPath 减负:图片/格式化全异步
# 10.3 Web 虚拟滚动全链路
Web 没有原生复用机制,需要"虚拟滚动"实现:
监听 scroll
↓
① 计算当前可见区域
↓
② 渲染可见的 N+buffer 个项目(DOM 中只存在 N 个节点)
↓
③ 滚出可视区的 DOM 移除(或复用)
2
3
4
5
6
7
主流方案:
- React-Window / React-Virtualized
- Vue: vue-virtual-scroller
- Vanilla:手写或 IntersectionObserver
# 10.4 跨端列表复用对照
| 平台 | 复用机制 | 单 cell 创建 | 适用规模 |
|---|---|---|---|
| Android RecyclerView | RecycledViewPool | 5-20ms | 10000+ |
| iOS UITableView | dequeueReusableCell | 5-15ms | 10000+ |
| iOS UICollectionView | dequeueReusableCell | 5-20ms | 10000+ |
| Web Virtual Scroll | 自定义/库 | 1-5ms | 5000+ |
| Flutter ListView.builder | 内置懒加载 | 3-10ms | 10000+ |
| Compose LazyColumn | 内置懒加载 | 3-10ms | 10000+ |
探索性思考:为什么"复用"是列表的唯一解?因为一个列表的真实可见数量永远是 ~10 个 item(屏幕高度限制),而总数据量可能是 10000 个。"创建 10000 个对象只为了滚动到第 9999 个"在数学上就是浪费。复用是把"列表大小"和"内存大小"解耦的工程范式 —— 这是所有平台共通的智慧。
# 11.声明式 UI 全链路
# 11.1 Compose 渲染全链路
@Composable 函数 setContent {}
↓
① Composer:构建 Slot Table(中间表示)
↓
② Composition:把 @Composable 调用变为 LayoutNode 树
↓
③ Measure phase:measure() on each LayoutNode
↓
④ Layout phase:place() on each LayoutNode
↓
⑤ Draw phase:onDraw() on Skia Canvas
↓
⑥ 上屏(与 View 体系一样的 RenderThread)
2
3
4
5
6
7
8
9
10
11
12
13
核心机制:
- Smart Recomposition:只重新执行依赖了变化 state 的 @Composable
- Skipping:参数没变的 @Composable 直接跳过
- Layout 模型:Compose 是"一次测量",比 View 的 RelativeLayout 双测量快
# 11.2 SwiftUI 渲染全链路
var body: some View
↓
① ViewBuilder 构造 View 树(值类型,不是引用)
↓
② ViewGraph 构建(内部数据结构)
↓
③ Layout:top-down 询问"建议尺寸" + bottom-up 返回"实际尺寸"
↓
④ Render:转换为 CALayer / Metal 调用
2
3
4
5
6
7
8
9
SwiftUI 的 Layout 系统:
- 父向子询问"我建议你这么大"
- 子返回"我接受这么大"
- 父决定最终位置
# 11.3 React 渲染全链路
JSX → React Element(虚拟 DOM)
↓
① Reconciliation(diff):找出最小变更
↓
② Fiber 调度:可中断的渲染
↓
③ Commit phase:批量应用 DOM 操作
↓
④ 浏览器 Layout / Paint
2
3
4
5
6
7
8
9
# 11.4 声明式 vs 命令式性能对照
| 场景 | 命令式(View/UIView) | 声明式(Compose/SwiftUI/React) |
|---|---|---|
| 静态 100 节点首屏 | 50-200ms | 30-100ms |
| 动态状态变更 | 触发整 ViewGroup invalidate | 智能 diff 只重渲变化部分 |
| 列表复用 | 手动 dequeue | 框架自动 |
| 主线程占用 | 高 | 中(部分工作可异步) |
Compose 在某些场景反而慢的原因:
- 首屏多了 Slot Table 构建开销
- 简单页面(< 20 节点)开销大于收益
- Skipping 失效(用了 lambda capture)会触发不必要重组
探索性思考:为什么 Compose / SwiftUI 没有"完全替代"传统 View?因为它们的性能优势集中在"动态变化频繁"的场景 —— 静态简单页面,传统 View 反而更快。架构演进不是替代而是分化:复杂动态页面用声明式,简单静态页面继续命令式。最佳实践都是"混合架构"。
# 12.跨端 UI 对照
# 12.1 五个全链路总览
| 链路 | Android | iOS | Web | Flutter | Compose |
|---|---|---|---|---|---|
| Inflate | XML+反射 | XIB / 代码 | DOM+CSS | Widget→Element | Slot Table |
| Layout | onMeasure/onLayout | layoutSubviews / Auto Layout | reflow | RenderObject | LayoutNode |
| Draw | onDraw + HWUI | drawRect + Core Animation | paint + Compositor | Skia | Skia |
| 列表复用 | RecyclerView | UITableView | 虚拟滚动 | ListView.builder | LazyColumn |
| 声明式 | Compose | SwiftUI | React/Vue | Widget | Compose |
# 12.2 各平台优化优先级
Android:
- 减层级(ConstraintLayout 用得对)
- AsyncLayoutInflater
- RecyclerView 复用配合 prefetch
- ViewStub 延迟 inflate
iOS:
- 代码创建 cell(vs XIB)
- estimatedRowHeight 异步算高
- dequeueReusableCell 配合 prefetchDataSource
- 避免 Off-screen Rendering(圆角/阴影)
Web:
- 减少 DOM 节点
- 用 transform/opacity 做动画(跳过 layout)
- 虚拟滚动
- CSS containment(contain: layout)
Compose / SwiftUI:
- remember 减少不必要重组
- 拆分小的 @Composable
- LazyColumn / LazyVStack 替代 Column
- derivedStateOf 派生状态
# 12.3 反直觉问题答疑
| 问题 | 答案 |
|---|---|
| 减少 View 层级一定能让渲染更快吗? | 是(核心原理),但 ConstraintLayout 的 overhead 在简单场景反向 |
| ConstraintLayout 一定比 LinearLayout 嵌套快吗? | 嵌套 ≥ 4 层时是;2-3 层反而慢 |
| 异步 inflate 是不是总比同步快? | 不是,复杂度 < 50ms 的 cell 异步更慢(attach 闪烁) |
| wrap_content 真的慢吗? | 嵌套时慢(双测量),单层不慢 |
| RelativeLayout 真的会"测量两次"吗? | 是,但仅当含 layout_above/below/baseline 时 |
| View 复用一定能加速滚动吗? | 是,无复用 1000 cell 必 OOM |
| 软件渲染什么时候比 GPU 快? | 极少数低端机或简单 2D(GPU 上下文切换开销大于 CPU 绘制) |
| 页面层级 5 vs 10 实测差多少? | 5 vs 10 层差 30-80ms(Inflate)+ 5-15ms(每帧 Layout) |
▶▶ 回扣 §02 案例:经验派的失败 100% 命中"反直觉问题"——不分场景滥用经验招。这正是写本节的意义:每个"经验"都有边界,没有边界的经验等于偏见。
# 13.治理一层减节点
核心命题:任何 UI 优化的第一步都是减少节点数。这是物理约束(节点数 = 工作量基数),任何上层优化都建立在这个基础上。
# 13.1 减节点的四种手法
① 删除:业务上确实不需要的节点直接删
② 合并:用一个节点表达多个节点的视觉效果
③ 扁平化:用 ConstraintLayout / Auto Layout 替代嵌套
④ 延迟:ViewStub / 懒加载,需要时才创建
2
3
4
# 13.2 删除:从业务侧砍
最被低估但最有效的优化:问产品经理"这个 View 真的需要吗"。
典型可砍场景:
- 装饰性的 divider(用 background 替代)
- 占位 View(FrameLayout 里嵌一层 LinearLayout)
- 调试残留 View
- "未来可能用到"的 View
# 13.3 合并:减少节点的同时不损失效果
Android merge / include:
<!-- 错误:嵌套 ViewGroup -->
<FrameLayout>
<LinearLayout>
<TextView />
<TextView />
</LinearLayout>
</FrameLayout>
<!-- 正确:用 merge 减少一层 -->
<merge>
<TextView />
<TextView />
</merge>
2
3
4
5
6
7
8
9
10
11
12
13
用单个 View 表达视觉效果:
<!-- 错误:3 个 View 表达"标题+小图标" -->
<LinearLayout>
<ImageView />
<TextView />
</LinearLayout>
<!-- 正确:用 drawableLeft 在 TextView 上 -->
<TextView android:drawableLeft="@drawable/icon" />
2
3
4
5
6
7
8
# 13.4 扁平化:用 ConstraintLayout(注意场景边界)
ConstraintLayout 适用场景:
- 嵌套层级 ≥ 4 层
- 包含相对定位需求
- 需要 chain / barrier / guideline 等高级特性
ConstraintLayout 不适用场景:
- 简单 LinearLayout(< 5 子 View)
- 单层水平/垂直排列
实测数据(详见 §17 实验一):
| 场景 | LinearLayout | ConstraintLayout |
|---|---|---|
| 嵌套 5 层 | 25ms | 8ms(3.1× 提升) |
| 单层 3 个 View | 1.2ms | 1.5ms(反而慢) |
# 13.5 延迟:ViewStub 与懒加载
ViewStub:
<ViewStub
android:id="@+id/stub_comment"
android:layout="@layout/comment_section"
android:visibility="gone" />
2
3
4
// 真正需要时才 inflate
findViewById<ViewStub>(R.id.stub_comment).inflate()
2
懒加载场景:
- 错误页面(90% 不显示)
- 评论区(用户可能不下滑)
- 广告位(部分场景不展示)
- 设置项的展开内容
探索性思考:为什么"减节点"是最难做的?因为减节点经常需要业务妥协——产品想加 5 个 View,开发要砍掉 2 个。这是"政治问题而非技术问题"。真正的高手不是会扁平化布局,而是能说服产品砍 View。技术 < 沟通。
▶▶ 回扣 §02 案例:详情页 9 层→4 层是减节点的主战场——Day 3 的"扁平化"不是简单技术问题,而是和产品 PK 后砍掉了非必要的相关推荐区(移到 ViewStub 延迟)。
# 14.治理二层延 Inflate
核心命题:把昂贵的 Inflate 工作"延后"或"移走"——让用户先看到关键内容,次要内容慢慢加载。
# 14.1 异步 Inflate(适合复杂页面)
Android AsyncLayoutInflater:
AsyncLayoutInflater(context).inflate(R.layout.complex_section, parent) { view, _, _ ->
parent.addView(view)
}
2
3
注意事项:
- 子线程不能用 ContextCompat.getColor 等需要主线程资源的 API
- inflate 完成后必须主线程 attach
- 简单 cell 不要异步(attach 闪烁 + 总耗时反增)
# 14.2 编译期生成(彻底跳过反射)
X2C / XmlToJava:编译期把 XML 转为 Java/Kotlin 代码,跳过运行时反射。
收益:
- 跳过 XML 解析
- 跳过反射 newInstance
- 通常加速 30-50%
代价:
- 编译时间增加
- 调试复杂度增加
# 14.3 ViewStub 延迟(最便宜的优化)
如 §13.5 所述。首屏只 inflate "用户首次看到"的部分。
# 14.4 RecyclerView 预创建(适合列表)
// 子线程提前 inflate cell 放进 RecycledViewPool
val pool = RecyclerView.RecycledViewPool()
pool.setMaxRecycledViews(TYPE_NORMAL, 20)
recyclerView.setRecycledViewPool(pool)
// 提前预热
backgroundExecutor.execute {
repeat(10) {
AsyncLayoutInflater(context).inflate(R.layout.cell_normal, null) { view, _, _ ->
// 把 view 包装成 ViewHolder 放进 pool
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 14.5 跨平台 Inflate 延迟对照
| 平台 | 异步 Inflate 方案 |
|---|---|
| Android | AsyncLayoutInflater / 子线程 LayoutInflater |
| iOS | dispatch_async + alloc/init UIView |
| Web | requestIdleCallback / Web Worker |
| Flutter | 内置(Widget tree 构建廉价) |
| Compose | 内置(@Composable 智能跳过) |
探索性思考:为什么"异步 Inflate"在简单 cell 上反而更慢?因为 attach 操作必须回主线程,且 attach 是 ViewParent 的 measure 触发链——简单 cell 自身 inflate < 5ms,跨线程切换的开销(context switch + attach delay)就 > 5ms,加上 attach 时的视觉闪烁,净收益是负的。异步不是免费的。
▶▶ 回扣 §02 案例:评论/广告/相关推荐异步 Inflate 是关键——这些区域单个 cell ~30ms,跨线程切换 ~5ms,净收益 25ms × 3 区 = 75ms。但简单的标题/正文 cell 不异步(< 5ms 没收益)。
# 15.治理三层缩重绘
核心命题:减少"每帧产出"的工作量——既要让"重绘范围"最小,也要让"重绘频率"最少。
# 15.1 局部 invalidate(最朴素)
// 错误:整个 View 重绘
invalidate()
// 正确:只重绘变化区域
invalidate(dirtyRect)
2
3
4
5
适用:自定义 View 中只更新部分内容(如时钟的指针)。
# 15.2 拆分独立 View(高频/低频隔离)
// 错误:把"经常变化的 progress"和"几乎不变的标题"放一个 View
class ProgressBar : View {
fun draw(canvas: Canvas) {
canvas.drawText(title, ...) // 每帧都画
canvas.drawProgress(progress, ...) // 每帧都画
}
}
// 正确:拆分
<LinearLayout>
<TextView id="@+id/title" /> // 只在数据变化时重绘
<ProgressBar id="@+id/progress" /> // 高频重绘
</LinearLayout>
2
3
4
5
6
7
8
9
10
11
12
13
# 15.3 setLayerType(缓存重绘结果)
// 把 View 渲染结果缓存到 GPU
view.setLayerType(View.LAYER_TYPE_HARDWARE, null)
2
适用场景:
- 静态背景
- 复杂 onDraw(如 SVG / Path)
- 经常 invalidate 但内容不变
禁忌:
- 给所有 View 加(GPU 内存爆掉)
- 给"不断变化内容"加(缓存等于失效)
# 15.4 Web 的 GPU 加速正确姿势
/* 用 transform 做动画(跳过 layout & paint) */
.anim {
transform: translateX(100px);
transition: transform 0.3s;
}
/* 提示浏览器创建独立合成层 */
.layer {
will-change: transform;
}
/* CSS Containment:限制 reflow 范围 */
.card {
contain: layout style paint;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 15.5 跨平台缩重绘对照
| 平台 | 局部 invalidate | 缓存渲染 | 高频/低频隔离 |
|---|---|---|---|
| Android | invalidate(Rect) | setLayerType | 拆 View |
| iOS | setNeedsDisplayInRect | shouldRasterize | 拆 UIView |
| Web | DOM 局部更新 | will-change | 拆 component |
| Compose | invalidate scope | drawWithCache | 拆 @Composable |
探索性思考:为什么"缩重绘"经常被忽视?因为它的代价反馈周期长——重绘代价是"每帧"的,1ms 看不出来,但 60 帧就是 60ms/秒。性能问题的"温水煮青蛙" —— 用户感知的卡顿是慢慢累积的,不是一次性的,所以工程师容易忽略每帧 1-2ms 的优化。
▶▶ 回扣 §02 案例:SVG 改 WebP(避免每帧实时解析)+ 大背景 setLayerType + 局部 invalidate(Rect) 三招组合,让 Draw 阶段从 22ms → 5ms,对帧时长贡献 -17ms。
# 16.治理四层 ROI 排序
核心命题:UI 优化的真正难点不是"会用什么",而是"先用什么"——按 ROI(投入产出比)排序。
# 16.1 ROI 排序公式
ROI = (优化收益 × 影响范围) / (改造成本 × 风险)
| 优化项 | 单次收益 | 影响范围 | 改造成本 | 风险 | ROI |
|---|---|---|---|---|---|
| 减节点(删除冗余) | -10-50ms | 单页面 | 极低 | 低 | ⭐⭐⭐⭐⭐ |
| 用 ViewStub 延迟 | -50-200ms | 首屏 | 极低 | 低 | ⭐⭐⭐⭐⭐ |
| 局部 invalidate | -5-20ms/帧 | 高频路径 | 低 | 极低 | ⭐⭐⭐⭐⭐ |
| 异步 Inflate(复杂 cell) | -50-300ms | 列表/复杂页 | 中 | 中(attach 闪烁) | ⭐⭐⭐⭐ |
| ConstraintLayout 扁平化 | -5-30ms | 单页面 | 中 | 低 | ⭐⭐⭐⭐ |
| RecyclerView prefetch | -20-50ms | 列表 | 低 | 低 | ⭐⭐⭐⭐ |
| setLayerType(动画区) | -5-15ms/帧 | 动画区 | 低 | 中(GPU 内存) | ⭐⭐⭐ |
| 改用 Compose / SwiftUI | varies | 整 App | 极高 | 高 | ⭐⭐ |
| 自定义渲染管线 | varies | 整 App | 极高 | 极高 | ⭐ |
# 16.2 决策路径建议
首屏慢(白屏 > 200ms):
- 先减节点(§13.1)
- 再 ViewStub 延迟非关键(§13.5)
- 复杂 cell 异步 Inflate(§14.1)
- 实在不行才考虑 Compose 重构
滚动卡(FPS < 50):
- 先看 onBindViewHolder 是否有 IO/解码(§10.1)
- 再扁平化 cell 层级(§13.4)
- RecycledViewPool prefetch(§14.4)
- 实在不行才虚拟化或换列表组件
局部卡(动画/交互):
- 先看 invalidate 范围(§15.1)
- 拆 View 隔离高频/低频(§15.2)
- setLayerType 配合静态部分(§15.3)
- 实在不行才考虑 OpenGL 自绘
# 16.3 不要做的"伪优化"
| 伪优化 | 真相 |
|---|---|
| 把所有 LinearLayout 改 ConstraintLayout | 简单场景反而慢 |
| 全量异步 Inflate | 简单 cell 闪烁 + 总耗时增加 |
| setLayerType 加在所有 View | GPU 内存爆掉 |
| 移除 wrap_content 改 fixed size | 业务受损 |
| 关闭硬件加速 | 一般不会比 GPU 快 |
# 16.4 优化前的"度量门槛"
不达到这些数据不要轻易优化:
- 首屏 inflate > 100ms(小于此值优化收益有限)
- 单 cell 创建 > 10ms(小于此值不值得异步)
- 帧时长 P99 > 25ms(小于此值卡顿不显著)
探索性思考:为什么"ROI 排序"是工程师最容易忽视的能力?因为做技术容易陷入"我用了哪些技术"而非"我解决了什么问题"。真正的高手不是用过的优化最多,而是判断准确——只用那些真正解决问题的优化。
▶▶ 回扣 §02 案例:方法派的胜利不在于用了 ConstraintLayout、AsyncLayoutInflater、setLayerType——经验派也用了。胜利在于按 ROI 排序:先减节点(高 ROI 低成本)→ 再延迟 inflate(中 ROI 中成本)→ 最后才动 setLayerType(低 ROI 高风险)。
# 17.求证实验 ⭐
本节是"为什么这些优化生效"的实证基础。每个实验严格遵循"6 步求证法":
- 猜想(直觉假设)
- 假设(带场景边界的命题)
- 设计(控制变量、可重复)
- 执行(采集真实数据)
- 验证(数据是否支持假设)
- 思考(实验启示和扩展)
# 17.1 实验一:扁平化收益(场景边界)
猜想:减少层级总能让渲染变快。
假设:嵌套 ≥ 4 层时,ConstraintLayout 比 LinearLayout 嵌套快;< 4 层时反向。
设计:
- 控制变量:相同视觉效果,相同 View 数量
- 变量:层级深度(2/3/5/8 层),布局类型(LL 嵌套 vs CL 扁平)
- 设备:Pixel 4,Android 11
- 采集:FrameMetrics 看 Layout 阶段耗时(首帧)
执行:
| 视觉布局 | LinearLayout 嵌套 | ConstraintLayout 扁平 |
|---|---|---|
| 2 层(3 个 TextView 排列) | 1.2ms | 1.5ms |
| 3 层(含图标 + 文本) | 2.5ms | 2.3ms |
| 5 层(带 padding 嵌套) | 8.5ms | 4.5ms |
| 8 层(深嵌套) | 25ms | 8ms |
验证:
- 假设成立。临界点在 3-4 层。
- 简单场景(≤ 3 层)ConstraintLayout 自身 overhead 大于扁平化收益。
- 复杂场景(≥ 5 层)扁平化收益线性放大。
思考:
- ConstraintLayout 的求解器是常数代价(约 1ms);LinearLayout 嵌套是 O(N²) 代价。
- 简单场景用 LinearLayout,复杂场景用 ConstraintLayout —— 这不是"哪个更好",而是"哪个适合"。
# 17.2 实验二:异步 Inflate 收益(场景边界)
猜想:异步 Inflate 总比同步快。
假设:当 cell inflate 成本 > 30ms 时,异步净收益为正;< 10ms 时为负。
设计:
- 变量:cell 复杂度(5 / 30 / 100ms)、inflate 数量(5 / 20 / 50 个)
- 测量:① 总耗时 ② 首屏可见时间 ③ 视觉闪烁
执行:
| 单 cell 成本 | 同步 5 个 | 异步 5 个 | 同步 20 个 | 异步 20 个 |
|---|---|---|---|---|
| 5ms | 25ms(流畅) | 30ms + 闪烁 | 100ms(顿) | 80ms + 闪烁 |
| 30ms | 150ms(白屏) | 70ms(流畅) | 600ms(白屏) | 200ms(流畅) |
| 100ms | 500ms(崩盘) | 150ms(流畅) | 2000ms(崩盘) | 400ms(流畅) |
验证:
- 假设成立。临界点在 30ms 单 cell 成本。
- 简单 cell 异步反而总耗时增加(context switch + attach 开销)。
思考:
- attach 必须主线程,无法纯异步。
- 异步 Inflate 的真正价值在于"复杂 cell",不是"所有 cell"。
# 17.3 实验三:局部重绘收益
猜想:局部 invalidate(Rect) 总比 invalidate() 快。
假设:当 invalidate 范围占 View 面积 < 30% 时,局部 invalidate 收益显著(>2×)。
设计:
- 1080×2400 屏幕的全屏自定义 View,每帧重绘
- 变量:invalidate 范围占比(5% / 30% / 80% / 100%)
执行:
| invalidate 范围 | 每帧 Draw 耗时 | FPS |
|---|---|---|
| 5%(小动画区) | 0.8ms | 60 |
| 30%(局部更新) | 4ms | 60 |
| 80%(大半屏更新) | 12ms | 50 |
| 100%(全屏) | 16ms | 35 |
验证:
- 假设成立。5% 范围比 100% 范围快 20×。
思考:
- 局部 invalidate 是"零成本"优化(只改一行代码)。
- 但前提是知道"哪个区域真的变了"——这需要业务侧的拆分。
# 17.4 实验四:列表复用与 onBindViewHolder 减负
猜想:onBindViewHolder 越简单,滚动 FPS 越高。
假设:onBindViewHolder 中含 IO/解码/复杂格式化时,FPS 必降到 < 40。
设计:
- 列表 1000 项,含图片 + 文本 + 时间格式化
- 变量:onBindViewHolder 实现(同步加载 / 异步加载 / 极简)
执行:
| 实现 | onBindViewHolder 平均耗时 | 滚动 FPS | 帧时长 P99 |
|---|---|---|---|
| 同步加载图片 + 同步格式化 | 35ms | 22 | 110ms |
| 异步加载图片(Glide)+ 同步格式化 | 8ms | 45 | 35ms |
| 异步图 + 异步格式化(缓存) | 1.5ms | 60 | 18ms |
验证:
- 假设成立。onBindViewHolder 必须 < 8ms(帧预算 50%)。
思考:
- onBindViewHolder 是"最高频"代码路径,每滚一行都执行。
- 任何 IO / 复杂计算必须移出。
# 17.5 实验五:声明式 UI vs 命令式 UI
猜想:Compose 比传统 View 更快。
假设:动态状态变化频繁场景下 Compose 占优;静态简单场景 View 占优。
设计:
- 同样视觉效果的页面(10 个 TextView + 5 个 ImageView)
- 变量:是否频繁变更状态(每秒 1 次 / 不变)
- 测量:首屏 inflate / 状态变更 / 内存
执行:
| 场景 | View 体系 | Compose |
|---|---|---|
| 首屏 inflate(无状态变更) | 25ms | 50ms(劣) |
| 状态变更(每秒 1 次,10 次) | 累计 80ms | 累计 30ms(优) |
| 内存(idle) | 8MB | 12MB(劣) |
验证:
- 假设成立。Compose 不是"全面更快",而是"动态场景更快"。
思考:
- 首屏 Compose 多了 Slot Table 构建开销。
- 状态变更 Compose 智能 Skipping,传统 View 必须 invalidate 整 ViewGroup。
# 17.6 五大实验启示
| 启示 | 含义 |
|---|---|
| 没有银弹 | 每个优化都有场景边界 |
| 数据驱动 | 不实测就不要"经验" |
| 临界点思维 | 找到临界点再决定用不用 |
| 高频路径优先 | onBindViewHolder > onCreateViewHolder |
| 度量先于优化 | 不知道现状就不要动手 |
▶▶ 回扣 §02 案例:经验派失败是因为没做实验直接套招;方法派胜利是因为每招都按本节实验逻辑验证后才落地。实验不是奢侈品,是优化前的必经之路。
# 18.实战案例
# 18.1 跨端同构案例:电商详情页
背景:某电商 App 详情页同构 Android / iOS / H5,三端 UI 性能各不相同。
症状:
- Android 首屏 1.2s,iOS 0.8s,H5 1.8s
- iOS 滚动稳定 60fps,Android 50fps,H5 30fps(移动 Safari)
归因:
- Android:XML 层级 8 层(深嵌套)
- iOS:用 XIB(NIB 反序列化慢于代码)
- H5:1500+ DOM 节点、未做虚拟滚动
治理:
- Android:扁平化(→ 4 层)+ AsyncLayoutInflater + RecyclerView prefetch
- iOS:cell 改代码创建(避免 XIB)+ estimatedRowHeight
- H5:DOM 减半 + 虚拟滚动
效果:
| 端 | 首屏(before) | 首屏(after) | 滚动 FPS(before) | 滚动 FPS(after) |
|---|---|---|---|---|
| Android | 1200ms | 450ms | 50 | 58 |
| iOS | 800ms | 380ms | 60 | 60 |
| H5 | 1800ms | 750ms | 30 | 50 |
核心洞察:同构页面也要"差异化优化"——三端的瓶颈不同(Android Inflate / iOS XIB / H5 DOM 数量),所以治理手段也不同。
# 18.2 平台特异案例:Android 折叠屏适配
背景:某新闻 App 折叠屏(如 Galaxy Z Fold)展开时整页重新 Layout,FPS 降至 12。
根因:
- Configuration 变化触发 Activity 重建(onCreate 重新 inflate)
- Inflate 重新走一遍(500ms+)
- 用户感知"折屏一下卡 1 秒"
治理:
// AndroidManifest.xml 处理 configChanges
<activity android:configChanges="screenSize|smallestScreenSize|screenLayout">
2
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// 自定义处理:仅重新计算布局,不 inflate
rootView.requestLayout()
}
2
3
4
5
效果:折屏切换从 1000ms → 80ms(12.5× 提升)。
# 18.3 Compose 反例:Skipping 失效导致重复重组
背景:某 App 用 Compose 重写后,长列表反而比 RecyclerView 慢。
根因:
@Composable
fun ItemRow(item: Item, onClick: () -> Unit) { // ❌ lambda 每次重组都新建
Row(modifier = Modifier.clickable { onClick() }) { ... }
}
// 父:
LazyColumn {
items(list) { item ->
ItemRow(item, onClick = { handleClick(item) }) // ❌ lambda 每次新建
}
}
2
3
4
5
6
7
8
9
10
11
治理:
@Composable
fun ItemRow(item: Item, onClick: (Item) -> Unit) {
Row(modifier = Modifier.clickable { onClick(item) }) { ... }
}
// 父:
LazyColumn {
items(list, key = { it.id }) { item -> // ✅ 加 key
ItemRow(item, onClick = onClickHandler) // ✅ 复用 lambda
}
}
2
3
4
5
6
7
8
9
10
11
效果:滚动 FPS 从 35 → 58(恢复到接近 RecyclerView 水平)。
洞察:Compose 不会自动给你"最优性能" —— 你必须理解 Skipping 机制(参数稳定 + 引用一致),否则 Compose 反而比传统 View 慢。
# 19.防劣化体系
优化不难,难的是"优化后不劣化"。需要"事前预防 + 事中拦截 + 事后回归"三道防线。
# 19.1 三道防线总览
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 编码期 Lint │ → │ CI 卡口 │ → │ 线上 SLO │
│ IDE 即时提示│ │ Macrobench │ │ 监控告警 │
└────────────┘ └────────────┘ └────────────┘
2
3
4
# 19.2 编码期 Lint
Android Lint:
<lint>
<issue id="UseConstraintLayout" severity="warning" /> <!-- 嵌套 ≥ 4 层提示 -->
<issue id="UnusedAttribute" severity="warning" />
<issue id="ContentDescription" severity="error" />
</lint>
2
3
4
5
自定义规则:
- 检测 layout XML 深度(自定义 lint detector)
- 检测 onBindViewHolder 中的 IO 操作
- 检测 invalidate() 调用(建议 invalidate(Rect))
# 19.3 CI 卡口
性能基线测试:
// Macrobenchmark
@Test
fun startupBenchmark() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
2
3
4
5
6
7
8
9
10
11
卡口规则:
- 首屏耗时退化 ≥ 5% → 阻断 PR
- 列表 P99 帧时长退化 ≥ 10% → 警告
- 内存峰值退化 ≥ 10% → 阻断 PR
# 19.4 线上 SLO
核心 SLO 指标:
| 指标 | 目标 | 告警阈值 |
|---|---|---|
| 首屏 P95 | < 1s | > 1.5s |
| 首屏 P99 | < 2s | > 3s |
| 滚动 FPS P95 | > 55 | < 50 |
| 帧时长 P99 | < 25ms | > 50ms |
| ANR 率 | < 0.05% | > 0.1% |
告警 + 自愈机制:
- 自动归因:性能退化告警 → 关联最近 PR
- 灰度回滚:发现问题自动回滚到上一版本
- 报告:每周生成性能趋势报告
# 19.5 文化建设
- 性能预算:新页面必须申报"渲染预算"(Inflate 时长、首帧时长)
- 性能 Code Review:UI 改动必须有 perf reviewer
- 性能 OKR:列表 FPS 进 OKR
探索性思考:为什么"防劣化"比"优化"更难?因为优化是"专项动作",防劣化是"日常文化"。专项可以由一两个高手完成,文化需要全员参与。这就是为什么大厂都有"性能委员会"——不是为了治标,是为了治本。
# 20.跨平台速查
# 20.1 工具速查
| 平台 | 节点级 | 阶段级 | 全局级 | 自动化测试 |
|---|---|---|---|---|
| Android | Layout Inspector / Profiler | Perfetto / Systrace / FrameMetrics | Choreographer / FPS | Macrobenchmark |
| iOS | Instruments View Hierarchy | Time Profiler / Core Animation | CADisplayLink | XCTest Performance |
| Web | Chrome DevTools Performance | Long Tasks API | INP | Puppeteer / Playwright |
| Compose | Layout Inspector | composition trace | Choreographer | Macrobenchmark |
| Flutter | Flutter Inspector | DevTools Timeline | DevTools FPS | flutter_driver |
# 20.2 关键 API 速查
| 目的 | Android | iOS | Web |
|---|---|---|---|
| 异步 Inflate | AsyncLayoutInflater | dispatch_async | requestIdleCallback |
| 延迟显示 | ViewStub | lazy var | dynamic import |
| 局部重绘 | invalidate(Rect) | setNeedsDisplayInRect | DOM 局部更新 |
| 缓存渲染 | setLayerType(HARDWARE) | shouldRasterize | will-change |
| 列表复用 | RecyclerView | UITableView | virtual scroll |
| 跳过 Layout | - | - | transform/opacity |
| 限制 reflow 范围 | - | - | contain: layout |
# 20.3 各平台首屏优化清单
Android 首屏 < 800ms:
- [ ] 关键路径 inflate 层级 ≤ 5
- [ ] 非关键 inflate 用 ViewStub / 异步
- [ ] 列表 ≥ 50 项必用 RecyclerView prefetch
- [ ] 启动期不解码大图 / 不解析复杂 SVG
- [ ] 减少冷启动期的 Bitmap 加载
iOS 首屏 < 600ms:
- [ ] cell 用代码创建(非 XIB)
- [ ] 用 estimatedRowHeight
- [ ] 滚动期不在 cellForRow 中做 IO
- [ ] 避免触发 Off-screen Rendering(圆角/阴影)
- [ ] 用 dequeueReusableCell
Web 首屏 < 1500ms:
- [ ] DOM 节点 ≤ 1500
- [ ] 关键 CSS inline
- [ ] 长列表用虚拟滚动
- [ ] transform / opacity 做动画
- [ ] CSS Containment
# 21.总结与延伸
# 21.1 五条核心原则
- 本质先行:UI 优化 = 减节点 + 缩重绘 + 延 inflate(每个手段对应一个物理约束)。
- 场景边界:每个"经验招"都有边界,没有边界的经验等于偏见。
- 数据驱动:不实测不要"经验",求证实验是优化的基础。
- ROI 排序:先做 ROI 高的(减节点 / ViewStub / 局部 invalidate),再做 ROI 低的(自定义渲染)。
- 防劣化文化:优化是专项,防劣化是日常 —— 优化 + 防劣化 = 性能可持续。
# 21.2 五个常见误区
| 误区 | 真相 |
|---|---|
| "扁平化总比嵌套快" | 简单场景反向(ConstraintLayout 自身 overhead) |
| "异步总比同步快" | 简单 cell 反而更慢 |
| "setLayerType 加上去就快" | GPU 内存代价 + 高频内容反而失效 |
| "Compose 全面替代 View" | 静态简单页面 View 更快 |
| "用了高级技术就是好优化" | ROI 才是最终评判标准 |
# 21.3 一句话总结
UI 优化的本质是减少"产帧成本"——通过减节点、缩重绘、延 inflate 三种手段,把 Layout / Draw / Inflate 三大开销压到帧预算之内。所有手段都有场景边界,没有边界的经验等于偏见。数据驱动 + ROI 排序 + 防劣化文化,是 UI 性能可持续的保障。
# 21.4 延伸阅读
卷三·01 渲染管线与原理:UI 优化的渲染基础卷三·02 FPS 与帧率检测:UI 性能的量化卷三·03 卡顿捕获与归因:UI 卡顿归因卷三·04 ANR 监控与治理:极端卡顿卷三·06 动画交互响应优化:交互动效专项卷四·04 列表与滚动性能:列表专项
下一篇预告:
卷三·06 动画交互响应优化—— 把"用户感知流畅"的最后一公里做好。