Highlight
Instruments 27 引入 Top Functions 视图、Run Comparison 和 Swift Executors 工具,配合 OSSignpost 标记业务区间,让开发者可以按图索骥地诊断 CPU 饱和、执行争用和系统阻塞三类卡顿问题。
核心内容
一个笔记 App 的三处卡顿
演讲者用一个真实的笔记 App 做案例。这个 App 支持手绘、插入图片、套索移动。测试时发现了三个问题:保存笔记后 Apple Pencil 响应延迟、滚动笔记列表掉帧、套索工具卡住不动。
这三个问题分别对应三种不同的性能根因,需要三种不同的诊断思路。
诊断的心智模型
(01:46)
Instruments 27 建立了一套四步诊断流程:
- CPU 饱和 — 主线程 CPU 飙到 100%,说明代码本身执行太慢
- 采样数据可视化 — 用 Call Tree、Flame Graph 或 Top Functions 找到耗时函数
- 执行争用 — 多个任务争抢 Main Actor 资源
- 系统阻塞 — CPU 闲置但 App 挂起,主线程在等待文件 I/O、同步锁或 IPC
第一步永远是 Time Profiler。看卡顿期间主线程 CPU 使用率:高了是算法问题,低了是阻塞问题。这个判断直接决定后续用哪个工具。
问题一:套索工具卡顿 —— Existential 的运行时开销
(05:37)
演讲者先用 OSSignpost 在套索选择逻辑外包裹了一个时间区间:
import os.signpost
let signposter = OSSignposter(subsystem: "Demo App", category: .pointsOfInterest)
var lassoIntervalState: OSSignpostIntervalState? = nil
func lassoSelectionUpdated() {
lassoIntervalState = signposter.beginInterval("Lasso Selection")
// Update selection in canvas...
}
func lassoSelectionEnded() {
// Finalize lasso selection...
signposter.endInterval("Lasso Selection", lassoIntervalState!)
}
关键点:
OSSignposter的subsystem设为 App 名称,category设为.pointsOfInterest,Instruments 会自动在 Points of Interest track 中显示beginInterval和endInterval必须成对调用,否则区间会延伸到 trace 结束- 通过 context menu 可以直接过滤到该区间对应的时间段
过滤后发现主线程 CPU 接近 100%,说明是代码执行太慢。切换到 Time Profiler 的 Top Functions 视图,最耗时的函数是 swift_project_boxed_opaque_existential。
这个 runtime 函数负责解包 existential(any Foo 类型的值)。因为 existential 的具体类型在编译期未知,运行时需要做额外的内存布局和动态分发工作。
(12:11)
演讲者展示了三种替代方案:
// 方案1:Concrete types —— 直接重载
func bar(_ a: TypeA) { }
func bar(_ b: TypeB) { }
// 方案2:Generics —— 编译期确定类型
func bar<T: Foo>(_ generic: T) { }
// 方案3:Enum —— 用关联值替代协议
enum Foo {
case a(TypeA)
case b(TypeB)
}
func bar(_ enum: Foo) { }
关键点:
any Foo(existential)在运行时需动态解包,产生swift_project_boxed_opaque_existential调用开销- Concrete types 和 generics 让编译器在编译期确定类型,消除动态分发
- Enum 的关联值模式适合类型集合固定且可枚举的场景
Run Comparison:验证修复效果
(13:30)
Instruments 27 新增了 Run Comparison 功能。把优化前后的两个 trace 放在同一个文档里对比,绿色表示性能提升,红色表示回归。
对比结果显示 swift_project_boxed_opaque_existential 调用被完全消除,整体执行时间下降。新引入的 generic 函数被标记为回归,但总体收益大于新增开销。
问题二:滚动掉帧 —— Main Actor 被 thumbnail 渲染霸占
(16:32)
Swift Executors instrument 是 Instruments 27 的新工具,可视化 Main Actor、global concurrent executor 和自定义 executor 的任务执行情况。
在 Main Actor track 上发现多个 renderThumbnail 任务,每个耗时几百毫秒。这些任务从 SwiftUI 调用,隐式继承了 Main Actor 上下文,导致 UI 更新被阻塞。
(18:29)
修复方式是在 Task 闭包上加 @concurrent:
let drawingData = note.drawingData
let canvasImages = note.decodeCanvas()
thumbnail = await Task(name: "Render Thumbnail") { @concurrent in
await renderThumbnail(
drawingData: drawingData,
canvasImages: canvasImages,
size: CGSize(width: 300, height: 240)
)
}.value
关键点:
Task { }默认继承调用方的 Actor 上下文,从 SwiftUI 调用会留在 Main Actor@concurrent显式将任务路由到 global concurrent executor,释放 Main Actor- Swift 编译器会检查
@concurrent闭包内是否存在数据竞争 - 移出 Main Actor 后,多个 thumbnail 渲染可以并行执行
问题三:保存卡顿 —— 同步文件 I/O 阻塞主线程
(19:25)
保存笔记时 UI 短暂挂起。Time Profiler 显示主线程 CPU 只有 20% 左右 —— 这是典型的系统阻塞。
切换到 System Trace,Inspector 面板显示主线程在执行 write 系统调用,试图写入 1.7 GB 数据。这个操作耗时 500 多毫秒,其中近 300 毫秒花在等磁盘响应。
(24:25)
修复方式是把文件写入移到后台 Task:
Task { @concurrent in
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
guard let data = try? encoder.encode(snapshots) else { return }
let id = signposter.beginInterval("Writing To File")
try? data.write(to: fileURL, options: .atomic)
signposter.endInterval("Writing To File", id)
}
关键点:
Data.write(to:options: .atomic)是同步调用,会阻塞当前线程直到磁盘操作完成.atomic选项会先写临时文件再重命名,增加了 I/O 开销- 把编码和写入都包进
@concurrentTask,主线程立即返回,UI 保持响应 - 验证时可以在 System Trace 中确认
writesyscall 已从主线程转移到后台线程
详细内容
OSSignpost 标记业务区间
(04:50)
在 Instruments 中定位卡顿的第一步是给关键业务路径加上时间标记。OSSignposter 会在 Points of Interest track 中显示自定义区间,方便后续过滤和对比。
import os.signpost
let signposter = OSSignposter(
subsystem: "com.example.NotebookApp",
category: .pointsOfInterest
)
class CanvasViewController {
var lassoIntervalState: OSSignpostIntervalState?
func lassoSelectionUpdated() {
lassoIntervalState = signposter.beginInterval("Lasso Selection")
// 遍历所有笔画,判断是否与套索路径相交
for stroke in allStrokes {
if lassoPath.intersects(stroke.boundingRect) {
selectedStrokes.insert(stroke)
}
}
}
func lassoSelectionEnded() {
// 完成套索选择,高亮选中笔画
highlightSelectedStrokes()
signposter.endInterval("Lasso Selection", lassoIntervalState!)
}
}
关键点:
OSSignposter的subsystem使用反向域名格式(如com.example.NotebookApp),category设为.pointsOfInterest后 Instruments 会自动在 Points of Interest track 中渲染彩色区间条beginInterval返回的OSSignpostIntervalState必须保存到实例变量,确保endInterval能正确关闭对应区间;若不成对调用,区间会延伸到 trace 结束- 在 Instruments 中右键点击 Points of Interest track 中的区间,选择 “Set Inspection Range” 即可只分析该时间段内的采样数据,排除无关噪声
用 @concurrent 把 Thumbnail 渲染踢出 Main Actor
(18:29)
SwiftUI 视图中发起的 Task { } 默认继承调用方的 Actor 上下文。如果调用方在 Main Actor 上(如 @MainActor 的 ViewModel 或 View body),Task 也会在 Main Actor 上执行,阻塞 UI 更新。
// 问题代码:Task 隐式继承 Main Actor,导致滚动掉帧
func updateThumbnail() async {
let drawingData = note.drawingData
let canvasImages = note.decodeCanvas()
// 错误:没有 @concurrent,任务留在 Main Actor
thumbnail = await Task(name: "Render Thumbnail") {
await renderThumbnail(
drawingData: drawingData,
canvasImages: canvasImages,
size: CGSize(width: 300, height: 240)
)
}.value
}
// 修复代码:@concurrent 将任务路由到 global concurrent executor
func updateThumbnailFixed() async {
let drawingData = note.drawingData
let canvasImages = note.decodeCanvas()
thumbnail = await Task(name: "Render Thumbnail") { @concurrent in
await renderThumbnail(
drawingData: drawingData,
canvasImages: canvasImages,
size: CGSize(width: 300, height: 240)
)
}.value
}
关键点:
Task { }默认继承调用方的 Actor 上下文,从 SwiftUI 调用会留在 Main Actor;Swift Executors instrument 的 Main Actor track 上会出现多个串行执行的renderThumbnail任务@concurrent显式将任务路由到 global concurrent executor,释放 Main Actor;多个 thumbnail 渲染可以并行执行,UI 更新不再被阻塞- 闭包前的
drawingData和canvasImages是值类型(Data和[UIImage]),按值捕获不存在数据竞争;Swift 编译器会检查@concurrent闭包内是否存在跨 Actor 的数据竞争 - 验证方式:在 Swift Executors instrument 中观察 Main Actor track 上的
renderThumbnail任务是否消失,同时 Global Concurrent Executor track 上是否出现对应任务
把同步文件 I/O 移到后台线程
(24:25)
保存笔记时如果直接调用 Data.write(to:options:),当前线程会被阻塞直到磁盘操作完成。对于大文件(如本例中 1.7 GB 的笔记数据),这会挂起主线程数百毫秒。
// 问题代码:同步写入阻塞主线程
func saveNote() {
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
guard let data = try? encoder.encode(snapshots) else { return }
// 阻塞!主线程等待 write syscall 返回
try? data.write(to: fileURL, options: .atomic)
}
// 修复代码:编码和写入都移到 @concurrent Task
func saveNoteFixed() {
Task { @concurrent in
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
guard let data = try? encoder.encode(snapshots) else { return }
let id = signposter.beginInterval("Writing To File")
try? data.write(to: fileURL, options: .atomic)
signposter.endInterval("Writing To File", id)
}
}
关键点:
Data.write(to:options: .atomic)是同步调用,.atomic选项会先写入临时文件再执行rename系统调用,增加了额外的 I/O 开销- 把编码和写入都包进
@concurrentTask 后,主线程在Task { }创建后立即返回,UI 保持响应;实际 I/O 操作在后台线程执行 - 在 System Trace 中验证:选中主线程的 Blocked 区间,Inspector 面板应不再显示
writesyscall;后台线程的 Running 区间中应出现write调用,且参数显示写入的文件路径和数据大小 - 如果保存操作需要返回成功/失败状态给 UI,可以用
Task { @concurrent in ... }配合await或Task.detached加回调,但注意不要在@concurrent闭包中直接修改@MainActor隔离的状态
Time Profiler 的三种视图
(07:23)
Time Profiler 用硬件定时器以 1 毫秒为间隔采样每个 CPU 核心的调用栈。同一个函数在不同调用路径中出现时,Call Tree 会分散显示。
Flame Graph 把调用树映射成空间块,垂直轴是调用栈深度,水平轴是总 CPU 时间。宽条代表高频出现的函数,一眼就能定位热点代码路径。
但 Flame Graph 对 Swift runtime 函数(如 swift_retain)不友好 —— 这些函数被大量调用点分散成小块,难以判断总体开销。
Top Functions 模式解决了这个问题。它丢弃调用层级,按 self weight(函数自身执行指令的时间)合并所有分散节点。左侧是排序后的函数列表,右侧显示选中函数的所有调用路径 Flame Graph。
System Trace 和线程状态
(20:37)
System Trace 可视化操作系统何时、为何暂停你的 App。线程有三种状态:
- Running:在 CPU 核心上 actively 执行
- Blocked:等待资源,被内核从 CPU 驱逐
- Runnable:资源已就绪,等待 OS 调度器分配 CPU 核心
Inspector 面板会显示系统调用的参数:文件描述符、内存地址、数据大小。选中 syscall 区间时,实心段表示 on-core 执行时间,半透明段表示 off-core 阻塞时间。
关键配置提醒
(03:24)
- 永远用 Release 构建做 Profiling。Debug 构建关闭了编译器优化并插入了大量运行时检查,测出来的数据会误导判断
- Swift Concurrency template 包含 Time Profiler,可以同时观察并发任务和 CPU 采样
- OSSignpost 的区间标记让 Run Comparison 的对比更精确,避免噪声干扰
核心启发
1. 给关键业务路径加上 OSSignpost 区间
做什么:在 App 的网络请求、数据库查询、复杂渲染、文件读写等关键路径上包裹 beginInterval/endInterval。
为什么值得做:没有标记的 trace 是一团乱麻,有了标记后可以直接过滤到业务区间,配合 Run Comparison 精确量化每次优化的效果。
怎么开始:在 App 启动时初始化一个 OSSignposter,在关键函数入口调用 beginInterval,出口调用 endInterval。
2. 审计代码中的 any 协议类型
做什么:扫描项目中高频调用的函数,检查是否存在 any SomeProtocol 参数。在性能敏感路径(渲染、动画、手势处理)中替换为 concrete types、generics 或 enum 关联值。
为什么值得做:swift_project_boxed_opaque_existential 这类 runtime 开销在单次调用中不明显,但在高频循环中会累积成明显的卡顿。
怎么开始:用 Instruments 的 Top Functions 视图跑一遍 App 的核心交互路径,看是否有 Swift runtime 函数排在前面。
3. 用 @concurrent 把计算任务踢出 Main Actor
做什么:检查所有从 SwiftUI 触发的 Task { } 闭包,如果内部包含耗时计算(图片解码、数据序列化、复杂布局计算),加上 @concurrent。
为什么值得做:Swift Concurrency 的隐式 Actor 继承让 Main Actor 阻塞变得隐蔽。Swift Executors instrument 可以可视化这个问题,@concurrent 是修复它的标准做法。
怎么开始:在 Xcode 中搜索 Task {(不带 @concurrent 的),逐个审查闭包内容。
4. 建立系统阻塞的检查清单
做什么:当 App 卡顿但 Time Profiler 显示 CPU 很低时,切到 System Trace,按以下顺序排查:文件 I/O(write/read syscall)、同步锁(pthread_mutex_lock)、IPC(XPC / mach_msg)。
为什么值得做:这类问题用 Time Profiler 根本看不到,但 System Trace + Inspector 面板可以直接定位到具体的 syscall 和参数。
怎么开始:在 Instruments 中选择 System Trace template,复现卡顿场景,在 Inspector 中查看 Blocked 区间的 syscall 详情。
5. 把 Run Comparison 纳入代码审查流程
做什么:每次性能优化 PR 都附上优化前后的 Instruments Run Comparison 截图。
为什么值得做:性能优化很容易引入回归。Run Comparison 的绿色/红色可视化让优化效果一目了然,也便于团队成员理解改动的影响。
怎么开始:在 Instruments 中打开两个 trace,点击 compare 按钮选择 baseline run,导出 flame graph 截图。
关联 Session
- 243 - What’s new in Instruments — Instruments 27 的新功能总览
- 267 - What’s new in Swift Testing — Swift Testing 与性能测试的结合
- 262 - What’s new in Swift — Swift 语言新特性,包括 generics 和 existential 的演进
- 258 - What’s new in Xcode 27 — Xcode 27 中的 coding assistant 和性能分析工作流
- 259 - Accelerate development with agents in Xcode 27 — 本 session 中提到的 coding assistant 自动重构 existential 代码
评论
GitHub Issues · utterances