自定义View绘制实践
# 07.自定义View绘制实践
深度篇 | drawRect与CoreGraphics、UIBezierPath、文本绘制、异步绘制、离屏渲染优化。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. Core Graphics 绘制引擎
- 4. UIBezierPath 矢量路径
- 5. drawRect 生命周期与时机
- 6. 文本绘制与 CoreText
- 7. 异步绘制策略
- 8. 离屏渲染与优化
- 9. 性能剖析与调试
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段满屏红圈的绩效看板
先看一段真实上线的绩效看板代码。需求:在每个员工名字后面画一个圆形进度环(0%~100%),15 人的团队每个人一个环,同屏展示:
// ProgressRingView.swift —— 绩效进度环视图
class ProgressRingView: UIView {
var progress: CGFloat = 0 // 0~1
var name: String = ""
var value: String = ""
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = min(bounds.width, bounds.height) / 2 - 8
// 1. 背景圆环
ctx.setStrokeColor(UIColor.lightGray.cgColor)
ctx.setLineWidth(6)
ctx.addArc(center: center, radius: radius,
startAngle: -.pi / 2, endAngle: .pi * 1.5, clockwise: true)
ctx.strokePath()
// 2. 进度弧线
ctx.setStrokeColor(UIColor.systemRed.cgColor)
ctx.setLineWidth(6)
ctx.setLineCap(.round)
let endAngle = -.pi / 2 + .pi * 2 * progress
ctx.addArc(center: center, radius: radius,
startAngle: -.pi / 2, endAngle: endAngle, clockwise: true)
ctx.strokePath()
// 3. 中心文字
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 16),
.foregroundColor: UIColor.black,
.paragraphStyle: paragraphStyle
]
let text = "\(name)\n\(value)"
let textRect = bounds.insetBy(dx: 16, dy: 16)
(text as NSString).draw(in: textRect, withAttributes: attrs)
}
}
// 使用:在 UITableViewCell 中
class DashboardCell: UITableViewCell {
let rings = (0..<15).map { _ in ProgressRingView() }
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
rings.forEach { contentView.addSubview($0) }
// 用 AutoLayout 排列 15 个环(grid 3×5)
}
override func layoutSubviews() {
super.layoutSubviews()
// 此处刷新所有环
rings.forEach { $0.setNeedsDisplay() }
}
}
现象:
- iPhone 12 上:进入看板页面,首次显示耗时约 180ms(主线程卡顿 3 帧以上)
- 最要命的是:每次滚动回来、每个 Cell 重新显示,
setNeedsDisplay()触发drawRect,再次卡顿 - Instruments 显示每个 ProgressRingView 的
drawRect耗时约 3.6ms,15 个环合计 54ms - Memory Graph 显示:15 个环的后备存储(backing store)总计 ~22.5 MB(每个环约 1.5MB 位图)
- 更诡异的是:progress 没变,每次
setNeedsDisplay之后drawRect还是完完整整重新画了一遍
# 1.2 顺藤摸到根因
假设 1:是不是 Core Graphics 路径绘制本身慢?—— 把
drawRect清空(什么都不画),耗时仍有 2.8ms。说明瓶颈不在画路径,而在 drawRect 被调用的开销本身。假设 2:换 CAShapeLayer 代替 drawRect 呢?
// CAShapeLayer 版本:不重写 drawRect
class LayerProgressRingView: UIView {
let bgLayer = CAShapeLayer()
let progressLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
layer.addSublayer(bgLayer)
layer.addSublayer(progressLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
// 只更新 path,不触发 drawRect
bgLayer.path = bgRingPath()
progressLayer.path = progressRingPath()
}
}
—— CAShapeLayer 版本每个环耗时 0.15ms(24× 性能差异),15 个环合计 2.3ms,内存只有 ~300KB(ShapeLayer 是矢量存储)。
- 假设 3:那为什么 drawRect 这么慢、这么吃内存?
带着这个疑问往下挖:
① drawRect 没有被调用的 2.8ms 开销到底是什么? → 第 5.4 节
② drawRect 的后备存储(backing store)为什么会吃 1.5MB/View? → 第 5.4 节
③ CAShapeLayer 为什么比 drawRect 快 24 倍? → 第 4.4 节
④ setNeedsDisplay 到 drawRect 之间发生了什么? → 第 5.1 节
⑤ 圆角 + masksToBounds 会不会触发离屏渲染? → 第 8 章
⑥ 文本绘制 `draw(in:)` 和 CoreText 哪个快? → 第 6 章
⑦ 能不能在后台线程画好再传给主线程? → 第 7 章
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 7 个问号一路追到底,每讲完一段原理,就解开一两个;最后在第 10 章,给出三种绘制方案的性能对比和选型指南。
本篇路线:
Core Graphics 绘制引擎(状态机模型) (第 3 章) ─→ 画笔底层
↓
UIBezierPath 矢量路径 (第 4 章) ─→ 路径工具
↓
drawRect 生命周期与后备存储 (第 5 章) ─→ 性能陷阱
↓
CoreText 文本绘制 (第 6 章) ─→ 文本优化
↓
异步绘制策略 (第 7 章) ─→ 后台渲染
↓
离屏渲染检测与优化 (第 8 章) ─→ 硬件加速
↓
性能剖析工具箱 (第 9 章) ─→ 武器库
↓
综合案例:三种绘制方案全景对比 (第 10 章)
📌 本篇定位:这是 iOS 专栏的绘制地基篇。前面几篇讲了布局(约束→frame)、事件响应(hitTest→手势),本篇覆盖从
drawRect到像素输出的完整链路。读完本篇后,任何"为什么卡""为什么吃内存""该用 drawRect 还是 CAShapeLayer"的问题都能从渲染管线中找到答案。
# 2. 架构概览
# 2.1 iOS 渲染管线五层模型
iOS 的渲染系统从开发者调用绘制 API 到屏幕像素输出,整体分为五层:
┌─────────────────────────────────────────────────────────────────┐
│ 第 0 层:开发者 API │
│ drawRect / Core Graphics / CoreText / UIBezierPath / CA layers │
│ 职责:描述"画什么" │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 1 层:Core Graphics(Quartz2D) │
│ CGContext(图形上下文)管理绘制状态机 │
│ 所有绘制命令转化为对 CGImage / CGLayer 的操作 │
│ 完成路径栅格化(Path → Pixels) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 2 层:Core Animation 渲染树 │
│ 每个 UIView 对应一个 CALayer(layer backing) │
│ CALayer.contents 存储最终的位图或矢量路径 │
│ 事务(CA::Transaction)将变更打包提交 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 3 层:Render Server(backboardd 进程) │
│ 独立于 App 进程的系统渲染服务 │
│ 执行 GPU 合成:纹理采样 + 图层混合 + 光栅化 │
│ 处理离屏渲染 Pass │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 4 层:GPU 硬件 │
│ 最终光栅化 → 帧缓冲 → 显示控制器 → 屏幕像素 │
│ 每 16.67ms(60fps)刷新一次 VSync 信号 │
└─────────────────────────────────────────────────────────────────┘
第 0~1 层是开发者的主战场,也是本篇核心。第 2~4 层在第 8 章(离屏渲染)和第 9 章(性能调试)中涉及。
# 2.2 为什么这么分层
疑惑:为什么 UIView 的绘制要分成 drawRect(位图)和 CALayer(硬件合成)两条路?
论证:
CPU 绘制 vs GPU 合成的分工——
drawRect走的是 CPU 光栅化路径:Core Graphics 在 CPU 上把贝塞尔曲线、文本、图像算法化地"画"成一张位图,然后这张位图作为CALayer.contents交给 GPU 做合成。而 CAShapeLayer、CATextLayer 直接把矢量描述发给 Render Server,由 GPU 在合成阶段光栅化。两者在硬件上的执行位置不同。后备存储(Backing Store)的必要性——
drawRect产生一张完全展开的位图(像素数组),必须分配物理内存来存储。这就是drawRect吃内存的根因——即使你的 View 是空白的,系统也会分配一整块 context 大小的后备位图。脏矩形合并的经济性——系统不是每次
setNeedsDisplay都立刻调drawRect,而是在 RunLoop 结束前合并多个 setNeedsDisplay 调用,只画一次。这避免了重复绘制,但代价是必须重新生成整个 View 的后备存储位图。异步渲染需要创建新 context——一旦想在后台线程绘制,必须创建独立的 CGContext(因为没有主线程的 drawRect context),绘制完成后生成 CGImage,再回到主线程设给
layer.contents。这带来了额外的内存峰值(两张位图同时存在)。反向验证:如果不分层会怎样?参考早期 iOS 3.x——还没有 CAShapeLayer,所有自定义图形都需要
drawRect。一个简单的圆形进度条需要几十行 drawRect 代码,且每次更新都重新光栅化整张位图。分层(drawRect 给 CPU,CALayer 给 GPU)让开发者可以用正确的工具做正确的事。
结论:五层模型把"描述绘什么(API)→ CPU 光栅化(CG)→ 合成打包(CA)→ GPU 光栅化(Render Server)→ 输出(硬件)"拆成独立阶段。理解这五层,就能在任何绘制性能问题中正确定位瓶颈在哪一层——而大多数问题出在第 5 章(drawRect 后备存储的 CPU/内存开销)和第 8 章(离屏渲染的额外 GPU Pass)。
# 3. Core Graphics 绘制引擎
# 3.1 CGContext 的本质——状态机画笔
CGContext 是 Core Graphics 的核心抽象——它不是一个简单的画布,而是一个带全局状态的绘制状态机:
CGContext 内部状态机(简化版):
┌────────────────────────────────────────┐
│ 当前图形状态 (GState) │
│ │
│ • 填充颜色 (fillColor) │
│ • 描边颜色 (strokeColor) │
│ • 线宽 (lineWidth) │
│ • 线帽样式 (lineCap) │
│ • 连接样式 (lineJoin) │
│ • 虚线模式 (lineDash) │
│ • 混合模式 (blendMode) │
│ • 透明度 (alpha) │
│ • 变换矩阵 (CTM) │
│ • 裁剪路径 (clipPath) │
│ • 阴影参数 (shadow) │
│ • 字体 (font) + 字号 (fontSize) │
│ • 字符间距 (characterSpacing) │
│ │
│ • 当前点 (currentPoint) │
│ • 路径 (currentPath) │
└────────────────────────────────────────┘
↕ push / pop (GState 栈)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 保存的状态1│ │ 保存的状态2│ │ 保存的状态3│
└──────────┘ └──────────┘ └──────────┘
每次调用 setFillColor、setLineWidth 等方法,都是在修改这个状态机的当前状态。后续的 fillPath()、strokePath() 读取当前状态来执行实际绘制。
# 3.2 图形状态栈 push/pop 机制
// 场景:在复杂绘制中临时改变颜色和线宽
ctx.saveGState() // 保存当前全部状态到栈
ctx.setFillColor(UIColor.red.cgColor)
ctx.setLineWidth(12)
ctx.setLineCap(.round)
// ... 在此临时状态中绘制 ...
ctx.restoreGState() // 恢复之前保存的状态
// 此后 fillColor、lineWidth、lineCap 都恢复到 save 之前的值
// 但 path 不受影响(路径不在 GState 中)
关键认知:saveGState / restoreGState 保存的是状态机参数,不是路径。路径(currentPath)是独立于 GState 的——这意味着你在 save 之前画的路径在 restore 之后依然存在。
// ⚠️ 常见误解:以为 save 会保存路径
ctx.saveGState()
ctx.addRect(CGRect(x: 0, y: 0, width: 100, height: 100))
ctx.restoreGState()
// 此时 currentPath 中仍然有那个矩形!restore 不清理路径
ctx.strokePath() // → 还是会画出那个矩形
# 3.3 坐标系翻转的暗坑
UIKit 和 Core Graphics 的坐标系是上下颠倒的——这是最常见的绘制 bug 来源:
UIKit 坐标系 (UIView.drawRect 中): Core Graphics 标准坐标系:
(0,0) (0,0)
┌─────────────────► x ┌────────► x
│ │
│ y 轴向下增长 │ y 轴向上增长
▼ ▼
(w, h) (w, -h)
UILabel.draw(in:) 使用的是 UIKit 坐标系
CGContext.addPath() 使用的是 CG 坐标系
何时需要翻转?
| 场景 | 坐标系 | 是否需要翻转 |
|---|---|---|
drawRect 中直接用 CGContext 绘制 | UIKit(已翻转) | 不需要 |
drawRect 中绘制文本 NSString.draw(in:) | UIKit | 不需要 |
创建位图上下文 UIGraphicsBeginImageContextWithOptions | UIKit(已翻转) | 不需要 |
| 创建 CGBitmapContext | Core Graphics(未翻转) | 需要! |
layer.delegate 的 draw(layer:in:) | Core Graphics | 取决于 layer 的 isGeometryFlipped |
// 在位图上下文中画图,然后导出——常见坑
UIGraphicsBeginImageContextWithOptions(size, false, 0)
// 在这里的绘制自动使用 UIKit 坐标系,正常
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// 但如果用的是 CGBitmapContext:
let ctx = CGContext(data: nil, width: w, height: h,
bitsPerComponent: 8, bytesPerRow: 0,
space: cgColorSpace, bitmapInfo: bitmapInfo)
// 此处坐标系是上下颠倒的!需要手动翻转:
ctx?.translateBy(x: 0, y: CGFloat(h))
ctx?.scaleBy(x: 1, y: -1)
// 现在绘制才能正常
# 3.4 位图上下文与离屏上下文
Core Graphics 支持三种上下文类型:
| 上下文类型 | 创建方式 | 用途 | 输出 |
|---|---|---|---|
| 视图上下文 | UIGraphicsGetCurrentContext() 在 drawRect 内 | 绘制到 UIView 的 backing store | CALayer.contents |
| 位图上下文 | UIGraphicsBeginImageContextWithOptions | 创建 UIImage | CGImage → UIImage |
| PDF 上下文 | UIGraphicsBeginPDFContextToFile | 导出 PDF | PDF 文件 |
| CGBitmapContext | CGContext(data:...) 原始创建 | 异步绘制 / 离屏渲染 | CGImage |
位图上下文的底层架构:
UIGraphicsBeginImageContextWithOptions(size, opaque, scale)
│
▼
创建 CGBitmapContext(size × scale)
│ • 分配像素缓冲区(size.width × size.height × 4 bytes × scale²)
│ • scale = 2 → 4 倍像素数(Retina)
│ • scale = 3 → 9 倍像素数(iPhone Plus/X)
│
▼
UIGraphicsPushContext → 推入全局 context 栈
│
▼
你的绘制代码(UIKit 坐标系已自动翻转)
│
▼
UIGraphicsGetImageFromCurrentImageContext → 提取 CGImage
│
▼
UIGraphicsPopContext → 弹出 context 栈
│
▼
UIGraphicsEndImageContext → 释放像素缓冲区
内存占用公式:
位图上下文内存 = width × scale × height × scale × 4 bytes
iPhone 12 (scale=3),一个 100×100pt 的 View:
上下文 = 100×3 × 100×3 × 4 = 360,000 bytes ≈ 351 KB
iPhone 12 (scale=3),一个全屏 390×844pt 的 View:
上下文 = 390×3 × 844×3 × 4 ≈ 11.8 MB
这就是第 1 章案例中,每个 ProgressRingView (~150×150pt) 占用约 1.5MB 的原因——drawRect 必须为整个 View bounds 分配一块完整位图,画多画少内存一样大。
# 4. UIBezierPath 矢量路径
# 4.1 贝塞尔曲线的数学本质
UIBezierPath 是 Core Graphics 路径构建的 ObjC/Swift 封装。一条贝塞尔曲线本质上是参数方程在 t=[0,1] 上的求值:
线性贝塞尔(一条线段):
B(t) = (1-t)·P₀ + t·P₁ (2 个控制点)
二次贝塞尔(一条抛物线弧段):
B(t) = (1-t)²·P₀ + 2(1-t)t·P₁ + t²·P₂ (3 个控制点)
三次贝塞尔(最常用的曲线):
B(t) = (1-t)³·P₀ + 3(1-t)²t·P₁ + 3(1-t)t²·P₂ + t³·P₃ (4 个控制点)
三次贝塞尔曲线的可视化:
P₁ (控制点 1) P₂ (控制点 2)
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━●
╱ ╲
╱ · • • 。 。 。 。 。 • • • ╲
╱ · · ╲
╱ · · ╲
P₀ (起点)───────────────────────────────────────────── P₃ (终点)
曲线始终经过 P₀ 和 P₃,但不经过 P₁ 和 P₂
P₁ 和 P₂ 控制曲线的弯曲方向和程度
// Swift 中绘制一条三次贝塞尔曲线
let path = UIBezierPath()
path.move(to: CGPoint(x: 50, y: 150))
path.addCurve(to: CGPoint(x: 250, y: 150), // 终点 P₃
controlPoint1: CGPoint(x: 80, y: 50), // 控制点 P₁
controlPoint2: CGPoint(x: 220, y: 250)) // 控制点 P₂
path.stroke()
# 4.2 二次曲线与三次曲线的选型
| 特性 | 二次曲线 addQuadCurve | 三次曲线 addCurve |
|---|---|---|
| 控制点数量 | 1 个 | 2 个 |
| 弯曲自由度 | 单方向弯曲 | 双方向弯曲(可做 S 形) |
| 与相邻曲线衔接 | 只能保证位置连续(C⁰ 连续) | 可保证切线连续(C¹ 连续) |
| 使用场景 | 简单圆角、抛物线弧 | 自定义形状、路径平滑 |
曲线连续性的工程意义:
C⁰ 连续(位置连续)—— 两条曲线在连接点碰在一起
→ UIBezierPath 默认保证(通过 move/addLine/addCurve 自然连接)
C¹ 连续(切线连续)—— 两条曲线在连接点切线方向一致
→ 需要手动调整控制点位置,让 P₂ 与 P₃ 的连线
与下一条曲线 P₀ 与 P₁ 的连线共线
C² 连续(曲率连续)—— 曲线在连接点曲率一致
→ 极少需要,仅专业矢量绘图软件关注
// C¹ 连续的连接:上个终点 P₃ 的下一个控制点方向 = 下条曲线 P₁ 的方向
// 第一个三次曲线
path.move(to: P0)
path.addCurve(to: P3, controlPoint1: P1, controlPoint2: P2)
// 第二个三次曲线与第一个 C¹ 连续:
// P3 与下一个控制点 P4 的连线必须和 P2 与 P3 的连线共线
path.addCurve(to: P6, controlPoint1: P4, controlPoint2: P5)
// 其中 P4 必须在 P2→P3 方向线的反向延长线上
# 4.3 路径填充规则的差异
当路径自相交时,填充规则决定哪些区域被填充:
// 五角星路径(自相交)
let starPath = UIBezierPath()
// ... 绘制五角星 ...
// 规则 1:non-zero winding(非零环绕)—— 默认
starPath.usesEvenOddFillRule = false
starPath.fill()
// 整个五角星被填充(不考虑自相交)
// 规则 2:even-odd(奇偶规则)
starPath.usesEvenOddFillRule = true
starPath.fill()
// 五角星的中心镂空(因为中心被环绕了偶数次)
非零环绕规则判定算法:
从要测试的点发出一条射线,每与路径交叉一次:
顺时针交叉 → winding + 1
逆时针交叉 → winding - 1
最终 winding ≠ 0 → 填充
winding = 0 → 不填充(镂空)
奇偶规则判定算法:
从要测试的点发出一条射线,与路径交叉次数:
奇数 → 填充
偶数 → 不填充(镂空)
# 4.4 CAShapeLayer 与 drawRect 的性能鸿沟
回到第 1 章的疑问:CAShapeLayer 为什么比 drawRect 快 24 倍?
它们走的完全不同的渲染路径:
drawRect 路径(CPU 光栅化):
UIBezierPath.stroke() / fill()
│
▼
Core Graphics 在 CPU 上将路径光栅化为像素
│ ├── 计算贝塞尔曲线上的每个采样点
│ ├── 反走样计算(2×2 子像素采样)
│ └── 位图混合写入
▼
CALayer.contents = 完整的位图 CGImage
│ • 整张位图传给 Render Server
│ • 带宽:150×150×3×3×4 ≈ 810 KB per frame(Retina)
▼
GPU 合成(仅做纹理采样 + 混合)
CAShapeLayer 路径(GPU 光栅化):
CAShapeLayer.path = CGPath
CAShapeLayer.fillColor / strokeColor / lineWidth ...
│
▼
CA::Transaction::commit() → 提交一组矢量绘制参数
│ • 传输量:路径控制点坐标 + 样式参数 ≈ 几百字节
▼
Render Server (backboardd) 在 GPU 上光栅化路径
│ ├── GPU 硬件加速的曲线细分(tessellation)
│ ├── GPU 的反走样(MSAA 或 Coverage AA)
│ └── 直接在帧缓冲中绘制
▼
输出到屏幕
| 维度 | drawRect | CAShapeLayer |
|---|---|---|
| 光栅化位置 | CPU(主线程) | GPU(Render Server) |
| 存储 | 完整位图(width×height×4×scale²) | 矢量路径(控制点 + 样式) |
| 更新成本 | 重新光栅化整张位图 | 仅更新 path 属性,GPU 重绘 |
| 带宽 | 整张位图传给 Render Server | 仅路径数据(几百字节) |
| 内存 | 大(位图像素数组) | 小(矢量描述 + GPU 临时帧缓冲) |
| 适合场景 | 复杂光栅效果(渐变、阴影、混合模式) | 简单几何图形、频繁更新的进度类控件 |
结论:drawRect 适合"画一次就不变"的位图级复杂效果(如照片滤镜、复杂混合),CAShapeLayer 适合所有矢量图形的频繁更新场景。第 1 章的进度环正是后者的典型——应毫不犹豫用 CAShapeLayer。
# 5. drawRect 生命周期与时机
# 5.1 从 setNeedsDisplay 到 drawRect
setNeedsDisplay() 到 drawRect 之间隔着一段 RunLoop 的协同处理:
调用 setNeedsDisplay()
│
▼
UIView 内部标记 _needsDisplay = true
(合并多个 setNeedsDisplay() 调用——仅标记,不重复)
│
▼
当前 RunLoop 迭代即将休眠(BeforeWaiting 时机)
│
▼
CA::Transaction 检测到有 View 标记为 needsDisplay
│
▼
调用 CALayer.display()
→ 如果有 delegate 实现了 display(_:),调用之
→ 否则调用 draw(in:)(CALayer 的绘制代理方法)
▼
draw(in:) 内部:
1. 创建 CGContext(后备存储位图)
2. 调用 UIView.draw(rect:)
3. 将 CGContext 内容填充到 CALayer.contents
4. 释放 CGContext 的像素缓冲区
│
▼
CA::Transaction::commit() → 提交到 Render Server
关键时机点:
// ❌ 立即模式下读 frame(拿不到更新后的值)
view.setNeedsDisplay()
print(view.frame) // 还是旧的,因为 drawRect 还没跑
// ✅ 强制同步读取
view.setNeedsDisplay()
view.layer.displayIfNeeded() // 强制立刻执行 drawRect
print(view.frame) // 布局已更新
// 但 displayIfNeeded 代价高——它在主线程同步执行完整绘制
# 5.2 drawRect 的脏矩形合并机制
UIKit 使用一个 CGRect 来跟踪"哪些区域需要重绘":
// setNeedsDisplay → 整个 View 需要重绘
view.setNeedsDisplay()
// drawRect 收到的 rect = view.bounds
// setNeedsDisplay(_:) → 只重绘指定区域
view.setNeedsDisplay(CGRect(x: 0, y: 0, width: 50, height: 50))
// drawRect 收到的 rect = (0, 0, 50, 50)
// 但 CGContext 仍然是全尺寸的——只是你可以只画(0,0,50,50)区域来省计算
合并规则:
- 同一个 RunLoop 迭代内多次调用
setNeedsDisplay()→ 合并为一次 - 同一个 RunLoop 迭代内多次调用
setNeedsDisplay(rectA)+setNeedsDisplay(rectB)→ 合并为rectA.union(rectB) setNeedsDisplay()和setNeedsDisplay(rect)同时存在 → 整个 View 重绘(全量覆盖局部)
# 5.3 contentMode 与 drawRect 的交互
当 View 的 bounds 改变时(如屏幕旋转),contentMode 决定是否触发 drawRect:
| contentMode | bounds 改变时的行为 | 是否触发 drawRect |
|---|---|---|
.redraw | 每次 bounds 改变都重绘 | ✅ 是 |
.scaleToFill .scaleAspectFit .scaleAspectFill | 缩放 contents 位图,不重绘 | ❌ 否 |
.center .top .bottom .left .right ... | 平移 contents 位图,不重绘 | ❌ 否 |
// 如果有自定义 drawRect,务必设置 contentMode = .redraw
// 否则旋转屏幕后内容会被拉伸而不是重新绘制
class CustomView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
contentMode = .redraw // ← 关键
}
}
# 5.4 内存爆炸:drawRect 的后备存储真相
第 1 章案例中 drawRect 被调用的 2.8ms"空壳"开销和 1.5MB 内存从哪来?答案在后备存储(Backing Store)。
drawRect 的完整开销链(即使 drawRect 内部一行代码都不写):
1. 分配后备存储位图
→ size = bounds.width × scale × bounds.height × scale × 4 bytes
→ 150×150pt @3× = 150×3 × 150×3 × 4 = 810,000 bytes ≈ 791 KB
2. 将位图清零(全部填成 0x00000000)
→ 系统调用 memset,CPU 遍历整块内存
3. 创建 CGContext 指向这块位图
→ 关联颜色空间(sRGB / P3)
→ 设置上下文参数(opaque / scale)
4. 调用 drawRect → (开发者代码,可为空)
5. 将 CGContext 的内容刻录到 CALayer.contents
→ CGImageCreate → 引用计数 +1
→ CALayer.contents = cgImage → 旧的 contents 引用计数 -1
6. 释放本次分配的位图内存(CGContext 析构时)
→ 但 CGImage 仍被 CALayer 引用,所以位图数据仍然存活
→ 这就是"画完仍然占内存"的原因
每一步的耗时(实测 iPhone 12,150×150pt @3× View):
| 步骤 | 耗时 | 说明 |
|---|---|---|
| 分配 + 清零位图 | ~0.9ms | calloc 分配 791KB + 清零 |
| 创建 CGContext | ~0.4ms | 颜色空间匹配、参数设置 |
| drawRect(空) | ~0ms | 开发者代码 |
| 刻录到 CALayer | ~1.0ms | CGImage 创建 + 编码 + CA 赋值 |
| 合计(空 drawRect) | ~2.3ms | |
| 加上实际绘制 | +1.3ms | 路径 + 文本 |
| 合计(实际 drawRect) | ~3.6ms |
这就是为什么第 1 章的 ProgressRingView 即使什么都不画也要 ~2.3ms——drawRect 的开销大部分不是"画"本身,而是"为画而准备和收尾"。CAShapeLayer 完全跳过了这个位图分配/清零/刻录链,把光栅化推迟到 GPU 端。
# 6. 文本绘制与 CoreText
# 6.1 NSAttributedString 的绘制管线
NSAttributedString.draw(in:) 实际上走的是一个多阶段管线:
NSAttributedString.draw(in: rect)
│
▼
内部创建 CTFramesetter(排版引擎)
│
├── 阶段 1:字符分析
│ ├── 字素分割(如 🇨🇳 是一个字素但两个 Unicode)
│ ├── 语言检测(决定断行规则)
│ └── 字体级联(Font Cascade——主字体缺字时找后备字体)
│
├── 阶段 2:排版计算
│ ├── CTTypesetter:确定换行点
│ ├── 计算每字符的 glyph 位置(CTRun)
│ ├── 行高计算(ascent + descent + leading)
│ └── 生成 CTFrame(包含所有行信息)
│
├── 阶段 3:光栅化
│ ├── 按字体大小从 CTFont 取 glyph 路径/位图
│ ├── Core Graphics 逐字符描画
│ └── 应用 NSForegroundColorAttributeName / NSStrokeColorAttributeName
│
└── 返回
关键性能开销:每次调用 draw(in:) 都会重新创建 CTFramesetter!这意味着排版计算(阶段 2)每次都重复执行——即使文本没变。
// ❌ 每次 drawRect 都重新排版
override func draw(_ rect: CGRect) {
let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 14)]
NSAttributedString(string: text).draw(in: rect)
// → 每次都 CTFramesetterCreate + 排版 + 光栅化
}
// ✅ 预排版——缓存 CTFrame
class CachedTextView: UIView {
var text: String = "" {
didSet { cachedFrame = nil; setNeedsDisplay() }
}
private var cachedFrame: CTFrame?
private func buildFrame() -> CTFrame {
if let cached = cachedFrame { return cached }
let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 14)]
let attrStr = NSAttributedString(string: text, attributes: attrs)
let framesetter = CTFramesetterCreateWithAttributedString(attrStr)
let path = CGPath(rect: bounds, transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
cachedFrame = frame
return frame
}
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
let frame = buildFrame()
CTFrameDraw(frame, ctx)
// → 只做光栅化,跳过了排版计算
}
}
# 6.2 CTFramesetter 排版引擎
CoreText 的排版引擎以 CTFramesetter 为核心,负责文本→字形布局的全过程:
CTFramesetter 内部架构:
CTFramesetterCreateWithAttributedString
│
▼
CTTypesetter(换行器)
│ 对每行文本:
│ ├── 从当前位置开始填充尽可能多的字符
│ ├── 直到碰到换行条件之一:
│ │ ├── 到达文本框宽度(自然换行)
│ │ ├── 遇到换行符 \n
│ │ ├── 遇到断字规则(中日韩文复杂)
│ │ └── 到达文本框底部(截断)
│ └── 生成 CTLine(行对象)
│
▼
CTFrame(框架:行的集合)
│ 每行包含:
│ ├── CTLine
│ │ ├── CTRun × N(每个 Run 对应一个连续的同属性文本段)
│ │ │ └── glyph 数组 + position 数组
│ │ │ glyph: 字体内的字形索引
│ │ │ position: 每个字形的 CGPoint 位置
│ │ └── 行度量:ascent, descent, leading, width
│ └── 总 frame 尺寸
CTRun 是最重要的优化单位——当一个 AttributedString 中连续多个字符属性完全相同时(相同字体、相同颜色),它们被合并为一个 CTRun。这减少了绘制时的状态切换(切换字体/颜色需要额外的 CPU 开销)。
# 6.3 自定义文本截断与省略号
UIKit 的 NSLineBreakMode 只有 6 种预设,很多时候不够用:
// 场景:最后一行被截断时显示"……展开",而不是"…"
// 需要自己用 CTFrame 来实现
class ExpandableTextView: UIView {
var text: String = ""
var lineLimit = 3
var truncationString = "…展开"
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
let attrStr = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 14)])
let framesetter = CTFramesetterCreateWithAttributedString(attrStr)
let path = CGPath(rect: bounds, transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
let lines = CTFrameGetLines(frame) as! [CTLine]
guard lines.count > lineLimit else {
CTFrameDraw(frame, ctx); return
}
// 绘制前 N-1 行(完整)
for i in 0..<(lineLimit - 1) {
drawLine(lines[i], in: ctx, atIndex: i, frame: frame)
}
// 最后一行:截断 + 追加自定义截断串
let lastLine = lines[lineLimit - 1]
// 在 lastLine 末尾追加 truncatedLine
let truncatedAttr = NSAttributedString(string: truncationString,
attributes: [.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.systemBlue])
let truncatedLine = CTLineCreateTruncatedLine(lastLine, bounds.width, .end, CTLineCreateWithAttributedString(truncatedAttr))!
drawLine(truncatedLine, in: ctx, atIndex: lineLimit - 1, frame: frame)
}
}
# 6.4 文本渐变与描边效果
// 文本渐变 —— 用 CGContext 做 text mask
func drawGradientText(_ text: String, in rect: CGRect, colors: [UIColor]) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
// 1. 用文本做 mask
ctx.saveGState()
let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 48)]
(text as NSString).draw(in: rect, withAttributes: attrs)
ctx.clip(using: .evenOdd) // 把文本路径设为裁剪区域
// 2. 在裁剪区域画渐变
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: colors.map { $0.cgColor } as CFArray,
locations: [0, 0.5, 1])!
ctx.drawLinearGradient(gradient,
start: CGPoint(x: rect.minX, y: rect.minY),
end: CGPoint(x: rect.maxX, y: rect.maxY),
options: [])
ctx.restoreGState()
}
// 文本描边 + 填充
func drawOutlinedText(_ text: String, in rect: CGRect) {
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 48),
.foregroundColor: UIColor.white,
.strokeColor: UIColor.black,
.strokeWidth: -3 // 负值 = 描边 + 填充;正值 = 仅描边
]
(text as NSString).draw(in: rect, withAttributes: attrs)
}
# 7. 异步绘制策略
# 7.1 主线程绘制的瓶颈数据
在一个复杂 UI 中,主线程耗时分配通常如下(实测数据):
| 任务 | 耗时占比 | 是否可异步 |
|---|---|---|
| 触摸事件处理 | 5~10% | ❌ 必须在主线程 |
| AutoLayout / Frame 计算 | 15~25% | ⚠️ 部分可异步 |
| drawRect(CPU 光栅化) | 30~50% | ✅ 可异步! |
| CA 事务提交 | 10~15% | ❌ 必须在主线程 |
| GPU 合成 | ~5% | ❌ Render Server |
drawRect 占用 30~50% 的主线程时间,是所有 UI 卡顿的第一大元凶。异步绘制的核心思路就是将这个时间移出主线程。
# 7.2 CGContext 的线程模型
Core Graphics 本身是线程安全的——你可以创建任意多个 CGContext,在任意线程上绘制:
Core Graphics 线程安全规则:
✅ 可以:在后台线程创建 CGBitmapContext → 绘制 → 生成 CGImage
✅ 可以:多个线程各操作各自的 CGContext
✅ 可以:NSAttributedString / CTFramesetter 在后台线程使用
❌ 不能:在后台线程直接调用 UIView.drawRect(必须是主线程)
❌ 不能:两个线程操作同一个 CGContext(需加锁)
// 基础异步绘制模式
DispatchQueue.global(qos: .userInitiated).async {
let size = CGSize(width: 300, height: 300)
UIGraphicsBeginImageContextWithOptions(size, false, 0)
defer { UIGraphicsEndImageContext() }
// 1. 在后台线程绘制(此上下文是全新的,不涉及主线程)
let ctx = UIGraphicsGetCurrentContext()!
ctx.setFillColor(UIColor.red.cgColor)
ctx.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height))
// ... 全部绘制操作都在后台 ...
let image = UIGraphicsGetImageFromCurrentImageContext()
// 2. 回主线程设给 layer.contents
DispatchQueue.main.async {
self.layer.contents = image?.cgImage
}
}
⚠️
UIGraphicsBeginImageContextWithOptions在后台线程使用时,调用栈不要引用任何 UIView/CALayer(否则会触发 main thread checker)。
# 7.3 异步绘制三步法
异步绘制三步法:
步骤 1:准备数据(主线程,~0.1ms)
├── 拷贝视图状态(frame, backgroundColor, ...)
├── 拷贝文本内容 + 样式
└── 拷贝路径数据
步骤 2:后台绘制(后台线程,异步,不阻塞)
├── 创建 CGBitmapContext
├── 执行完整绘制逻辑(与 drawRect 一致)
└── 生成 CGImage
步骤 3:回主线程显示(主线程,~0.2ms)
├── 检查视图是否仍需要该绘制结果(可能已移出屏幕)
├── 如果仍需要 → layer.contents = cgImage
└── 如果不需要 → 丢弃(跳过 layer 赋值)
class AsyncDrawView: UIView {
private var drawToken = 0 // 取消令牌
func setNeedsAsyncDisplay() {
drawToken += 1
let expectedToken = drawToken
let size = bounds.size
let scale = traitCollection.displayScale
DispatchQueue.global(qos: .userInitiated).async {
let image = self.drawImage(size: size, scale: scale)
DispatchQueue.main.async {
guard expectedToken == self.drawToken else { return } // 已过期
self.layer.contents = image?.cgImage
}
}
}
private func drawImage(size: CGSize, scale: CGFloat) -> UIImage? {
let format = UIGraphicsImageRendererFormat()
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { ctx in
// 在此闭包中执行绘制——当前线程是后台线程
UIColor.red.setFill()
ctx.fill(CGRect(origin: .zero, size: size))
// ...
}
}
}
取消令牌的价值:列表快速滑动时,一个 Cell 可能在 100ms 内就滚出屏幕了。如果没有取消令牌,后台线程还在为这个 Cell 绘制——结果没人消费,纯费 CPU 和内存。token 让旧绘制任务"发现已经过期就立刻放弃"。
# 7.4 异步绘制与 CALayer 的配合
如果不想自己管理异步绘制,可以让 CALayer 的 delegate 来完成:
class AsyncLayer: CALayer {
override class func defaultValue(forKey key: String) -> Any? {
if key == "drawsAsynchronously" { return true }
return super.defaultValue(forKey: key)
}
// CALayer 的 displaysAsynchronously = true 时,
// display() 和 draw(in:) 会在后台线程被调用
}
// 但注意:UIKit 的 UIView 不支持此属性
// 只有不使用 UIView 的裸 CALayer(有 delegate)才生效
对于需要大量异步绘制的场景,推荐使用成熟的第三方库如 YYAsyncLayer(YYKit)或 AsyncDisplayKit(Texture),它们已封装了完整的命令取消、线程池、内存管理。
# 8. 离屏渲染与优化
# 8.1 离屏渲染的触发条件
离屏渲染(Offscreen Rendering)是 GPU 性能的隐形杀手——它会触发一次额外的渲染 Pass,为图层创建独立的帧缓冲,然后才合成到屏幕:
正常渲染(No Offscreen):
GPU 直接在当前帧缓冲中光栅化 → 输出到屏幕
→ 1 个 Pass
离屏渲染:
GPU 创建临时帧缓冲 → 在临时缓冲中渲染 → 将临时缓冲合成到主帧缓冲 → 输出到屏幕
→ 2 个 Pass(临时缓冲创建 + 销毁 + 合成开销)
触发离屏渲染的条件:
| 触发条件 | 具体场景 | 为什么 |
|---|---|---|
cornerRadius + masksToBounds | 圆角 + 裁剪 | GPU 需要先渲染内容再裁剪圆角 |
shadow 不带 shadowPath | 阴影 | GPU 需要光栅化整层来计算阴影轮廓 |
mask | 图层蒙版 | 两个 layer 先合成再应用到目标 |
groupOpacity = true | 组透明度 | 子图层需要先合成再应用透明度 |
edgeAntialiasingMask != 0 | 边缘抗锯齿 | 需要额外 Pass 做抗锯齿 |
shouldRasterize = true | 强制光栅化 | 见第 8.4 节 |
allowsGroupOpacity = true | 组不透明度 | 需要预合成子图层 |
# 8.2 cornerRadius + masksToBounds 的终极解法
cornerRadius 配合 masksToBounds 是离屏渲染的第一入口。四种解法:
// ❌ 触发离屏渲染
imageView.layer.cornerRadius = 20
imageView.layer.masksToBounds = true
// ✅ 方案 A:用 CAShapeLayer 作为 mask(仍离屏,但可控)
let mask = CAShapeLayer()
mask.path = UIBezierPath(roundedRect: imageView.bounds, cornerRadius: 20).cgPath
imageView.layer.mask = mask // ⚠️ 仍然触发离屏,但性能稍好
// ✅ 方案 B:用 Core Graphics 裁剪后生成圆角图片(CPU 端解决)
func roundedImage(_ image: UIImage, radius: CGFloat) -> UIImage? {
let rect = CGRect(origin: .zero, size: image.size)
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
UIBezierPath(roundedRect: rect, cornerRadius: radius).addClip()
image.draw(in: rect)
let result = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return result
}
imageView.image = roundedImage(originalImage, radius: 20)
// → 完全避免离屏渲染:图片在 CPU 端圆角化完毕
// ✅ 方案 C:iOS 13+ 用 layer.cornerCurve = .continuous + 无 masksToBounds 的 trick
// (仅当 layer.contents 本身就是圆角图片时才不需要 masksToBounds)
// ✅ 方案 D:用 border + 背景色模拟圆角(文本类 View 可用)
label.layer.cornerRadius = 8
label.layer.masksToBounds = true // 对子像素文本仍然离屏,但文本面积小
// 如果只是背景圆角,省去 masksToBounds 改用 UIImageView 背景图
实测数据(iPhone 12,100 个圆角 ImageView):
| 方案 | 离屏渲染 | 帧率 |
|---|---|---|
cornerRadius + masksToBounds | 100 次离屏 Pass | 38fps |
| CPU 预圆角化 | 0 次 | 59fps |
| CAShapeLayer mask | 100 次离屏 Pass(但每次更快) | 42fps |
# 8.3 shadowPath 的性能救赎
// ❌ 未指定 shadowPath → GPU 必须光栅化整层来计算阴影
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.3
view.layer.shadowOffset = CGSize(width: 0, height: 2)
view.layer.shadowRadius = 8
// → 触发离屏渲染!
// ✅ 指定 shadowPath → GPU 直接用 path 做阴影计算,无需离屏
view.layer.shadowPath = UIBezierPath(roundedRect: view.bounds, cornerRadius: 8).cgPath
// → 不触发离屏渲染!
shadowPath 为什么能避免离屏:因为它直接告诉 GPU "阴影的形状就是这个 path",GPU 只需要对这个 path 做高斯模糊(shadowRadius)就可以了——不需要先光栅化整层再分析边缘。
# 8.4 shouldRasterize 的正确使用姿势
layer.shouldRasterize = true 强制 Core Animation 把该层的渲染结果缓存为一张位图:
view.layer.shouldRasterize = true
view.layer.rasterizationScale = UIScreen.main.scale
适用场景(必须同时满足三个条件才能用):
| 条件 | 原因 |
|---|---|
| ✅ 图层内容是静态的(不频繁变化) | 每次内容变化都要重建缓存位图 |
| ✅ 图层结构复杂(多个子层 + 效果) | 缓存收益 > 缓存成本 |
| ✅ 图层会被多次渲染(如页面间切换) | 复用缓存才有意义 |
// ✅ 适用:复杂的静态阴影卡片
cardView.layer.shouldRasterize = true
cardView.layer.rasterizationScale = UIScreen.main.scale
// ❌ 不适用:列表 Cell(频繁复用,缓存反复失效)
// Cell 每次从重用池取出都重建缓存,反而更慢
// ❌ 不适用:进度动画(每帧内容都变,缓存 100% 失效)
shouldRasterize 的极限:缓存的位图超过屏幕面积的 2.5% 时,系统自动丢弃缓存(避免暴占显存)。对于大图或全屏 View,shouldRasterize 反而会频繁失效重建——越用越卡。
# 9. 性能剖析与调试
# 9.1 Core Animation Instrument 实战
Instruments 的 Core Animation 模板是调试绘制性能的一站式工具:
# 开启方式
Xcode → Product → Profile → Core Animation
# 或:Instruments → Core Animation → 选择设备 + App
# 六个核心调试选项(在右侧面板):
| 选项 | 显示效果 | 诊断目标 |
|---|---|---|
| Color Blended Layers | 绿色 = 不透明,红色 = 需混合 | 视图混合(alpha 通道) |
| Color Hits Green and Misses Red | 绿色 = 缓存命中,红色 = 缓存未命中 | shouldRasterize 效果 |
| Color Copied Images | 蓝色 = 图片被 GPU 拷贝 | 大图格式兼容性 |
| Color Misaligned Images | 黄色 = 像素未对齐 | 抗锯齿性能损失 |
| Color Offscreen-Rendered Yellow | 黄色 = 离屏渲染 | 离屏渲染检测 |
| Flash Updated Regions | 黄色闪烁 = 重绘区域 | drawRect 调用检测 |
典型诊断流程:
1. 先开 Color Offscreen-Rendered Yellow
→ 任何黄色区域都是离屏渲染焦点
2. 再开 Color Blended Layers
→ 任何红色区域(非绿色)说明有不透明混合
3. 再开 Color Misaligned Images
→ 任何黄色 = 像素未对齐
4. 最后开 Flash Updated Regions
→ 黄色闪烁 = 有 drawRect 在反复被调用
# 9.2 像素级对齐与抗锯齿
当 View 的 frame 不是整数像素时(如 x = 10.33),GPU 必须做抗锯齿(anti-aliasing)——额外采样邻接像素来平滑边缘。代价约 10~20% 渲染开销:
// ❌ 非整数 frame → 触发抗锯齿
view.frame = CGRect(x: 10.33, y: 20.67, width: 100, height: 50)
// ✅ 整像素对齐
view.frame = CGRect(x: 10, y: 21, width: 100, height: 50)
// 等价于:view.frame = CGRectIntegral(unsafeFrame)
// ✅ 自动对齐工具
func pixelCeil(_ value: CGFloat, scale: CGFloat = UIScreen.main.scale) -> CGFloat {
return ceil(value * scale) / scale
}
func pixelFloor(_ value: CGFloat, scale: CGFloat = UIScreen.main.scale) -> CGFloat {
return floor(value * scale) / scale
}
# 9.3 视图混合(blending)检测
不透明的 View 可以跳过 GPU 混合计算。而透明的 View(alpha < 1.0、backgroundColor = nil、或内容包含透明像素)强制 GPU 做图层混合:
// ❌ 透明背景 → 必须与下层混合
view.backgroundColor = nil // 或 .clear
label.backgroundColor = .clear
imageView.image = transparentPNG
// ✅ 不透明背景 → GPU 跳过混合(直接覆写)
view.backgroundColor = .white
view.isOpaque = true // 告诉系统这个视图完全不透明
label.backgroundColor = color // 即使 color = .white 也可以
关键坑:isOpaque = false 的 View(默认值对于 UILabel 等控件的背景是 .clear 的情况下)会强制整个区域做逐像素混合。一个 UILabel 如果 background = .clear 且文字有透明度(如 50% 透明度),每个字都要与背景混合——这在列表中尤其昂贵。
# 9.4 内存占用测量与画像
// 测量 CALayer.contents 的内存占用
extension CALayer {
var contentsMemory: Int {
guard let contents = contents else { return 0 }
let image = contents as! CGImage
return image.bytesPerRow * image.height
}
}
// 测量整个 View 树的绘制内存
func measureDrawMemory(for rootView: UIView) {
var totalBytes = 0
func traverse(_ view: UIView) {
totalBytes += view.layer.contentsMemory
view.subviews.forEach { traverse($0) }
}
traverse(rootView)
print("绘制内存总计: \(Double(totalBytes) / 1024.0 / 1024.0) MB")
}
// 或在 Instruments → Allocations → VM Tracker
// 关注 Dirty Memory(进程独占总内存)中的 CG Image 条目
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的绩效看板 ProgressRingView,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① drawRect 空壳的 2.8ms 开销是什么? | 第 5.4:位图分配(0.9ms) + CGContext 创建(0.4ms) + CGImage→CA.contents 刻录(1.0ms) + 杂项(0.5ms) |
| ② 为什么吃 1.5MB/View? | 第 5.4:150×150pt @3× = 450×450 pixels × 4 bytes = 810KB,加上 CGImage 副本 + CA 内部纹理副本 ≈ 1.5MB |
| ③ CAShapeLayer 为什么快 24 倍? | 第 4.4:跳过 CPU 位图分配/清零/刻录链,把矢量数据直接发给 GPU 在合成阶段光栅化 |
| ④ setNeedsDisplay 到 drawRect 之间? | 第 5.1:标记后在本 RunLoop 空闲时统一执行,多次调用合并为一次 |
| ⑤ 圆角 + masksToBounds 触不触发离屏? | 第 8.2:是的——应改 CPU 预圆角化或 CAShapeLayer mask |
| ⑥ draw(in:) 和 CoreText 哪个快? | 第 6.1:CoreText 预排版比每次调 draw(in:) 快约 2~3×(避免重复排版) |
| ⑦ 能不能在后台线程绘制? | 第 7.3:可以——三步法:数据拷贝 → 后台 CGBitmapContext → 主线程设 contents |
三种最终方案的完整对比:
| 方案 | 单环耗时 | 15 环合计 | 内存/环 | 60fps |
|---|---|---|---|---|
| 纯 drawRect(原版) | 3.6ms | 54ms | 1.5MB | ❌ 18fps |
| CAShapeLayer(方案 A) | 0.15ms | 2.3ms | ~20KB | ✅ 60fps |
| 异步绘制(方案 B) | ~0ms(主线程) | ~0ms | ~800KB(峰值×2) | ✅ 60fps |
| CAShapeLayer + 预排版 CoreText(方案 C) | 0.12ms | 1.8ms | ~20KB | ✅ 60fps |
最终推荐方案:CAShapeLayer + CATextLayer(方案 C)
// 终版:把圆环从 drawRect 迁移到 CAShapeLayer + CATextLayer
class OptimizedProgressRing: UIView {
private let bgLayer = CAShapeLayer()
private let progressLayer = CAShapeLayer()
private let textLayer = CATextLayer()
override init(frame: CGRect) {
super.init(frame: frame)
bgLayer.fillColor = nil
bgLayer.strokeColor = UIColor.lightGray.cgColor
bgLayer.lineWidth = 6
layer.addSublayer(bgLayer)
progressLayer.fillColor = nil
progressLayer.strokeColor = UIColor.systemRed.cgColor
progressLayer.lineWidth = 6
progressLayer.lineCap = .round
layer.addSublayer(progressLayer)
textLayer.alignmentMode = .center
textLayer.contentsScale = UIScreen.main.scale
layer.addSublayer(textLayer)
}
func configure(name: String, value: String, progress: CGFloat) {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = min(bounds.width, bounds.height) / 2 - 8
bgLayer.path = UIBezierPath(arcCenter: center, radius: radius,
startAngle: -.pi/2, endAngle: .pi*1.5, clockwise: true).cgPath
let endAngle = -.pi/2 + .pi * 2 * progress
progressLayer.path = UIBezierPath(arcCenter: center, radius: radius,
startAngle: -.pi/2, endAngle: endAngle, clockwise: true).cgPath
textLayer.string = "\(name)\n\(value)"
textLayer.fontSize = 14
textLayer.frame = bounds.insetBy(dx: 16, dy: 16) // 仅更新 text rect
}
}
# 10.2 一次 drawRect 的全生命周期
把"一个 View 调用 setNeedsDisplay 到像素出现在屏幕上"的全过程串成一棵树:
view.setNeedsDisplay()
│
├─ UIKit 标记阶段(当前线程、几乎零开销)
│ ├─ UIView 内部设置 _needsDisplay = true
│ ├─ 记录脏矩形 rect
│ └─ 通知 Core Animation 该 layer 需要更新 contents
│
├─ RunLoop 空闲触发(BeforeWaiting)
│ └─ CA::Transaction 遍历所有 needsDisplay = true 的 View
│
├─ CALayer.display() → 第 5 章
│ ├─ 分配后备存储位图(alloc + memset 清零)
│ │ └─ size = bounds.w×scale × bounds.h×scale × 4 bytes ── 第 5.4
│ ├─ 创建 CGContext 指向该位图
│ │ └─ 关联颜色空间、设置 opaque/scale ── 第 3 章
│ ├─ 调用 UIView.draw(rect:) ── 第 3~6 章
│ │ ├─ CGContext 路径绘制 / UIBezierPath ── 第 4 章
│ │ ├─ 文本绘制 / CoreText 排版 ── 第 6 章
│ │ └─ 图像绘制 / 混合模式
│ └─ CGImageCreate → CALayer.contents = image ── 第 5.4
│
├─ CA::Transaction::commit() ── 第 2 章
│ └─ 所有被修改的 Layer 树打包为一个事务
│
├─ Render Server (backboardd) ── 第 8 章
│ ├─ 离屏渲染检测:如有 cornerRadius+masksToBounds
│ │ └─ 创建临时帧缓冲 → 执行离屏 Pass ── 第 8.1
│ ├─ 纹理采样 + 图层混合
│ └─ 光栅化到帧缓冲
│
└─ GPU → 显示控制器 → 屏幕像素
# 10.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:CPU 与 GPU 分工——正确的问题交给正确的处理器
Core Graphics 在 CPU 上光栅化位图,CAShapeLayer 把矢量发给 GPU 在合成阶段光栅化——同样的贝塞尔曲线,走 CPU 还是 GPU 性能差 24 倍。这不是"谁更好"的问题,是适合不适合的问题。CPU 适合复杂、一次性的光栅效果(滤镜、自定义混合),GPU 适合简单几何图形的频繁重绘。选错处理器是绘制性能的第一大杀手。
哲学 2:后备存储是 drawRect 的宿命——能不用就不用
drawRect 的本质是用 CPU 帮你创建一张完整的位图。这张位图的内存 = width × height × 4 × scale²,和画什么内容无关。这意味着一个 200×200pt 的空白 View 和 200×200pt 的复杂图表占用相同的内存。如果你不需要跨像素的复杂混合效果,就不要用 drawRect——用 subview + CALayer 组合达成同样的视觉效果。
哲学 3:声明式的矢量优于命令式的位图
UIBezierPath + CAShapeLayer 是一条 "path 声明 + GPU 渲染" 的声明式链路——你声明"我要画一个圆角矩形",把数据量只有几百字节的路径控制点发给 GPU。drawRect 是一条命令式链路——你把一条条 ctx.addLine 变成完整的像素数组(几十万字节)再发给 GPU。声明式链路带宽省 1000 倍,渲染硬件加速。优先声明式,只在必要时降级到命令式。
哲学 4:异步是非阻塞的极致——但 token 必须跟上
异步绘制把主线程的 30~50% 绘制时间移到后台——但如果不加取消令牌,100 个 Cell 同时离开屏幕,后台线程仍在为它们绘制。cancel token 把"我还在画你吗?"变成一条 O(1) 检查,让无效工作第一时间停止。异步不解决所有问题,取消机制让异步不变成浪费。
# 10.4 绘制速查表
| 场景 | 推荐方案 | 避免方案 |
|---|---|---|
| 圆形进度环、仪表盘 | CAShapeLayer | drawRect |
| 自定义圆角(列表大量) | CPU 预圆角化生成 UIImage | cornerRadius + masksToBounds |
| 阴影 | 设 shadowPath | 不设 path 的默认阴影 |
| 复杂静态图表(绘制一次不变) | 异步绘制 + 缓存 image | 主线程 drawRect 反复重绘 |
| 文本片段(富文本混排) | CoreText 预排版 | 每次都 draw(in:) 重新排版 |
| 像素级对齐 | CGRectIntegral | 非整数 frame |
| 透明背景 Label | 设 opaque 背景色 | backgroundColor = .clear |
| 频繁变化的动画 | Core Animation 属性动画 | setNeedsDisplay 每帧重绘 |
| 视图效果组合(阴影+圆角+渐变) | shouldRasterize(静态且 < 2.5% 屏幕面积) | 不加限制的 rasterize |
绘制方案决策树:
你的内容是什么类型的?
│
├── 简单几何图形(圆、环、矩形、线条)
│ └── CAShapeLayer ✅
│
├── 需要复杂光栅效果(渐变混合、模糊、滤镜)
│ ├── 频繁更新 → 异步绘制 + CGBitmapContext
│ └── 一次性的 → drawRect
│
├── 文本
│ ├── 静态单样式 → UILabel(最省)
│ ├── 富文本 + 固定内容 → NSAttributedString.draw(in:)(一次就行)
│ └── 富文本 + 可变内容 → CoreText 预排版 + 异步绘制
│
├── 图片
│ ├── 圆角 → CPU 预圆角化
│ ├── 阴影 → shadowPath + cornerRadius
│ └── 滤镜 → CIFilter + CIContext
│
└── 复杂自定义视图(含多种元素的组合)
└── subview 组合(多种 CALayer 叠加) > drawRect 统一绘制
调试三问:
1. drawRect 被频繁调用了吗?
→ Instruments → Core Animation → Flash Updated Regions
→ 黄色闪烁多 → 考虑 CAShapeLayer 或 images 缓存替代
2. 内存飙升了吗?
→ Instruments → Allocations → VM Tracker → 看 Dirty Memory 增长
→ 如果 CG Image 条目持续增加 → 有 drawRect 泄漏或 shouldRasterize 滥用
3. 离屏渲染在多吗?
→ Instruments → Core Animation → Color Offscreen-Rendered Yellow
→ 黄色区域 → 检查 cornerRadius/shadow/mask 的使用方式
下一篇:了解了绘制管线底层的 CPU/GPU 分工和性能优化,下一步进入 08.CoreAnimation动画实战 (opens new window)——把 UIView 动画、显式动画、过渡动画的完整原理打通。