本节摘要:状态是 SwiftUI 的水分:没有它,图纸只是静态画。本节沿"所有权阶梯"讲四种属性包装器——@State(视图私有)、@Binding(子视图回写)、@ObservableObject 加 @StateObject(引用型状态源)、@EnvironmentObject(跨层注入)——并立下单向数据流的纪律:数据沿树向下流动,事件逆流向上回写。最后用阳台花园的"列表加详情"把四种输送方式接成一条完整水路。
5.1 的例子只有一个 @State,够单个视图活。真实界面是多节点的树:列表页有列表的状态,每行有自己的交互,详情页要改列表里的数据——水往哪里存、怎么流,就是本节的全部内容。先立总纲,一张所有权阶梯表:
| 属性包装器 | 存的是什么 | 谁拥有 | 什么时候用 |
|---|---|---|---|
| @State | 值类型(结构体、枚举) | 本视图私有 | 视图内部的小状态:开关、输入草稿 |
| @Binding | 一份引用(借来的写权限) | 父视图 | 子控件要改父的状态:TextField、滑杆 |
| @StateObject 加 @ObservableObject | 引用类型状态源 | 本视图创建并持有 | 跨视图共享的仓库:植物数据、会话 |
| @EnvironmentObject | 已注入的环境对象 | 应用或祖先视图 | 多层深处都要读的全局数据:设置、主题 |
记忆钩子:值给视图私有用 State,借写权限用 Binding,共享仓库用对象,满树皆知用环境。
// 第一级:@State——视图私有的值状态 struct AddPlantSheet: View { @State private var draftName = "" // 输入草稿,只这张表单关心 @State private var reminderOn = true var body: some View { Form { TextField("植物名字", text: $draftName) // $ 是 Binding 的入口(见第二级) Toggle("接收提醒", isOn: $reminderOn) Text("预览:\(draftName.isEmpty ? "(未命名)" : draftName)") // 每敲一个字 draftName 变 → body 重算 → 预览行实时更新(5.1 的回路) } } }
第二级:@Binding。上例里 $draftName 已经在用了——美元符把状态"借"给系统控件回写。自己写的子视图同样可以要这份借条:
// 子视图:声明我要一份可读写的借条 struct IntervalPicker: View { @Binding var days: Int // 借来的写权限,值本体在父视图 var body: some View { Stepper("每 \(days) 天浇水", value: $days, in: 2...15) .font(.body) } } // 父视图:把自有状态借出去 struct AddPlantForm: View { @State private var interval = 3 var body: some View { Form { IntervalPicker(days: $interval) // $interval:把 State 借成 Binding Text("存入时周期为 \(interval) 天") } } } // 子视图里按加减号 → days 变 → 父的 interval 同步变 → 父的 Text 也刷新 // 一份数据、两个视图、单一边写路径——这就是单向数据流的样子
第三级:可观察对象。当状态要跨页面共享(列表与详情改同一份数据),值类型的 State 力不从心,引用型状态源登场——2.5 的类加一套发布机制:
import Combine final class PlantStore: ObservableObject { // 2.5 的仓库,穿上可观察外衣 @Published var plants: [Plant] = [ // @Published:一变就广播 Plant(name: "龟背竹", kind: .foliage, passedDays: 2), Plant(name: "多肉", kind: .succulent, passedDays: 4) ] func water(_ plant: Plant) { if let i = plants.firstIndex(where: { $0.id == plant.id }) { plants[i].water() // 改的是仓库,界面自动跟 } } } struct PlantListView: View { @StateObject private var store = PlantStore() // 创建并持有(本视图是状态源的主人) var body: some View { NavigationStack { List(store.plants) { plant in NavigationLink(value: plant) { PlantRow(plant: plant) } } .navigationDestination(for: Plant.self) { plant in PlantDetailView(plant: plant, store: store) // 仓库传下去(共享同一实体,2.5) } } } } struct PlantDetailView: View { let plant: Plant @ObservedObject var store: PlantStore // 引用别人拥有的仓库:ObservedObject var body: some View { VStack(spacing: 16) { Text(plant.statusText).font(.title2) Button("浇水") { store.water(plant) // 唯一动作:改仓库——没有一行"刷新界面" } .buttonStyle(.borderedProminent) } } } // 点击浇水 → store.plants 变化 → @Published 广播 → 所有观察该仓库的视图重算 → // 列表行与详情同时更新(若返回列表,行状态已是新的——4.1 的"忘了 reload"在这个体系里不存在)
第四级:@EnvironmentObject,给"满树皆知"的数据省去逐层传参:
// 注入:在树根放一次 @main struct GardenApp: App { @StateObject private var store = PlantStore() var body: some Scene { WindowGroup { PlantListView().environmentObject(store) } // 全树可见 } } // 深层视图直接取用(中间层完全不用改签名) struct SettingsView: View { @EnvironmentObject var store: PlantStore var body: some View { Text("花园里共有 \(store.plants.count) 株植物") // 中间隔了几层也能拿到 } }

两个包装器包的都是可观察对象,差别一句话:谁创建谁用 StateObject,只借不改所有权用 ObservedObject。写反的后果真实存在——详情页若用 StateObject 加同名初始化,每次重算都可能重建仓库,列表与详情就各看各的数据了。@Published 的广播粒度也值得知道:它按"被标记的属性"广播,plants 变化通知观察者,仓库里没标记的属性再怎么改也无人知晓——2.5 引用语义"改远处对象"的老陷阱,在这里以"忘了标 Published 所以不刷新"的新面目出现。
背景:早期版本里详情页自己存了一份 passedDays 本地副本,浇水后详情更新、返回列表却还是旧值。操作:删掉本地副本,全部读写指向 store。结果:列表与详情永远一致,改动代码量反而更少。解读:这不是修了个 bug,是消除了一个 bug 类别——"界面各自存数据"这个动作本身被纪律禁止。变式:需要本地草稿(编辑表单的暂存)时,用 @State 存草稿、确认时一次性写回仓库——草稿是视图私有状态,与共享数据分层存放,两不耽误。
💡 关键直觉:SwiftUI 架构的全部问题是"这份数据归谁"。所有权定了,包装器自动确定;所有权定错,补丁会越打越多。
水通了,枝条怎么排——下一节讲布局容器的协商规则。