Chapter 04

状态管理:@State、@Binding 与 Observable

理解 SwiftUI 的响应式数据流,掌握从局部状态到全局共享状态的完整管理方案

4.1 什么是"状态"?为何需要状态管理?

在 SwiftUI 中,状态(State)是驱动界面渲染的数据。界面是状态的函数:View = f(State)。当状态发生变化时,SwiftUI 会自动重新计算并更新相关的视图——开发者无需手动调用 reloadData()updateUI()

ℹ️
SwiftUI 的核心理念

"数据驱动 UI"。不要直接操作视图(命令式),而是修改数据,让框架决定如何更新界面(声明式)。这与 React、Vue 等前端框架的思想是一致的。

不同场景需要不同的状态管理工具:

工具适用场景数据范围
@State视图内部的局部状态单个视图私有
@Binding父子视图间共享可写状态两个视图之间
@StateObject视图拥有并管理的引用类型对象视图及其子视图
@ObservedObject视图观察但不拥有的引用类型对象视图及其子视图
@ObservableiOS 17+ 新式可观察对象任意范围
@EnvironmentObject整个视图树共享的全局状态整个应用
@Environment读取系统提供的环境值系统环境

4.2 @State — 局部状态

@State 是最基础的状态管理工具。它让 SwiftUI 在视图结构体之外存储这个值(因为 SwiftUI 的 View 是值类型,struct 不能自我修改),并在值变化时自动触发视图重新渲染。

ℹ️
@State 的存储位置

SwiftUI 运行时维护一份独立的存储,@State 变量实际存储在那里,而不是视图结构体本身。这就是为什么即使视图被重新创建,状态值也不会丢失。

struct CounterView: View {
    // @State 声明局部状态,通常设为 private 防止外部访问
    @State private var count = 0
    @State private var isShowingAlert = false
    @State private var inputText = ""

    var body: some View {
        VStack(spacing: 20) {
            // 显示状态值
            Text("计数:\(count)")
                .font(.largeTitle)
                .fontWeight(.bold)

            HStack(spacing: 20) {
                Button("-") {
                    count -= 1      // 修改状态 → 触发重渲染
                }
                .buttonStyle(.bordered)

                Button("+") {
                    count += 1
                }
                .buttonStyle(.borderedProminent)
            }

            // TextField 双向绑定文本状态
            TextField("输入内容", text: $inputText)  // $ 前缀获取 Binding
                .textFieldStyle(.roundedBorder)
                .padding(.horizontal)

            Text("你输入了:\(inputText)")
                .foregroundColor(.secondary)
        }
    }
}

// @State 也可以用于复杂类型
struct FormView: View {
    @State private var selectedColor = Color.blue
    @State private var sliderValue: Double = 0.5
    @State private var items: [String] = ["苹果", "香蕉"]

    var body: some View {
        VStack {
            Slider(value: $sliderValue, in: 0...1)
            ColorPicker("选择颜色", selection: $selectedColor)

            Button("添加元素") {
                items.append("新项目 \(items.count)")  // 修改数组状态
            }
        }
    }
}

4.3 @Binding — 双向绑定

@Binding 是一个对其他地方存储的状态的引用。子视图通过 @Binding 接收父视图的状态,读取并修改它,而实际数据存储在父视图中。这实现了"双向绑定"——子视图对数据的修改会直接反映到父视图。

// 子视图:接收 Binding,可以读写
struct ToggleRow: View {
    let title: String
    @Binding var isOn: Bool  // 不是自己的状态,是对外部状态的引用

    var body: some View {
        HStack {
            Text(title)
            Spacer()
            Toggle("", isOn: $isOn)   // 再次用 $ 向下传递
        }
        .padding(.horizontal)
        .onTapGesture {
            isOn.toggle()              // 直接修改,父视图也会更新
        }
    }
}

// 父视图:持有实际状态,用 $ 前缀创建 Binding 传入
struct SettingsView: View {
    @State private var notificationsOn = true
    @State private var darkModeOn = false
    @State private var soundOn = true

