我把状态管理拆成四段记。先问三件事:状态放在哪,谁可以改,改完界面怎么跟上。
SwiftUI 用属性包装器回答这三问,从近到远是一架梯子。UIKit 没有这架梯子,数据在控制器里,界面要自己写进去。一页里同时堆了请求、校验和跳转,再把状态收成 ViewModel,或者收成「一份值 + 一组动作」。Combine 把变化送出去。RxSwift、ReactorKit、TCA 再规定动作和副作用怎么走。界面最后仍是读这份状态。
环境按 Xcode 16。SwiftUI 新代码用 iOS 17 的 @Observable。更早的系统用 ObservableObject 那一档,文里分开写。
SwiftUI 阶梯
梯子排的是所有权。上面一档持有存储,下面一档借用。
这一块视图
@State 自己的值
@Binding 改上一档的值
这一屏的对象(@Observable)
@State 创建并持有
let 只读,传给子视图
@Bindable 子视图要绑定字段
@Environment 祖先注入,跨层共用
系统存储
@AppStorage 偏好
@SceneStorage 这个场景要恢复的界面状态
iOS 17 之前,对象那几档是另一套名字。
| 现在 | iOS 17 之前 | 谁持有 |
|---|---|---|
@State 拿着 @Observable | @StateObject | 创建它的视图 |
普通参数,或 @Bindable | @ObservedObject | 外面传进来 |
@Environment | @EnvironmentObject | 祖先放进环境 |
| 属性直接被跟踪 | @Published | 对象自己 |
View 是结构体,body 会反复求值。普通 var count = 0 每次求值都是一份新的。包装器把存储放在结构体外面,跟这块视图的身份走。
@State
一个开关、一次输入、这一页的计数,用 @State。private 表示这份值由这块视图,以及它交出的绑定去改。初始化表达式每次创建视图值时都会执行,SwiftUI 只采纳第一次的结果。能算出来的值做成计算属性,例如 var canSubmit: Bool { !name.isEmpty }。再存一个 @State 用 onChange 去同步,两份会分叉。onChange 留给「值变了要做一件额外的事」,比如打点。
import SwiftUI
struct CounterView: View {
// 方式:@State
// 源在这块视图自己身上。View 是结构体,body 会反复执行;
// 存储跟视图身份走,所以重算之后 count 还是原来的数。
@State private var count = 0
var body: some View {
Button("\(count)") {
count += 1 // 事件里只改 @State,读过 count 的 body 会重算
}
}
}
@Binding
子视图要改父视图的值时,用 @Binding。传 $count。$ 交出的是绑定,子视图写的是同一份存储。子视图里再声明一份 @State,按钮能加,父视图那个数字不动。
import SwiftUI
struct CounterLabel: View {
// 方式:@Binding
// 子视图不持有存储。这里的 count 就是父视图那份 @State。
@Binding var count: Int
var body: some View {
Button("\(count)") { count += 1 }
}
}
struct CounterScreen: View {
// 方式:@State,源在父视图
@State private var count = 0
var body: some View {
// $count 的类型是 Binding<Int>,子视图因此能写回这一份
CounterLabel(count: $count)
}
}
@Observable
字段和方法多起来,把这一屏做成类,用 @Observable。这是 iOS 17 的对象观察。创建它的视图再用 @State 拿着实例,重算之后还是这一个对象。body 读过哪些属性,就跟踪哪些。@MainActor 把改属性限制在主线程,SwiftUI 按这次写入去刷新界面。
子视图只显示时,参数用普通的 let,仍是同一份 @Observable。它在 body 里读到的属性会被跟踪。
import SwiftUI
@MainActor
@Observable // 方式:Observation。属性变化会通知读过它的视图
final class CounterModel {
private(set) var count = 0 // 外面只能读,写入走 increment()
func increment() {
count += 1
}
}
struct CounterModelView: View {
// 方式:@State 持有 @Observable
// 这里存的是类实例。视图重算不会换一个新的 CounterModel。
@State private var model = CounterModel()
var body: some View {
Button("\(model.count)") {
model.increment() // 改的是模型。body 读过 count,所以会重算
}
}
}
struct CounterReadout: View {
// 方式:把 @Observable 只读传下来
// let 不复制存储。子视图和父视图看的是同一个对象。
let model: CounterModel
var body: some View {
Text("\(model.count)")
}
}
@Bindable
子视图要交给 TextField 这种需要绑定的控件时,用 @Bindable。它不创建对象,只让外面传进来的 @Observable 能写出 $model.name。创建那一块已经有 @State 时,那里的 $model.name 可以直接用,不必再套一层。
import SwiftUI
@MainActor
@Observable
final class ProfileModel {
var name = ""
}
struct NameField: View {
// 方式:@Bindable
// 对象是父视图持有的。@Bindable 只负责把 name 投影成 Binding<String>。
@Bindable var model: ProfileModel
var body: some View {
TextField("名字", text: $model.name) // $model.name 写回 ProfileModel.name
}
}
@Environment
很多层要同一份模型时,用环境。根上用 @State 创建一次,.environment(model) 放进去。子视图用 @Environment 取出。每一层再 @State 一份,就是多份计数。根上忘了注入,子视图一读就会崩。可以写成 @Environment(CounterModel.self) private var model: CounterModel?,没有注入时是 nil。从环境取出之后还要做绑定,在 body 里再取一次:@Bindable var model = model。
旧代码是 .environmentObject(model) 和 @EnvironmentObject。两套键不一样,注入和读取用同一套。
import SwiftUI
struct CounterRootView: View {
// 方式:@State 持有,全树只创建这一次
@State private var model = CounterModel()
var body: some View {
CounterDetailView()
.environment(model) // 方式:环境注入。子视图不用逐层写参数
}
}
struct CounterDetailView: View {
// 方式:@Environment
// 从祖先注入的那一份 @Observable 里取。读到的属性仍会触发重算。
@Environment(CounterModel.self) private var model
var body: some View {
Text("\(model.count)")
}
}
@StateObject
iOS 17 之前用 ObservableObject。要带动界面的字段标 @Published。创建并持有用 @StateObject,外面传进来用 @ObservedObject。任意一个 @Published 变化,依赖这个对象的视图都会重算,包括 body 没读的字段。
import Combine
import SwiftUI
final class LegacyCounterModel: ObservableObject {
// 方式:@Published。赋值时通过 ObservableObject 通知界面
@Published private(set) var count = 0
func increment() {
count += 1
}
}
struct LegacyCounterView: View {
// 方式:@StateObject
// 由这块视图创建并持有。对象活在视图身份上,body 重算不会换新实例。
@StateObject private var model = LegacyCounterModel()
var body: some View {
Button("\(model.count)") {
model.increment()
}
}
}
@ObservedObject private var model = LegacyCounterModel() 的默认值会在视图每次初始化时执行。父视图重算、这块视图再次初始化,计数回到 0。创建并持有,用 @StateObject。对象是参数传进来的,才用 @ObservedObject。@AppStorage 读写 UserDefaults,适合通知开关、外观这种偏好。@SceneStorage 跟着这个场景走,适合选中的标签、滚动位置。键是字符串,类型是包装器支持的那几种(Bool、Int、Double、String、URL、Data,以及 RawRepresentable)。$notificationsOn 同样是绑定,可以传给子视图。列表、账号、网络结果放在页面模型里。SwiftData 的 @Query 是查询结果,写入走 ModelContext,和这架梯子分开。@FocusState 管焦点。@GestureState 只活在一次手势里,手势结束回到初值。
import SwiftUI
struct SettingsView: View {
// 方式:@AppStorage
// 源在 UserDefaults,键是 "notificationsOn"。视图重算之后开关状态还在。
@AppStorage("notificationsOn") private var notificationsOn = false
var body: some View {
Toggle("通知", isOn: $notificationsOn) // $ 得到 Binding,和 @State 传给子视图相同
}
}
按这个选:
- 一个值,一块视图:
@State。子视图要改:@Binding。 - 多个字段和动作:
@Observable。创建处@State,只读下传用let,要$字段用@Bindable。 - 跨很多层:根上
@State创建,.environment传下去。 - 偏好用
@AppStorage,场景恢复用@SceneStorage。 - 目标早于 iOS 17:
ObservableObject,创建处@StateObject,传入处@ObservedObject,子树用@EnvironmentObject。
UIKit 数据流
UIKit 的界面是一棵你拿着的控件。状态变了,没有人替你重算界面。事件进控制器,控制器改模型,再把模型写进控件。中间漏一步,屏幕上的字和数据就分开了。
点击 / 输入 / 回调
│
▼
控制器改模型
│
▼
apply() 写进控件
投影只在 apply() 里写。按钮、通知、网络回调都先改模型,再调用这一个方法。两处各自写 label.text,很快会有一条路径忘了改标题或忘了改按钮。
自己刷新
import UIKit
final class CounterViewController: UIViewController {
private var count = 0 // 这一页的源
private let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
label.frame = CGRect(x: 20, y: 100, width: 200, height: 40)
view.addSubview(label)
let button = UIButton(type: .system)
button.frame = CGRect(x: 20, y: 150, width: 80, height: 40)
button.setTitle("加一", for: .normal)
button.addTarget(self, action: #selector(increment), for: .touchUpInside)
view.addSubview(button)
apply() // 第一次把 0 画上去
}
@objc private func increment() {
count += 1
apply() // 改完模型再画,标签只在这里写
}
private func apply() {
label.text = "\(count)"
}
}
count 是源,label.text 是投影。控制器在的时候,这份源就在。
向上回调
设置页持有 notificationsOn,这一页的标题也要显示开或关,所以值放在设置页。开关那一行只负责把新的 Bool 交上来。
import UIKit
final class ToggleRow: UIView {
let toggle = UISwitch()
var onChange: ((Bool) -> Void)? // 父视图填进来,用来接收新值
override init(frame: CGRect) {
super.init(frame: frame)
toggle.addTarget(self, action: #selector(switched), for: .valueChanged)
addSubview(toggle)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func switched() {
onChange?(toggle.isOn)
}
}
import UIKit
final class SettingsViewController: UIViewController {
private var notificationsOn = false
private let row = ToggleRow()
override func viewDidLoad() {
super.viewDidLoad()
row.onChange = { [weak self] isOn in
self?.notificationsOn = isOn // 源仍在设置页
self?.apply()
}
}
private func apply() {
title = notificationsOn ? "开" : "关"
}
}
闭包被 row 留着。闭包再强引用控制器,控制器又留着 row,两边互相持有。[weak self] 把控制器那边断开。一个开关用闭包即可。同一块视图要交好几件事,收成 delegate,子视图调用父视图的方法。
父视图把数据推进子视图,走配置方法:cell.configure(item)。Cell 显示这份值,源仍在控制器或它的数组里。
列表快照
列表的状态是「有哪些行、按什么顺序」。改数组之后做一份快照交进去,由 diffable data source 处理插入和删除。
import UIKit
struct Item: Identifiable {
let id: UUID
var title: String
}
enum Section: Hashable {
case main
}
final class ItemListViewController: UIViewController {
private var dataSource: UITableViewDiffableDataSource<Section, Item.ID>!
func apply(items: [Item]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item.ID>()
snapshot.appendSections([.main])
snapshot.appendItems(items.map(\.id)) // 快照里只放稳定 id
dataSource.apply(snapshot, animatingDifferences: true)
}
}
Item 遵守 Identifiable,ID 稳定。Cell 用这个 id 去取标题。把会变的标题算进哈希,同一次编辑会被看成删掉旧行、再插入新行。
广播、KVO、单例
| 通道 | 方向 | 我用它的时候 |
|---|---|---|
| target-action | 控件 → 控制器 | 按钮、开关 |
| delegate、闭包 | 子 → 父 | 一行开关、cell 上的按钮 |
| 配置方法 | 父 → 子 | configure(_:) 把模型推进 cell |
NotificationCenter | 谁都能发,谁都能听 | 键盘、进后台 |
| KVO | 观察某个对象的属性 | 系统对象上还在用 |
| 单例 | 全局读写 | 当成外部存储,读进这一页的模型 |
NotificationCenter 适合「发生了一件事」。某一页的字段若靠通知传来传去,发送方和接收方要对着字符串才能对上。
Session.shared 这种单例谁都能写。页面从它读进自己的模型,改完再写回去。刷新仍走这一页的 apply()。
控制器因此会变大:它同时是模型的主人、网络的回调、控件的操作者,还顺手 push 下一页。跳转单独记在 路由管理。剩下的,是下一节要搬出去的部分。
进阶架构
这些模式回答的是同一件事:状态只有一条改法,界面只负责把当前值画出来。
| 模式 | 状态放哪 | 界面怎么跟上 | 我什么时候用 |
|---|---|---|---|
| MVC | 控制器 | 控制器调用 apply | 一页、几个字段 |
| MVP | Presenter | Presenter 调 View 协议上的方法 | 逻辑要单测,视图保持只会显示 |
| MVVM | ViewModel | UIKit 订阅;SwiftUI 直接读 | 这一屏的状态和动作要离开控制器 |
| VIPER | Interactor 持有实体,Presenter 准备要显示的字段 | Presenter 调 View | 模块边界要写死、人多 |
| 单向数据流 | 一份值类型 State | 渲染这份 State | 状态转移要能复现,副作用要单独测 |
MVP
View 是协议,只暴露 show(count:) 这种方法。Presenter 持有数字,用 weak 指回 View。按钮把事件交给 Presenter,Presenter 改完数,再叫 View。
protocol CounterView: AnyObject {
func show(count: Int)
}
final class CounterPresenter {
private weak var view: CounterView? // 控制器持有 Presenter,这里用 weak 避免成环
private var count = 0
init(view: CounterView) {
self.view = view
}
func increment() {
count += 1
view?.show(count: count)
}
}
控制器遵守 CounterView,在 show 里改标签。控制器持有 Presenter。Presenter 不引用 UIKit 时,测试里换一个假的 View,就能断言它收到了 1。
每个字段一条协议方法,字段一多协议就长。SwiftUI 直接读状态,MVP 主要留在 UIKit。
MVVM
ViewModel 持有这一屏的状态和动作。视图把点击送进去,把状态画出来。ViewModel 不拿 UILabel,也不 push 下一页。
SwiftUI 里它就是上一节的 @Observable 类,创建处用 @State。UIKit 要把变化送到控件,用下一节的 Combine:@Published 加 sink。字段只有一个、一次赋值就刷新一次,用闭包就够。
两个界面看同一份时,共享的是这一个对象。UIKit 在外面创建一次,两个控制器的 init 都接它。SwiftUI 在根上创建一次,用环境传下去。
单向数据流
ViewModel 的方法里可以同时加一、发请求、改 isLoading。调用一深,这一帧的值是谁改的,要对着方法体找。
单向数据流把「现在的值」收成结构体,把「发生了什么」收成枚举。一个函数吃进旧状态和动作,写出新状态。视图只发动作,只读状态。
struct CounterState: Equatable {
var count = 0
var fact: String?
}
enum CounterAction {
case incrementTapped
case factLoaded(String)
}
func reduce(_ state: inout CounterState, _ action: CounterAction) {
switch action {
case .incrementTapped:
state.count += 1 // 同步改状态,不在这里发请求
case let .factLoaded(text):
state.fact = text // 请求回来之后,用下一个动作写入
}
}
请求留在这个函数外面。点按钮若还要拉一段说明,调用方先发出 .incrementTapped,请求回来再发出 .factLoaded。reduce 保持同步。测试就是:给定这个状态和这个动作,得到那个状态。
界面侧仍是 UIKit 的 apply,或 SwiftUI 的 body。手上永远是一份 CounterState。
这和 MVVM 可以叠在一起:ViewModel 内部用 reduce 改字段,对外仍是 increment()。小页面这样就够。动作和副作用要在模块之间传递时,再做成下一节的库。
VIPER
VIPER 把一屏拆成五块。
| 角色 | 做什么 |
|---|---|
| View | 显示,把交互交出去 |
| Interactor | 用例:网络、存储 |
| Presenter | 把实体变成界面要的字段 |
| Entity | 数据 |
| Router | 离开这一屏 |
每一块一个协议,模块可以单独换。一个设置页也上五套类型,文件数会先涨上来。我在这块业务跨多屏、而且要脱离界面测试时,才拆到这个粒度。
再粗一点的分法是三层:界面、用例、数据源。用例返回这一屏要的模型,界面只 apply。单向数据流可以叠在上面:Interactor 跑副作用,Reducer 改状态,Router 管跳转。
流程状态和页面状态分开。栈里有哪些页,用路由那篇的 path 或导航控制器。这一页上的字段留在模型或 State 里。
主流状态管理
视图最后仍是读状态、发动作。梯子上的 @State 管控件自己的值。下面这些负责把变化送出去,或规定这一屏的状态怎么变。三方库再把动作和副作用收成同一套形状,方便换掉依赖来测试。
| 名字 | 它规定什么 | 常见搭配 | 我怎么看 |
|---|---|---|---|
| Combine | 值一变就送出去 | UIKit 的 sink;连续输入再加工 | 系统框架。状态仍放在模型里 |
| RxSwift | 一条可取消的事件流 | UIKit + MVVM | 老工程的绑定层。新页面的请求我用 async 方法 |
| ReactorKit | Action → Mutation → State | 必须有 RxSwift | 每屏一个 Reactor,副作用在 mutate |
| ReSwift | 一个全局 Store | UIKit | Redux 的 Swift 版。一个店装下整个 App |
| TCA | Reducer、Effect、Store,功能可以嵌套 | SwiftUI,也能接 UIKit | 现在讨论最多的一套。概念多,测试是它的主场 |
Combine
@Published 包住一个属性。$count 是发布者,每次赋值都发出新值。UIKit 用 sink 接住,在里面写控件。
import Combine
final class CounterViewModel {
@Published private(set) var count = 0 // $count 会在每次赋值时发出新值
func increment() {
count += 1
}
}
import Combine
import UIKit
final class CounterCombineViewController: UIViewController {
private let viewModel: CounterViewModel
private let label = UILabel()
private var cancellables = Set<AnyCancellable>() // 订阅留在这里,控制器在才继续收值
init(viewModel: CounterViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$count
.receive(on: DispatchQueue.main) // 改控件排到主队列
.sink { [weak self] count in
self?.label.text = "\(count)"
}
.store(in: &cancellables)
}
@objc private func increment() {
viewModel.increment()
}
}
这里有三件容易漏的事。
sink返回的AnyCancellable放进控制器的集合。用局部变量接,方法一结束,订阅就取消了。- 闭包用
[weak self]。集合留着订阅,订阅再强引用控制器,就成环了。 .receive(on: DispatchQueue.main)把改控件排到主队列。它是异步的:当时已经在主线程,标签也会等到这一轮事件之后再改。上游线程确定是主线程时,可以直接在sink里写标签。
按钮只调用 increment()。标签只在订阅里写。视图不保存第二份 count。
一次赋值对应一次刷新,上面的 sink 就够。字是连续打进来的,再让流停一停、去掉重复。
import Combine
final class SearchViewModel {
@Published var keyword = ""
private var cancellables = Set<AnyCancellable>()
init() {
$keyword
.debounce(for: .milliseconds(300), scheduler: RunLoop.main) // 停手 300 毫秒再往下
.removeDuplicates() // 同一个词不重复搜索
.sink { [weak self] text in
self?.search(text)
}
.store(in: &cancellables)
}
private func search(_ text: String) {}
}
目标系统早于 iOS 17 时,让这个类遵守 ObservableObject,SwiftUI 用 @StateObject 直接读 count。iOS 17 的新页面用 @Observable。它给 SwiftUI 跟踪属性,没有 $count 这种发布者。UIKit 要订阅字段,继续用 @Published。
RxSwift
Observable 表示以后还会来的值。BehaviorRelay 记住当前值,后来的订阅者马上拿到它。订阅放进 DisposeBag,控制器释放时一起取消。
import RxRelay
import RxSwift
final class RxCounterViewModel {
let count = BehaviorRelay(value: 0) // 记住当前值,新订阅者马上能拿到
func increment() {
count.accept(count.value + 1) // 用 accept 发出下一个值
}
}
控制器里把流送到标签。这和 Combine 的 sink 是同一类事。
import RxSwift
viewModel.count
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] count in
self?.label.text = "\(count)"
})
.disposed(by: disposeBag) // 控制器释放时取消订阅
状态仍在 ViewModel 里。Rx 负责运送值,不负责这一页有哪些字段。绑到界面的那一段可以用 Driver:主线程、不把错误传到界面、共享一次订阅。网络那一段留在 Observable,失败在那里处理。
ReactorKit
ReactorKit 把一屏收成 Reactor。视图把 Action 送进 action。mutate 做副作用,吐出 Mutation。reduce 用旧 State 和 Mutation 算出新 State。视图只订阅 state。
import ReactorKit
import RxSwift
final class CounterReactor: Reactor {
enum Action {
case increment
}
enum Mutation {
case bump
}
struct State {
var count = 0
}
let initialState = State()
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case .increment:
return .just(.bump) // 没有网络,动作直接变成一次变更
}
}
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case .bump:
state.count += 1 // 加一写在纯函数里,连点两次各加一次
}
return state
}
}
加一没有副作用,mutate 只把动作变成变更,加一写在 reduce 里。这样连续点两次,每次都在当时的状态上加。拉网络时,mutate 返回一个稍后才发出结果的 Observable,reduce 里只赋值。
import RxCocoa
import RxSwift
button.rx.tap
.map { CounterReactor.Action.increment } // 点击变成动作
.bind(to: reactor.action)
.disposed(by: disposeBag)
reactor.state
.map(\.count)
.distinctUntilChanged()
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] count in
self?.label.text = "\(count)"
})
.disposed(by: disposeBag)
label.rx.text 也能接这条流。它的元素类型是 String?,和 Binder 对上再 bind。
SwiftUI 用同一份 Reactor,模块是 ReactorKitSwiftUI。State 标 @ObservableState(宏在 ReactorKitObservation),视图拿 ObservedReactor,直接读字段、send 动作。要双向绑输入框时,让 Action 遵守 BindableAction,视图用 @ReactorBindable。
工程里已经有 RxSwift,每屏又要把副作用和纯计算分开,再用 ReactorKit。
ReSwift
一个 Store 拿着整个 App 的状态。各处 dispatch 动作。Reducer 是纯函数。副作用放 Middleware,做完再 dispatch 下一个动作。
import ReSwift
struct AppState: StateType {
var count = 0
}
struct Increment: Action {}
func counterReducer(action: Action, state: AppState?) -> AppState {
var state = state ?? AppState() // 第一次进来还没有状态
switch action {
case is Increment:
state.count += 1
default:
break // 别的页面的动作从这里走掉
}
return state
}
let store = Store<AppState>(reducer: counterReducer, state: nil)
store.dispatch(Increment())
页面遵守 StoreSubscriber,在 newState(state:) 里 apply。viewWillAppear 里 subscribe,离开时 unsubscribe。
状态只有一棵树,动作可以打日志、可以重放。一个计数器的动作和设置页的动作住在一起,模块靠自己划状态的分支。新代码里这条路我写得少,一屏一棵状态树的位置多半是 TCA,或者就是上面那个自己写的 reduce。
TCA
TCA 把一屏叫做 Feature。State 是值,Action 是枚举。Reducer 根据动作改状态,并返回 Effect。Store 是运行时:视图 send 动作,读状态来画。
import ComposableArchitecture
enum NumberAPI {
static func fact(for count: Int) async -> String {
"关于 \(count) 的说明"
}
}
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
var fact: String?
}
enum Action {
case incrementTapped
case factTapped
case factResponse(String)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .incrementTapped:
state.count += 1
return .none // 没有副作用
case .factTapped:
return .run { [count = state.count] send in
// 请求在 Effect 里。先抄出 count,闭包不抓整个 state
let fact = await NumberAPI.fact(for: count)
await send(.factResponse(fact)) // 结果再变回一个动作
}
case let .factResponse(fact):
state.fact = fact
return .none
}
}
}
}
NumberAPI.fact 只代替那次请求。真正的接口会失败,那时再 send 一个失败动作,Reducer 里写上错误字段。依赖用 TCA 的 @Dependency 注册之后,测试里可以换成立刻返回的实现。状态仍然只在 Reducer 里改。
import ComposableArchitecture
import SwiftUI
struct CounterFeatureView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Button("\(store.count)") {
store.send(.incrementTapped) // 视图只发动作,不自己改 count
}
Button("说明") {
store.send(.factTapped)
}
if let fact = store.fact {
Text(fact)
}
}
}
}
创建处把初值和 Reducer 交给 Store。
CounterFeatureView(
store: Store(initialState: CounterFeature.State()) {
CounterFeature() // 初值和 Reducer 交给 Store,视图只拿着它
}
)
@ObservableState 让视图只跟踪自己读过的字段,和 SwiftUI 的 @Observable 是同一类跟踪。TCA 自带的观察可以在 iOS 17 之前用。系统的 @Observable 仍然要 iOS 17。
子功能的 State 嵌在父功能的 State 里,父 Reducer 带上子 Reducer。导航栈也放进 State,和 路由管理 里那份 path 一样:栈是数据,离开页面是一次状态变化。
我用它之前先看三件事能不能付得起:每个点击都有对应的 Action,副作用能在测试里换成假的,这一屏值得单独一个 Feature。一个开关用 @State 就够。
我先按这个选
- 值只活在一块 SwiftUI 视图里:
@State。子视图要改:@Binding。 - 这一屏有动作和多字段:
@Observable,创建处用@State。很多页共用:.environment。 - 目标早于 iOS 17:
ObservableObject。创建处@StateObject,传入处@ObservedObject,子树用@EnvironmentObject。 - UIKit:模型放控制器,只在
apply里写控件。子视图用闭包把新值交上来。列表交一份 id 快照。 - 这一屏的逻辑要离开控制器:ViewModel。UIKit 用
@Published和sink。SwiftUI 直接读。 - 状态转移要能单测:收成
reduce。请求做完,再投递下一个动作。 - 工程已经是 Rx:绑定继续用 RxSwift。每屏还要把副作用和纯计算分开,再加 ReactorKit。
- 新的 SwiftUI 模块、副作用多、要测整条路径:用 TCA。
- 偏好用
@AppStorage。键盘和进后台用NotificationCenter。这两处都写进页面模型之后,再刷新界面。
参考资料
- State:视图自己持有的存储
- Combine:
@Published和sink - Migrating from the Observable Object protocol to the Observable macro:
ObservableObject怎么迁到@Observable - RxSwift:事件流和 UI 绑定
- ReactorKit:Action、Mutation、State
- ReSwift:全局 Store 和 Reducer
- The Composable Architecture:Feature、Effect、Store