这是我的 SwiftUI 学习笔记系列第三篇。虽有 AI 助力开发,但若想指挥它写出优雅且有品位的代码,并在调试时透彻理解底层逻辑,仍需深度思考。在当下,学习一门语言,掌握语法已非首要,更重要的是建立一张知识地图。希望这个系列能绘就这张图。作者初学SwiftUI,文中难免有疏漏或理解不到位之处,欢迎各位开发者指正探讨,我们共同进步。
SwiftUI 状态管理入门
一、问题的由来
学习 SwiftUI 的时候,最让人困惑的就是状态管理。
@State、@Binding、@StateObject、@ObservedObject、@EnvironmentObject、@Environment……一下子冒出这么多概念,每个前面都带一个 @,看着就头大。
但是仔细想下来,它们要解决的其实是同一个问题:
界面是会变的,驱动这些变化的数据放在哪里?
SwiftUI 是声明式框架。你不是告诉系统"把第三行文字改成红色",而是告诉它"当 isError 为 true 时,文字是红色的"。至于什么时候改、怎么改,框架替你处理。
那么 isError 这个变量存在哪里?谁有权修改它?修改之后怎么通知界面刷新?
整套状态管理工具,就是回答这三个问题的。
二、@State:最简单的情况
我们从最简单的例子开始。
struct Counter: View { @State private var count = 0 var body: some View { Button("已点击 \(count) 次") { count += 1 } }}
点一下按钮,数字加一,界面自动刷新。@State 就干了这么一件事。
但这里有一个问题。Counter 是一个结构体(struct),结构体是值类型,理论上属性不能在内部被修改。那 count += 1 为什么能工作?
答案是:@State 的数据根本不存在结构体里面。
SwiftUI 在框架内部开辟了一块存储空间,专门放这个值。结构体上的 @State 只是一个指针,指向那块空间。
你可以这样理解。
Counter 结构体(随时可能被销毁重建) └── @State count ──→ 【SwiftUI 内部存储:0, 1, 2, 3...】
这个设计有一个重要的推论:SwiftUI 可以随时销毁并重建 Counter 结构体,状态不会丢失。
很多初学者会以为,每次父视图重新渲染时,count 会被重置为 0。不会的。初始值 = 0 只在视图第一次出现在屏幕上时使用一次,之后就不管了。
什么时候状态才会真正销毁?当这个视图从视图树中被移除的时候。
if showCounter { Counter() // 出现时创建状态,消失时销毁状态}
Toggle 关掉,Counter 消失,状态归零。再打开,一切重头开始。
最后一点,@State 应该始终标记为 private。它就是视图自己的私事,外部不应该知道,也不应该碰。如果你发现外部需要读写这个值,那说明你需要下一个工具。
三、@Binding:借来的写权限
实际开发中,经常需要把一个视图拆成多个子组件。这时候问题来了。
struct PlayerView: View { @State private var isPlaying = false var body: some View { VStack { Text(isPlaying ? "播放中" : "已暂停") PlayButton(/* 怎么把 isPlaying 传下去? */) } }}
PlayButton 需要修改 isPlaying,但这个状态属于 PlayerView。
有人会想,在 PlayButton 里面再建一个 @State var isPlaying。这就错了——两份独立的状态,各管各的,永远不会同步。
正确的做法是使用 @Binding。
struct PlayButton: View { @Binding var isPlaying: Bool var body: some View { Button(isPlaying ? "暂停" : "播放") { isPlaying.toggle() } }}
传递的时候,在变量前面加 $。
PlayButton(isPlaying: $isPlaying)
$ 的意思是:"别把值复制一份给你了,给你一个遥控器,你可以用它来读和写原始数据。"
不加 $,传过去的是一个普通的 Bool 值,改了也没用。加了 $,传过去的是一个 Binding<Bool>,改它就是改源头。
Binding 的本质很简单,就是两个闭包:一个负责读,一个负责写。你甚至可以手动创建。
let binding = Binding( get: { self.isPlaying }, set: { newValue in print("值变了:\(newValue)") self.isPlaying = newValue })
用一句话总结 @State 和 @Binding 的关系:
@State 是"我的数据",@Binding 是"借我用一下你的数据"。
四、当数据变复杂:ObservableObject
@State 适合简单的值:一个布尔值、一个数字、一个字符串。
但真实的应用不会这么简单。一个播客 App,需要管理当前播放的节目、播放进度、播放列表。这些数据有自己的逻辑(播放、暂停、快进),需要用一个专门的类来管理。
class PodcastPlayer: ObservableObject { @Published var isPlaying = false @Published var currentTime: TimeInterval = 0 func play() { isPlaying = true } func pause() { isPlaying = false }}
两个新东西。
ObservableObject 是一个协议,意思是"这个对象是可以被观察的"。
@Published 是一个属性包装器,意思是"这个属性变了,就发通知"。
内部机制是这样的:@Published 属性变化时,会自动调用 objectWillChange.send()(注意是 will Change,变化之前发通知)。SwiftUI 收到通知后,安排一次界面刷新。
有了 ObservableObject,下一个问题是:视图怎么和它连接?
SwiftUI 给了三种方式。
4.1 @StateObject:我创建,我负责
struct PlayerView: View { @StateObject private var player = PodcastPlayer() var body: some View { Text(player.isPlaying ? "播放中" : "已暂停") }}
@StateObject 和 @State 的理念一样:
4.2 @ObservedObject:你给我的,我只看看
struct PlayerView: View { @ObservedObject var player: PodcastPlayer var body: some View { Text(player.isPlaying ? "播放中" : "已暂停") }}
@ObservedObject 不创建对象,只接收外部传进来的对象。它不管对象的生死,只负责观察。
规则:谁创建,谁用 @StateObject;谁接收,谁用 @ObservedObject。
struct App: View { @StateObject private var player = PodcastPlayer() // 创建 var body: some View { PlayerView(player: player) // 传递 MiniPlayer(player: player) // 传递 }}struct PlayerView: View { @ObservedObject var player: PodcastPlayer // 接收}
4.3 @EnvironmentObject:别一层层传了
有时候,一个对象需要被很深层的子视图使用。一层一层传下去太麻烦了。
App → TabView → HomeTab → ArticleList → ArticleRow → BookmarkButton
BookmarkButton 需要访问用户数据,难道每一层都要加一个参数?
@EnvironmentObject 解决这个问题。在顶层注入一次,后代视图随时取用。
// 顶层注入ContentView() .environmentObject(player)// 任何后代视图直接取struct BookmarkButton: View { @EnvironmentObject var player: PodcastPlayer // 不用通过 init 传入,直接从环境里拿}
它按类型匹配。环境里有 PodcastPlayer 类型的对象,就能拿到。没有的话,运行时直接崩溃——所以要小心,确保注入了再用。
三种方式对比如下。
| | |
|---|
@StateObject | | |
@ObservedObject | | |
@EnvironmentObject | | |
五、核心原则:单一真相之源
工具讲完了。但工具越多,越容易出问题。这一节讲的是怎么正确地使用它们。
SwiftUI 状态管理有一个核心原则,叫做 单一真相之源(Single Source of Truth)。
意思很简单:一份数据只能有一个权威来源。
反面例子:
// ❌ 错误:两份数据表示同一件事struct PlayerView: View { @State private var isPlaying = false // 第一份 @ObservedObject var player: PodcastPlayer // player.isPlaying 是第二份}
两个 isPlaying,早晚会不一致。你改了这个忘了改那个,bug 就来了。
正确做法是只保留一份,其他的都从它算出来。
// ✅ 正确:一份源数据,其他都是派生的class PodcastPlayer: ObservableObject { @Published var currentTime: TimeInterval = 0 // 源数据 @Published var duration: TimeInterval = 200 // 源数据 var progress: Double { currentTime / duration } // 派生,用计算属性 var remaining: TimeInterval { duration - currentTime } // 派生}
progress 和 remaining 不需要存储,因为它们随时可以从 currentTime 和 duration 算出来。如果你把它们也存成 @Published,就多了两份需要手动同步的数据,迟早会出问题。
原则可以概括为一句话:
能算出来的,就不要存。
另一个问题是:状态应该放在哪一层?
答案是:放在使用它的最近的公共祖先。
只有 A 用 → 放 A 里面(@State)。A 和 B 都用 → 放它们的共同父视图里,通过 @Binding 传下去。全局都用 → 放最顶层,用 @EnvironmentObject 传下去。
放得太低,其他视图拿不到。放得太高,不相关的视图会被不必要地刷新。
六、@Environment:系统级的配置
最后一个工具,@Environment。
它和 @EnvironmentObject 名字很像,容易混淆,但用途不同。
- •
@EnvironmentObject 传递的是你自己创建的对象 - •
@Environment 访问的是系统级的配置值
struct ContentView: View { @Environment(\.colorScheme) var colorScheme // 当前是浅色还是深色模式 @Environment(\.locale) var locale // 当前语言 @Environment(\.sizeCategory) var sizeCategory // 当前字体大小设置 var body: some View { if colorScheme == .dark { Text("深色模式") } else { Text("浅色模式") } }}
这些值沿视图树向下传播,可以在任意层级被覆盖。
VStack { Text("跟随系统") // 使用系统设置 Text("强制深色") .environment(\.colorScheme, .dark) // 覆盖}
你也可以自定义环境值。需要三步:
// 1. 定义 Keystruct ThemeColorKey: EnvironmentKey { static let defaultValue: Color = .blue}// 2. 扩展 EnvironmentValuesextension EnvironmentValues { var themeColor: Color { get { self[ThemeColorKey.self] } set { self[ThemeColorKey.self] = newValue } }}// 3. 使用struct SomeView: View { @Environment(\.themeColor) var themeColor}
@Environment 和 @EnvironmentObject 的最大区别:@Environment 有默认值,找不到不会崩溃。
所以简单的配置项用 @Environment,复杂的数据模型用 @EnvironmentObject。
七、总结
下面用一张图把所有概念串起来。
场景 工具──────────────────────────────────────────视图自己的简单状态 @State子视图需要读写父视图的状态 @Binding需要独立的模型类管理数据 ObservableObject + @Published ├── 创建者 @StateObject ├── 接收者 @ObservedObject └── 跨多层传递 @EnvironmentObject系统配置或自定义配置值 @Environment
贯穿始终的原则只有两条。
第一,单一真相之源。 同一份数据只有一个来源,其他地方要么通过 Binding 引用它,要么通过计算属性从它派生。
第二,状态驱动界面。 你永远不要直接操作界面元素,你只修改状态。状态一变,SwiftUI 自动帮你刷新界面。
整个循环就是:
用户操作 → 修改状态 → SwiftUI 重新计算 body → 界面更新 → 等待用户操作
你负责管好数据,SwiftUI 负责管好界面。各司其职。