WWDC Quick Look 💓 By SwiftGGTeam
Demystify SwiftUI performance

Demystify SwiftUI performance

观看原视频

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,不再依赖整个 Dog
  • DogHeader 独立出来,只接收 namebreed
  • 每个子视图的依赖在调用处一目了然

把同步阻塞改为异步加载

// 问题代码: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

评论

GitHub Issues · utterances