用 Instruments 优化 SwiftUI 性能
Optimize SwiftUI performance with Instruments
2025年6月9日
一句话判断
SwiftUI 的性能问题有两大根源:body 太慢和更新太多——新的 SwiftUI Instrument 能同时定位两者,特别是那个 Cause & Effect Graph,第一次把”为什么这个 View 会更新”这件事可视化了。
这场 Session 讲了什么
Session 介绍了 Instruments 26 中全新的 SwiftUI Instrument,以及它如何帮助诊断两类性能问题:长 body 更新(view body 执行时间过长导致 hitch)和不必要的更新(过多 view body 更新挤占帧预算)。
核心亮点是 Cause & Effect Graph——它把 SwiftUI 内部的 AttributeGraph 依赖关系可视化,让你看到”用户点击按钮 → 状态变更 → 哪些 View 的 body 被标记为过时 → 是否真的重新执行了 body”这条完整的因果链。这解决了 SwiftUI 开发者长期的痛点:breakpoint 拿到的调用栈全是 AttributeGraph 的递归调用,根本看不出为什么我的 View 要更新。
值得深挖的点
SwiftUI 的渲染循环与 hitch
每个帧周期:处理事件 → 更新 UI(执行变更的 View body)→ 提交给系统渲染 → 显示。所有这些必须在一个帧 deadline 内完成(通常 16.67ms @60fps)。如果某个 body 执行太长,过了 deadline,下一个帧就延迟显示——上一帧在屏幕上停留了两个帧周期,这就是 hitch。
关键认知:1ms 的 body 执行看起来不多,但屏幕上有几十上百个 View 时,累加起来就可能超 deadline。Session 的案例中,LandmarkListItemView 的 distance 属性每次 body 都创建 NumberFormatter 和 MeasurementFormatter,这就是典型的”每帧重复做无用功”。
修复方案很直接:把格式化计算移到预处理阶段(比如 LocationFinder 的位置更新回调中),body 里只读缓存值。
@Observable 的依赖粒度陷阱
Session 给了一个非常典型的反面案例。ModelData 用 @Observable 管理收藏列表,isFavorite 方法访问 favoritesCollection.landmarks 数组。问题是:每个 LandmarkListItemView 都通过这个方法间接依赖了整个数组。所以每次收藏/取消收藏,所有 item view 的 body 都被标记为过时——即使只有一个的显示状态实际变了。
修复方案是为每个 item view 创建独立的 Observable view model,让每个 View 只依赖自己的收藏状态,而非整个数组。这样一次收藏操作只会触发 1 个 View 的 body 更新,而不是 N 个。
Cause & Effect Graph 的三种节点
- Gesture 节点:用户的交互动作(点击、滑动等),是因果链的起点
- State 变更节点:@Observable 属性变更或 @State 变更,标注了变更的属性名和所属 View 类型,选中可看到 backtrace
- View body 节点:body 执行的结果,dimmed 状态表示虽然被检查但实际没有重新执行
还有两种特殊节点:External Environment(系统级环境变更,如暗色模式切换)和 EnvironmentWriter(App 内通过 .environment modifier 做的变更)。
Environment 的隐性开销
每个读取 Environment 的 View 都依赖整个 EnvironmentValues 结构体。当任何环境值更新时,所有相关 View 都要检查自己读的值是否变了。没变就跳过 body,但”检查”本身也有成本——如果你的 App 有大量 View 读取 Environment(几乎所有 SwiftUI App 都是),频繁变化的环境值(如 geometry 值、timer)放在 Environment 里会累积成显著开销。
代码片段
1. 诊断慢 body:定位格式化开销
场景:LandmarkListItemView 的 distance 属性每次 body 执行都创建 Formatter。
// ❌ 修复前:每次 body 执行都创建 formatter
var distance: String {
let formatter = MeasurementFormatter()
formatter.numberFormatter = NumberFormatter()
formatter.numberFormatter?.maximumFractionDigits = 1
return formatter.string(from: location.distance)
}
// ✅ 修复后:formatter 复用,距离预计算缓存
class LocationFinder {
private let formatter: MeasurementFormatter
private var distanceCache: [String: String] = [:]
init() {
let nf = NumberFormatter()
nf.maximumFractionDigits = 1
formatter = MeasurementFormatter()
formatter.numberFormatter = nf
}
func updateDistances(for landmarks: [Landmark], location: CLLocation) {
for landmark in landmarks {
distanceCache[landmark.id] = formatter.string(
from: landmark.location.distance(from: location)
)
}
}
func cachedDistance(for landmark: Landmark) -> String {
distanceCache[landmark.id] ?? ""
}
}
2. 修复 @Observable 过度更新:拆分依赖
场景:收藏操作触发所有 item view 更新。
// ❌ 修复前:所有 view 依赖整个 favorites 数组
struct LandmarkListItemView: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var isFavorite: Bool {
modelData.isFavorite(landmark) // 间接依赖 favorites 数组
}
}
// ✅ 修复后:每个 view 有独立的 Observable view model
@Observable
class LandmarkFavoriteModel {
var isFavorite: Bool = false
}
class ModelData {
var favoriteModels: [String: LandmarkFavoriteModel] = [:]
func model(for landmark: Landmark) -> LandmarkFavoriteModel {
if let existing = favoriteModels[landmark.id] { return existing }
let model = LandmarkFavoriteModel()
model.isFavorite = favoritesCollection.landmarks.contains(where: { $0.id == landmark.id })
favoriteModels[landmark.id] = model
return model
}
}
struct LandmarkListItemView: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var favoriteModel: LandmarkFavoriteModel {
modelData.model(for: landmark)
}
}
坑:拆分后每次收藏操作只更新 1 个 view body 而不是 N 个。但要注意收藏列表本身的视图(比如收藏计数 badge)仍然需要依赖整个数组。
最佳实践
View body 里不做任何计算。Formatter 创建、字符串拼接、数据筛选——这些全部挪到 body 外面。body 应该只是”读缓存值 → 构建 View 树”。
@Observable 的依赖粒度尽量细。如果一个方法访问了某个数组,所有调用这个方法的 View 都会依赖这个整个数组。考虑把频繁变化的数据拆成独立的 Observable 对象。
频繁变化的值不要放 Environment。geometry proxy 值、timer、高频更新的状态——这些会让大量 View 反复做”检查是否需要更新”的开销。
先用新的 SwiftUI Instrument 做一轮 profiling,再改代码。它的 Long View Body Updates 和 Cause & Effect Graph 能帮你定位到具体的 View 和具体的原因,避免凭猜测优化。
还有什么值得关注
- SwiftUI Instrument 的三个子轨道:View Body Updates、Representable Updates(UIViewRepresentable/UIViewControllerRepresentable 的更新)、Other Updates(SwiftUI 内部的其他工作)。
- CPU Profiler vs Time Profiler:Session 308 深入讲了这个区别,做 SwiftUI 性能分析时优先用 CPU Profiler。
- Hangs and Hitches Instrument:和 SwiftUI Instrument 配合使用——SwiftUI Instrument 找出长更新,Hangs Instrument 确认这些更新是否真的导致了用户可感知的卡顿。