揭秘 SwiftUI
Demystify SwiftUI
2021年6月10日
一句话判断
如果你对 SwiftUI 视图identity、lifetime 和 dependency 这三个概念还模模糊糊,这场 Session 就是来帮你正本清源的——搞懂了这三个概念,90% 的「SwiftUI 不按预期刷新」问题都能自己解决。
这场 Session 讲了什么
这是 WWDC 2021 中最被推荐的 SwiftUI 深度理解 session。它不讲新 API,而是深入剖析 SwiftUI 的核心运作机制:视图的身份标识(Identity)、生命周期(Lifetime)和依赖关系(Dependency)。这三个概念决定了 SwiftUI 何时创建新视图、何时复用旧视图、何时触发刷新。
Identity 决定了 SwiftUI 如何判断两个视图是否「同一个」。显式 identity 通过 id() 修饰符指定,结构性 identity 通过视图在视图树中的位置确定。如果你用 ForEach(0..<count) 来渲染列表,索引就是 identity;如果你用 ForEach(items, id: \.self),item 的值就是 identity。
Lifetime 指的是一个视图从创建到销毁的时间跨度。当一个视图的 identity 发生变化(比如在 ForEach 中改变了 id),SwiftUI 会销毁旧视图并创建新视图,这意味着 @State 会被重置。很多「状态莫名丢失」的 bug 就是 identity 变化导致的。
Dependency 是 @State、@StateObject、@ObservedObject 这些属性包装器背后的机制。SwiftUI 通过 dependency tracking 知道哪些数据变化会影响哪些视图。
值得深挖的点
结构性 Identity 的陷阱
SwiftUI 使用视图的类型和位置来推断 identity。这意味着 if-else 分支中的两个不同视图会被视为不同的 identity。比如 if showText { Text("A") } else { Text("B") },切换条件时 SwiftUI 会销毁 Text("A") 然后创建 Text("B"),而不是修改现有文本。这在大多数情况下没问题,但如果分支中包含 @State,状态会在切换时丢失。
正确的做法是用单一视图配合数据变化来实现,而不是用条件分支切换视图。比如 Text(showText ? "A" : "B") 就是同一个视图,只是内容变了。
@StateObject vs @ObservedObject 的选择逻辑
@StateObject 拥有对象的 lifecycle——视图创建时初始化,视图销毁时释放。@ObservedObject 只持有引用,不负责创建和销毁。这个区分在视图重建时至关重要:如果你用 @ObservedObject 来持有视图自己创建的数据,每次父视图刷新导致子视图重建时,@ObservedObject 引用的对象可能已经被替换了。用 @StateObject 则能保证对象不会被意外重新创建。
代码片段
// 错误示范:条件分支导致状态丢失
struct CounterView: View {
@State private var count = 0
let useRedStyle: Bool
var body: some View {
// 切换 useRedStyle 时,整个视图的 @State 会被重置
// 因为两个分支被视为不同的视图
if useRedStyle {
VStack {
Text("Count: \(count)")
.foregroundColor(.red)
Button("增加") { count += 1 }
}
} else {
VStack {
Text("Count: \(count)")
.foregroundColor(.blue)
Button("增加") { count += 1 }
}
}
}
}
// 正确做法:单一视图,样式用条件控制
struct CounterViewFixed: View {
@State private var count = 0
let useRedStyle: Bool
var body: some View {
VStack {
Text("Count: \(count)")
.foregroundColor(useRedStyle ? .red : .blue)
Button("增加") { count += 1 }
}
}
}
// @StateObject vs @ObservedObject 的正确使用
class UserProfileViewModel: ObservableObject {
@Published var name = ""
@Published var avatar = ""
}
// 视图拥有 ViewModel 的生命周期
struct ProfileEditorView: View {
@StateObject private var viewModel = UserProfileViewModel()
var body: some View {
Form {
TextField("姓名", text: $viewModel.name)
}
}
}
// 父视图传递 ViewModel,子视图不负责创建
struct ProfileRowView: View {
@ObservedObject var viewModel: UserProfileViewModel
var body: some View {
HStack {
Text(viewModel.name)
}
}
}
// 使用 id() 控制视图的强制重建
struct ItemEditorView: View {
let itemId: UUID
@State private var draftTitle = ""
var body: some View {
TextField("标题", text: $draftTitle)
// 当 itemId 变化时,整个视图被重建
// @State 会被重置为初始值
.id(itemId)
}
}
最佳实践
当你发现 @State 值在「不该重置的时候被重置了」,检查是否是因为视图的 identity 发生了变化。常见的触发因素:在 ForEach 中使用了不稳定的 id、条件分支切换了视图类型、父视图的结构变化导致子视图在视图树中的位置移动。
避免在视图的 body 中创建新的 ObservableObject 实例。每次 body 计算都会执行,这意味着每次父视图刷新都会创建新实例。用 @StateObject 在视图初始化时创建一次,或者从父视图通过参数传入。
还有什么值得关注
Group不会引入新的 identity 层级,它只是语法糖。如果你需要在不影响 identity 的前提下组织代码,用Group而不是VStack。AnyView(类型擦除)会破坏结构性 identity,导致 SwiftUI 无法进行高效的 diff 计算。尽量避免使用。ForEach中的id参数应该指向一个稳定的唯一标识符(比如 UUID),而不是索引或者会变化的值。