本篇目标

理解 SwiftUI 数据驱动的核心理念,掌握 @State、@Binding 的用法,学会用状态驱动界面刷新,能构建带交互的界面。

一、数据驱动的核心理念

SwiftUI 是数据驱动的界面框架:状态变化,界面自动刷新。

核心公式:

UI = f(State)

  • 你只负责描述状态。

  • SwiftUI 负责根据状态渲染界面。

  • 状态改变时,相关视图自动重建。

对比 UIKit:

方式

做法

UIKit

手动更新 UI 元素

SwiftUI

修改状态,界面自动刷新

二、@State

@State 用于管理视图内部的状态。

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Text("计数:\(count)")
            Button("加一") {
                count += 1
            }
        }
    }
}

特点:

  • private 修饰,只在本视图内使用。

  • 值改变时,视图自动刷新。

  • 适合管理简单、局部的状态。

常用场景:

  • 开关状态

  • 输入框文字

  • 计数器

  • 页面切换标记

struct ToggleView: View {
    @State private var isOn = false

    var body: some View {
        Toggle("启用", isOn: $isOn)
    }
}

三、@Binding

@Binding 用于在子视图中读写父视图的状态。

父视图:

struct ParentView: View {
    @State private var name = ""

    var body: some View {
        VStack {
            Text("你好,\(name)")
            ChildView(name: $name)
        }
    }
}

子视图:

struct ChildView: View {
    @Binding var name: String

    var body: some View {
        TextField("输入名字", text: $name)
    }
}

说明:

  • $name 把 @State 转换成 Binding。

  • 子视图通过 @Binding 修改父视图的状态。

  • 适用于父子视图共享状态。

可复用组件:

struct CustomToggle: View {
    @Binding var isOn: Bool
    let title: String

    var body: some View {
        HStack {
            Text(title)
            Spacer()
            Toggle("", isOn: $isOn)
                .labelsHidden()
        }
        .padding()
    }
}

四、@StateObject 与 @ObservedObject

当状态是一个对象,需要用 ObservableObject。

定义 ViewModel:

import SwiftUI
import Combine

class CounterViewModel: ObservableObject {
    @Published var count = 0

    func increment() {
        count += 1
    }
}

@StateObject(创建):

struct CounterView: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        VStack {
            Text("计数:\(viewModel.count)")
            Button("加一") {
                viewModel.increment()
            }
        }
    }
}

@ObservedObject(传入):

struct CounterDisplay: View {
    @ObservedObject var viewModel: CounterViewModel

    var body: some View {
        Text("计数:\(viewModel.count)")
    }
}

区别:

属性包装器

用途

生命周期

@StateObject

视图自己创建并持有

跟随视图

@ObservedObject

外部传入

由外部管理

使用原则:

  • 视图自己创建的对象:用 @StateObject。

  • 视图外部传入的对象:用 @ObservedObject。

五、@EnvironmentObject

@EnvironmentObject 用于跨视图共享状态。

注入:

class AppState: ObservableObject {
    @Published var username = "Guest"
}

@main
struct MyApp: App {
    @StateObject private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
        }
    }
}

使用:

struct ProfileView: View {
    @EnvironmentObject var appState: AppState

    var body: some View {
        Text("用户:\(appState.username)")
    }
}

特点:

  • 无需逐层传递。

  • 通过环境注入,任意子视图都能访问。

  • 适合全局状态:用户信息、主题、设置。

六、@Environment

@Environment 用于读取系统环境值。

struct MyView: View {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.dismiss) var dismiss

    var body: some View {
        Text(colorScheme == .dark ? "深色模式" : "浅色模式")

        Button("关闭") {
            dismiss()
        }
    }
}

常用环境值:

环境值

说明

\.colorScheme

深色/浅色模式

\.dismiss

关闭当前视图

\.horizontalSizeClass

水平尺寸类

\.locale

语言区域

\.calendar

日历

\.scenePhase

应用生命周期

七、@Published

@Published 用于在 ObservableObject 中标记可发布属性。

class LoginViewModel: ObservableObject {
    @Published var username = ""
    @Published var password = ""
    @Published var isLoggedIn = false

    var canSubmit: Bool {
        !username.isEmpty && !password.isEmpty
    }

    func login() {
        isLoggedIn = true
    }
}

特点:

  • 值变化时,通知订阅者。

  • 只能在 ObservableObject 中使用。

  • 配合 @StateObject / @ObservedObject 使用。

八、状态提升与单向数据流

状态提升:把状态放到共同的父视图,通过 @Binding 传递。

struct ParentView: View {
    @State private var text = ""

    var body: some View {
        VStack {
            TextInputView(text: $text)
            TextPreviewView(text: text)
        }
    }
}

struct TextInputView: View {
    @Binding var text: String

    var body: some View {
        TextField("输入", text: $text)
    }
}

struct TextPreviewView: View {
    let text: String

    var body: some View {
        Text("预览:\(text)")
    }
}

单向数据流:

  • 状态定义在顶层。

  • 数据向下传递。

  • 事件向上传递。

  • 保证数据一致性和可预测性。

九、综合示例:登录表单

struct LoginView: View {
    @State private var username = ""
    @State private var password = ""
    @State private var isLoggedIn = false
    @State private var showError = false

    var body: some View {
        VStack(spacing: 16) {
            TextField("用户名", text: $username)
                .textFieldStyle(.roundedBorder)

            SecureField("密码", text: $password)
                .textFieldStyle(.roundedBorder)

            Button("登录") {
                if username.isEmpty || password.isEmpty {
                    showError = true
                } else {
                    isLoggedIn = true
                }
            }
            .buttonStyle(.borderedProminent)
            .disabled(username.isEmpty || password.isEmpty)

            if showError {
                Text("请填写完整信息")
                    .foregroundColor(.red)
                    .font(.caption)
            }

            if isLoggedIn {
                Text("登录成功:\(username)")
                    .foregroundColor(.green)
            }
        }
        .padding()
    }
}

十、避坑指南

  • @State 用 private:避免外部修改。

  • 不要用 @State 存对象:对象用 @StateObject。

  • @ObservedObject 不持有生命周期:外部传入的对象用 @ObservedObject。

  • @EnvironmentObject 未注入会崩溃:确保上层注入。

  • @Published 只能在类中:结构体用 @State。

  • 避免状态过多:状态太多说明视图拆分不清晰。

  • 状态尽量靠近使用位置:就近原则,避免全局污染。

  • @Published 需要 import Combine:@Published 定义在 Combine 框架里,而 import SwiftUI 并不自动带上它。新版编译器(成员导入可见性)下会报「类型不遵循 ObservableObject」,看起来像状态写错了,其实只是少了一行 import Combine。

十一、检查清单

  • [ ] 理解数据驱动界面的理念

  • [ ] 会使用 @State 管理局部状态

  • [ ] 会使用 @Binding 实现父子共享

  • [ ] 理解 @StateObject 与 @ObservedObject 的区别

  • [ ] 会使用 @EnvironmentObject 共享全局状态

  • [ ] 会使用 @Environment 读取系统值

  • [ ] 能构建带交互的表单界面

  • [ ] 理解单向数据流

📦 这几种状态管理写法在 HelloWorld 的 SettingsView.swift 里都能看到,文件顶部标了对应的笔记编号。