WWDC Quick Look 💓 By SwiftGGTeam
Analyze hangs with Instruments

Analyze hangs with Instruments

观看原视频

Highlight

Instruments 新增了 Hangs 模板,配合 Time Profiler 和 SwiftUI 模板,开发者可以定位主线程卡顿、异步延迟和主线程阻塞三类性能问题,将响应延迟从数百毫秒降到 100 毫秒以下。

核心内容

你的 App 用户点击按钮,界面却卡住半秒才响应。这种体验像按开关后灯泡延迟亮起,立刻打破”真实交互”的幻觉。

Apple 的研究表明,100 毫秒以下的延迟对离散交互(如点击按钮)几乎不可感知;250 毫秒以上用户会明显察觉;超过 500 毫秒就是真正的 hang。Instruments 的 Hangs 模板从 250 毫秒开始标记 micro hangs,500 毫秒以上标记为正式 hang。

01:52)找到 hang 后,分析通常分三步:在 Instruments 中识别 hang 类型、定位根因、修复并验证。这场演讲聚焦分析环节,演示了三种典型 hang。

主线程忙碌型 Hang

19:38)演讲用一个背景选择界面做示例。BackgroundThumbnailView 直接在主线程同步加载缩略图:

struct BackgroundThumbnailView: View {
    static let thumbnailSize = CGSize(width:128, height:128)
    var background: BackyardBackground

    var body: some View {
        Image(uiImage: background.thumbnail)
    }
}

当 Grid 中同时显示大量缩略图时,每个 background.thumbnail 都在主线程执行图片解码和缩放,导致界面卡住。

修复思路是将同步加载改为异步。先用 ProgressView 占位,再通过 .task 在后台加载:

struct BackgroundThumbnailView: View {
    static let thumbnailSize = CGSize(width:128, height:128)
    var background: BackyardBackground
    @State private var image: UIImage?

    var body: some View {
        if let image {
            Image(uiImage: image)
        } else {
            ProgressView()
                .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center)
                .task {
                    image = await background.thumbnail
                }
        }
    }
}

关键点:

  • @State private var image 保存异步加载后的图片
  • ProgressView() 在图片就绪前显示加载指示器
  • .task 修饰符自动在后台执行异步代码,完成后刷新 UI
  • background.thumbnail 从同步属性改为 async 计算属性,将耗时操作移出主线程

异步延迟型 Hang

33:40)即使使用了 await,如果后台任务本身耗时过长,用户依然会看到长时间的 ProgressView。这种情况在 Instruments 中表现为:主线程没有忙碌,但 hang 持续时间很长。

解决方法是把 thumbnail 的计算也做成异步:

public var thumbnail: UIImage {
    get async {
        // compute and cache thumbnail
    }
}

这样解码、缩放都在后台完成,主线程只负责显示结果。

主线程阻塞型 Hang

38:52)最隐蔽的一类。代码看起来用了 await,但后续代码仍然阻塞主线程:

class ColorizingService {
    static let shared = ColorizingService()
    func colorize(_ grayscaleImage: CGImage) async throws -> CGImage
}

struct ImageTile: View {
    var body: some View {
        mainContent
            .task {
                let colorizer = ColorizingService.shared
                result = try await colorizer.colorize(image)
            }
    }
}

问题出在 ColorizingService.shared@MainActor 上的同步属性。虽然 colorizeasync,但访问 shared 本身会阻塞主线程。更隐蔽的是,即使写成 await ColorizingService.shared.colorize(image)shared 的同步访问仍然发生在 await 之前。

修复方法是避免在主线程访问同步单例。可以将单例引用提前获取,或重构为不依赖主线程的初始化方式。

详细内容

使用 Instruments 分析 Hang

01:52)在 Xcode 15 中,选择 Product > Profile,然后选择 Hangs 模板。录制 App 运行后,Instruments 会标记出所有超过阈值的 hang 区间。

时间线区域用红色条标记 hang,下方是各线程的活动情况。点击一个 hang 区间,下方详情面板会显示该时间段内各线程的调用栈。

三类 Hang 的识别特征

Hang 类型主线程状态识别方法
主线程忙碌100% CPUTime Profiler 显示主线程在执行你的代码
异步延迟空闲,等待中Hang 持续时间长,主线程没有活动
主线程阻塞空闲但阻塞调用栈停在同步锁或 actor 隔离的代码上

Grid vs LazyVGrid 的选择

20:26)演讲还对比了 GridLazyVGrid 的性能差异。Grid 会一次性创建所有子视图,而 LazyVGrid 只在可见区域创建。对于大量数据,优先使用 LazyVGrid

ScrollView {
    LazyVGrid(columns: [.init(.adaptive(minimum: BackgroundThumbnailView.thumbnailSize.width))]) {
        ForEach(BackyardBackground.allBackgrounds) { background in
            BackgroundThumbnailView(background: background)
        }
    }
}

关键点:

  • LazyVGrid 只在单元格进入可视区域时才创建视图
  • .adaptive(minimum:) 自动计算列数,适配不同屏幕宽度
  • 配合异步加载,可以避免初次显示时的集中卡顿

结合其他 Instruments 模板

00:55)Hangs 模板告诉你”哪里卡了”,但要知道”为什么卡”,往往需要添加其他模板:

  • Time Profiler:查看各线程的 CPU 使用情况
  • SwiftUI:分析视图 body 的执行时间
  • Disk Activity:检查是否有同步 I/O 阻塞主线程
  • Network:确认延迟是否来自网络请求

核心启发

  • 做什么:给 App 的每个列表界面添加异步图片加载

  • 为什么值得做:用户滑动列表时最敏感的就是图片加载卡顿。用 ProgressView + .task + async 属性,可以将图片解码完全移出主线程

  • 怎么开始:找到列表中直接同步加载图片的代码,把加载逻辑改为 async 计算属性,视图层用 .task 触发

  • 做什么:在 Instruments 中建立”Hangs + Time Profiler”组合模板,定期 profile 发布版本

  • 为什么值得做:很多 hang 在开发环境不明显,因为数据量小、设备性能好。录制真实用户场景才能暴露问题

  • 怎么开始:Product > Profile,选择 Hangs 模板,录制后点击红色 hang 条,再添加 Time Profiler 查看调用栈

  • 做什么:审计代码中的单例模式,检查是否有 @MainActor 同步属性被频繁访问

  • 为什么值得做static let shared 配合 @MainActor 是主线程阻塞的隐藏陷阱。即使后续代码用了 await,访问单例本身的同步操作已经阻塞了

  • 怎么开始:全局搜索 .shared,检查其 actor 隔离属性。如果是 @MainActor,考虑改为非隔离初始化或提前缓存引用

  • 做什么:用 LazyVGridLazyVStack 替换一次性加载大量数据的 Grid/VStack

  • 为什么值得做:列表有 2000 条数据时,ForEach 会创建 2000 个视图。Lazy 系列只在可视区域创建,大幅降低初始加载压力

  • 怎么开始:检查所有使用 GridVStack + ForEach 的列表代码,评估数据量是否适合改为 Lazy 版本

关联 Session

评论

GitHub Issues · utterances