iOS App 从 0 到 1

SwiftUI 状态管理完全指南

从 @State / Binding,到 ObservableObject,再到 iOS 17 @Observable:选型、迁移与新手翻车点一次讲清。

1状态管理到底在管什么

声明式 UI 的核心公式:UI = f(state)。你改的是数据,SwiftUI 负责重算 body。新手最常卡在:该用哪个包装器、对象该谁创建、为啥一转屏数据没了。

本地 @State → 父子 Binding → 对象 Store → 环境共享
本章按「够用顺序」展开:先会本地与绑定,再学对象与环境;最后给 iOS 17+ 的 @Observable。结构参考掘金《SwiftUI 状态管理完全指南》,并按新手实操改写。原文链接
记三条:① 状态尽量放最小范围;② 谁创建对象谁用拥有型包装器;③ 新项目优先 @Observable(最低 iOS 17),旧项目仍要会 ObservableObject。

2@State:视图私有状态

最基础的工具。适合开关、输入框草稿、计数器——只给当前 View 用的简单值。

struct CounterView: View {
  @State private var count = 0          // 建议 private
  @State private var name = ""

  var body: some View {
    VStack(spacing: 12) {
      Text("点了 \(count) 次")
      Button("加一") { count += 1 }

      TextField("名字", text: $name)   // $name → Binding<String>
      Text(name.isEmpty ? "还没填" : "你好,\(name)")
    }
    .padding()
  }
}
要点说明
声明标 private,避免外面乱改
类型优先 Int/String/Bool/小 struct;大列表别全塞这里
刷新赋值变化 → SwiftUI 重算 body
$ 前缀从 @State 投影出 Binding,交给子控件读写
≈ Compose remember { mutableStateOf };≈ ArkUI V2 @Local(组件内、不可外部初始化)。
真实问题:在 body 里写 var count = 0 不加 @State
View 是结构体,每次刷新都可能重建,普通属性留不住。可变 UI 状态必须进属性包装器(或外部 Store)。

3@Binding:子视图改父状态

子视图不拥有数据,只拿着「通往父状态的引用」。父传 $xxx,子声明 @Binding。

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

  var body: some View {
    VStack {
      Text(isOn ? "开" : "关")
      ToggleRow(isOn: $isOn)   // 注意 $
    }
  }
}

struct ToggleRow: View {
  @Binding var isOn: Bool      // 引用,不是拷贝

  var body: some View {
    Toggle("开关", isOn: $isOn)
  }
}
没有 $ 就变成「只传当前值的拷贝」,子改不动父。≈ Compose 的 value + onValueChange;≈ ArkUI @Param + @Event。
真实问题:子视图里再声明一个 @State,和父不同步
你复制了一份本地状态。要共享就用 @Binding,或把状态上提到共同父级(状态提升)。

4ObservableObject + @Published(iOS 13+ 传统写法)

业务一复杂(列表、登录、网络结果),别堆几十个 @State。做成可观察的类,视图订阅它。

import Combine

final class UserViewModel: ObservableObject {
  @Published var name = "张三"
  @Published var age = 25

  func updateName(_ newName: String) {
    name = newName   // @Published 会通知订阅者刷新
  }
}
概念作用
ObservableObject声明「我是可观察对象」(配合 Combine)
@Published哪个属性变了要通知 UI
方法业务逻辑放类里,View 只负责展示与触发
没标 @Published 的属性改了,界面通常不会自动刷(除非你手动发 objectWillChange)。

5@StateObject vs @ObservedObject(必懂)

这是新手翻车第一名:对象被反复重建,列表清空、登录态丢失。

@StateObject@ObservedObject
谁创建本视图创建并拥有别人创建,我只观察
生命周期视图身份保持期间只初始化一次若父每次传入新实例,会跟着换
典型用法@StateObject private var vm = UserViewModel()子视图:@ObservedObject var vm: UserViewModel
struct ContentView: View {
  @StateObject private var viewModel = UserViewModel()  // 拥有

