一、基本骨架
从代码到像素,都经历了什么?一帧画面是怎么到屏幕上的?
┌──────────────────────────────────────────────────────────────────────────────┐
│ 一帧的完整生命周期 (Render Loop) │
│ │
│ VSYNC₁ VSYNC₂ VSYNC₃ │
│ │ │ │ │
│ │ ┌─────────── App 进程 ───────────────┐ │ │
│ │ │ ① Handle Event ② Commit Transaction│ │ │
│ │ │ (触摸/定时器) ┌────────────────┐ │ │ │
│ │ │ │Layout → Display ││ │ │
│ │ │ │Prepare → Package││ │ │
│ │ │ └────────────────┘│ │ │
│ │ └──────────┬──────────────────────────┘ │ │
│ │ │ Layer Tree 发送 │ │
│ │ ▼ │ │
│ │ ┌─────────── Render Server (独立进程) ─────┐ │ │
│ │ │ ③ Render Prepare ④ Render Execute(GPU)│ │ │
│ │ │ (编译绘制指令) (逐层合成到纹理) │ │ │
│ │ └──────────────────────────┬────────────────┘ │ │
│ │ │ 最终纹理就绪 │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ ⑤ Display/硬件合成│◀─── 帧上屏 ────│ │
│ │ └──────────────────┘ │ │
│ │ │ │
│ │◀─── 1 frame (16.67ms @60Hz / 8.33ms @120Hz) ──►│ │ │
│ │◀──────────── 2 frames: 事件到上屏的最小延迟 (Double Buffering) ──────►│ │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ 超时在 App 端 (①②) ──→ Hang (卡顿/无响应) + Commit Hitch (掉帧) │
│ 超时在 GPU 端 (③④) ──→ Render Hitch (掉帧/动画抖动) │
│ │
│ Hang: 主线程被占 > 250ms,用户感知 "按不动" "界面冻结" │
│ Hitch: 帧未在 VSYNC deadline 前就绪,用户感知 "动画跳了一下" │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │CPU: Event│→ │CPU: Commit│→│GPU: Render │→ │Hardware: Display │ │
│ │ 事件处理 │ │ 提交变更 │ │ 合成 + 离屏 │ │ 像素点亮 │ │
│ └──────────┘ └──────────┘ └──────────────┘ └───────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
渲染机制定义了每一帧画面从产生到上屏幕的流水线;
动画是连续多帧的有规律变化,利用渲染流水线实现;
卡顿是流水线中任意环节超时,导致帧被丢弃;
- 核心概念关系: |概念 | 本质 | 与其他概念的关系 | | -------------- | ------------------------- | ------------------------------ | | Render Loop | 系统以屏幕刷新率(60/120Hz)驱动的持续循环 | 所有可见变化的底层引擎 | | CALayer | 视觉内容的载体,持有位图和属性 | 动画的作用对象,渲染的输入 | | Core Animation | 动画+渲染的基础框架 | 管理 Layer Tree,驱动 Render Server | | 动画 (Animation) | 属性随时间的插值变化 | 在 Render Loop 中被逐帧求值 | | Hang(卡顿) | 主线程被占用导致事件无法及时处理 | 用户感知为"按不动""无反应" | | Hitch(掉帧) | 某帧未能在 VSYNC 截止时间前就绪 | 用户感知为动画跳跃、滚动卡顿|
二、Render Loop(渲染循环):渲染机制拆解,一帧是怎样诞生的
2.1 五个阶段
Rnder Loop 是一个以 VSYNC 为节拍、流水线式并行的循环。在 Double Buffering 模式下,一帧从事件到上屏幕需要经过 2 个 VSYNC 周期。
VSYNC₁ VSYNC₂ VSYNC₃
│ │ │
┌─── App 进程 ───────┤ │ │
│ ① Event Phase │ │ │
│ ② Commit Phase │ │ │
└───────────────────┤ │ │
│ ┌─ Render Server──┤ │
│ │ ③ Prepare Phase │ │
│ │ ④ Execute Phase │ │
│ └─────────────────┤ │
│ │ ⑤ Display │
│ │ 帧上屏 │
| 阶段 | 进程 | 做什么 | 关键耗时原因 |
|---|---|---|---|
| Event | App | 接收触摸、定时器等事件,决定 UI 是否需要变化 | — |
| Commit | App | Layout → Display(drawRect) → Prepare(图片解码) → 打包 Layer Tree 发给 Render Server | 布局复杂、视图层级深、大图解码 |
| Render Prepare | Render Server | 遍历 Layer Tree,编译为 GPU 绘制指令流水线 | Layer 数量多、需要 Offscreen Pass |
| Render Execute | GPU | 逐层合成到最终纹理 | Offscreen Pass、大面积模糊/阴影 |
| Display | 硬件 | 把纹理推上屏幕 |
2.2 Commit 阶段的四个子步骤
Commit 是 App 端最关键的阶段,它本身又分为四步:
Commit Transaction
│
├─ 1. Layout 调用 layoutSubviews / SwiftUI body
│ → setNeedsLayout 触发
│
├─ 2. Display 调用 drawRect / draw(_:)
│ → setNeedsDisplay 触发
│ → 生成 backing store (位图)
│
├─ 3. Prepare 图片解码 + 色彩空间转换
│ → 大图 / 非标准格式图开销大
│
└─ 4. Package 递归打包 Layer Tree 发送
→ 层级越深越慢
Commit Transaction是一个 RunLoop 循环结束时自动提交的隐式事务;Backing Store是 Layer 的位图缓存。
2.3 Double Buffering 与 Triple Buffering
Double Buffering(双缓冲) 是 iOS 渲染流水线的默认工作模式,指系统同时维护 两个帧缓冲区,让 App 准备下一帧和屏幕显示当前帧可以并行进行,互不干扰。
- Double Buffering(默认):App 和 Render Server 各占一个 VSYNC 周期,总延迟 2 帧。
- Triple Buffering(降级模式):当 Render Server 来不及时,系统自动切换,给 Render Server 多一帧的时间。帧延迟增加到 3 帧,但能避免更严重的掉帧。
为什么需要缓冲区?
如果只有一个缓冲区(Single Buffering),屏幕 正在读取这个缓冲区显示画面 的同时,GPU 也在往里写新内容,就会出现 画面撕裂(Screen Tearing)——上半截是旧帧,下半截是新帧。
三、CALayer 与三棵树:动画的根基
3.1 Layer 是什么?
CALayer 是一个 模型对象,它不做绘制,它只持有:
- 几何信息: bounds / position / transform / anchorPoint
- 视觉属性: backgroundColor / opacity / cornerRadius / shadow
- 内容: contents 位图
View 和 Layer 的关系: iOS 上每个 UIView 都自动持有一个 backing layer。View 负责事件响应(触摸、手势)和响应链,Layer 负责视觉呈现。你改 view.frame 其实改的是view.layer 的属性。Layer 不处理事件、不参与响应链。
3.2 三棵 Layer Tree
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Model Tree │ │ Presentation Tree│ │ Render Tree │
│ (图层树) │ │ (呈现树) │ │ (渲染树) │
│ │ │ │ │ │
│ 你代码改的值 │ │ 动画进行中的当前值 │ │ 实际渲染用 │
│ = 动画目标值 │ │ = 屏幕上的即时值 │ │ (私有,不可访问)│
└─────────────┘ └──────────────────┘ └─────────────┘
│ ▲
│ layer.presentationLayer
└────────────────────┘
- Model Tree(图层树): 比如
layer.position = newPos改的就是它。它始终保存 “最终目标值”。 - Presentation Tree(呈现树): 动画进行时,layer 实际所在位置是
layer.presentationLayer。 - Render Tree(渲染树): Core Animation 内部使用,无法访问。
关键推论: 给 layer 加动画后,Model Tree 里的值就已经是终点值了。动画结束后如果 removedOnCompletion = YES(默认),layer 就直接呈现 Model Tree 的值。如果你没改 Model Tree 的值,layer 就会"跳回去"——这就是动画结束后 layer 回到原位的经典问题。
presentationLayer 的使用场景: 用户在动画飞行途中点击/拖拽 layer 时,需要用 presentationLayer 获取当前真实位置来做 hitTest 或启动新动画。
3.3 CATransaction - 变更打包器
所有对 Layer 的属性修改都被 CATransaction 捕获:
- 隐式事务: 哪怕不写 begin/commit,系统也会在每个 RunLoop 循环自动包裹一次。
- 显式事务:
[CATransaction begion] ... [CATransaction commit],可以控制动画时长、completionBlock 等。
隐式事务是 UIView 隐式动画(改 layer 属性自动产生 0.25s 动画)的底层机制。UIView 的 animateWithDuration: 本质上就是开一个显式事务并配置参数。
四、动画系统
动画 = 内容(什么在变) + 时间(多久完成) + 变化规律(怎么变)
| 要素 | 对应 API | 说明 |
|---|---|---|
| 内容 | keyPath (如 position, opacity, transform.rotation.z) | 必须是 CALayer 上标记为 Animatable 的属性 |
| 时间 | duration + timingFunction | timingFunction 控制"时间的流速"(加速/减速/弹性) |
| 变化规律 | 动画子类决定(Basic = 两点插值,Keyframe = 多点插值,Spring = 弹簧物理) | — |
- 动画类的继承体系:
CAAnimation (基类:timingFunction, delegate, removedOnCompletion)
│
├─ CAPropertyAnimation (抽象:keyPath, additive, cumulative)
│ │
│ ├─ CABasicAnimation (fromValue / toValue / byValue)
│ │ │
│ │ └─ CASpringAnimation (mass / stiffness / damping / initialVelocity)
│ │
│ └─ CAKeyframeAnimation (values / keyTimes / path / calculationMode)
│
├─ CATransition (type / subtype — 转场快照动画)
│
└─ CAAnimationGroup (animations[] — 组合多个动画)
4.1 CABasicAnimation — 两点插值
提供起止状态,系统通过插值(Interpolation)算出任意时刻的值。三个属性的语义:
- fromValue:起始值(绝对值)
- toValue:结束值(绝对值)
- byValue:变化量(相对值,"变化了多少")
4.2 CAKeyframeAnimation — 多点插值
关键帧动画 = N 段 BasicAnimation 的串联。提供一组 values 和对应的 keyTimes(归一化 0~1),系统在相邻关键帧之间插值。
calculationMode 决定插值方式:linear(默认)
4.3 CASpringAnimation — 弹簧物理
继承自 CABasicAnimation,用弹簧力学模型驱动动画曲线:mass(质量越大,运动越慢,但衰减也越慢)等。
4.4 CATransition — 两张快照之间的过渡
CATransition 不指定 from/to 值。它的工作方式:
- 把动画添加到 layer 时,拍下当前 layer 的快照(开始状态)
- 紧接着你对 layer 做修改(比如替换子视图、改文字)
- 修改后的 layer 是结束状态
- 系统在两张快照之间播放指定的过渡效果
4.5 CAAnimationGroup — 组合动画
把多个动画放在 animations 数组里同时执行。注意:
- Group 的 duration 是一个 硬截止:到时间所有子动画停止,不管子动画是否结束。
- 各子动画独立执行,不互相等待。
五、Hang(卡顿/无响应):主线程被占的代价
Hang = 主线程无法在合理时间内处理用户事件。
WWDC23 统计过一期人类感知阈值,大概如下:
0ms 100ms 250ms 500ms
│─── 感觉即时 ──│── 微妙可感 ──│── 明显延迟 ──│── 严重卡顿 ──▶
│ │ │
目标上限 Micro Hang(系统开始上报) Hang
5.1 Hang 的三种类型
| 类型 | 主线程 CPU | 表现 | 典型原因 |
|---|---|---|---|
| Busy Main Thread | 高(60~100%) | 主线程在拼命算东西 | 大量布局计算、同步图片处理、JSON 解析 |
| Blocked Main Thread | 极低(~0%) | 主线程在等锁/等IO/等网络 | 同步网络请求、信号量等待、锁竞争、同步文件IO |
| Asynchronous Hang | 可高可低 | 不是当前事件导致的,而是之前调度到主线程的任务占了时间 | dispatch_async(main) 的耗时任务、@MainActor 下的同步代码 |
同步 Hang:
用户点击 → [────── 主线程处理耗时 ──────] → 响应
←─── 这段就是 hang ───→
异步 Hang:
之前调度的任务 → [──── 主线程被占 ────]
↑ 用户点击来了,但得排队
←── 这段是 hang ──→
5.2 Swift Concurrency 中的陷阱
WWDC23 Session 10248 中详细阐述的一个经典问题:
struct BackgroundThumbnailView: View {
var body: some View { // body 隐式继承 @MainActor
ProgressView()
.task { // .task 闭包继承外部 actor 隔离 → 也在 MainActor
image = background.thumbnail // 同步属性 → 在主线程执行!
}
}
}
- 问题:
.task闭包继承body的@MainActor隔离,同步属性thumbnail在主线程执行。await只在调用async函数时才切换线程。 - 解法: 把
thumbnail改为asyncgetter,使其能在 Cooperative Thread Pool 上执行:
public var thumbnail: UIImage {
get async { /* compute and cache */ }
}
// 使用处
.task {
image = await background.thumbnail // 现在能离开 @MainActor 了
}
六、Hitch(掉帧):动画不流畅的元凶
Hitch = 某一帧没能在 VSYNC deadline 前就绪,导致前一帧重复显示。
单次 hitch time(毫秒)不方便跨测试对比。Apple 定义了 Hitch Time Ratio:
Hitch Time Ratio = 总 hitch 时间 / 总持续时间 (单位: ms/s)
| 等级 | Hitch Time Ratio | 用户感知 |
|---|---|---|
| Good | < 5 ms/s | 基本无感 |
| Warning | 5~10 ms/s | 能注意到部分中断 |
| Critical | > 10 ms/s | 严重影响体验,必须立即修复 |
6.1 Hitch 的两种类型
| 类型 | 超时发生在 | 常见原因 |
|---|---|---|
| Commit Hitch | App 端 Commit 阶段 | 复杂布局、drawRect 耗时、大图解码、深层级打包 |
| Render Hitch | Render Server / GPU | Offscreen Pass 过多、大面积模糊/阴影、复杂遮罩 |
6.2 Offscreen Pass(离屏渲染)—— Render Hitch 的主要元凶
- 当屏渲染:GPU 的任务是把所有 layer 从后往前逐个画到一块最终纹理上(就是你屏幕看到的那一帧画面):
最终纹理(屏幕画面)
┌──────────────────┐
│ │
│ 第1层:蓝色背景 │ ← GPU 先画这个
│ 第2层:白色卡片 │ ← 再叠上这个
│ 第3层:文字 │ ← 最后叠上这个
│ │
└──────────────────┘
GPU 直接在最终纹理上一层层往上画,画完就上屏。
这就是"正常渲染",也叫"当屏渲染"。
-
离屏渲染:GPU 无法直接在最终纹理上绘制某个 layer,必须先在 离屏纹理 上画好再拷贝回来。每次 Offscreen Pass 都是额外的 纹理切换 + 像素拷贝。
- 为什么无法直接在最终纹理上绘制?
- 如下图,阴影其实在 “最底层”,要先画;
- 但是阴影的形状取决于 “上层的圆形和长条”,还没画呢。
┌─────────────────────────────────┐ │ 最终纹理 │ │ │ │ ●●●●● │ │ ●●●●●●● ← 圆形 │ │ ●●●●● │ │ ████████ ← 长条 │ │ │ │ 阴影的形状 = 圆形+长条的轮廓 │ │ 但 GPU 还没画圆形和长条呢! │ │ 它怎么知道阴影该长什么样? │ └─────────────────────────────────┘- 解决办法 = 离屏渲染:
步骤1:GPU 切到临时纹理,先把圆形和长条画上去 ┌── 临时纹理 ──┐ │ ●●●●● │ │ ●●●●●●● │ → 现在知道轮廓了 │ ●●●●● │ │ ████████ │ └──────────────┘ 步骤2:把轮廓变黑 + 模糊 = 阴影形状 ┌── 临时纹理 ──┐ │ ░░░░░░░ │ │ ░░░░░░░░░ │ → 这就是阴影 │ ░░░░░░░ │ │ ░░░░░░░░░░ │ └──────────────┘ 步骤3:把阴影拷贝回最终纹理 步骤4:在最终纹理上再画一次圆形和长条(盖在阴影上面)- 圆形和长条被画了两次,还多了纹理切换和拷贝。这就是离屏渲染慢的原因。
- 为什么无法直接在最终纹理上绘制?
-
四大触发场景:
| 场景 | 为什么必须离屏 | 怎么避免 |
|---|---|---|
| 阴影 | GPU 不知道阴影形状,得先画内容才能反推 | 设 shadowPath,直接告诉 GPU 形状,不用反推 |
| 遮罩 (mask) | 先画内容,再用 mask 裁剪,裁掉的像素不能污染最终纹理 | 用 cornerRadius + masksToBounds 代替自定义 mask layer |
| 圆角 + 裁剪内容 | 子视图超出圆角范围需要被裁掉,和遮罩同理 | 确认子视图不超出 bounds 时去掉 masksToBounds |
| 模糊/毛玻璃 | 需要拷贝底层像素到临时纹理再做模糊 | 不可避免,控制数量和面积 |
七、遇到问题怎么查?
用户反馈"卡"
│
├─ 按钮按不动 / 界面冻结 → 这是 Hang
│ │
│ ├─ Time Profiler 看 CPU 高 → Busy Main Thread
│ │ → 减少主线程计算、用 async/await 移到后台
│ │
│ └─ Thread States 看线程 Blocked → Blocked Main Thread
│ → 找到阻塞的系统调用(锁/IO/信号量),异步化
│
└─ 滚动/动画跳帧 → 这是 Hitch
│
├─ Animation Hitches 模板看 Commit 阶段超时 → Commit Hitch
│ → 简化布局、减少 drawRect、预处理图片、扁平化层级
│
└─ Render/GPU 阶段超时 → Render Hitch
→ View Debugger 看 offscreen count
→ 设置 shadowPath、用 cornerRadius 代替 mask
八、GPU 优化
- 图层混合(Blending):当 layer 不是完全不透明时(opacity < 1 或 backgroundColor 为 nil/透明),GPU 需要把当前 layer 和底下的 layer 做像素混合计算。
- 优化方式:给 view 设不透明背景色、设 opaque = YES、避免不必要的透明。
- shouldRasterize(光栅化缓存):把一个复杂的 layer 子树一次性渲染成位图缓存,后续帧直接复用。适合内容不常变的复杂视图(如带阴影+圆角+多子视图的卡片)。但缓存有 100ms 未使用自动释放的限制,且 内容变化时需要重新光栅化,用不好反而更慢。
- 像素对齐(Pixel Alignment):frame 的坐标不是整数像素时,GPU 需要做抗锯齿混合。用
CGRectIntegral或 SnapKit 的snp.makeConstraints保持像素对齐。