编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

    • README
    • ObjC编程入门

    • Swift编程入门

    • 专栏博客

      • iOS系统架构概览
      • Runtime消息转发机制
      • RunLoop运行循环揭秘
      • UIView生命周期与渲染
      • 事件响应与手势识别
      • AutoLayout布局系统
      • 自定义View绘制实践
        • 1. 案例引入
          • 1.1 一段满屏红圈的绩效看板
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 iOS 渲染管线五层模型
          • 2.2 为什么这么分层
        • 3. Core Graphics 绘制引擎
          • 3.1 CGContext 的本质——状态机画笔
          • 3.2 图形状态栈 push/pop 机制
          • 3.3 坐标系翻转的暗坑
          • 3.4 位图上下文与离屏上下文
        • 4. UIBezierPath 矢量路径
          • 4.1 贝塞尔曲线的数学本质
          • 4.2 二次曲线与三次曲线的选型
          • 4.3 路径填充规则的差异
          • 4.4 CAShapeLayer 与 drawRect 的性能鸿沟
        • 5. drawRect 生命周期与时机
          • 5.1 从 setNeedsDisplay 到 drawRect
          • 5.2 drawRect 的脏矩形合并机制
          • 5.3 contentMode 与 drawRect 的交互
          • 5.4 内存爆炸:drawRect 的后备存储真相
        • 6. 文本绘制与 CoreText
          • 6.1 NSAttributedString 的绘制管线
          • 6.2 CTFramesetter 排版引擎
          • 6.3 自定义文本截断与省略号
          • 6.4 文本渐变与描边效果
        • 7. 异步绘制策略
          • 7.1 主线程绘制的瓶颈数据
          • 7.2 CGContext 的线程模型
          • 7.3 异步绘制三步法
          • 7.4 异步绘制与 CALayer 的配合
        • 8. 离屏渲染与优化
          • 8.1 离屏渲染的触发条件
          • 8.2 cornerRadius + masksToBounds 的终极解法
          • 8.3 shadowPath 的性能救赎
          • 8.4 shouldRasterize 的正确使用姿势
        • 9. 性能剖析与调试
          • 9.1 Core Animation Instrument 实战
          • 9.2 像素级对齐与抗锯齿
          • 9.3 视图混合(blending)检测
          • 9.4 内存占用测量与画像
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次 drawRect 的全生命周期
          • 10.3 设计哲学回扣
          • 10.4 绘制速查表
      • CoreAnimation动画实战
      • ViewController容器管理
      • 转场动画与导航设计
      • 网络层架构设计
      • 数据持久化方案选型
      • 多线程与GCD实践
      • 内存管理与ARC原理
      • Block底层原理与应用
      • KVO与KVC实现机制
      • 性能优化与启动加速
      • 组件化与模块设计
      • 安全加密与逆向防护
      • 跨平台与混编方案
  • Web开发和进阶

  • Linux应用开发

  • IoT智能硬件开发

  • Apps
  • iOS开发和进阶
  • 专栏博客
杨充
2026-06-20
目录

自定义View绘制实践

# 07.自定义View绘制实践

深度篇 | drawRect与CoreGraphics、UIBezierPath、文本绘制、异步绘制、离屏渲染优化。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段满屏红圈的绩效看板
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 iOS 渲染管线五层模型
    • 2.2 为什么这么分层
  • 3. Core Graphics 绘制引擎
    • 3.1 CGContext 的本质——状态机画笔
    • 3.2 图形状态栈 push/pop 机制
    • 3.3 坐标系翻转的暗坑
    • 3.4 位图上下文与离屏上下文
  • 4. UIBezierPath 矢量路径
    • 4.1 贝塞尔曲线的数学本质
    • 4.2 二次曲线与三次曲线的选型
    • 4.3 路径填充规则的差异
    • 4.4 CAShapeLayer 与 drawRect 的性能鸿沟
  • 5. drawRect 生命周期与时机
    • 5.1 从 setNeedsDisplay 到 drawRect
    • 5.2 drawRect 的脏矩形合并机制
    • 5.3 contentMode 与 drawRect 的交互
    • 5.4 内存爆炸:drawRect 的后备存储真相
  • 6. 文本绘制与 CoreText
    • 6.1 NSAttributedString 的绘制管线
    • 6.2 CTFramesetter 排版引擎
    • 6.3 自定义文本截断与省略号
    • 6.4 文本渐变与描边效果
  • 7. 异步绘制策略
    • 7.1 主线程绘制的瓶颈数据
    • 7.2 CGContext 的线程模型
    • 7.3 异步绘制三步法
    • 7.4 异步绘制与 CALayer 的配合
  • 8. 离屏渲染与优化
    • 8.1 离屏渲染的触发条件
    • 8.2 cornerRadius + masksToBounds 的终极解法
    • 8.3 shadowPath 的性能救赎
    • 8.4 shouldRasterize 的正确使用姿势
  • 9. 性能剖析与调试
    • 9.1 Core Animation Instrument 实战
    • 9.2 像素级对齐与抗锯齿
    • 9.3 视图混合(blending)检测
    • 9.4 内存占用测量与画像
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次 drawRect 的全生命周期
    • 10.3 设计哲学回扣
    • 10.4 绘制速查表

# 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(硬件合成)两条路?

论证:

  1. CPU 绘制 vs GPU 合成的分工——drawRect 走的是 CPU 光栅化路径:Core Graphics 在 CPU 上把贝塞尔曲线、文本、图像算法化地"画"成一张位图,然后这张位图作为 CALayer.contents 交给 GPU 做合成。而 CAShapeLayer、CATextLayer 直接把矢量描述发给 Render Server,由 GPU 在合成阶段光栅化。两者在硬件上的执行位置不同。

  2. 后备存储(Backing Store)的必要性——drawRect 产生一张完全展开的位图(像素数组),必须分配物理内存来存储。这就是 drawRect 吃内存的根因——即使你的 View 是空白的,系统也会分配一整块 context 大小的后备位图。

  3. 脏矩形合并的经济性——系统不是每次 setNeedsDisplay 都立刻调 drawRect,而是在 RunLoop 结束前合并多个 setNeedsDisplay 调用,只画一次。这避免了重复绘制,但代价是必须重新生成整个 View 的后备存储位图。

  4. 异步渲染需要创建新 context——一旦想在后台线程绘制,必须创建独立的 CGContext(因为没有主线程的 drawRect context),绘制完成后生成 CGImage,再回到主线程设给 layer.contents。这带来了额外的内存峰值(两张位图同时存在)。

  5. 反向验证:如果不分层会怎样?参考早期 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 动画、显式动画、过渡动画的完整原理打通。

上次更新: 2026/06/28, 17:55:19
AutoLayout布局系统
CoreAnimation动画实战

← AutoLayout布局系统 CoreAnimation动画实战→

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式