学习SwiftUI的心智模型
一、引言
最近我在思考一个问题:学习一门编程语言,最重要的是什么?
不是语法,不是 API,而是心智模型(Mental Model)。
所谓心智模型,就是你在使用这门技术时,脑子里想象的那幅画面。同样是写代码,写 C 和写 Java 时,你脑子里的画面完全不同。这个画面对不对、清不清晰,直接决定了你能不能写出好的代码。
今天这篇文章,我想聊聊不同编程语言和技术背后的心智模型,最后落入最近正在学习的 SwiftUI。
二、每种语言都有它的世界观
2.1 C/C++:你是内存的上帝
写 C 的时候,你脑子里想的是什么?是内存。
int a = 10; // 在栈上某个位置写入 4 个字节int *p = &a; // 把那个位置的地址存到另一个位置struct Node { int val; // 偏移 0 字节 struct Node* next;// 偏移 8 字节};
每一行代码,你看到的不是"变量赋值",而是对一片内存空间的操作。变量是地址,函数调用是栈帧的压入弹出,指针是间接寻址。
C 的世界观是冯·诺依曼体系的直接映射。你就是一个能看见整个内存空间的上帝,每一条语句都是你对这片空间的一次操纵。
2.2 Java:你是社会的架构师
写 Java 的时候,画面完全不同。你脑子里想的不是内存,而是一个个具有属性和行为的对象。
public interface PaymentGateway { PayResult pay(Order order);}public class OrderService { private final PaymentGateway gateway; // 我只知道契约,不知道你是谁}
Java 的世界观是:你是一个架构师,在设计一个由自治实体组成的社会。对象之间通过接口(契约)定义协议,通过依赖注入组装关系。
这就是为什么 Java 生态里设计模式、Spring、DDD 如此流行——它们都在回答同一个问题:如何更好地组织这个"对象社会"。
2.3 Go:你是工厂的管理者
Go 的核心不只是"有 goroutine 和 channel",它的世界观来自 Tony Hoare 的 CSP 理论:
不要通过共享内存来通信,要通过通信来共享内存。
func producer(ch chan<- int) {for i := 0; ; i++ { ch <- i // 我只知道往管道里写 }}func consumer(ch <-chan int) {for v := range ch { process(v) // 我只知道从管道里读 }}
Go 的心智模型是:你是一个工厂管理者,设计一条条流水线(goroutine),工位之间通过传送带(channel)传递物料。每个工位的工作尽可能简单和独立。整个工厂追求的不是精巧,而是可靠、可理解、可维护。
三、把这些差异压缩一下
如果再抽象一层,会发现这些技术虽然看起来差异很大,但底下其实都在回答同一组问题。
对工程实践来说,一个很有用的统一框架是:
程序 = 状态 + 转换规则 + 可见性边界
也就是:
- 状态放在哪里;
- 状态怎样变化;
- 谁可以看到、读取、修改这些状态。
放到不同技术里,大致可以这样理解:
这个框架的价值在于:以后你碰到一门新的编程语言,不必一上来就陷进语法细节。先问这三个问题,通常就已经抓到它的骨架了。
有了这个框架,再看 SwiftUI,会清楚很多。
四、SwiftUI 的心智模型
SwiftUI 的核心公式只有一个:
UI = f(State)
界面是状态的函数。你不是在"构建界面",你是在声明一种映射关系——当状态是这样时,界面长这样。
struct CounterView: View { @State privatevar count =0 var body: some View { Button("Count: \(count)") { count +=1 } }}
这段代码里,你没有命令式地说“把按钮标题改掉”。 你只是声明:按钮标题取决于 count。 当 count 变化时,SwiftUI 会基于新的状态重新计算界面。
SwiftUI 思维是声明式的:你描述规则,框架执行规则。
用上面的三个问题来分析 SwiftUI:
| |
|---|
| 局部状态由 SwiftUI 以与 view identity 相关联的持久存储管理;共享数据则通过 @Binding、@Environment、Observation 等机制流动。 |
| 用户交互、任务、异步结果或外部模型变化,都会触发依赖失效和界面重算。 |
| @State 适合本地拥有的状态,@Binding 是可读写借用,@Environment 负责向下传播,@Observable / ObservableObject 负责把模型变化接入更新系统。 |
但 SwiftUI 的精妙之处在于,它的运行机制建立在三个支柱之上。Apple 在 WWDC 2021 的 "Demystify SwiftUI" 演讲中明确提出了它们:Identity、Lifetime、Dependency。
这三个概念,是真正理解 SwiftUI 的关键。
五、第一支柱:Identity(身份)
SwiftUI 里最重要的问题之一是:
这一次出现的 View,和上一次的是不是“同一个”?
它决定了动画怎么做、状态保不保留、性能怎么优化。
SwiftUI 有两种方式判断身份。
5.1 结构性身份
当你没有显式指定 ID 时,SwiftUI 靠代码中的位置来识别 View。
VStack { Text("Hello") // 身份 = VStack 的第 1 个子元素 Text("World") // 身份 = VStack 的第 2 个子元素}
这件事最值得警惕的地方,是条件分支:
if isLoggedIn { HomeView() // 身份 A:true 分支} else { LoginView() // 身份 B:false 分支}
对 SwiftUI 来说,这不是“同一个 view 在两种状态之间切换”,而是两个不同身份的 view。分支切换时,一个离场,一个入场;与旧身份绑定的状态也会随之结束。
这引出了一个常见的坑:
// ❌ 看起来是同一个 View,但身份不同if isActive { DetailView().background(.blue) // 身份 A} else { DetailView().background(.gray) // 身份 B}// 切换时 DetailView 的 @State 会重置!// ✅ 应该这样写DetailView() .background(isActive ? .blue : .gray)// 始终是同一个 View,只是参数不同
原则:同一个东西就应该是同一个 View,用数据来改变外观,而不是用 if-else 创建两个 View。
这也解释了为什么 SwiftUI 的 body 返回类型是 some View 而不是 any View。some View 要求编译时确定具体类型,而这个类型本身就编码了结构路径信息,是身份系统的基石。
5.2 显式身份
另一种方式是你主动告诉 SwiftUI "这个 View 是谁"。
ForEach(users, id: \.id) { user in UserRow(user: user) // 每个 UserRow 的身份 = user.id 的值}
这里的关键是 id 值必须稳定。
// ❌ id 随内容变化 → 编辑后身份变了 → 状态丢失ForEach(items, id: \.self) { item in EditableRow(item: item)}// ✅ id 永远稳定struct Item:Identifiable { let id =UUID() // 创建时生成,永不改变 var content: String // 内容可以变}ForEach(items) { item in EditableRow(item: item)}
还有一个工具是 .id() modifier:
TextEditor(text: $draft) .id(document.id) // 当 document 换了一篇,TextEditor 的身份变了 // 整个 View "死亡并重生",@State 重置为初始值
.id() 是你手动控制 View 生死的最直接手段。
六、第二支柱:Lifetime(生命周期)
理解 Lifetime 的关键,是区分两种完全不同的"生命"。
6.1 View 值 vs View 身份
SwiftUI 的 View 是 struct(值类型)。每次 body 重新求值,这个 struct 就被创建一次、使用一次、然后丢弃。这个值是易失的。
但 View 的身份可以跨越很多次 body 求值而持续存在。
struct CounterView: View { @State privatevar count =0var body: some View { Button("Count: \(count)") { count +=1 } }}
画成时间线:
每次 body 求值都创建了新的 struct,但 @State 没有重置。因为 @State 绑定的是"身份",不是"值"。
6.2 @State 的真相
@State 的数据根本不存储在 View struct 里。SwiftUI 在框架内部维护了一张表:
@State 的初始值只在身份首次出现时使用一次。之后每次 body 重新求值,@State 从这张表里取值,忽略你写的初始值。
你可以用一个简单的实验验证这一点:
struct ProofView: View { @State privatevar count =0init() { print("struct 被创建了") // 会打印很多次 }var body: some View { Button("Count: \(count)") { count +=1 } .onAppear { print("身份出现了") } // 只打印一次 }}
你会看到 "struct 被创建了" 打印多次,但 count 始终正确递增,不会回到 0。
6.3 身份变化 = 状态重置
理解了上面的机制,就能理解这个关键推论:当身份变化时,所有关联的 @State 都会被销毁并重新创建。
struct ParentView: View { @State private var showA =truevar body: some View { if showA { ChildView(label: "A") // 身份 A } else { ChildView(label: "B") // 身份 B(不同的身份!) } }}struct ChildView: View {let label: String@State private var tapCount =0var body: some View { Button("\(label):\(tapCount) taps") { tapCount +=1 } }}
操作序列:
- 切换到 B → "B: 0 taps"(tapCount 重置了!身份变了)
- 切换回 A → "A: 0 taps"(又重置了!旧的身份 A 已经死了)
如何理解?
“showA 决定当前启用 true 还是 false ;两个分支里的 ChildView 不是同一个 identity,所以各自的本地状态互不继承”
6.4 @StateObject vs @ObservedObject
这两个属性包装器的区别,本质上就是生命周期归属的区别。
// @StateObject:我创建,我拥有,生命周期跟随 View 身份struct ParentView: View {@StateObject private var vm =MyViewModel()var body: some View { ChildView(vm: vm) }}// @ObservedObject:别人创建,我只是观察struct ChildView: View {@ObservedObject var vm: MyViewModel// 我不拥有 vm 的生命周期,我只是"看着它"}
用后端的话说:@StateObject 类似"这个服务是我创建并管理的",@ObservedObject 类似"这个服务是别人注入给我的"。
谁创建,谁用 @StateObject。谁接收,谁用 @ObservedObject。
七、第三支柱:Dependency(依赖)
最后一个支柱解决的问题是:状态变了,SwiftUI 怎么知道哪些 View 需要更新?
7.1 依赖图
SwiftUI 在运行时维护了一张依赖图。
struct RootView: View {@State private var name ="Alice"@State private var score =0var body: some View { VStack { HeaderView(name: name) // 依赖 name ScoreView(score: score) // 依赖 score FooterView() // 无依赖 } }}
当 score 变化时:
SwiftUI 怎么判断"参数变没变"?依靠 Equatable 协议。这也是 View 必须是 struct 的原因之一——值类型天然可以逐字段比较。
7.2 @Observable:精准依赖追踪
iOS 17 引入的 @Observable 宏,是依赖追踪的一次重大升级。
旧模型的问题:
class OldViewModel: ObservableObject { @Published var name = "" @Published var avatar = UIImage()}struct ProfileView: View { @ObservedObject var vm: OldViewModel var body: some View { Text(vm.name) // 只用了 name,但 avatar 变化时也会触发更新 // 因为 @ObservedObject 监听的是整个对象 }}
新模型的解决:
@Observableclass NewViewModel { var name ="" var avatar =UIImage()}struct ProfileView: View { var vm: NewViewModel var body: some View { Text(vm.name) // SwiftUI 追踪到 body 执行时只读了 vm.name // 只有 name 变化才触发更新,avatar 变化不影响 }}
背后的原理是 @Observable 宏在属性的 getter 中插入了追踪代码。SwiftUI 执行 body 时会开启追踪模式,记录下所有被读取的属性,从而精确建立依赖关系。
这是从对象级别粒度到属性级别粒度的跃迁。
八、三大支柱的联动
最后,用一个完整的例子来展示三大支柱是如何协同工作的。
struct ChatApp: View { @State private var currentRoom: Room? =nilvar body: some View { NavigationSplitView { RoomListView(selectedRoom: $currentRoom) } detail: {if let room = currentRoom { ChatView(room: room) .id(room.id) } else { Text("请选择一个聊天室") } } }}struct ChatView: View {let room: Room@State private var draft =""var body: some View { VStack { MessageList(room: room) TextField("输入消息", text: $draft) } .task { await loadMessages(for: room) } }}
用户从聊天室 A 切换到聊天室 B:
先看 ChatView(room: room).id(room.id)。当用户从聊天室 A 切到聊天室 B,room.id 变了,ChatView 的 identity 也跟着变。于是旧 identity 对应的 lifetime 结束,本地 @State,也就是 draft,随之结束;新 identity 出现后,draft 重新初始化。
再看 .task(id: room.id)。Apple 对 task(id:) 的文档说明也很明确:如果 id 变化,SwiftUI 会取消旧任务并启动新任务;如果 view 消失,任务也会自动取消。
最后是依赖。ChatView.body 读取了 room 和 draft,MessageList 依赖 room,TextField 依赖 draft。当这些依赖变化时,SwiftUI 会沿着依赖关系让相关部分重算,而不是把整个界面当成一块黑盒重新来过。
这也是为什么 SwiftUI 真正难的地方,不在语法,而在你是否已经习惯这样思考:
- 先问 identity;
- 再问 lifetime;
- 最后问 dependency。
如果你忘了写 .id(room.id) 会怎样?SwiftUI 用结构性身份,发现 if-let 分支没变(还是 true),判定身份没变。于是 draft 保留了聊天室 A 写到一半的消息,.task 不会重新执行,用户在聊天室 B 看到的还是 A 的内容。
九、总结
回到开头那个问题:学习一门技术,最重要的是什么?
我现在越来越倾向于一个答案:先找到它要求你的那幅“内部画面”。
C 要你看到内存。Java 要你看到对象和协作。Go 要你看到并发单元之间的通信。而 SwiftUI 要你看到的,不再是“我命令界面去改变”,而是:
- 哪些状态属于谁;
- 哪些视图其实是同一个;
- 状态变化会沿着怎样的依赖关系传播。
对 SwiftUI 来说,可以把这条链记成:
Identity → Lifetime → Dependencies → UI Update
也就是:
- 先确定“它是谁”;
- 才能知道“它能活多久”;
- 然后才能知道“谁依赖它”;
- 最后才谈得上“界面该怎么更新”。
你一旦开始这样想,SwiftUI 里很多原本看起来“玄学”的行为,就会突然变得合理起来。