开篇
SwiftUI 从 2019 年发布到 iOS 18,已经不再是一个"玩具框架"。但很多开发者从 UIKit 迁移过来时,最大的困惑不是语法,而是心智模型:命令式 UIKit 里,界面是一连串副作用(label.text = ...、tableView.reloadData());声明式 SwiftUI 里,界面是状态的函数 View = f(State)。一旦接受这个前提,“为什么我的界面不更新"和"为什么它更新了三次"这两个经典问题就有了统一的解释框架。
现实中的痛点往往集中在三处:状态放错了位置(把 @State 放进模型、把 @StateObject 当成万能药),身份标识用错(ForEach 用数组下标做 id 导致动画和 diff 全乱),以及在 body 里做昂贵计算导致滚动掉帧。本文按"范式 → 属性包装器 → Observation → 数据流 → 身份标识 → 动画 → 性能"的顺序把这些问题一次讲透,目标读者是已经能写出界面、但说不清刷新时机的 iOS 开发者。
本文基于 SwiftUI iOS 17/18、Swift 5.9/6 与 Xcode 15/16。iOS 17 引入的 Observation 框架是分水岭,因此文中会重点讲新旧方案的选择与迁移,而不是把 @StateObject 和 @Observable 混着讲。
一、声明式范式与视图树
1.1 View 是值类型
View 是一个 protocol,核心只有一个 body 属性。任何遵循它的类型都是结构体,也就是说视图是值,不是对象。这一点决定了后面所有行为:
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack(spacing: 12) {
Text("当前计数:\(count)")
.font(.title2)
Button("加一") { count += 1 }
}
.padding()
}
}
每次 count 变化,SwiftUI 不是去"修改"某个界面对象,而是重新调用 body 生成一棵新的视图树。旧的树被丢弃,新的树被 diff。值类型意味着创建视图极其廉价(就是一个结构体初始化),所以"每次都重建"并不慢——真正慢的永远是你放进 body 里的计算。
1.2 diff 与视图树
SwiftUI 维护的是一棵描述树(view tree),底层映射到 UIKit/AppKit 的真实渲染节点。body 重新执行后,SwiftUI 对比新旧描述树的结构:如果某节点的类型和位置一致,就复用底层节点并只更新变化的属性;如果类型变了,整棵子树被替换。
这就是为什么下面这种写法是灾难:
// 反面示例:条件分支返回不同类型,导致整棵子树被重建
if isLoading {
ProgressView()
} else {
ProgressView().tint(.green) // 其实是同一类型,OK
}
if/else 在 SwiftUI 中通过 _ConditionalContent 表达,两个分支是不同的泛型类型。频繁切换分支会让子树反复销毁重建,onAppear/onDisappear 也会反复触发。如果只是想改颜色,就用 modifier 而不是分支。
二、属性包装器全景
SwiftUI 的"状态"本质上是一组属性包装器,它们的差别只在所有权和刷新范围。下表是速查:
| 包装器 | 所有权 | 适用场景 | iOS 17 后是否推荐 |
|---|---|---|---|
@State | 视图持有 | 视图私有、值类型状态 | 推荐 |
@Binding | 借用 | 子视图回写父状态 | 推荐 |
@StateObject | 视图持有 | 视图创建并持有的引用类型 | 逐步被 @State + @Observable 取代 |
@ObservedObject | 外部持有 | 外部传入的引用类型 | 逐步被 @Observable 取代 |
@EnvironmentObject | 祖先注入 | 跨层级共享引用类型 | 逐步被 @Environment 取代 |
@Environment | 系统/自定义注入 | 读取环境值 | 推荐 |
2.1 @State 与 @Binding
@State 是视图的私有存储,SwiftUI 把它存在视图树之外,所以 body 重建时值不丢。规则很简单:只在创建它的视图内部使用,且值类型优先。
@Binding 是对父级状态的引用,用于子视图回写:
struct SearchBar: View {
@Binding var text: String
var body: some View {
TextField("搜索", text: $text)
.textFieldStyle(.roundedBorder)
}
}
struct SearchScreen: View {
@State private var keyword = ""
var body: some View {
SearchBar(text: $keyword)
}
}
$keyword 生成的就是 Binding<String>。注意:@Binding 不持有数据,它只是一个 get/set 闭包对。
2.2 @StateObject 与 @ObservedObject
这是最容易被误用的一对。@StateObject 由当前视图创建并持有,生命周期跟随视图;@ObservedObject 只是引用外部传进来的对象,视图不负责它的生命周期。
final class CartModel: ObservableObject {
@Published var items: [String] = []
@Published var total: Double = 0
}
struct CartBadge: View {
@ObservedObject var cart: CartModel // 外部传入,不持有
var body: some View { Text("\(cart.items.count)") }
}
struct CartScreen: View {
@StateObject private var cart = CartModel() // 这里创建,这里持有
var body: some View { CartBadge(cart: cart) }
}
最常见的坑:把 @ObservedObject 用在"本该由自己创建"的对象上。这样每次父视图重建,对象都被重新初始化,数据全丢。判据只有一句:这个对象是"我创建的"还是"别人给我的”。
2.3 @EnvironmentObject 与 @Environment
@EnvironmentObject 用于跨层级共享,注入用 .environmentObject(_:):
struct RootView: View {
@StateObject private var session = Session()
var body: some View {
HomeView().environmentObject(session)
}
}
struct DeepChild: View {
@EnvironmentObject var session: Session
var body: some View { Text(session.username) }
}
@Environment 则读取系统或自定义的环境值,例如 @Environment(\.colorScheme)、@Environment(\.dismiss)、@Environment(\.dynamicTypeSize)。前者是"共享对象",后者是"环境键值",不要混用。
三、iOS 17 的 Observation
3.1 @Observable
iOS 17 引入 Observation 框架,用宏把普通类变成可观察对象,不再需要 ObservableObject 和 @Published:
import Observation
@Observable
final class ProfileModel {
var name: String = ""
var avatarURL: URL?
var followers: Int = 0
}
struct ProfileView: View {
@State private var model = ProfileModel() // 注意:@State,不是 @StateObject
var body: some View {
Text(model.name)
Text("粉丝 \(model.followers)")
}
}
关键改进是属性级追踪:@Observable 只追踪 body 中实际读取的属性。上面这个 body 读了 name 和 followers,那么 avatarURL 变化时 body 不会重跑。而 ObservableObject 是对象级的,任意 @Published 变化都会让所有观察者重跑。对复杂视图这是数量级的性能差异。
3.2 @Bindable
@Observable 对象没有 $ 投影,需要 @Bindable 才能拿到绑定:
struct EditProfile: View {
@Bindable var model: ProfileModel
var body: some View {
TextField("昵称", text: $model.name) // 需要 @Bindable
}
}
注意 @Bindable 只提供绑定,不管理生命周期;持有仍然由 @State 负责。
3.3 从旧方案迁移
迁移路线可以总结为一张对照表:
| 旧写法 | 新写法 |
|---|---|
class X: ObservableObject + @Published | @Observable class X |
@StateObject var x = X() | @State var x = X() |
@ObservedObject var x: X | var x: X(自动追踪) |
@EnvironmentObject var x: X | @Environment(X.self) var x |
.environmentObject(x) | .environment(x) |
$x.property(直接可用) | 需要 @Bindable var x |
迁移时有两个真实约束:一是 @Observable 需要 iOS 17+,如果你的 App 还要支持 iOS 16,就得保留 ObservableObject 或做双轨;二是 @Observable 对象如果是 let 属性传进来,子视图仍会自动追踪,但无法写回,写回必须用 @Bindable。
四、数据流方向与单一数据源
SwiftUI 的官方建议是"单一数据源"(single source of truth)。数据流永远是单向的:状态向下传递,事件向上传递。
实践中的分层通常是:
// 顶层:应用级状态
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView().environment(appState)
}
}
}
// 页面级:ViewModel
@Observable
final class FeedViewModel {
private(set) var posts: [Post] = []
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
posts = await api.fetchFeed()
}
}
两条经验法则:
- 能在视图内部消化的状态(输入框文字、开关)就放
@State,不要上提。 - 需要被多个视图读取、或需要跨页面存活的状态,才上提到 ViewModel 或环境。
把状态"往上提"的成本是耦合和刷新范围扩大,所以原则是能下放就下放。
五、列表与身份标识
ForEach 的性能和动画正确性完全取决于身份标识。有三种用法:
struct Item: Identifiable {
let id: UUID
var title: String
}
struct ItemList: View {
let items: [Item]
var body: some View {
List(items) { item in // 依赖 Identifiable
Text(item.title)
}
ForEach(items, id: \.id) { item in // 显式 key path
Text(item.title)
}
}
}
绝对不要用数组下标当 id:
// 反面示例:用 offset 当 id,删除中间元素时整列身份错乱
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
Text(item.title)
}
原因:SwiftUI 用 id 判断"这一行还是不是原来那一行"。用下标时,删除第 0 项会让第 1 项"变成"第 0 项,SwiftUI 认为它内容变了而不是位置变了,于是动画错位、onAppear 误触发、输入框焦点乱跳。凡是会增删排序的列表,id 必须来自数据本身的稳定标识。
六、动画与 transition
SwiftUI 的动画绑定在状态变化上。最基础的是 withAnimation:
struct ExpandableCard: View {
@State private var expanded = false
var body: some View {
VStack {
Button(expanded ? "收起" : "展开") {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
expanded.toggle()
}
}
if expanded {
Text("详情内容")
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
transition 只在视图插入/移除时生效;如果视图一直在树里只是改了属性,那要用 .animation(_:value:):
RoundedRectangle(cornerRadius: 8)
.fill(expanded ? .blue : .gray)
.animation(.easeInOut(duration: 0.25), value: expanded)
这里 value: 参数是必须的。旧版 .animation(_:) 不带 value 在 iOS 15 后已废弃,因为它会导致整个子树被隐式动画污染。
七、性能陷阱
body 是高频调用的热路径。下面是最常见的几个坑:
- 在 body 里做昂贵计算:格式化日期、排序、正则匹配都应在模型层算好。
body里每次重建都跑一遍。 - body 过长:把大
body拆成小的struct子视图(不是varcomputed property)。拆成 struct 才能让 SwiftUI 独立 diff;拆成 computed property 等于内联,没有优化。 AnyView滥用:AnyView擦除类型,diff 时无法做类型比较,会退化成整棵重建。仅在确实需要类型擦除时用。@ObservedObject过度刷新:用 iOS 17 的@Observable自动获得属性级追踪,或对值类型视图用.equatable()配合Equatable实现。
struct RowView: View, Equatable {
let item: Item
var body: some View { Text(item.title) }
}
// 使用时
RowView(item: item).equatable()
.equatable() 让 SwiftUI 在输入相等时跳过 body,前提是你正确实现了 Equatable。当行内容简单、数量大时收益明显。
FAQ
Q:为什么 @State 改了界面不更新?
最常见原因是 @State 被写在了 class 里,或者视图被放进了非 SwiftUI 容器。@State 只对 struct View 生效。
Q:@StateObject 和 @ObservedObject 到底怎么选?
一句话:这个对象是不是"我 new 出来的"。是,用 @StateObject;是别人传给我的,用 @ObservedObject。iOS 17 后前者换 @State + @Observable。
Q:@Observable 和 ObservableObject 能混用吗?
能,但不建议长期混用。过渡期可以两个类都保留,但同一数据链路上不要一半旧一半新,否则刷新范围难以推理。
Q:onAppear 会被调用多次正常吗?
正常。只要视图被移除后重新插入,onAppear 就会再触发。用身份标识稳定列表可以避免不必要的移除重建。
相关阅读
- Swift 语言基础 — 值类型、结构体与协议是理解 View 的前提
- UIKit 与 SwiftUI 混合开发 — 在既有 UIKit 工程里引入 SwiftUI 的落地方式
- Swift 并发 Combine 与 async/await — ViewModel 里异步加载数据的两种写法
小结
SwiftUI 的状态管理,归根到底只有三句话:视图是值,状态是唯一真相,界面是状态的函数。属性包装器的选择只取决于两个问题——数据归谁持有、谁需要被通知。iOS 17 的 Observation 用属性级追踪替代了对象级追踪,在复杂视图上是实打实的性能提升,新项目应当直接采用 @Observable + @State 组合。列表永远用稳定 id,动画永远绑定在状态变化上,body 里永远不做昂贵计算。把这三条守住,绝大多数"界面不更新"和"掉帧"的问题都不会出现。下一步建议把状态层与并发加载结合阅读,理解 ViewModel 中 async 方法如何驱动刷新。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。