Understand and eliminate hangs from your app
Developer Tools 进阶 20m

理解并消除你的 App 中的挂起

Understand and eliminate hangs from your app

2021年6月10日

在 Apple 官方观看视频

一句话判断

Hang(挂起)是比崩溃更糟糕的用户体验——App 没崩溃但卡住不动了。Xcode 13 的新 Hang Report 让你终于能看到用户遇到了多少次挂起以及每次挂起的堆栈。

这场 Session 讲了什么

Session 讲解了 App 挂起(Hang)的诊断和修复。Hang 是指主线程被阻塞超过 250ms,导致 UI 无响应。和崩溃不同,Hang 不会终止 App,但用户体验同样糟糕——用户看到的是”App 卡死了”。

Xcode 13 在 Organizer 中新增了 Hang Report。和崩溃报告类似,它按挂起类型聚合,显示影响用户数和发生频率。每个 Hang Report 包含主线程的完整堆栈——你看到的就是”主线程卡在哪个函数里”。

Session 把 Hang 分为三类:I/O 阻塞(主线程做网络请求或文件读写)、锁竞争(主线程等待某个锁)、同步调度(主线程通过 DispatchQueue.sync 等待其他队列的结果)。每种类型有不同的修复策略。

值得深挖的点

250ms 阈值背后的用户感知

用户对 UI 响应的感知阈值大约是 100ms——超过这个时间用户会感觉”有延迟”。250ms 是 Apple 定义的”明显卡顿”阈值,超过这个时间用户会尝试再次点击或认为 App 有问题。Hang Report 只报告超过 250ms 的挂起,但你的优化目标应该是把主线程操作控制在 100ms 以内。

同步调度的隐蔽陷阱

最常见的 Hang 模式之一是 DispatchQueue.global().sync { ... }。这在主线程上调用时会造成死锁风险——如果全局队列的任务也需要主线程(比如通过 DispatchQueue.main.async 回调),就会形成循环等待。即使不死锁,sync 调用也会阻塞主线程直到 block 执行完。修复方案是把 sync 改成 async,或者用 async/await 重写。

代码片段

诊断 Hang 的常见模式

// 模式 1:主线程做同步网络请求(最大的 Hang 来源)
func fetchUserProfile() {
    let url = URL(string: "https://api.example.com/user")!
    // 错误:同步请求阻塞主线程
    let data = try! Data(contentsOf: url)
    
    // 正确:异步请求
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data else { return }
        DispatchQueue.main.async {
            self.updateUI(with: data)
        }
    }.resume()
    
    // 最佳:async/await
    // let (data, _) = try await URLSession.shared.data(from: url)
}

修复同步调度导致的 Hang

// 错误:主线程同步等待其他队列
func loadData() {
    var result: [Item] = []
    DispatchQueue.global().sync {
        // 如果这个 block 需要时间,主线程就被卡住
        result = expensiveComputation()
    }
    updateUI(with: result)
}

// 正确方案 1:改为异步
func loadData() {
    DispatchQueue.global().async {
        let result = expensiveComputation()
        DispatchQueue.main.async {
            self.updateUI(with: result)
        }
    }
}

// 正确方案 2:用 async/await
func loadData() async {
    let result = await Task.detached {
        expensiveComputation()
    }.value
    updateUI(with: result)
}

使用 os.signposter 标记关键区间

import os.log

let logger = Logger(subsystem: "com.example.app", category: "performance")

func loadScreen() {
    // 用 signpost 标记,方便 Instruments 分析
    let signpostID = OSSignpostID(log: logger.os_log)
    os_signpost(.begin, log: logger.os_log, name: "ScreenLoad", signpostID: signpostID)
    
    // 加载操作
    configureViews()
    loadData()
    
    os_signpost(.end, log: logger.os_log, name: "ScreenLoad", signpostID: signpostID)
}

最佳实践

诊断流程: 在 Organizer 的 Hang Report 中找到影响用户最多的挂起。查看堆栈,确定是 I/O、锁还是同步调度。如果是 I/O,把同步操作改为异步。如果是锁,重构数据访问模式。如果是同步调度,改用 async/await。

预防措施: 在 CI 中使用 XCTMetric 测量关键操作的耗时。设置阈值——如果某个页面加载超过 200ms,测试失败。定期用 Instruments 的 Time Profiler 检查主线程的调用栈分布。

还有什么值得关注

  • Hang Report 区分了”前台上 FG”和”后台 BG”的挂起——你只需要关注前台挂起。
  • os_signpost 也可以在 Instruments 中创建自定义的性能分析区间。
  • Xcode 13 的 Instruments 新增了 Swift Concurrency 模板,专门分析 async/await 任务的生命周期。
WWDC 2021