ArturoYi
iOS
SwiftUI 按所有权从 @State 排到 environment。UIKit 改完模型,再在一处写进控件。页面变大之后用 MVVM 或单向数据流。Combine、RxSwift、ReactorKit、TCA 负责把变化送出去。

我把状态管理拆成四段记。先问三件事:状态放在哪,谁可以改,改完界面怎么跟上。

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 留给「值变了要做一件额外的事」,比如打点。

CounterView.swift
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,按钮能加,父视图那个数字不动。

CounterLabel.swift
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 里读到的属性会被跟踪。

CounterModel.swift
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 可以直接用,不必再套一层。

NameField.swift
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。两套键不一样,注入和读取用同一套。

CounterRootView.swift
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 没读的字段。

LegacyCounterModel.swift
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 只活在一次手势里,手势结束回到初值。

SettingsView.swift
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,很快会有一条路径忘了改标题或忘了改按钮。

自己刷新

CounterViewController.swift
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 交上来。

ToggleRow.swift
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)
    }
}
SettingsViewController.swift
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 处理插入和删除。

ItemListViewController.swift
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一页、几个字段
MVPPresenterPresenter 调 View 协议上的方法逻辑要单测,视图保持只会显示
MVVMViewModelUIKit 订阅;SwiftUI 直接读这一屏的状态和动作要离开控制器
VIPERInteractor 持有实体,Presenter 准备要显示的字段Presenter 调 View模块边界要写死、人多
单向数据流一份值类型 State渲染这份 State状态转移要能复现,副作用要单独测

MVP

View 是协议,只暴露 show(count:) 这种方法。Presenter 持有数字,用 weak 指回 View。按钮把事件交给 Presenter,Presenter 改完数,再叫 View。

CounterPresenter.swift
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。调用一深,这一帧的值是谁改的,要对着方法体找。

单向数据流把「现在的值」收成结构体,把「发生了什么」收成枚举。一个函数吃进旧状态和动作,写出新状态。视图只发动作,只读状态。

CounterReduce.swift
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 方法
ReactorKitAction → Mutation → State必须有 RxSwift每屏一个 Reactor,副作用在 mutate
ReSwift一个全局 StoreUIKitRedux 的 Swift 版。一个店装下整个 App
TCAReducer、Effect、Store,功能可以嵌套SwiftUI,也能接 UIKit现在讨论最多的一套。概念多,测试是它的主场

Combine

@Published 包住一个属性。$count 是发布者,每次赋值都发出新值。UIKit 用 sink 接住,在里面写控件。

CounterViewModel.swift
import Combine

final class CounterViewModel {
    @Published private(set) var count = 0 // $count 会在每次赋值时发出新值

    func increment() {
        count += 1
    }
}
CounterCombineViewController.swift
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 就够。字是连续打进来的,再让流停一停、去掉重复。

SearchKeyword.swift
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,控制器释放时一起取消。

RxCounterViewModel.swift
import RxRelay
import RxSwift

final class RxCounterViewModel {
    let count = BehaviorRelay(value: 0) // 记住当前值,新订阅者马上能拿到

    func increment() {
        count.accept(count.value + 1) // 用 accept 发出下一个值
    }
}

控制器里把流送到标签。这和 Combine 的 sink 是同一类事。

RxCounterBind.swift
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。

CounterReactor.swift
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 里只赋值。

CounterReactorBind.swift
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 下一个动作。

ReSwiftCounter.swift
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 动作,读状态来画。

CounterFeature.swift
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 里改。

CounterFeatureView.swift
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。

CounterFeatureHost.swift
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。这两处都写进页面模型之后,再刷新界面。

参考资料

Copyright © 2026