前阵子遇到一个很奇怪的 BUG:用 NavigationStack push 一个 View,CPU 使用率竟然冲上 100%!当 Xcode 运行一段 SwiftUI 代码时,我们应该关注哪些方面?特此回顾 WWDC 21 Demystify SwiftUI

当 Xcode 运行一段 SwiftUI 代码时,我们应该关注哪些方面?

  1. Identity — 是 SwiftUI 用来区分视图元素的「身份证」
  2. Lifetime — 是 SwiftUI 用来管理视图与其相关状态的生命周期
  3. Dependency — 是 SwiftUI 用来决定视图为何以及何时需要更新

Identity

SwiftUI 中 Identity 分为两种:Explicit Identity 和 Structural Identity。

外表相似的双胞胎并肩站在走廊中

当遇到两个外表很相似的双胞胎,我们该如何区分?对,可以通过姓名来区分!这就是 Explicit Identity。

我查了点资料,我们仅知道这对著名的双胞胎姐妹是格雷迪双胞胎姐妹,并不知道各自的姓名。那我问你:你觉得哪个更恐怖一点?我是觉得左边那位更恐怖一些。
通过她俩的相对位置来区分:一位是站在左边,另一位是站在右边。这就是 Structural Identity。

接下来,我们来看一个更实际的例子:

SwiftUI 条件分支映射为 ConditionalContent 的 True 和 False 两种结构身份

左边的代码在 SwiftUI 「眼里」当条件为真时显示 AdoptionDirectory 视图,条件为假时显示 DogList 视图。只要条件恒定,SwiftUI 能知道哪个是「真分支」哪个是「假分支」。

View body 中的 if 语句在 SwiftUI 里会被编译成 _ConditionalContent<TrueView, FalseView> 这样的泛型类型,TrueView 和 FalseView 是两个分支的具体类型。这个转换由 ViewBuilder 完成。
View 协议的 body 会隐式包裹在 ViewBuilder 中,ViewBuilder 会把 body 里的条件逻辑拼成一个统一的泛型视图类型。

Structural Identity 是 Swift 通过视图元素在视图层级中的类型与位置来识别视图身份的方法。

PawView 示例

在这个 代码示例 里,我们可以通过点击事件来更新 dog.isGood 状态,从而触发 pawView 的刷新。

    @ViewBuilder
    var pawView: some View {
        if dog.isGood {
            PawView(tint: .green)
                .padding(.top, 32)
            Spacer()
                .padding(.bottom, 32)
        } else {
            Spacer()
            PawView(tint: .red)
                .padding(.bottom, 32)
        }
    }

条件分支切换时,绿色爪印消失后红色爪印重新出现的动画

问题一:为什么是绿色爪印消失后再出现红色爪印呢?

因为 SwiftUI 是通过 Structural Identity 来区分绿色爪印和红色爪印,当 dog.isGood 变化时,SwiftUI 认为它需要销毁绿色爪印视图和创建红色爪印视图。

不难得出:视图的 Identity 发生变化后,SwiftUI 会认为它需要销毁旧视图并创建新视图。

问题二:单个爪印直接从绿色过渡到红色应该会更丝滑,我要怎么做?

    var pawView: some View {
        PawView(tint: dog.isGood ? .green : .red)
            .padding(.top, 32)
            .padding(.bottom, 32)
            .frame(maxHeight: .infinity, alignment: dog.isGood ? .top : .bottom)
    }

我们可以把 pawView 里的 if 分支拿掉,单个 PawView 的 Structural Identity 就保持不变;dog.isGood 变化时,只是同一个视图的 tintColor 和位置在变,状态与动画都能连续。

保持同一 PawView 身份后,爪印颜色和位置连续过渡的动画

Lifetime

我们给 PawView 加上 PawViewState 状态,来看看 Structural Identity 的变化对视图状态的影响

class PawViewState {
    let id: UUID

    init(id: UUID = UUID()) {
        self.id = id
        print("PawViewState(\(id)) init at \(Date())")
    }

    deinit {
        print("PawViewState(\(id)) deinit at \(Date())")
    }
}

struct PawView: View {
    let tint: Color
    @State private var state: PawViewState = .init()

    var body: some View {
        Circle()
            .fill(tint)
            .frame(width: 180, height: 180)
            .overlay(
                Image(systemName: "pawprint.fill")
                    .resizable()
                    .scaledToFit()
                    .foregroundColor(.white)
                    .padding(44)
            )
    }
}

