Highlight
SwiftUI 在 iOS 17 和 macOS Sonoma 中通过优化依赖追踪、减少 body 计算开销、规范 List/Table 的 identity 使用,让开发者可以用
Self._printChanges()定位多余刷新,用@Observable缩小依赖范围,用稳定的 ForEach 内容结构避免全量重建,从而系统性地提升界面响应速度。
核心内容
依赖过多导致不必要的刷新
你在写 SwiftUI 时一定遇到过这种情况:修改了一个无关的属性,整个视图树都在重新计算。比如一个展示狗狗详情的页面,你只想更新狗狗的名字,结果连图片也跟着刷新了。
(03:59)
SwiftUI 用依赖图来决定哪些视图需要更新。每个视图的 body 计算依赖于它的输入值和动态属性。当 Dog 结构体发生变化时,所有依赖它的子视图都会重新计算。问题是,有些子视图其实并不需要知道 Dog 的全部信息。
(04:05)
Apple 的解决方案是缩小依赖范围。与其把整个 Dog 对象传给 ScalableDogImage,不如只传它需要的 Image。这样当狗狗的其他属性变化时,图片视图不会收到任何更新通知。
(08:48)
同步阻塞让界面卡住
另一个常见场景是在视图初始化时做耗时操作。比如你在 @State 对象的 init() 里发起网络请求,这个请求会阻塞主线程,导致界面假死。
(12:22)
正确的做法是把耗时操作移到异步任务中,用 .task 修饰符在视图出现时执行。这样 body 可以立即返回,界面保持响应。
(12:23)
List 和 Table 的 identity 陷阱
List 和 Table 为了支持选择、滑动操作、重排序等高级功能,需要在创建时就确定每一行的 identity。这意味着 ForEach 的内容必须产生固定数量的视图。
(15:12)
如果你在 ForEach 里用了条件视图或 AnyView,SwiftUI 就无法提前知道每行对应多少个视图,只能把所有视图都创建出来。数据量小的时候没问题,数据量大时就会明显卡顿。
(16:08)
把过滤逻辑移到模型层,让 ForEach 只负责展示,可以大幅提升大列表的加载和滚动性能。
(17:35)
详细内容
用 _printChanges() 定位多余依赖
SwiftUI 提供了一个调试工具 Self._printChanges(),可以打印出触发视图更新的原因。
struct ScalableDogImage: View {
@State private var scaleToFill = false
var dog: Dog
var body: some View {
let _ = Self._printChanges()
dog.image
.resizable()
.aspectRatio(
contentMode: scaleToFill ? .fill : .fit)
.frame(maxHeight: scaleToFill ? 500 : nil)
.padding(.vertical, 16)
.onTapGesture {
withAnimation { scaleToFill.toggle() }
}
}
}
(04:02)
关键点:
let _ = Self._printChanges()放在 body 开头,每次视图更新时会在控制台打印原因- 打印
@Self表示视图本身的值发生了变化(即传入的dog变了) - 打印
@scaleToFill表示@State属性发生了变化 - 这个方法前缀有下划线,仅供调试使用,不要提交到 App Store
当你看到图片视图因为 dog.name 变化而刷新时,就知道依赖范围太大了。把 dog 改成 dog.image 后,再修改名字就不会触发图片刷新了。
(08:46)
struct DogView: View {
@Environment(\.isPlayTime) private var isPlayTime
var dog: Dog
var body: some View {
DogHeader(name: dog.name, breed: dog.breed)
ScalableDogImage(dog.image)
DogDetailView(dog)
LetsPlayButton()
.disabled(dog.isTired)
}
}
关键点:
ScalableDogImage现在只接收Image,不再依赖整个DogDogHeader独立出来,只接收name和breed- 每个子视图的依赖在调用处一目了然
把同步阻塞改为异步加载
// 问题代码:init 中同步获取数据
struct DogRootView: View {
@State private var model = FetchModel()
var body: some View {
DogList(model.dogs)
}
}
@Observable class FetchModel {
var dogs: [Dog]
init() {
fetchDogs() // 同步阻塞
}
func fetchDogs() {
// Takes a long time
}
}
(12:22)
// 修复后:异步获取
struct DogRootView: View {
@State private var model = FetchModel()
var body: some View {
DogList(model.dogs)
.task { await model.fetchDogs() }
}
}
@Observable class FetchModel {
var dogs: [Dog]
init() {}
func fetchDogs() async {
// Takes a long time
}
}
(12:23)
关键点:
.task修饰符在视图出现时启动异步任务,视图创建本身不会被阻塞fetchDogs()标记为async,内部可以用await做网络请求@Observable自动追踪属性读取,只有dogs变化时才会触发视图更新
List 中避免可变视图数量
// 问题:条件视图导致 List 需要创建所有视图来确定 identity
List {
ForEach(dogs) { dog in
if dog.likesTennisBalls {
DogCell(dog)
}
}
}
(16:08)
// 修复:过滤逻辑移到模型层
List {
ForEach(tennisBallDogs) { dog in
DogCell(dog)
}
}
(17:35)
关键点:
- List 需要预先知道所有行的 identity
- 如果 ForEach 的内容包含条件分支,SwiftUI 必须创建每个元素的所有可能视图才能确定行数
- 把过滤放在模型层,ForEach 接收的数据已经是过滤后的,每行只产生一个视图
- 对于分组列表,嵌套 ForEach 是推荐的,SwiftUI 会特殊处理这种结构
// Sectioned List 是嵌套 ForEach 的合法用法
struct DogsByToy: View {
var model: DogModel
var body: some View {
List {
ForEach(model.dogToys) { toy in
Section(toy.name) {
ForEach(model.dogs(toy: toy)) { dog in
DogCell(dog)
}
}
}
}
}
}
(18:25)
Table 的简化初始化
iOS 17 和 macOS Sonoma 引入了简化的 Table 初始化方式:
// iOS 16 的方式
struct DogTable: View {
var dogs: [Dog]
var body: some View {
Table(of: Dog.self) {
// Columns
} rows: {
ForEach(dogs) { dog in
TableRow(dog)
}
}
}
}
(19:21)
// iOS 17 的新方式,自动创建 TableRow
struct DogTable: View {
var dogs: [Dog]
var body: some View {
Table(of: Dog.self) {
// Columns
} rows: {
ForEach(dogs)
}
}
}
(19:22)
关键点:
- 新初始化方式自动为每个数据元素创建
TableRow - 这保证了每行只有一个视图,identity 计算更高效
- 这个 API 向后兼容到 Table 首次支持的系统版本
- 注意语义变化:如果
TableRow里用的是不同的数据(如dog.bestFriend),在 iOS 17 中 identity 会基于dog而非dog.bestFriend
核心启发
1. 给复杂列表加上 _printChanges() 诊断
如果你的列表滚动卡顿或数据更新时界面闪烁,在关键子视图里加上 let _ = Self._printChanges(),运行后观察控制台输出。如果发现修改一个属性触发了大量无关视图的更新,就逐个把依赖缩小到最小范围。入口是 LLDB 的 expression Self._printChanges() 或在代码中临时添加。
2. 用 @Observable 替换 @ObservedObject + @Published
新项目中直接用 @Observable class Model 定义数据模型,视图中不再需要 @ObservedObject 或 @StateObject。SwiftUI 会自动追踪实际读取的属性,只更新真正依赖这些属性的视图。入口是 import Observation,然后把 ObservableObject 改成 @Observable。
3. 审查所有 List/Table 中的 ForEach 内容
检查你的 List 和 Table,确保 ForEach 的闭包里不包含 if 条件、AnyView 或嵌套的可变结构。如果有条件过滤,把它移到 ViewModel 里。入口是找到所有 List { ForEach(...) } 和 Table { ForEach(...) },确认每个元素只产生一个视图。
4. 把 .task 作为数据加载的默认方式
如果你在 onAppear 里调用同步方法获取数据,或者在 init() 里做网络请求,改成 .task { await model.load() } 的异步模式。这能避免界面假死,同时让 SwiftUI 在视图消失时自动取消任务。入口是把同步加载方法改成 async,然后加上 .task 修饰符。
5. 用 inspectorColumnWidth 调试布局时顺便检查性能
Inspector 和 Sheet 的自定义展示也涉及视图重建。如果你在 Inspector 里展示大量数据,确保 Inspector 的内容视图也遵循同样的依赖最小化原则。
关联 Session
- Demystify SwiftUI — SwiftUI identity 基础概念
- Discover Observation in SwiftUI — @Observable 新观察机制详解
- Analyze Hangs in Instruments — 用 Instruments 定位主线程卡顿
- Explore UI animation hitches and the render loop — 理解掉帧的底层原因
评论
GitHub Issues · utterances