  var body: some View {
    ChildView(viewModel: viewModel)  // 往下传同一实例
  }
}

struct ChildView: View {
  @ObservedObject var viewModel: UserViewModel          // 只观察

  var body: some View {
    Text(viewModel.name)
    Button("改名") { viewModel.updateName("李四") }
  }
}
口诀:创建方用 StateObject,接收方用 ObservedObject。子视图里写 @ObservedObject var vm = UserViewModel() 几乎一定是错的。
真实问题:一旋转屏幕 / 导航回来,ViewModel 数据没了
多半是用了 @ObservedObject 在本页「就地 new」。改成 @StateObject,或把对象放到更上层 App / 父页面持有。

6@EnvironmentObject:跨多层共享(传统)

登录态、主题配置等「整棵树都可能用到」的对象,不必一层层参数往下传。

final class AppState: ObservableObject {
  @Published var isLoggedIn = false
}

@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.isLoggedIn ? "已登录" : "未登录")
  }
}
漏写 .environmentObject 会在运行时直接崩溃(找不到对象)。预览里也要同样注入,否则 Preview 炸。
真实问题:Preview crashed:No ObservableObject of type … found
给 Preview 补上:ProfileView().environmentObject(AppState())。

7@Environment:系统环境值

读系统/框架提供的值(深浅色、尺寸类、dismiss 等),不是你自己的业务 Store。

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

  var body: some View {
    VStack {
      Text(colorScheme == .dark ? "深色模式" : "浅色模式")
      Button("关闭") { dismiss() }
    }
  }
}

8@AppStorage / @SceneStorage(轻量持久化)

简单开关、用户名这类小数据可直接绑 UserDefaults;场景级临时状态用 SceneStorage。大列表请看第 09 章文件存储。

struct SettingsView: View {
  @AppStorage("username") private var username = ""
  @AppStorage("isDarkMode") private var isDarkMode = false
  @SceneStorage("draft") private var draft = ""  // 随场景,偏临时

  var body: some View {
    Form {
      TextField("用户名", text: $username)
      Toggle("深色模式", isOn: $isDarkMode)
      TextField("草稿", text: $draft)
    }
  }
}
@AppStorage@SceneStorage
底层UserDefaults场景存储
存活杀 App 还在场景结束可清
适合设置项、开关滚动位置、未提交草稿

9@Observable(iOS 17+ 推荐)

现代写法:一个宏搞定可观察类,不用 ObservableObject / @Published。新项目优先学这个。

import SwiftUI

@Observable
final class UserModel {
  var name = "张三"
  var age = 25
  var email = "zhangsan@example.com"
}

struct ContentView: View {
  @State private var user = UserModel()   // 用 @State 持有(拥有)

  var body: some View {
    VStack {
      Text("姓名:\(user.name)")
      TextField("修改姓名", text: $user.name)
    }
  }
}

更简洁

去掉协议与一堆 @Published。

更精细

按访问的属性刷新,减少无谓重绘。

持有方式

本页拥有 → @State;需要子页双向绑属性 → @Bindable。

10@Bindable:给 @Observable 做双向绑定

父把 @Observable 实例传给子;子要用 $settings.xxx 时,给参数加 @Bindable。

@Observable
final class Settings {
  var isDarkMode = false
}

struct ParentView: View {
  @State private var settings = Settings()

  var body: some View {
    ChildView(settings: settings)
  }
}

struct ChildView: View {
  @Bindable var settings: Settings

  var body: some View {
    Toggle("深色模式", isOn: $settings.isDarkMode)
  }
}

11@Environment + @Observable(现代全局态)

@Observable
final class AppState {
  var isLoggedIn = false
  var userName = ""
}

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

  var body: some Scene {
    WindowGroup {
      ContentView()
        .environment(appState)   // 注意:不是 environmentObject
    }
  }
}

struct ProfileView: View {
  @Environment(AppState.self) private var appState