在我触发两次点击事件(Good dog → Bad dog, Bad dog → Good dog)后,Xcode console 输出如下:

PawViewState(39BFC159-AE06-45A5-9083-B1C5BDF053C3 init at 2025-11-30 05:42:02 +0000 // Good dog 1 init
PawViewState(5FB6A6D4-1E81-43A1-8AB1-04C2DEE8455A init at 2025-11-30 05:42:05 +0000 // Bad dog 1 init
PawViewState(39BFC159-AE06-45A5-9083-B1C5BDF053C3 deinit at 2025-11-30 05:42:06 +0000 // Good dog 1 deinit
PawViewState(33E05E98-2553-4C8D-81B0-A56E2E353962 init at 2025-11-30 05:42:07 +0000 // Good dog 2 init
PawViewState(5FB6A6D4-1E81-43A1-8AB1-04C2DEE8455A deinit at 2025-11-30 05:42:08 +0000 // Bad dog 1 deinit

从上面的例子中总结:

  1. 视图的 Identity 发生变化后,SwiftUI 会认为它需要销毁旧视图并创建新视图。也就是说,视图的生命周期是否延续取决于 Identity 是否保持不变。
  2. SwiftUI 里状态的生命周期和视图的生命周期是绑定在一起的。也就是说,一个视图被创建时状态才会被创建,视图被销毁时状态就随之消失。

因此,可得:

SwiftUI 把视图的「本质」归结为它持有的状态,并把这份状态和视图的身份绑定在一起。
身份不变,状态就被保留下来;身份一变,SwiftUI 就当成全新的视图,状态也重建。这样能清晰地区分:哪些状态应该延续(身份稳定的视图),哪些状态应该重置(身份变化的视图)。

FavoritePetsView 示例

再看这个 例子,来辅助理解上面的总结。

Favorite Pets 列表显示五只宠物及右上角 Add 按钮

struct Pet: Identifiable {
    var name: String
    var kind: Animal
    var id: UUID { // 不稳定的 identity
        UUID()
    }
}

struct FavoritePetsView: View {
    @State private var pets: [Pet]

    init(pets: [Pet]) {
        self.pets = pets
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(pets) { pet in
                    PetRow(pet: pet)
                }
            }
            .listStyle(.plain)
            .navigationTitle("Favorite Pets")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Add") {
                        addTomCat()
                    }
                }
            }
        }
    }

    func addTomCat() {
        let newPet = Pet(name: "汤姆", kind: .cat)
        pets.insert(newPet, at: 0)
    }
}

当点击 Add 按钮时,「汤姆猫」出现在 List 的首位。让我们用 SwiftUI Instruments 来查看当前视图的更新次数。

SwiftUI Instruments 显示身份不稳定时 PetRow body 执行 29 次

因为 PetRow Identity 的不稳定性,导致 PetRow 的 body 被执行多次。如果放在一个大型项目中,可能会导致 App 出现卡顿。

使用计算属性生成 UUID 的方式遵循 Identifiable 会导致视图 Identity 不稳定,所以作如下修改,以提供稳定的 Identity。

struct Pet: Identifiable {
    var name: String
    var kind: Animal
    let id = UUID()
}

可以再次运行 SwiftUI Instruments 来查看当前视图的更新次数:

使用稳定 id 后 SwiftUI Instruments 显示 PetRow body 仅创建 6 次

PetRow 的 body 被执行的次数锐减至 6 次!原本已有 5 个宠物,再加上新增的「汤姆猫」,总计 6 次,与预期相符。

TreatJar 示例

再看这个稍微复杂一点的 例子。Treat Jar View 展示的是给宠物准备的零食。如果有零食过期,那就置灰。

Treat Jar 页面中已过期零食变灰,未过期零食保持高亮

我们借助于 ExpirationModifier 来实现置灰的效果。

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 16) {
                    ForEach(treats, id: \.serialNumber) { treat in
                        TreatCell(treat: treat)
                            .modifier(ExpirationModifier(date: treat.expiryDate, referenceDate: referenceDate))
                    }
                }
                .padding(16)
            }
            .background(Color(.systemGroupedBackground))
            .navigationTitle("Treat Jar")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button(referenceDateIsExpired ? "Restore" : "Expire") {
                        toggleExpirationSimulation()
                    }
                }
            }
        }
    }
struct ExpirationModifier: ViewModifier {
    var date: Date
    var referenceDate: Date = .now

    func body(content: Content) -> some View {
        if date < referenceDate {
            content.opacity(0.3)
        } else {
            content
        }
    }
}

