Highlight
Apple 把 Hang 定义为应用主线程无法及时处理用户事件的无响应时段,并提供 Instruments、MetricKit、Xcode Organizer 与 Dispatch 实践来定位和消除这类卡顿。
核心内容
用户点开一杯 Mango Tango 的食谱,屏幕没有反应。几秒后页面才进入。用户会说这个 App 卡、慢、点不动。
Apple 在这场 session 里把这种体验叫 Hang(挂起)。它的本质很具体:主线程正在处理上一件事,新的触摸事件只能排队等待。
主线程的 Main Run Loop(主运行循环)每次接收一个事件,执行事件处理,再更新 UI。事件处理耗时过长,UI 更新就会推迟。用户继续点击,事件还会继续堆积。
开发者以前常靠本机复现来找这种问题。这个方法覆盖不了真实用户的设备、网络、存储压力和旧硬件。Apple 给出的路径是:开发期用 Instruments 记录调用栈,上线后用 MetricKit 收集真实 Hang,再用 Xcode Organizer 观察版本之间的 Hang rate。
解决方向也很明确。主线程只做更新 UI 所需的关键工作。图片解码、联系人查询、网络请求、文件 I/O、缓存刷新和维护任务,都要减少、延后或移到后台队列。
详细内容
Hang 发生在主运行循环的一次事件处理中
(01:45)主运行循环负责在主线程上处理用户事件。一次循环里,应用接收事件、执行处理逻辑、按需更新 UI。处理逻辑拖长,用户输入到 UI 更新之间就出现延迟。
import UIKit
final class RecipeViewController: UIViewController {
private let statusLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(statusLabel)
}
@objc func didTapRecipe() {
statusLabel.text = "Loading"
renderVisibleRecipeContent()
statusLabel.text = "Ready"
}
private func renderVisibleRecipeContent() {
// Only do the work needed for the first visible screen.
}
}
关键点:
RecipeViewController是一个普通的 UIKit 视图控制器,所有 UI 对象都在主线程创建和修改。didTapRecipe()代表一次用户点击事件的处理函数。statusLabel.text = "Loading"和statusLabel.text = "Ready"都是 UI 更新,必须留在主线程。renderVisibleRecipeContent()只应生成首屏需要的内容。session 中的 Desserted 示例只显示四张食材图片,主线程没有必要一次加载全部图片。
主线程忙碌:提前加载用户看不到的资源
(04:02)Desserted 的食谱页只显示四张食材图片。如果主线程一次读取、准备并合成全部食材图片,大部分工作不会改变当前屏幕,却会延长这次 Run Loop。
import UIKit
final class ImageTileStore {
private let cache = NSCache<NSString, UIImage>()
private let renderQueue = DispatchQueue(label: "com.example.recipe.render", qos: .userInitiated)
func image(for identifier: String, makeImage: @escaping () -> UIImage, completion: @escaping (UIImage) -> Void) {
let key = identifier as NSString
if let image = cache.object(forKey: key) {
completion(image)
return
}
renderQueue.async { [cache] in
let image = makeImage()
cache.setObject(image, forKey: key)
DispatchQueue.main.async {
completion(image)
}
}
}
}
关键点:
NSCache<NSString, UIImage>()保存已经生成的图片瓦片,避免重复生成。renderQueue是后台 Dispatch 队列,用来执行图片准备工作。cache.object(forKey:)命中缓存时直接返回,主线程只做一次内存读取。renderQueue.async把耗时的makeImage()移出主线程。DispatchQueue.main.async回到主线程执行completion,方便调用方安全更新 UI。
主线程阻塞:同步 API、I/O 和锁都会让事件排队
(06:53)同步 API 从调用开始到返回之前会阻塞当前线程。网络请求、文件 I/O、数据存储读取和同步原语都可能把主线程卡住。session 特别提醒,使用 semaphore 把异步 API 包成同步调用,应避免出现在主线程。
import Foundation
let recipeQueue = DispatchQueue(label: "com.example.recipe.worker", qos: .userInitiated)
func loadRecipeSummary(completion: @escaping (String) -> Void) {
recipeQueue.async {
let summary = "Mango Tango takes 5 minutes."
DispatchQueue.main.async {
completion(summary)
}
}
}
关键点:
recipeQueue.async让食谱摘要的准备工作在后台队列执行。qos: .userInitiated表示这项工作与用户当前操作有关。summary在后台生成,主线程不等待同步返回。DispatchQueue.main.async把 UI 更新交还主线程。- 这个模式对应 session 中的建议:能使用异步 API 时使用异步 API;没有异步 API 时,用 Grand Central Dispatch(GCD,中央调度)移动自有代码。
使用 Core Animation 处理圆角,避免 CPU 位图重绘
(05:55)Desserted 为图片添加圆角时,原方案是创建位图图形上下文、把图片转成 bitmap、应用 UIBezierPath、再转回图片。这个流程吃 CPU 和内存。session 建议用 Core Animation 的 layer 能力交给 GPU 处理。
import UIKit
func applyRoundedCorners(to imageView: UIImageView) {
imageView.layer.cornerRadius = 12
imageView.layer.masksToBounds = true
}
关键点:
imageView.layer访问 UIKit 视图背后的 Core Animation layer。cornerRadius = 12设置圆角半径。masksToBounds = true让图片内容裁剪到圆角边界内。- 代码没有创建 bitmap 图形上下文,避免在主线程做 CPU 密集的图像转换。
开发期用 Instruments 看主线程在做什么
(11:23)诊断 Hang 时,第一件事是知道卡住期间应用在执行什么。Time Profiler(时间分析器)显示调用栈随时间的变化;System Trace(系统跟踪)补充系统调用、虚拟内存缺页、I/O、进程内和进程间交互。
Instruments
Template: Time Profiler + System Trace
Target: Desserted
Steps:
1. Record
2. Reproduce the tap that hangs
3. Select the hang interval
4. Inspect the main-thread call tree
关键点:
Time Profiler + System Trace对应 session 中同时使用的两个 Instruments 工具。Reproduce the tap that hangs把用户点击造成的卡顿录进 trace。Select the hang interval聚焦 Hang 发生的时间段。Inspect the main-thread call tree对应 session 中找到loadAllMessages占用 4.6 秒的步骤。
上线后用 MetricKit 收集真实用户的 Hang
(13:02)应用发布后,本机 trace 不够。MetricKit 可以收集现场发生的 Hang 调用树。它把 Hang 期间采样到的调用栈聚合成 call tree,帮助开发者按真实用户遇到的频率排序修复。
import MetricKit
final class AppMetricsSubscriber: NSObject, MXMetricManagerSubscriber {
func start() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
print(payload)
}
}
}
关键点:
MXMetricManagerSubscriber是 MetricKit 的订阅者协议。MXMetricManager.shared.add(self)注册订阅者,让应用接收 MetricKit 载荷。didReceive(_ payloads: [MXDiagnosticPayload])接收诊断载荷,Hang 诊断属于这类现场问题数据。print(payload)只是最小示例;真实应用应把诊断内容保存或上传到自己的分析系统。
用 Notification 和缓存替代每次点击时的昂贵查询
(16:31)Desserted 的社交图标需要知道联系人是否是好友。每次进入页面都查询全部联系人,会让主线程等待框架做昂贵操作。session 建议观察变更通知,在通知到来时异步更新缓存。
import Foundation
extension Notification.Name {
static let friendsDidChange = Notification.Name("friendsDidChange")
}
final class FriendStatusStore {
private let updateQueue = DispatchQueue(label: "com.example.friends.update", qos: .utility)
private var cachedFriendIDs = Set<String>()
func startObserving() {
NotificationCenter.default.addObserver(
forName: .friendsDidChange,
object: nil,
queue: nil
) { [weak self] _ in
self?.refreshCache()
}
}
private func refreshCache() {
updateQueue.async { [weak self] in
let latestIDs = Set(["contact-1", "contact-2"])
self?.cachedFriendIDs = latestIDs
}
}
}
关键点:
Notification.Name定义可观察的状态变化名称。NotificationCenter.default.addObserver注册观察者,让对象在状态变化时收到回调。refreshCache()避免在回调里直接做重活,改为把更新派发到updateQueue。cachedFriendIDs保存查询结果,页面进入时可以读取缓存状态。- 这个结构对应 session 中的建议:值不常变化时,不要在每次用户操作时重新做昂贵查询。
用 GCD 预热计算,但小心任务顺序
(20:28)GCD 还可以预热计算。应用提前把任务派发到 prefetchQueue,主线程继续响应用户。结果真正需要时,再等待预热队列完成。session 同时提醒,异步会改变代码执行顺序,必须确认哪些任务存在先后依赖。
import Foundation
let prefetchQueue = DispatchQueue(label: "com.example.recipe.prefetch", qos: .utility)
func prefetchIngredients() {
prefetchQueue.async {
_ = ["mango", "yogurt", "ice"]
}
}
func usePrefetchedIngredients(completion: @escaping ([String]) -> Void) {
prefetchQueue.async {
let ingredients = ["mango", "yogurt", "ice"]
DispatchQueue.main.async {
completion(ingredients)
}
}
}
关键点:
prefetchQueue是串行队列,适合维持预热任务的执行顺序。prefetchIngredients()提前启动后台工作,不阻塞主线程。usePrefetchedIngredients在同一队列读取结果,避免与预热任务乱序。DispatchQueue.main.async只把最终 UI 更新切回主线程。
核心启发
-
做什么:给图片列表增加首屏优先渲染和后台预热。 为什么值得做:session 的食谱示例说明,提前处理不可见图片会制造 Hang。 怎么开始:用
NSCache保存已生成图片,用DispatchQueue.async在后台准备下一屏图片,只在DispatchQueue.main.async中设置UIImageView.image。 -
做什么:为联系人、权限、订阅状态这类低频变化数据建立缓存。 为什么值得做:每次点击都查询系统资源,会把主线程暴露给不可控延迟。 怎么开始:用
NotificationCenter.default.addObserver监听状态变化,在后台队列刷新缓存,页面进入时读取缓存。 -
做什么:给现有 App 增加 Hang 诊断通道。 为什么值得做:MetricKit 能把真实用户遇到的 Hang 聚合成调用树,帮助你先修影响面最大的卡顿。 怎么开始:实现
MXMetricManagerSubscriber,通过MXMetricManager.shared.add注册订阅者,把收到的MXDiagnosticPayload写入日志系统。 -
做什么:把同步网络、文件读取和数据解析从主线程迁出。 为什么值得做:session 指出同步 API 会从调用到返回一直阻塞,网络和 I/O 延迟还受设备环境影响。 怎么开始:优先寻找带 completion handler 的异步 API;自有代码用
DispatchQueue.async执行,最后回主线程更新 UI。 -
做什么:建立进入页面的性能回归检查。 为什么值得做:Xcode Organizer 可以按版本展示 Hang rate,适合发现新版本引入的性能退化。 怎么开始:为关键页面录制 Instruments trace,记录主线程耗时基线;发布后在 Organizer 中观察新版本 Hang rate。
关联 Session
- Ultimate application performance survival guide — 建立应用性能优化的指标、工具和工作流。
- Diagnose Power and Performance regressions in your app — 用 Xcode Organizer 定位功耗和性能回归。
- Detect and diagnose memory issues — 使用 Xcode 工具诊断内存压力,减少由内存导致的性能问题。
- Analyze HTTP traffic in Instruments — 用 Instruments 分析网络请求,排查网络延迟带来的等待。
- Swift concurrency: Behind the scenes — 理解并发运行时如何帮助减少线程管理问题。
评论
GitHub Issues · utterances