  var body: some View {
    Text(appState.isLoggedIn ? "欢迎 \(appState.userName)" : "未登录")
  }
}
传统:environmentObject + @EnvironmentObject。现代:environment(_:) + @Environment(Type.self)。别混用同一套注入/读取 API。

12怎么选:一张表

场景iOS 13–16iOS 17+(推荐)
单个视图内部@State@State
父子共享简单值@Binding@Binding
本页业务模型@StateObject + ObservableObject@State + @Observable
子页观察/绑定模型@ObservedObject传入 + 必要时 @Bindable
全局共享@EnvironmentObject@Environment(Type.self)
小开关持久化@AppStorage@AppStorage
最低系统若必须 ≤16:老写法保底。能定 17+:直接上 @Observable,少踩 StateObject/ObservedObject 的坑。

13从 ObservableObject 迁到 @Observable

加 @Observable → 删 @Published → StateObject→State → Observed→Bindable → Environment 新写法
旧
final class OldVM: ObservableObject {
  @Published var text = ""
}
struct OldView: View {
  @StateObject private var vm = OldVM()
  var body: some View {
    TextField("", text: $vm.text)
  }
}
新
@Observable
final class NewVM {
  var text = ""
}
struct NewView: View {
  @State private var vm = NewVM()
  var body: some View {
    TextField("", text: $vm.text)
  }
}

14迷你实战:待办 Store(现代写法)

把「状态 + 行为」收进一个模型;界面只负责渲染与调用。完整持久化版见第 12 章。

@Observable
final class TodoViewModel {
  struct Todo: Identifiable, Equatable {
    let id = UUID()
    var title: String
    var isCompleted = false
  }

  var todos: [Todo] = []
  var newTitle = ""

  func addTodo() {
    let t = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !t.isEmpty else { return }
    todos.insert(Todo(title: t), at: 0)
    newTitle = ""
  }

  func toggle(_ todo: Todo) {
    guard let i = todos.firstIndex(of: todo) else { return }
    todos[i].isCompleted.toggle()
  }
}

struct TodoListView: View {
  @State private var vm = TodoViewModel()

  var body: some View {
    NavigationStack {
      List {
        ForEach(vm.todos) { todo in
          HStack {
            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
              .onTapGesture { vm.toggle(todo) }
            Text(todo.title)
              .strikethrough(todo.isCompleted)
          }
        }
      }
      .navigationTitle("待办")
      .toolbar {
        ToolbarItem(placement: .bottomBar) {
          HStack {
            TextField("新待办", text: $vm.newTitle)
            Button("添加") { vm.addTodo() }
          }
        }
      }
    }
  }
}

15你会鸿蒙时:快速对照

SwiftUI≈ ArkUI V2 / MVVM
@State@Local
@Binding@Param + @Event
@Observable 类@ObservedV2 + @Trace ViewModel
@Environment / environment@Provider / @Consumer、AppStorageV2
@AppStoragePreferences / AppStorageV2 小键值

16踩坑 FAQ

改了数组里某个元素的字段,界面不刷新?
对 @Published 数组,改 todos[i].done 有时观测不到。写成替换元素或整表赋值;或用 @Observable 并确保触发了属性写。模型用 struct + 替换下标更稳。
子视图预览单独打开就崩
缺 Environment / EnvironmentObject。Preview 里手动注入假数据。
所有状态都塞进全局 Environment 好不好?
不好。全局一变,订阅面大,难测也难推理。能页面级 Store 就别全局;全局只放会话、主题等真·跨页数据。
@State 能存 class 吗?
iOS 17+ 用 @State 持有 @Observable 类是官方路径。更早系统对 class 请用 @StateObject。

17本章收口

基础层

@State、@Binding

传统对象层

ObservableObject、StateObject/ObservedObject、EnvironmentObject

持久化小品

@AppStorage、@SceneStorage

现代层

@Observable、@Bindable、@Environment(Type.self)

下一步:第 06 章生命周期决定「何时改状态 / 何时拉数」;第 12 章会把 Store + 持久化串成完整待办。

iOS App 从 0 到 1 · 主讲 SwiftUI / iOS