WWDC Quick Look 💓 By SwiftGGTeam
Profile, fix, and verify: Improve app responsiveness with Instruments

Profile, fix, and verify: Improve app responsiveness with Instruments

观看原视频

Highlight

Instruments 27 引入 Top Functions 视图、Run Comparison 和 Swift Executors 工具,配合 OSSignpost 标记业务区间,让开发者可以按图索骥地诊断 CPU 饱和、执行争用和系统阻塞三类卡顿问题。

核心内容

一个笔记 App 的三处卡顿

演讲者用一个真实的笔记 App 做案例。这个 App 支持手绘、插入图片、套索移动。测试时发现了三个问题:保存笔记后 Apple Pencil 响应延迟、滚动笔记列表掉帧、套索工具卡住不动。

这三个问题分别对应三种不同的性能根因,需要三种不同的诊断思路。

诊断的心智模型

01:46

Instruments 27 建立了一套四步诊断流程:

  1. CPU 饱和 — 主线程 CPU 飙到 100%,说明代码本身执行太慢
  2. 采样数据可视化 — 用 Call Tree、Flame Graph 或 Top Functions 找到耗时函数
  3. 执行争用 — 多个任务争抢 Main Actor 资源
  4. 系统阻塞 — 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!)
}

关键点:

  • OSSignpostersubsystem 设为 App 名称,category 设为 .pointsOfInterest,Instruments 会自动在 Points of Interest track 中显示
  • beginIntervalendInterval 必须成对调用,否则区间会延伸到 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 开销
  • 把编码和写入都包进 @concurrent Task,主线程立即返回,UI 保持响应
  • 验证时可以在 System Trace 中确认 write syscall 已从主线程转移到后台线程

详细内容

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!)
    }
}

关键点:

  • OSSignpostersubsystem 使用反向域名格式(如 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 更新不再被阻塞
  • 闭包前的 drawingDatacanvasImages 是值类型(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 开销
  • 把编码和写入都包进 @concurrent Task 后,主线程在 Task { } 创建后立即返回,UI 保持响应;实际 I/O 操作在后台线程执行
  • 在 System Trace 中验证:选中主线程的 Blocked 区间,Inspector 面板应不再显示 write syscall;后台线程的 Running 区间中应出现 write 调用,且参数显示写入的文件路径和数据大小
  • 如果保存操作需要返回成功/失败状态给 UI,可以用 Task { @concurrent in ... } 配合 awaitTask.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

评论

GitHub Issues · utterances