理解并消除你的 App 中的挂起
Understand and eliminate hangs from your app
2021年6月10日
一句话判断
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 任务的生命周期。