    var body: some View {
        VStack {
            ToggleRow(title: "通知", isOn: $notificationsOn)
            ToggleRow(title: "深色模式", isOn: $darkModeOn)
            ToggleRow(title: "声音", isOn: $soundOn)

            // 在父视图中使用这些状态
            if notificationsOn {
                Text("通知已开启").foregroundColor(.green)
            }
        }
    }
}

// 用 Binding.constant() 创建不可修改的 Binding(用于预览)
#Preview {
    ToggleRow(title: "预览", isOn: .constant(true))
}
💡
$ 前缀的含义

@State var count = 0 中,countInt 类型的值,而 $countBinding<Int> 类型的绑定。需要传递可写引用时用 $,需要只读值时直接用变量名。

4.4 @StateObject 与 @ObservableObject(经典模式)

当状态逻辑复杂,需要封装到单独的类中时,使用 ObservableObject 协议(iOS 14+)。这是 iOS 17 引入 @Observable 宏之前的主流方案。

ObservableObject — 可观察对象协议

遵循 ObservableObject 的类,其 @Published 属性变化时会通知所有观察它的视图。

// ViewModel:遵循 ObservableObject 协议
class CounterViewModel: ObservableObject {
    // @Published:属性变化时自动通知观察者(视图)
    @Published var count = 0
    @Published var history: [Int] = []

    var average: Double {    // 计算属性,不触发通知
        history.isEmpty ? 0 : Double(history.reduce(0, +)) / Double(history.count)
    }

    func increment() {
        count += 1
        history.append(count)
    }

    func reset() {
        count = 0
        history.removeAll()
    }
}

// @StateObject:视图"拥有"这个对象,负责其生命周期
// 视图第一次渲染时创建,视图销毁时对象也销毁
struct CounterView: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        VStack {
            Text("计数:\(viewModel.count)")
            Text("均值:\(viewModel.average, specifier: "%.1f")")
            Button("+1") { viewModel.increment() }
            Button("重置") { viewModel.reset() }

            // 传给子视图时用 @ObservedObject
            HistoryView(viewModel: viewModel)
        }
    }
}

// @ObservedObject:视图"观察"外部传入的对象,不管理其生命周期
struct HistoryView: View {
    @ObservedObject var viewModel: CounterViewModel

    var body: some View {
        List(viewModel.history, id: \.self) { value in
            Text("历史值:\(value)")
        }
    }
}
⚠️
@StateObject vs @ObservedObject

关键区别在于所有权@StateObject 负责创建和管理对象(视图树中有且仅有一个创建者),@ObservedObject 只是观察外部传入的对象。如果错误地用 @ObservedObject 创建对象,视图每次重建时对象都会被重新创建,导致状态丢失。

4.5 @Observable 宏(iOS 17 新模式)

iOS 17 引入了基于 Swift 5.9 宏系统的 @Observable,大幅简化了可观察对象的写法,并解决了 ObservableObject 的一些性能问题(精细追踪,只有被访问的属性发生变化才触发重渲染)。

ℹ️
@Observable 的优势

① 不再需要 @Published,所有存储属性自动可观察;② 性能更好,精细追踪每个属性;③ 语法更简洁;④ 可用于结构体(未来支持);⑤ 与 @State 无缝配合,不再需要区分 @StateObject@ObservedObject

import Observation   // iOS 17+,需要导入

// 旧方式(iOS 14+)
class OldUserStore: ObservableObject {
    @Published var name = "小明"
    @Published var age = 25
    @Published var isLoggedIn = false
}

// 新方式(iOS 17+)—— 使用 @Observable 宏
@Observable
class UserStore {
    var name = "小明"      // 自动可观察,无需 @Published
    var age = 25
    var isLoggedIn = false

    // 不想被观察的属性:加 @ObservationIgnored
    @ObservationIgnored
    var internalCache: [String: Any] = [:]

    func login(name: String) {
        self.name = name
        self.isLoggedIn = true
    }

    func logout() {
        isLoggedIn = false
    }
}

// 在视图中使用 @Observable 对象
struct ProfileView: View {
    // 视图拥有对象:用 @State(不再需要 @StateObject)
    @State private var store = UserStore()

