UIView生命周期与渲染
# UIView生命周期与渲染
深入篇 | init→layout→draw→dealloc全链路,CALayer背板原理,CoreAnimation渲染管线,离屏渲染触发与优化实战。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. UIView生命周期
- 4. 布局系统
- 5. 显示与绘制
- 6. CoreAnimation渲染管线
- 7. 离屏渲染
- 8. 性能优化实战
- 9. 动画与渲染
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一个不会刷新的Label
先看一段在视频剪辑 App 里真实出现的 UI bug。开发同学写了一个显示当前播放进度的 UILabel,逻辑是每隔 0.1 秒用 GCD Timer 更新文字。代码很简单:
// ProgressView.m —— 播放进度显示
@interface ProgressView : UIView
@property (nonatomic, strong) UILabel *timeLabel;
@property (nonatomic, assign) CGFloat currentTime;
@end
@implementation ProgressView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
_timeLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 200, 44)];
[self addSubview:_timeLabel];
// 启动高精度计时器
[self startTimer];
}
return self;
}
- (void)startTimer {
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,
dispatch_get_main_queue());
dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC, 0.01 * NSEC_PER_SEC);
dispatch_source_set_event_handler(timer, ^{
self.currentTime += 0.1;
self.timeLabel.text = [NSString stringWithFormat:@"%.1f s", self.currentTime];
});
dispatch_resume(timer);
}
@end
现象:
- 本地测试:Label 每 0.1 秒更新一次,流畅无误
- 预生产:播放大视频文件时,Label 偶尔卡住 1-2 秒不更新,然后一次性跳到当前时间——但计时器本身没停,
currentTime的值是正确的
直觉怀疑:是不是 GCD Timer 在主线程被阻塞了?加日志发现 Timer 的 block 每 0.1 秒都执行了,self.timeLabel.text = ... 也确实被赋值了——但屏幕上就是没刷新。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是 UI 更新被 RunLoop Mode 切换跳过了?—— Timer 用的
dispatch_get_main_queue(),GCD 跟 Mode 无关。否定。 - 假设 2:是不是 Label 的 frame 为 0 导致不可见?——
frame打印出来一直是(0,0,200,44),没有问题。 - 假设 3:是不是视频解码把主线程 CPU 打满了?—— Time Profiler 显示 CPU 确实高,但 GCD 主队列的 block 也执行了,
text也确实改动了——只是没渲染到屏幕。 - 假设 4:那为什么 "赋值了文字,屏幕不刷新"?—— 查阅
UILabel文档后找到关键点:设置text属性会调用setNeedsDisplay(标记为 "需要重绘"),但真正的重绘发生在下一个 RunLoop 周期 CoreAnimation 提交事务时。如果某个 RunLoop 周期耗时过长(比如连续解码多个视频帧),那 Label 的 "需要重绘" 标记就会堆积到下一次事务提交时才统一执行。 - 假设 5:那为什么
setNeedsLayout也有同样的行为?—— 布局更新也是 "标记脏→RunLoop Observer 提交事务时统一布局"。
顺着这条线挖下去,发现整条渲染链路远比想象复杂——从 UILabel.text = @"xxx" 到像素亮起来,中间隔着 SetNeedsDisplay 标记 → CoreAnimation 事务提交 → Render Server 进程外合成 → GPU 绘制四层抽象。每一层都有独立的时机控制。
这一段事故里至少藏着 7 个原理点:
① UIView 和 CALayer 是什么关系?为什么修改 Layer 属性能直接生效?→ 第 2 章
② layoutSubviews 到底什么时候被调用?为什么有时连续调用多次? → 第 3、4 章
③ setNeedsDisplay 不是立刻绘制,那"立刻"到底指什么时机? → 第 5 章
④ CoreAnimation 的事务什么时候提交?谁负责提交? → 第 6 章
⑤ Render Server 是什么?为什么渲染在 App 进程外? → 第 6.3 节
⑥ 离屏渲染为什么影响性能?如何发现和避免? → 第 7 章
⑦ drawRect 到底是 GPU 还是 CPU 在画? → 第 5 章
# 1.3 我们要回答什么
这个 Label 不刷新的小事故就是本篇的主线案例。每讲完一段原理就解开一两个问号;最后在第 10 章,把整条链路兜回到 ProgressView 上,回答清楚"为什么 Label 赋值了但不刷新,又怎样保证实时刷新"。
本篇路线:
架构概览 (第 2 章)
↓
UIView 生命周期 (第 3 章) ─→ 解开"什么回调节点什么时候调"
↓
布局 → 显示与绘制 (第 4-5 章) ─→ 解开"setNeedsDisplay 不是立刻画的"
↓
CoreAnimation 管线 → 离屏渲染 (第 6-7 章) ─→ 解开"像素到底怎么上屏"
↓
性能优化 → 动画 (第 8-9 章) ─→ 武器库
↓
综合案例 (第 10 章) ─→ 案例彻底剖开
📌 本篇定位:这是 iOS 专栏中 "看得见" 的基石篇。前面 Runtime 讲了消息怎么走,RunLoop 讲了时机怎么调度——这一篇要把 "像素怎么亮起来" 从 UIView API 一路追到 GPU 指令级。读完本篇,任何 UIView 的性能问题你都能立刻回答:瓶颈在 Layout、Display、Render 三层中的哪一层。
# 2. 架构概览
# 2.1 UIView与CALayer双重身
疑惑:为什么 UIView 有一个 .layer 属性?为什么 view.frame 和 view.layer.frame 总是一样?
论证:每一个 UIView 内部都持有一个 CALayer。UIView 负责事件响应和生命周期管理,CALayer 负责视觉内容和渲染合成。它们是"伴侣关系"——UIView 是 CALayer 的 delegate(严格说是 CALayer 的 delegate 指向 UIView)。
UIView (轻量级 ObjC 对象)
├── 属性: frame, bounds, center, backgroundColor, alpha, hidden
├── 方法: layoutSubviews, drawRect, addSubview, hitTest
├── 事件: touchesBegan, gestureRecognizer
│
└── 内部持有 ──────────────────────────────────────┐
│
CALayer (轻量级 C 对象) │
├── 属性: frame, bounds, position, backgroundColor, opacity, hidden
├── 属性: contents (位图 CGImageRef,屏幕显示的本质内容)
├── 方法: setNeedsDisplay, display, drawInContext
│
└── 内部持有 ──────────────────────────────────────┐
│
backing store (CABackingStore) ── GPU 可读的像素缓冲区
└── contents (位图)
UIView 和 CALayer 的属性映射:
| UIView 属性 | CALayer 对应属性 | 说明 |
|---|---|---|
frame | frame | UIView 的 frame setter 调了 layer.frame = ... |
bounds | bounds | 同上 |
center | position | view.center = layer.position |
backgroundColor | backgroundColor | 颜色值直接传递给 layer |
alpha | opacity | 0.0~1.0 |
hidden | hidden | YES / NO |
transform | transform 或 affineTransform | CGAffineTransform / CATransform3D |
clipsToBounds | masksToBounds | YES / NO |
contentMode | contentsGravity | UIViewContentMode → 对应的 gravity 字符串 |
关键差异——CALayer 有但 UIView 没有的:
| CALayer 属性 | 说明 |
|---|---|
cornerRadius | 圆角半径(直接设会触发离屏渲染) |
borderWidth / borderColor | 边框 |
shadow* 系列 | 阴影(触发离屏渲染) |
mask | 遮罩层(触发离屏渲染) |
contents | 真正显示在屏幕上的位图 |
sublayerTransform | 3D 透视变换 |
# 2.2 为什么分层设计
疑惑:为什么不把 UIView 和 CALayer 合并?一个对象管一切不更直接吗?
论证:
职责分离——UIView 负责 iOS 特有的 "触摸事件 + 响应链",CALayer 负责 "视觉效果 + 合成"。分开后,macOS 的 NSView 也能共用同一个 CALayer 底层(它们确实共用)。CALayer 是跨平台的渲染引擎,UIView 是 iOS 平台的事件封装。
性能分层——UIView 是 ObjC 对象,每次创建都有 ObjC 运行时开销(消息发送、引用计数);CALayer 是 C 对象(
CA::Layer),更轻量。在不需要事件交互的场景(比如粒子系统、大量小元素),直接操作 CALayer 比创建 UIView 高效得多。进程隔离——CALayer 的合成是由 Render Server(独立进程)完成的。如果 UIView 和 Layer 合并,就意味着 App 进程要直接跟 GPU 驱动通信——一旦 App 崩溃,整个渲染系统跟着挂。Render Server 进程外合成是 iOS 图形栈最精妙的安全设计(第 6.3 节展开)。
反向验证:Android 的 View 包含了测量、布局、绘制、事件全部责任——这就是为什么 Android 的 View 对象比 CALayer 重得多,
inflate一个复杂布局可能耗时几十毫秒。iOS 把 "绘制" 下沉到 Layer,"事件" 留在 View——一份重量由两个对象分摊。
# 3. UIView生命周期
# 3.1 init→addSubview→layout→draw→dealloc
UIView 从生到死的完整生命周期(以代码初始化的 View 为例):
alloc & init(frame:)
│
▼
[superview addSubview:self] ← 加入视图层级
│
▼
willMoveToSuperview: ← 即将加入父视图
│
▼
didMoveToSuperview ← 已加入父视图(此时 self.superview 非空)
│
▼
┌─ layoutSubviews ──────────────────────────────┐
│ 触发条件: │
│ 1. addSubview 后首次布局 │
│ 2. frame / bounds 改变 │
│ 3. setNeedsLayout 被调用 │
│ 4. UIScrollView 滚动时 │
│ 5. 旋转屏幕 │
│ 6. 手动调用 layoutIfNeeded │
│ │
│ 此时:frame 已确定,子视图位置应基于 self.bounds 设置│
└────────────────────────────────────────────────┘
│
▼
drawRect: ← 首次显示时或 setNeedsDisplay 后
│
▼
┌─ 正常显示阶段 ──────────────────────┐
│ 重复 layoutSubviews → drawRect │
│ App 运行期间的不断交互 │
└────────────────────────────────────┘
│
▼
willMoveToSuperview:nil ← 即将从父视图移除
│
▼
[superview willRemoveSubview:self]
│
▼
[self removeFromSuperview] ← 真正移除
│
▼
didMoveToSuperview ← superview 为 nil
│
▼
dealloc ← 引用计数归零,释放
# 3.2 各回调的调用时机与顺序
@interface MyView : UIView
@end
@implementation MyView
// 1️⃣ 指定初始化方法(代码创建时)
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
// ✅ 此时可以设置属性、创建子视图
// ❌ 不要依赖 self.frame.size(可能还没最终确定)
return self;
}
// 1️⃣' xib/storyboard 反序列化时
- (instancetype)initWithCoder:(NSCoder *)coder {
self = [super initWithCoder:coder];
// ✅ 反序列化属性
return self;
}
// 1️⃣'' xib/storyboard 加载完成
- (void)awakeFromNib {
[super awakeFromNib];
// ✅ xib 加载完毕,outlet 已连接,可以做一些 IB 中的额外配置
}
// 2️⃣ 即将加入父视图
- (void)willMoveToSuperview:(UIView *)newSuperview {
[super willMoveToSuperview:newSuperview];
// newSuperview 为 nil = 即将被移除
// newSuperview 非 nil = 即将被添加
}
// 3️⃣ 已经加入父视图
- (void)didMoveToSuperview {
[super didMoveToSuperview];
// ✅ self.superview 可用,但 frame 可能还没最终确定
}
// 4️⃣ 即将加入窗口(可见)
- (void)willMoveToWindow:(UIWindow *)newWindow {
[super willMoveToWindow:newWindow];
// newWindow 为 nil = 即将离开屏幕
// newWindow 非 nil = 即将显示在屏幕上
}
// 5️⃣ 已经加入窗口
- (void)didMoveToWindow {
[super didMoveToWindow];
// ✅ self.window 可用,视图真的可见了
}
// 6️⃣ 布局子视图(可能被多次调用!)
- (void)layoutSubviews {
[super layoutSubviews];
// ✅ 在这里设置子视图的 frame
// ⚠️ 别在这里做耗时的计算——此方法可能高频调用
}
// 7️⃣ 绘制(自定义绘制时使用)
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
// ✅ 使用 CoreGraphics API 在这里画
// ⚠️ CPU 绘制,开销大——慎用
}
// 8️⃣ 析构
- (void)dealloc {
// ✅ 移除通知、KVO、Timer
}
@end
关键点——layoutSubviews 可能被多次调用,但不一定每次调用后都调 drawRect。系统会尽量在同一个 RunLoop 周期内合并多次 setNeedsLayout 为一次 layoutSubviews。
# 3.3 ViewController生命周期联动
UIViewController 的生命周期与 UIView 紧密交织:
viewDidLoad()
│
└─ view 已创建(从 xib/code/loadView)但 frame 还未确定
✅ 初始化子视图、设置属性
❌ frame/bounds 不可靠
viewWillAppear:
│
└─ view 即将加入 window 层级
✅ 开始动画、注册通知
viewWillLayoutSubviews / viewDidLayoutSubviews
│
├─ layoutSubviews 被调用前 / 后
│ ✅ viewDidLayoutSubviews 中 frame 已确定
│ ⚠️ 可能被多次调用(旋转屏幕、状态栏变化等)
viewDidAppear:
│
└─ view 已经可见
✅ 启动 Timer、开始网络请求
viewWillDisappear: / viewDidDisappear:
│
└─ view 即将/已经不可见
✅ 暂停 Timer、保存状态
dealloc
│
└─ VC 被释放
📌 面试常考题:
viewDidLoad和viewDidLayoutSubviews哪个能拿到正确的 frame?答:viewDidLayoutSubviews——因为在viewDidLoad时 view 的 frame 还是 xib/storyboard 里的原始值,尚未根据实际屏幕尺寸计算。
# 4. 布局系统
# 4.1 frame与bounds的经典陷阱
疑惑:frame 和 bounds 都表示矩形,到底有什么区别?
论证:
| frame | bounds | |
|---|---|---|
| 坐标系 | 父视图的坐标系 | 自身的坐标系 |
| 原点含义 | 在父视图中的位置 | 可视区域的偏移(默认 (0,0)) |
| 大小关系 | frame.size 不一定等于 bounds.size(旋转时) | — |
| 何时变化 | 移动/缩放视图 | 滚动 UIScrollView 的 contentOffset |
UIView *parent = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 300, 300)];
UIView *child = [[UIView alloc] initWithFrame:CGRectMake(50, 50, 100, 100)];
[parent addSubview:child];
child.frame → {{50, 50}, {100, 100}} // 在 parent 里的位置
child.bounds → {{0, 0}, {100, 100}} // 自身坐标系原点始终是 (0,0)
经典陷阱——bounds 改变会影响子视图位置:
// 如果改了 parent.bounds.origin → child 在屏幕上的位置就变了
parent.bounds = CGRectMake(50, 50, 300, 300);
// ↑ 相当于把 parent 的 "可视窗口" 向右下移动了 50pt
// → child 会向左上移动 50pt(因为 parent 的坐标系原点的物理位置变了)
这就是 UIScrollView 滚动的底层原理——UIScrollView 在滚动时改变了 自身 bounds.origin,导致所有子视图看起来在移动。它从没改过子视图的 frame。
# 4.2 layoutSubviews触发条件
layoutSubviews 不是"你想让它调它就调"——它只在特定条件满足时由系统触发:
| 触发条件 | 示例 | 是否触发 layoutSubviews |
|---|---|---|
addSubview: | 往一个已有 frame 的父视图加子视图 | ✅ |
removeFromSuperview | 移除子视图 | ✅(父视图) |
setFrame: / setBounds: | 改变尺寸 | ✅ |
| 屏幕旋转 | 从竖屏到横屏 | ✅ |
| UIScrollView 滚动 | 每次滚动 | ✅ |
setNeedsLayout | 手动标记 | ✅(下一个 RunLoop 周期) |
layoutIfNeeded | 立刻布局(如果有脏标记) | ✅(立即) |
设置 text 属性 | UILabel 文字改了 | ❌(只触发布局标记,不一定调 layoutSubviews) |
改变 hidden | 隐藏/显示 | ❌ |
修改 backgroundColor | 背景色 | ❌ |
关键差异——setNeedsLayout vs layoutIfNeeded:
// setNeedsLayout:标记为"需要布局",在下一个 RunLoop 周期执行
[self.view setNeedsLayout];
NSLog(@"0. 标记布局了,但还没执行");
// layoutIfNeeded:如果有标记,立刻执行布局
[self.view layoutIfNeeded];
NSLog(@"1. 布局已经执行完了,frame 已更新");
动画中的布局:
// 方式 1:在动画 block 里调 layoutIfNeeded → 布局变更被动画化
[UIView animateWithDuration:0.3 animations:^{
self.redView.frame = CGRectMake(100, 100, 200, 200);
[self.view layoutIfNeeded]; // ← 立刻布局,变化过程会动画
}];
// 方式 2:只改约束 constant,不调 layoutIfNeeded → 无动画,下一个 RunLoop 才更新
self.redViewTopConstraint.constant = 100;
# 4.3 AutoLayout与手动布局的桥梁
- (void)layoutSubviews {
[super layoutSubviews]; // ← AutoLayout 在这里执行约束计算
// 然后在下面可以基于 AutoLayout 计算的结果做额外的手动布局
self.shadowView.frame = CGRectInset(self.imageView.frame, -4, -4);
}
执行顺序:[super layoutSubviews] 内部 → 触发 updateConstraints → 求解 AutoLayout 约束 → 更新所有受约束视图的 frame → 返回 → 你写的自定义布局代码。
# 5. 显示与绘制
# 5.1 setNeedsDisplay脏标记机制
回到主线案例的核心疑问——为什么 label.text = @"xxx" 设置了但屏幕不立刻更新?
论证:UILabel 在 setText: 里做了两件事:
- 更新内部的
_text属性 - 调用
[self setNeedsDisplay]—— 标记 "我需要重绘"
但 setNeedsDisplay 不是立刻触发 drawRect:——它只是在 CALayer 内部设置了一个脏标记(_displayDisabled 或类似的 flag)。真正的绘制发生在 下一个 RunLoop 周期,CoreAnimation 提交事务时。
主线程执行流:
RunLoop 周期 1:
├── Timer block: label.text = @"0.1 s" → setNeedsDisplay(标记脏)
├── Timer block: label.text = @"0.2 s" → setNeedsDisplay(再次标记,合并)
├── Timer block: label.text = @"0.3 s" → setNeedsDisplay(再次合并)
├── ...
├── RunLoop BeforeWaiting
├── CoreAnimation Observer: 提交事务! ← 此时才真正执行 drawRect
│ └── CALayer.display → UIView.drawRect → 生成 0.3 s 的位图
└── Render Server: 合成 → 显卡输出像素
最终屏幕上显示的始终是最后一个 RunLoop 周期结束时的那次绘制结果。
这就是案例中 "Label 卡住 1-2 秒然后一跳" 的完整解释:
时间线:
T=0.0s: text = "0.0s" → setNeedsDisplay(标记)
T=0.1s: text = "0.1s" → setNeedsDisplay(标记被覆盖)
T=0.2s: text = "0.2s" → setNeedsDisplay(再次覆盖)
T=0.3s: ...RunLoop 来了个耗时操作(视频解码,300ms)...
T=0.6s: 耗时操作结束,RunLoop 继续
T=0.6s: CoreAnimation 提交事务 → label 被重绘 → 显示 "0.6s"
↑ 中间 0.1~0.5s 的更新全部丢失——因为绘图线程没机会执行
解决方案:不要依赖 setNeedsDisplay 的延迟合批——如果对实时性要求极高,应在每个 Timer 回调里手动调用 CATransaction flush 强制提交当前事务(但代价是性能)。
# 5.2 drawRect全流程剖析
疑惑:drawRect: 里面到底发生了什么?为什么它跑在 CPU 上?
drawRect: 的完整流程:
[view setNeedsDisplay]
│
▼
CALayer 设置脏标记
│
▼
RunLoop BeforeWaiting → CoreAnimation 提交事务
│
▼
CALayer 的 display 方法被调用
│
▼
[self displayLayer:layer] ← UIView 作为 CALayer 的 delegate 收到回调
│
▼
[self drawRect:rect] ← 你在子类里重写的这个方法
│
▼
CoreGraphics 函数在此执行(CPU 运算)
├── CGContextRef ctx = UIGraphicsGetCurrentContext(); ← 系统在调用 drawRect 前创建好了上下文
├── CGContextSetFillColorWithColor(ctx, ...)
├── CGContextAddRect(ctx, ...)
├── CGContextDrawPath(ctx, ...)
└── 其他 CG API...
│
▼
绘制结果写入 CALayer 的 contents(CGImageRef 位图)
│
▼
CALayer.contents 就绪 → Render Server 拿去合成 → GPU 输出
关键点:
drawRect:纯粹是 CPU 绘制(软件光栅化)——所有CGContext*函数在 CPU 上执行,不经过 GPU 的 shader 管线。- 系统在
drawRect:调用前自动创建了CGContextRef——你是 "往画布上画",而不是 "创建画布"。 drawRect:只为你的视图创建 "它自己的位图"——子视图的样貌不会被画进来(子视图有自己的 layer 和自己的位图)。- 不要手动调用
drawRect:——要触发重绘,调setNeedsDisplay。
# 5.3 contents的位图本质
所有 UIView 在屏幕上显示的本质,是 CALayer 上指向的一块位图(contents 属性)。
// 直接设置 Layer 的 contents(绕过 drawRect)
UIImage *image = [UIImage imageNamed:@"cat.jpg"];
self.customView.layer.contents = (__bridge id)image.CGImage;
// ↑ 这相当于"直接把照片贴在 layer 上",没经过任何绘制
不同类型视图的 contents 来源:
| 视图类型 | contents 来源 | 何时生成 |
|---|---|---|
| UIImageView | image.CGImage | 设置 image 属性时(不解码,GPU 解码) |
| UILabel | drawRect 生成的位图 | setNeedsDisplay 后,事务提交时 |
| 自定义 drawRect | CoreGraphics 在 CPU 画的 | 同上 |
| UIButton | 同 UILabel + 子 layer 叠加 | 同上 |
| 纯色背景 View | 无 contents(GPU 直接填充颜色) | 不需要 contents |
⚠️
drawRect:的最大代价:它生成一个与视图尺寸同大的位图。在 iPhone 14 Pro(3× 屏幕)上,一个 100pt × 100pt 的视图 = 300px × 300px × 4 字节(RGBA)= 360 KB 的位图。如果你滚动列表时每帧都触发drawRect:——内存分配 + CPU 绘制的开销不容忽视。
# 6. CoreAnimation渲染管线
# 6.1 四大阶段:Commit→Layout→Display→Render
一次完整的渲染周期(从 setNeedsDisplay 到像素亮起来),CoreAnimation 将它分成四个阶段:
App 进程 Render Server 进程 GPU
─────── ───────────────── ───
┌─ 阶段 1: Commit ─────────────────┐
│ CATransaction 提交 │
│ ├── 收集所有变更的 Layer 属性 │
│ ├── 构建 render tree(渲染树) │
│ └── 通过 IPC 把树发给 Render ────► Render Server 收到 render tree
│ Server │
└────────────────────────────────────┘
┌─ 阶段 2: Layout ─────────────────────────────────────────────────────────┐
│ 在 App 进程(非主线程!) │
│ ├── 执行 layoutSubviews(如果需要) │
│ └── frame/bounds 变更 │
└─────────────────────────────────────────────────────────────────────────┘
┌─ 阶段 3: Display ─────────────────┐
│ 在 App 进程 │
│ ├── 执行 drawRect(如果需要) │
│ └── 生成 CALayer.contents 位图 │
└────────────────────────────────────┘
│
▼
contents 就绪
│
▼
┌─ 阶段 4: Render ────────────────────────────────────────────────────────┐
│ Render Server 把图层树转换为 OpenGL / Metal 指令 │
│ ├── 图层合成(alpha 混合、mask) │
│ ├── 发给 GPU │
│ │ ├── Vertex Shader │
│ │ ├── Rasterization │
│ │ └── Fragment Shader → 写入 Framebuffer → 扫描输出到屏幕 🖥 │
│ └── vsync 同步(跟着屏幕刷新率) │
└─────────────────────────────────────────────────────────────────────────┘
耗时分布分析(以 iPhone 14 60FPS 为基准):
| 阶段 | 典型耗时 | 在哪执行 | 受什么影响 |
|---|---|---|---|
| Commit | < 1ms | App 主线程 | Layer 数量 |
| Layout | 0~10ms | App 主线程(layoutSubviews) | 布局复杂度、约束数量 |
| Display | 0~50ms | App 任意线程 | drawRect 复杂度、位图尺寸 |
| Render | 1~5ms | Render Server + GPU | 图层数量、混合、滤镜 |
# 6.2 CATransaction隐式事务
疑惑:我不写 [CATransaction begin],CoreAnimation 怎么知道什么时候 "该提交了"?
论证:CoreAnimation 在 每个 RunLoop 的 BeforeWaiting 阶段 自动提交事务。它通过一个 Observer 来监听这个时机。
// CoreAnimation 内部注册的 Observer(伪代码)
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(
kCFAllocatorDefault,
kCFRunLoopBeforeWaiting | kCFRunLoopExit, // ← 关注这两个时机
YES, 0,
&CA::Transaction::observer_callback, // ← 回调里调用 commit
NULL
);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
CATransaction 的四条核心方法:
// 1. 开始一个事务(通常不需要手动写——每个 RunLoop 周期默认有一个)
[CATransaction begin];
// 2. 设置事务属性
[CATransaction setAnimationDuration:0.5]; // 动画时长
[CATransaction setDisableActions:YES]; // 关闭隐式动画
[CATransaction setCompletionBlock:^{ // 事务提交后的回调
NSLog(@"动画完毕");
}];
// 3. 提交事务(立即把所有 Layer 变更推给 Render Server)
[CATransaction commit];
// 4. 强制 flush(不等 RunLoop,立刻提交)
[CATransaction flush];
[CATransaction flush] 就是主线案例的 "强制立刻渲染" 答案:
dispatch_source_set_event_handler(timer, ^{
self.currentTime += 0.1;
self.timeLabel.text = [NSString stringWithFormat:@"%.1f s", self.currentTime];
[CATransaction flush]; // ← 不等 RunLoop,立刻提交渲染
});
⚠️ 警告:频繁
flush会破坏 CoreAnimation 的合批优化——本来一次 RunLoop 周期可以合并 10 次setNeedsDisplay为一次drawRect,你现在每次flush强制它画一次,CPU 开销暴涨。
# 6.3 Render Server进程外渲染
疑惑:为什么 iOS 需要一个单独的进程来做渲染合成?在 App 进程里合成不行吗?
论证:Render Server(进程名 backboardd)是 iOS 的 "图形合成器",类似于 macOS 的 WindowServer 进程。
三大理由:
安全隔离——App 进程是沙盒化的,不能访问其他 App 的图层数据。Render Server 拥有全局视角,收到所有 App 的 render tree 后统一合成 → 输出到 Framebuffer。一个 App 崩溃了,不影响其他 App 的显示。
GPU 资源管理——GPU 是一个共享资源,由 Render Server 独占调度,避免多个 App 进程直接竞争 GPU(那会导致死锁和竞态)。Render Server 按 Z-order 把所有 App 的图层合成一张完整的帧。
系统 UI 特权——状态栏、控制中心、Home Indicator 这些系统 UI 的图层是 App 无权访问的。Render Server 把它们叠在 App 的图层之上——你对系统 UI 层的任何访问尝试都会被 Render Server 拒绝。
App A 图层树 App B 图层树 系统 UI 图层
┌──────────┐ ┌──────────┐ ┌──────────┐
│ ████████ │ │ ░░░░░░░░ │ │ 状态栏 │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└────────────────────┼───────────────────┘
│ (IPC)
▼
Render Server
┌──────────────────────┐
│ 按 Z-order 合成 │
│ 系统UI (最顶层) │
│ App B │
│ App A │
└──────────────────────┘
│
▼
OpenGL/Metal → GPU → Framebuffer → 屏幕
# 7. 离屏渲染
# 7.1 什么是离屏渲染
疑惑:GPU 不是天生就在 "屏幕上" 渲染吗?什么叫 "离屏"?
论证:正常渲染(On-Screen Rendering)——GPU 把计算结果直接写入 Framebuffer(帧缓冲区),然后显示器扫描 Framebuffer 把它输出到屏幕。
离屏渲染(Off-Screen Rendering)——GPU 不直接把结果写入 Framebuffer,而是先在另一块缓冲区(离屏缓冲区)算好,再作为纹理贴回到 Framebuffer。多了一次额外的 "纹理复制" 操作。
正常渲染(On-Screen):
图层 A ──► GPU ──► Framebuffer ──► 屏幕
图层 B ──► GPU ──► Framebuffer ──► 屏幕
(并行、逐个画)
离屏渲染(Off-Screen):
图层 A ──┐
图层 B ──┼─► GPU ──► 离屏缓冲区 ──► 纹理 ──► Framebuffer ──► 屏幕
(先合成在一个临时纹理里,再贴回去 → 多了一次内存拷贝)
为什么离屏渲染慢?
| 开销项 | 说明 |
|---|---|
| 创建临时缓冲区 | 需要分配一块与最终渲染区域同大的 GPU 内存 |
| 切换渲染目标 | GPU 切换 Framebuffer → 离屏缓冲区 → Framebuffer,每切换一次都有状态开销 |
| 额外纹理读取 | 从离屏缓冲区读回数据并贴到最终画面 |
| 无法批量 | 离屏渲染破坏 GPU 的合批(batching)优化 |
# 7.2 七大触发条件
| 触发条件 | 代码 | 为什么触发离屏渲染 |
|---|---|---|
1. cornerRadius + masksToBounds | layer.cornerRadius = 8; layer.masksToBounds = YES; | GPU 不知道圆角外该丢弃什么(有子图层时无法直接裁剪) |
2. mask | layer.mask = maskLayer; | 遮罩图层和被遮罩图层需要先合成为中间纹理 |
3. shadow* | layer.shadowOpacity = 0.5; | 阴影需要先算出完整图形才能模糊和偏移 |
4. group opacity | layer.opacity = 0.5; layer.allowsGroupOpacity = YES; | 多个子图层组合后再做半透明 |
5. shouldRasterize = YES | layer.shouldRasterize = YES; | 本质就是先渲染到离屏缓冲区再缓存(用空间换时间) |
| 6. 抗锯齿边缘 | layer.edgeAntialiasingMask != 0; | 需要周围像素参与计算 |
7. maskedCorners(iOS 11+) | layer.maskedCorners = kCALayerMinXMinYCorner; | 部分圆角也需要离屏 |
验证——实现指定圆角但不触发离屏渲染:
// ❌ 触发了离屏渲染(cornerRadius + masksToBounds)
imageView.layer.cornerRadius = 8;
imageView.layer.masksToBounds = YES;
// ✅ 不触发(用 CoreGraphics 直接画出一个圆角图片)
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0);
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds
cornerRadius:8];
[path addClip];
[originalImage drawInRect:imageView.bounds];
UIImage *roundedImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
imageView.image = roundedImage; // contents 已经是圆角的了,GPU 不需要动态裁剪
# 7.3 圆角优化的正确姿势
| 方案 | 是否离屏渲染 | 适用场景 | 代价 |
|---|---|---|---|
cornerRadius + masksToBounds | 是 | 包含子视图需要裁剪时 | GPU 开销 |
cornerRadius 单独使用(无子视图、无 image) | 否 | 纯色背景的 UILabel/UIView | 无 |
| CPU 预裁剪图片 | 否 | UIImageView 显示本地图片 | CPU 时间 + 内存 |
CAShapeLayer 作为 mask | 是 | 不规则形状 | GPU 开销 |
使用 UIGraphicsImageRenderer 画圆角图片 | 否 | 网络异步加载的图片 | CPU 时间 |
// 最优方案——异步裁剪 + 缓存
- (void)setRoundedImage:(UIImage *)image forImageView:(UIImageView *)imageView {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc]
initWithSize:imageView.bounds.size];
UIImage *rounded = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds
cornerRadius:8];
[path addClip];
[image drawInRect:imageView.bounds];
}];
dispatch_async(dispatch_get_main_queue(), ^{
imageView.image = rounded; // 一张已经圆角的图片,GPU 无额外开销
});
});
}
# 7.4 Instruments调试离屏渲染
方法 1:模拟器 Debug 菜单
Simulator → Debug → Color Off-screen Rendered
→ 黄色高亮 = 触发了离屏渲染的区域
方法 2:Instruments → Core Animation
Core Animation instrument → 勾选 "Color Offscreen-Rendered Yellow"
方法 3:代码调试
// 在任意 UIView 子类中
- (void)drawRect:(CGRect)rect {
// 检查当前是否在离屏渲染
// 这段代码只能用于调试,不要上生产
NSLog(@"drawRect called");
}
# 8. 性能优化实战
# 8.1 异步绘制原理(ASDK/Texture)
drawRect: 默认在主线程执行——对于一个复杂 cell,drawRect 耗时 50ms,用户就会感知到一帧卡顿。Texture(前身 ASDK / AsyncDisplayKit)的核心优化就是 把 drawRect 挪到后台线程执行:
传统绘制: Texture 异步绘制:
主线程 setNeedsDisplay 主线程 setNeedsDisplay
↓ ↓
主线程 CALayer.display 主线程 CALayer.display
↓ ↓
主线程 drawRect (CPU 绘制,50ms) 后台线程 CGContext (CPU 绘制,50ms)
↑ ↓
⚠️ 卡一帧 主线程把画好的位图设给 layer.contents
↑ ↓
没有卡顿(主线程只做了赋值操作)
关键技术点:
1. 用 CGBitmapContext 在后台线程创建离屏上下文
2. 把 CoreGraphics API 调用直接画到后台上下文中
3. 拿到 CGImage 后 dispatch 回主线程设给 layer.contents
简化版实现:
- (void)displayLayer:(CALayer *)layer {
// 重写 CALayerDelegate——跳过系统的 drawRect 路径
dispatch_async(dispatch_get_global_queue(0, 0), ^{
UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat defaultFormat];
format.scale = [UIScreen mainScreen].scale;
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc]
initWithSize:layer.bounds.size
format:format];
UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
// 后台线程绘制
[self drawContentInContext:ctx.CGContext];
}];
dispatch_async(dispatch_get_main_queue(), ^{
layer.contents = (__bridge id)image.CGImage;
});
});
}
# 8.2 光栅化与shouldRasterize
shouldRasterize = YES 的语义:把当前图层及其子图层"拍一张快照"缓存起来,后续直接复用这张快照,不再重新合成子图层。
// 适用场景:结构复杂但不经常变化的图层
complexView.layer.shouldRasterize = YES;
complexView.layer.rasterizationScale = [UIScreen mainScreen].scale;
| 场景 | 是否开启 | 原因 |
|---|---|---|
| 静态复杂图层(不缩放、不旋转) | ✅ | 避免每次帧都重新合成 |
| 频繁变化的图层 | ❌ | 每帧都要失效→重新光栅化,更慢 |
| 图层正在动画 | ❌ | 动画期间每帧都重画 |
| 内存敏感的设备 | ❌ | 光栅化缓存占用额外的 GPU 内存 |
# 8.3 图层混合与不透明优化
**图层混合(Blending)**是 GPU 最常见的性能杀手——当一个像素点上有多个半透明图层时,GPU 需要从 Framebuffer 读回当前像素颜色(这很慢!),与新颜色做 alpha 混合计算,再写回去。
正常(不透明):
GPU 直接写入颜色值 → 快
混合(透明):
GPU 读取当前颜色 → 混合计算 → 写入新颜色 → 慢(多了 "读回" 这一步)
优化策略:
// 1. 设置 opaque = YES(告诉 GPU 这个视图完全不透明,跳过混合)
self.view.opaque = YES;
// ⚠️ 这要求视图的背景色 alpha = 1.0,且填满自身 bounds
// 2. 避免不必要的透明背景
label.backgroundColor = [UIColor whiteColor]; // ✅ 显式设置不透明背景
// label.backgroundColor = [UIColor clearColor]; // ❌ 透明背景会触发混合
// 3. 使用 Instruments 检查混合情况
// Simulator → Debug → Color Blended Layers
// 绿色 = 无混合 ✅
// 红色 = 有混合 ⚠️
# 9. 动画与渲染
# 9.1 UIView动画与CALayer动画的分工
// UIView 动画(最常用)
[UIView animateWithDuration:0.3 animations:^{
self.redView.frame = CGRectMake(100, 100, 200, 200);
}];
// 底层实际是:
// 1. 设置 CALayer 属性的 model 值
// 2. 创建 CABasicAnimation 加到 layer 上
// 3. CATransaction commit → Render Server 看到动画 → 渐进式渲染
// CALayer 隐式动画
self.redView.layer.position = CGPointMake(200, 200);
// UIView 默认关闭了 CALayer 的隐式动画,需要手动开
[CATransaction begin];
[CATransaction setDisableActions:NO];
self.redView.layer.position = CGPointMake(200, 200);
[CATransaction commit];
// CALayer 显式动画——完全自己控制
CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"position"];
anim.fromValue = [NSValue valueWithCGPoint:startPoint];
anim.toValue = [NSValue valueWithCGPoint:endPoint];
anim.duration = 0.5;
[self.redView.layer addAnimation:anim forKey:@"move"];
分工:
| UIView 动画 | CALayer 动画 | |
|---|---|---|
| 设计意图 | 面向业务开发,闭包式 API | 面向底层控制,delegate 回调 |
| 自动加动画 | UIView.animate block 内 | 仅在 actionForLayer:forKey: 返回非 nil 时(独立 CALayer) |
| 动画结束回调 | completion block | animationDidStop:finished: delegate |
| Model 值处理 | 自动更新 model 值 | 需手动处理(否则动画结束弹回原位) |
# 9.2 隐式动画的触发条件
CALayer 的大部分属性被修改时,在特定条件下会触发隐式动画(0.25 秒的淡入过渡)。但 UIView 的 backing layer 默认关闭了隐式动画——也就是说,直接改 self.view.layer.position 不会动画。
// 没有隐式动画的场景(UIView 的 backing layer):
self.view.layer.position = CGPointMake(200, 200); // ❌ 直接跳变
// 有隐式动画的场景(独立 CALayer):
CALayer *standaloneLayer = [CALayer layer];
standaloneLayer.position = CGPointMake(200, 200); // ✅ 0.25 秒动画过渡
// 手动控制 UIView backing layer 是否开隐式动画:
[CATransaction begin];
[CATransaction setDisableActions:NO]; // 打开隐式动画
self.view.layer.position = CGPointMake(200, 200); // ✅ 现在有动画了
[CATransaction commit];
# 9.3 presentationLayer与modelLayer
疑惑:动画过程中,真实的 layer 在哪?怎么拿到 "动画中间状态" 的值?
论证:CALayer 有两层数据存储:
- modelLayer:你的代码看到的 "真实值"(比如
layer.position直接读取的值) - presentationLayer:屏幕当前实际显示的值(动画中间帧)
Model Layer: position = (200, 300) ← 最终目标
Presentation Layer: position = (156, 278) ← 动画播放到一半时屏幕上的真实位置
// 获取屏幕上的真实位置:
CGPoint screenPosition = [self.redView.layer.presentationLayer position];
实战——拿到动画中的实时 frame:
- (CGRect)realTimeFrame {
CALayer *presentation = self.layer.presentationLayer;
return presentation ? presentation.frame : self.layer.frame;
}
这就是为什么在手势拖拽中可以 "拿到动画中控件的实时位置"——读的是 presentationLayer,不是 modelLayer。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 Label 不刷新问题,七个疑问现在逐条作答:
| 疑问 | 答案 |
|---|---|
| ① UIView 和 CALayer 的关系? | 第 2.1:UIView 持有 CALayer,前者管事件,后者管显示和合成 |
| ② layoutSubviews 什么时候调? | 第 3.1 + 4.2:addSubview / frame改变 / 旋转 / UIScrollView滚动 / setNeedsLayout 后 |
| ③ setNeedsDisplay 不是立刻画,"立刻"指什么? | 第 5.1:下一个 RunLoop 周期 CoreAnimation 提交事务时 |
| ④ CoreAnimation 事务何时提交? | 第 6.2:RunLoop 的 BeforeWaiting 阶段,由内置 Observer 触发 |
| ⑤ Render Server 是什么? | 第 6.3:独立进程 backboardd,负责所有 App 的图层合成和 GPU 调度 |
| ⑥ 离屏渲染为何影响性能?如何避免? | 第 7 章:多一次纹理复制 + GPU 状态切换;圆角用 CPU 预裁剪图片代替 cornerRadius |
| ⑦ drawRect 是 CPU 画还是 GPU 画? | 第 5.2:CPU(CoreGraphics 软件光栅化),结果写入 layer.contents |
四种修复方案(按实时性需求递进):
方案 A:接受默认行为(不修改)
刷新有几百毫秒延迟,但在绝大多数场景下不可感知。如果你的 Label 不需要严格跟视频帧同步——不要动。
方案 B:用 CATransaction flush 强制立刻提交
dispatch_source_set_event_handler(timer, ^{
self.currentTime += 0.1;
self.timeLabel.text = [NSString stringWithFormat:@"%.1f s", self.currentTime];
[CATransaction flush]; // ← 立刻提交渲染
});
代价:破坏 CoreAnimation 的合批优化,频繁调用会增加 CPU 开销。
方案 C:用 displayLayer: 绕过 setNeedsDisplay 延迟
// 重写 CALayer delegate 方法,手动设置 contents
- (void)displayLayer:(CALayer *)layer {
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc]
initWithSize:layer.bounds.size];
UIImage *img = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
NSString *text = [NSString stringWithFormat:@"%.1f s", self.currentTime];
[text drawAtPoint:CGPointZero withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:14]}];
}];
layer.contents = (__bridge id)img.CGImage; // 不走 drawRect,不依赖事务提交时机
}
代价:需要手动管理文字渲染(没有 UILabel 的排版能力)。
方案 D:打上 CATransaction 时间戳 + 选择性 flush
@property (nonatomic, assign) CFTimeInterval lastFlushTime;
dispatch_source_set_event_handler(timer, ^{
self.currentTime += 0.1;
self.timeLabel.text = [NSString stringWithFormat:@"%.1f s", self.currentTime];
CFTimeInterval now = CACurrentMediaTime();
if (now - self.lastFlushTime > 1.0 / 30.0) { // 限制 flush 频率不超过 30 FPS
[CATransaction flush];
self.lastFlushTime = now;
}
});
代价:略复杂,但平衡了实时性和 CPU 开销。推荐方案。
# 10.2 一个像素的一生
把 self.timeLabel.text = @"1.0 s" 到像素亮起来的全过程串成知识树:
self.timeLabel.text = @"1.0 s";
│
├─ UILabel 内部:
│ ├── 更新 _text 属性
│ ├── 计算 intrinsicContentSize(如有约束依赖)
│ └── [self setNeedsDisplay] ← 标记 CALayer 脏
│
├─ 等待 RunLoop 一个周期结束
│ └── RunLoop BeforeWaiting
│
├─ CoreAnimation Observer 触发 ────────────── 第 6.2 节
│ └── CATransaction::commit()
│ │
│ ├── 收集脏 Layer → 构建 render tree
│ │
│ ├── Layout 阶段 ──────────────────── 第 4 章
│ │ └── 如有 setNeedsLayout → layoutSubviews
│ │
│ ├── Display 阶段 ─────────────────── 第 5 章
│ │ ├── CALayer.display
│ │ ├── UIView.displayLayer:(作为 delegate)
│ │ ├── drawRect: → CoreGraphics CPU 绘制
│ │ └── 生成 CGImageRef → layer.contents
│ │
│ └── 打包 render tree → IPC → Render Server
│
├─ Render Server(backboardd 进程)─────── 第 6.3 节
│ ├── 收到 render tree
│ ├── 按 Z-order 排列所有图层(含系统 UI)
│ ├── 离屏渲染检测 ──────────────────── 第 7 章
│ │ ├── 有 cornerRadius + masksToBounds?
│ │ ├── 有 shadow?
│ │ └── 有 mask?
│ │
│ ├── 将图层树翻译为 OpenGL / Metal 指令
│ └── 发送指令到 GPU
│
└─ GPU ────────────────────────────────────────
├── Vertex Shader:处理顶点坐标
├── Rasterization:把矢量转为像素
├── Fragment Shader:计算每个像素的最终颜色
│ ├── 纹理采样(layer.contents 位图)
│ ├── alpha 混合(如果有透明图层)
│ └── 离屏渲染(如果需要)
│
├── 写入 Framebuffer
└── 显示控制器扫描 Framebuffer → 屏幕亮起 🖥
每一步都对应专栏的一篇——理解一个像素的一生,就是理解 iOS 渲染的全部。
# 10.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:分层抽象——View 管交互,Layer 管显示
UIView 和 CALayer 的双重身份不是 "重复设计",而是 把 "交互关注" 和 "渲染关注" 彻底解耦。这种分离让 iOS 的图形栈可以在不引入 UI 依赖的前提下运行(比如 ARKit 的纯 Layer 渲染管线)。分层的本质是让每层做自己最擅长的事。
哲学 2:按需标记、统合提交——setNeedsDisplay 的 "懒惰" 是智能
setNeedsDisplay 只在 CALayer 上设置一个脏标记,不立刻画——因为下一个 RunLoop 周期内可能还有 10 次 setNeedsDisplay 要被合并。苹果选择 "等到事务提交时再一口气画"——在用户感知不到延迟的前提下,最大化 CPU/GPU 的合批效率。这是典型的 "延迟物化" 思想。
哲学 3:进程外合成——用进程边界保护渲染安全
Render Server 用独立进程 (backboardd) 做图层合成——这不是为了 "微内核",而是为了把渲染失败隔离在渲染进程内部。一个 App 崩溃了,它的位图还在 Render Server 缓存里,显示不受影响。iOS 进程模型的 "多进程安全" 在图形栈上体现得淋漓尽致。
哲学 4:GPU 贵在 "一次过"——离屏渲染是默认关闭的道路
GPU 的批量渲染是它的核心优势——一次状态设置,画一万个三角形。离屏渲染(切换渲染目标 → 画 → 切换回 → 贴纹理)打破了批量优势,相当于 "在高速公路上强行掉头"。苹果默认让 99% 的渲染走 on-screen——只有显式要求(cornerRadius+masksToBounds / shadow / mask)才触发支路。这是 "默认快速、例外可慢" 的设计。
# 10.4 渲染速查表
| 概念 | 核心要点 | 调试工具 |
|---|---|---|
| UIView 生命周期 | init → addSubview → willMove/didMove → layoutSubviews → drawRect → dealloc | lldb 断点 |
| CALayer 背板 | UIView 持有 CALayer,Layer 负责显示和合成 | view.layer |
| frame vs bounds | frame=在父视图中的位置,bounds=自身坐标系 | po view.frame |
| setNeedsDisplay | 标记脏,不立刻画——RunLoop BeforeWaiting 才提交绘制 | — |
| drawRect | CPU 绘制,CoreGraphics API,生成 layer.contents 位图 | Time Profiler |
| CoreAnimation 管线 | Commit → Layout → Display → Render,事务在 RunLoop BeforeWaiting 提交 | Core Animation instrument |
| Render Server | backboardd 进程,App 的图层树通过 IPC 发给它 | ps aux \| grep backboardd |
| 离屏渲染 | GPU 先画到临时缓冲区再贴回 → 多一次纹理复制 | Color Off-screen Rendered (黄色) |
| 图层混合 | 透明图层触发 alpha 混合 → GPU 需读回 Framebuffer | Color Blended Layers (红色) |
| shouldRasterize | 快照缓存,适合静态复杂图层 | Core Animation instrument |
| presentationLayer | 动画中间帧的屏幕实际显示值 | layer.presentationLayer |
60 秒诊断命令:
// 1. 看是否有离屏渲染(模拟器)
// Simulator → Debug → Color Off-screen Rendered
// 2. 看图层混合
// Simulator → Debug → Color Blended Layers
// 3. 看图层是否发生了光栅化
// Simulator → Debug → Color Hits Green and Misses Red
// 4. 打印视图层级
(lldb) po [[[UIApplication sharedApplication] keyWindow] recursiveDescription]
// 5. 看某个 layer 的 contents 大小
(lldb) po [NSValue valueWithCGSize:self.view.layer.frame.size]
// CGSize * 屏幕 scale * 4 字节 = contents 占用内存
// 6. 抓帧分析(Instruments → Core Animation → 录制)
drawRect 要不要用的判断:
需要动态绘制的图形(图表、签名、手写轨迹)?
├─ 是 → drawRect: ✅
└─ 否 → 组合 UIView / CALayer + 图片 ✅(避免 CPU 绘制)
└─ 还需要高性能?
├─ 是 → 用 CALayer 直接操作(不创建 UIView)
└─ 否 → 用 UIImageView(硬件解码,无需 CPU 参与)
下一篇:你已经知道 UIView 怎么布局和绘制、像素怎么上屏,下一章进入 05.事件响应与手势识别 (opens new window)——把 "手指点下去的一瞬间,hitTest 怎么遍历视图树、手势识别器怎么跟 RunLoop 抢主线程" 彻底剖开。