SwiftUI View 的 Identity 与 Lifetime

前阵子遇到一个很奇怪的 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。

alt text

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

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

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

02.jpg

左边的代码在 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 的刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
@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)
}
}

Simulator Screen Recording - iPhone 17 - 2025-11-30 at 12.53.09.gif

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

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

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

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

1
2
3
4
5
6
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 和位置在变,状态与动画都能连续。

Simulator Screen Recording - iPhone 17 - 2025-11-30 at 13.24.36.gif

Lifetime

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 输出如下:

1
2
3
4
5
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 示例

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

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 来查看当前视图的更新次数

03.jpg

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

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

1
2
3
4
5
struct Pet: Identifiable {
var name: String
var kind: Animal
let id = UUID()
}

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

04.jpg

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

TreatJar 示例

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

08.png

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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()
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
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 给出的视图更新次数如下:

06.jpg

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

1
2
3
4
5
6
7
8
9
10
11
12
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

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

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

1
2
3
4
5
6
7
8
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 按钮,可以看到:

07.jpg

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

Dependency

09.jpg

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

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

回顾

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 页面。

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

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

10.jpg

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