    var body: some View {
        VStack {
            Text(store.name)
            Text(store.isLoggedIn ? "已登录" : "未登录")
            Button("登录") { store.login(name: "新用户") }

            // 传入子视图:直接传,不需要 $ 也不需要 @ObservedObject
            UserDetailView(store: store)
        }
    }
}

// 子视图接收 @Observable 对象
struct UserDetailView: View {
    var store: UserStore    // 普通属性,SwiftUI 自动追踪

    var body: some View {
        Text("年龄:\(store.age)")
    }
}

// 如果需要 Binding(配合 TextField 等),使用 @Bindable
struct EditProfileView: View {
    @Bindable var store: UserStore   // @Bindable 让属性可以用 $ 创建 Binding

    var body: some View {
        TextField("姓名", text: $store.name)   // $ 前缀获取 Binding
    }
}

4.6 @EnvironmentObject — 全局共享状态

@EnvironmentObject 是解决"状态透传"问题的方案。当多层视图都需要访问同一个状态,但中间层不需要时,不必一层层手动传递,只需将对象注入环境即可。

// 全局状态对象(旧方式,仍广泛使用)
class AppState: ObservableObject {
    @Published var currentUser: String? = nil
    @Published var cartCount = 0
    @Published var theme = "light"
}

// 在 App 入口注入环境
@main
struct MyApp: App {
    @StateObject private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)  // 注入到整个视图树
        }
    }
}

// 任意深度的子视图都可以直接使用
struct CartBadge: View {
    @EnvironmentObject var appState: AppState  // 无需手动传入

    var body: some View {
        Image(systemName: "cart")
            .overlay(alignment: .topTrailing) {
                if appState.cartCount > 0 {
                    Text("\(appState.cartCount)")
                        .font(.caption2)
                        .padding(4)
                        .background(.red)
                        .foregroundColor(.white)
                        .clipShape(Circle())
                        .offset(x: 8, y: -8)
                }
            }
    }
}

// @Observable 版本:用 .environment() 注入(iOS 17+)
@Observable
class NewAppState {
    var currentUser: String? = nil
    var cartCount = 0
}

// 注入方式略有不同
ContentView()
    .environment(newAppState)   // 不是 .environmentObject,是 .environment

// 子视图接收方式
struct SomeView: View {
    @Environment(NewAppState.self) var appState  // 使用类型参数
    var body: some View {
        Text(appState.currentUser ?? "未登录")
    }
}

4.7 @Environment — 读取系统环境值

@Environment 用于读取 SwiftUI 系统预置的环境值,如当前颜色模式、字体大小、语言方向等。

struct AdaptiveView: View {
    // 读取系统颜色模式(深色/浅色)
    @Environment(\.colorScheme) var colorScheme

    // 读取用户首选的字体大小等级
    @Environment(\.dynamicTypeSize) var dynamicTypeSize

    // 读取语言阅读方向(LTR/RTL)
    @Environment(\.layoutDirection) var layoutDirection

    // 读取地区/语言
    @Environment(\.locale) var locale

    // dismiss 操作(关闭当前视图)
    @Environment(\.dismiss) var dismiss

    // openURL 操作(打开链接)
    @Environment(\.openURL) var openURL

    var body: some View {
        VStack {
            // 根据颜色模式切换样式
            Text("当前模式:\(colorScheme == .dark ? "深色" : "浅色")")
                .foregroundColor(colorScheme == .dark ? .white : .black)
                .padding()
                .background(colorScheme == .dark ? .black : .white)
                .cornerRadius(8)

            Button("关闭") {
                dismiss()   // 关闭当前 sheet 或导航页
            }

            Button("访问官网") {
                openURL(URL(string: "https://developer.apple.com")!)
            }
        }
    }
}

// 常用系统环境值
// \.colorScheme      — .light / .dark
// \.dynamicTypeSize  — 用户字体大小偏好
// \.locale           — 语言地区
// \.timeZone         — 时区
// \.dismiss          — 关闭视图操作
// \.openURL          — 打开 URL 操作
// \.isEnabled        — 视图是否可交互
// \.editMode         — List 编辑模式
// \.presentationMode — 视图展示模式(旧式,推荐用 dismiss)

4.8 单向数据流