此时执行点击左上角 Expire 按钮来模拟所有的零食过期。SwiftUI Instruments 给出的视图更新次数如下:

条件分支切换过期状态后,SwiftUI Instruments 显示 TreatCell body 创建 2 次

代表两种零食的 TreatCell 创建了两次,但实际上零食过期后,只需置灰即可,不需要重新创建 TreatCell。

struct ExpirationModifier: ViewModifier {
    var date: Date
    var referenceDate: Date = .now

    func body(content: Content) -> some View {
        if date < referenceDate { // Structural Identity
            content.opacity(0.3)
        } else {
            content
        }
    }
}

使用了 ExpirationModifier 之后,ForEach 中的 Content View 的 Identity 属于 Structural Identity。

// ForEach 中的 Content View
TreatCell(treat: treat)
 .modifier(ExpirationModifier(date: treat.expiryDate, referenceDate: referenceDate))

根据之前的经验,我们可以通过这种方式来优化,即去除 if 分支:

struct ExpirationModifier: ViewModifier {
    var date: Date
    var referenceDate: Date = .now

    func body(content: Content) -> some View {
        content.opacity(date < referenceDate ? 0.3 : 1)
    }
}

运行 SwiftUI Instruments,再次点击左上角 Expire 按钮,可以看到:

移除条件分支后,Instruments 只记录 ExpirationModifier body 的 7 次更新

TreatCell.body 没有再被执行过了!所以在 View 中使用 if 分支时,可以停下来问自己:我是要多个视图还是某一视图的两种状态呢?引入不必要的分支,可能会导致性能下降,甚至视图的状态丢失。

Dependency

DogView 代码中依赖、body 和 action 的对应区域

(本节不是本文讨论的重点,于是我放上 AI 对此的总结)

  • 依赖的含义:视图的输入(属性、@State/@Binding/@Environment/ObservableObject 等)。依赖一旦变更,对应视图就必须重新计算 body。
  • 依赖关系的结构:虽然视图层级像树,但同一个依赖可被多处使用,整体更像一张依赖图。某个依赖变化时,SwiftUI 只让依赖它的视图重建,而不是全量刷新。
  • 身份在其中的作用:视图的身份(显式 id 或结构身份)决定依赖变化如何被路由到具体节点;身份稳定,就能更好地保持状态和避免多余重建。
  • 选择稳定且唯一的标识:数据驱动的视图(ForEach 等)需要稳定、唯一的 id。随机 id 或数组下标会让身份漂移,造成闪烁、错误动画或状态丢失。
  • 精简条件分支:多余的 if/switch 会制造额外的身份节点,导致重复重建。能用「惰性」修饰符(如条件放进 opacity,1 时无影响)折叠条件时,更新会更集中高效。
  • 更新流程心智模型:依赖变化 → 标记相关视图 → 重新求 body 生成新值;值本身是短暂对比用的,持久状态与视图身份绑定并随身份存续。

回顾

还记得文章开头我提及的奇怪 BUG 吗?这是能复现的 demo,而且在 iOS 17.1.2 上能稳定复现。

typealias DetailState = DetailViewModel.State

final class DetailViewModel: ObservableObject {
    struct State: Identifiable {
        let title: String
        let subtitle: String
        var text: String = "123"
//
        var id: UUID {
            let newID = UUID()
            print("🆔🆔🆔 [DetailState] id accessed, generated: \(newID)")
            return newID
        }

        // Solution: Use stable explicit identity
//        let id = UUID()
    }

    @Published var state: State

    // MARK: - Textfield logic

    init(state: State) {
        self.state = state
    }
}

核心就在于通过 Navigator push Detail 页面时,DetailState 的 explicit identity 不稳定。每次求值 body 都会重新访问 State.id,计算属性每次生成新的 UUID,导致 Identifiable 身份变化。SwiftUI 认为当前 NavigationStack 顶端元素变了,于是销毁旧的 Detail 页面并创建新的 Detail 页面。

Button("Show Detail") {
    let state: DetailState = .init(title: "Detail Title", subtitle: "Detail subtitle")
    navigator.push(to: .detail(state))
}

运行 SwiftUI Instruments 来看看视图的变化次数:

SwiftUI Instruments 显示 DetailView 更新约 4900 次并出现严重卡顿

从上图可以看出:在短短几秒之内,DetailView 更新了将近 5000 次,SwiftUI 内部的 NavigationStack 更新了将近一万五千次,整个 App 出现了严重的卡顿!