SwiftUI 遵循单向数据流(Unidirectional Data Flow)原则:数据总是从父视图向子视图流动,子视图通过 Binding 或回调通知父视图修改数据。

ℹ️
数据流向示意

AppState(全局)
    ↓ @EnvironmentObject / @Environment
RootView(@StateObject 拥有 ViewModel)
    ↓ 参数传递 / @Binding
ChildView A(@ObservedObject 观察 ViewModel)
    ↓ @Binding
ChildView B(@State 局部状态)
    ↑ 回调/Binding 通知父视图

4.9 实战示例:计数器 + 设置页面

综合运用本章所有状态管理工具,构建一个包含计数器和设置页面的完整示例。

// ─── 全局应用状态 ───
@Observable
class AppSettings {
    var accentColor: Color = .blue
    var stepSize: Int = 1
    var showHistory: Bool = true
    var maxCount: Int = 100
}

// ─── 计数器 ViewModel ───
@Observable
class CounterModel {
    var count = 0
    var history: [Int] = []

    func increment(by step: Int, max: Int) {
        guard count + step <= max else { return }
        count += step
        history.append(count)
    }

    func decrement(by step: Int) {
        guard count - step >= 0 else { return }
        count -= step
        history.append(count)
    }

    func reset() {
        count = 0
        history.removeAll()
    }
}

// ─── 设置子视图(使用 @Binding 接收可写状态)───
struct SettingsSheet: View {
    @Bindable var settings: AppSettings
    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationStack {
            Form {
                Section("计数设置") {
                    Stepper("步进值:\(settings.stepSize)",
                            value: $settings.stepSize, in: 1...10)
                    Stepper("最大值:\(settings.maxCount)",
                            value: $settings.maxCount, in: 10...1000, step: 10)
                }
                Section("显示设置") {
                    Toggle("显示历史记录", isOn: $settings.showHistory)
                    ColorPicker("主题色", selection: $settings.accentColor)
                }
            }
            .navigationTitle("设置")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("完成") { dismiss() }
                }
            }
        }
    }
}

// ─── 主视图 ───
struct CounterRootView: View {
    @State private var counter = CounterModel()
    @State private var settings = AppSettings()
    @State private var showSettings = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 32) {

                // 计数显示
                Text("\(counter.count)")
                    .font(.system(size: 80, weight: .bold, design: .rounded))
                    .foregroundColor(settings.accentColor)

                // 操作按钮
                HStack(spacing: 24) {
                    Button {
                        counter.decrement(by: settings.stepSize)
                    } label: {
                        Image(systemName: "minus.circle.fill")
                            .font(.system(size: 48))
                            .foregroundColor(settings.accentColor.opacity(0.7))
                    }

                    Button {
                        counter.increment(by: settings.stepSize, max: settings.maxCount)
                    } label: {
                        Image(systemName: "plus.circle.fill")
                            .font(.system(size: 48))
                            .foregroundColor(settings.accentColor)
                    }
                }

                Button("重置", role: .destructive) {
                    counter.reset()
                }

                // 历史记录
                if settings.showHistory && !counter.history.isEmpty {
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack {
                            ForEach(counter.history.suffix(10), id: \.self) { val in
                                Text("\(val)")
                                    .padding(.horizontal, 10)
                                    .padding(.vertical, 4)
                                    .background(settings.accentColor.opacity(0.1))
                                    .cornerRadius(8)
                            }
                        }
                        .padding(.horizontal)
                    }
                }
            }
            .navigationTitle("计数器")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showSettings = true
                    } label: {
                        Image(systemName: "gearshape.fill")
                    }
                }
            }
            .sheet(isPresented: $showSettings) {
                SettingsSheet(settings: settings)   // 传入设置对象
            }
        }
    }
}
小结

本章系统学习了 SwiftUI 状态管理的完整工具链:@State 管理局部状态,@Binding 实现父子双向绑定,@StateObject/@ObservedObject 处理引用类型(旧方式),@Observable 宏是 iOS 17 的现代化方案,@EnvironmentObject/@Environment 解决全局状态共享问题。核心原则是单向数据流——数据从上到下,事件从下到上。