开篇
iOS 架构讨论了十年,从 MVC 到 MVVM、VIPER、RIBs,再到 SwiftUI 时代的 TCA。问题从来不是「哪个模式最先进」,而是「状态放在哪里、副作用怎么隔离、怎么测」。UIKit 时代 MVVM 之所以流行,是因为 UIViewController 太胖了,需要一个地方安放业务逻辑;SwiftUI 时代这个问题变了——视图本身是值类型、状态驱动,MVVM 的很多约定反而成了负担。
于是出现了两条路线。一条是轻量派:继续用 MVVM,把 @Observable 对象当作 ViewModel,配合 async/await 处理副作用,代码量少、上手快。另一条是严格派:引入 TCA(The Composable Architecture),用 State/Action/Reducer/Store 把状态变更收敛成纯函数,副作用显式建模,换来极强的可测试性与可组合性。
本文按「MVC 遗产 → SwiftUI 下的 MVVM → 副作用难题 → 单向数据流 → TCA 构件 → 组合与作用域 → 依赖注入 → 可测试性 → 分层 → 选型」的顺序展开,基于 Swift 5.9/6、iOS 17/18 与 TCA 1.x。结论先给:页面级状态用 MVVM 足够,跨模块、长生命周期、需要严格测试的业务用 TCA 更划算。
一、MVC 的遗产与 ViewModel 的由来
UIKit 的 MVC 在实践中退化成 Massive View Controller:UIViewController 同时承担视图生命周期、布局、数据转换、网络请求、导航。它的问题不是「模式错了」,而是职责没有边界——控制器既是视图的宿主,又是数据的持有者。
MVVM 的原始定义来自 WPF:ViewModel 是视图的「模型」,通过双向绑定与视图同步。iOS 引入 MVVM 时其实做了简化:UIViewController 保留视图职责,ViewModel 持有状态和业务逻辑,两者通过绑定(早期是 KVO,后来是 Combine 的 @Published)通信。
// UIKit 时代的经典 MVVM
final class FeedViewModel {
@Published private(set) var posts: [Post] = []
@Published private(set) var isLoading = false
private let api: APIClient
init(api: APIClient) { self.api = api }
func load() {
isLoading = true
api.fetchFeed { [weak self] result in
DispatchQueue.main.async {
self?.isLoading = false
if case .success(let posts) = result { self?.posts = posts }
}
}
}
}
这段代码里的三件事——回调、线程切换、[weak self]——在 SwiftUI + async/await 下全都不需要了。这也是为什么 MVVM 在 SwiftUI 时代需要重新定义:ViewModel 不再是「绑定适配器」,而是一个被 @State 持有的可观察状态容器。
二、SwiftUI 下的 MVVM
iOS 17 之后,MVVM 在 SwiftUI 里的标准写法收敛成了这样:
import Observation
@Observable
@MainActor
final class FeedViewModel {
private(set) var posts: [Post] = []
private(set) var isLoading = false
private(set) var errorMessage: String?
private let api: APIClient
init(api: APIClient = .live) { self.api = api }
func load() async {
isLoading = true
defer { isLoading = false }
do { posts = try await api.fetchFeed() }
catch { errorMessage = error.localizedDescription }
}
}
struct FeedView: View {
@State private var model = FeedViewModel()
var body: some View {
List(model.posts) { PostRow(post: $0) }
.overlay { if model.isLoading { ProgressView() } }
.task { await model.load() }
}
}
三个关键约定:
- ViewModel 是引用类型 +
@Observable,由视图用@State持有。@Observable的属性级追踪让刷新范围最小化,这是 SwiftUI 声明式界面与状态管理 里讲过的机制。 - ViewModel 标注
@MainActor,保证状态赋值在主线程,同时await网络请求时不阻塞。 private(set):视图只能读,不能直接改,所有变更走方法。这是 MVVM 唯一真正重要的纪律。
视图的职责被压缩成两件事:声明布局、把用户事件转发给 ViewModel。凡是出现「视图里写业务判断」的地方,都是漏网的逻辑。
三、MVVM 的副作用难题
MVVM 在 SwiftUI 下最难受的部分是副作用。副作用包括网络请求、持久化、导航、弹窗、埋点。它们在 MVVM 里没有统一的位置,于是每个团队都发明了自己的写法:
// 写法 A:ViewModel 里直接做导航
func didSelect(_ post: Post) { route = .detail(post) } // 导航状态混进业务状态
// 写法 B:视图层监听状态变化触发副作用
.onChange(of: model.didSave) { _, saved in
if saved { dismiss() } // 散落在视图里的副作用,难以测试
}
// 写法 C:回调闭包
var onFinish: (() -> Void)?
这三种写法都能跑,但都不可测试:导航、弹窗、埋点全部耦合在视图或 ViewModel 的具体实现里,写单测必须起一个真实的视图或 Mock 掉一堆东西。
另一个痛点是状态的一致性。一个页面的状态往往由多个来源组合而成:本地缓存、网络响应、用户输入、上一次的筛选条件。在 MVVM 里,这些状态散落在 ViewModel 的多个属性上,任何一处忘记同步就会出现「加载完了但列表没变」「错误提示还在但已经重试成功」这类不一致。根因是状态变更没有单一入口。
四、单向数据流
单向数据流(Unidirectional Data Flow, UDF)是对上述问题的直接回应。它只有三条规则:
- 状态(State)是唯一真相,只读。
- 所有变更由**事件(Action)**描述,不允许直接改状态。
- 状态变更由一个纯函数完成:
newState = reduce(state, action)。
这三条规则把「谁改了状态」这个问题变成可枚举的:只有 reduce 会改状态,而它是纯函数,同样的 (state, action) 必然产出同样的 newState。于是:
- 可测试:给定初始状态和动作序列,断言最终状态,不需要视图、不需要 Mock。
- 可重放:把动作序列存下来就能复现任何 bug 现场。
- 可组合:小 reducer 能拼成大 reducer。
SwiftUI 本身已经天然接近 UDF(View = f(State)),缺的只是「变更收敛到纯函数」这一步。这正是 TCA 要补的。前端生态里的 前端状态管理方案
走的是同一条路,Redux 的三原则与这里的表述几乎逐字对应。
五、TCA 的核心构件
TCA 用四个类型把 UDF 落地。
State:描述页面全部状态的 struct,必须 Equatable。
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
var isLoading = false
}
}
Action:所有可能发生的事件的 enum,包括用户操作、生命周期、异步结果。
enum Action {
case incrementTapped
case decrementTapped
case factResponse(String)
}
Reducer:(inout State, Action) -> Effect<Action> 的纯函数,body 里用 Reduce 组合。
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementTapped:
state.count += 1
return .none
case .decrementTapped:
state.count -= 1
return .none
case .factResponse(let fact):
state.isLoading = false
state.fact = fact
return .none
}
}
}
}
Store:运行时的容器,持有当前状态、接收动作、执行副作用。视图通过 StoreOf<Feature> 驱动。
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
Button("加一") { store.send(.incrementTapped) }
}
}
}
注意 store.count 直接读属性——@ObservableState 让 Store 支持属性级追踪,只有用到的字段变化才会刷新视图,与 @Observable 的行为一致。
TCA 与 MVVM 的根本差异是:MVVM 的 ViewModel 是一个对象,方法可以直接改属性;TCA 的 Reducer 是纯函数,改状态只能通过返回的动作。后者约束更强,代价是模板代码更多。
六、Reducer 的组合与作用域
TCA 真正拉开差距的地方是组合。一个复杂页面可以拆成若干子 feature,各自有独立的 State/Action/Reducer,然后组合成父 feature:
@Reducer
struct AppFeature {
@ObservableState
struct State: Equatable {
var feed = FeedFeature.State()
var profile = ProfileFeature.State()
var selectedTab: Tab = .feed
}
enum Action {
case feed(FeedFeature.Action)
case profile(ProfileFeature.Action)
case tabSelected(Tab)
}
var body: some ReducerOf<Self> {
Scope(state: \.feed, action: \.feed) { FeedFeature() }
Scope(state: \.profile, action: \.profile) { ProfileFeature() }
Reduce { state, action in
switch action {
case .tabSelected(let tab):
state.selectedTab = tab
return .none
case .feed, .profile:
return .none
}
}
}
}
Scope 把子 feature 的状态和动作「投影」到父级。这种组合在 MVVM 里没有对应物——ViewModel 之间要么互相引用(强耦合),要么靠回调传递(不可组合)。
另一个实用特性是 ifLet / forEach,用于把可选状态和集合状态映射成子 feature:
Reduce { state, action in ... }
.ifLet(\.$detail, action: \.detail) { DetailFeature() }
.forEach(\.rows, action: \.rows) { RowFeature() }
这让「列表里每一行都是一个小 feature」这种结构变得自然,而每一行都可以单独写测试。
七、依赖注入与环境
TCA 把依赖统一收敛到 DependencyKey:
struct APIClient {
var fetchFeed: @Sendable () async throws -> [Post]
}
extension APIClient: DependencyKey {
static let liveValue = APIClient {
let (data, _) = try await URLSession.shared.data(from: feedURL)
return try JSONDecoder().decode([Post].self, from: data)
}
static let testValue = APIClient { [] } // 测试默认值
static let previewValue = APIClient { [.mock] }
}
extension DependencyValues {
var api: APIClient {
get { self[APIClient.self] }
set { self[APIClient.self] = newValue }
}
}
Reducer 里用 @Dependency(\.api) var api 取依赖,测试时用 withDependencies 覆盖:
await withDependencies {
$0.api.fetchFeed = { [.init(id: 1, title: "测试")] }
} operation: {
await store.send(.loadTapped)
await store.receive(.response(.success([...]))) { $0.posts.count == 1 }
}
这套机制的价值在于依赖是显式的:一个 feature 用了哪些外部能力,看 @Dependency 列表就知道,不需要读实现。相比之下,MVVM 里的依赖注入往往靠构造函数传参,层级一深就要写一堆转发。
MVVM 侧也有对应的轻量方案:用 @Environment 注入服务,或用 @Observable 包一个 AppContainer。区别是 TCA 的依赖是按 key 查找 + 可覆盖,MVVM 的方案更依赖约定。
八、Effect 与可测试性
TCA 里所有副作用都返回 Effect<Action>,这是它可测试性的来源。常见的几种:
// 异步请求,结果回传成 action
case .loadTapped:
state.isLoading = true
return .run { send in
do { await send(.response(.success(try await api.fetchFeed()))) }
catch { await send(.response(.failure(error))) }
}
// 防抖 + 取消:新的请求自动取消旧的
case .searchChanged(let text):
state.query = text
return .run { send in
try await Task.sleep(for: .milliseconds(300))
await send(.searchResponse(try await api.search(text)))
}
.cancellable(id: SearchID.self, cancelInFlight: true)
// 合并多个 effect
case .refreshTapped:
return .merge(
.send(.loadTapped),
.send(.analytics(.refresh))
)
.cancellable(id:cancelInFlight:) 是搜索防抖的标准写法:同一个 id 的新 effect 会取消在飞的那个,等价于 Combine 的 switchToLatest。而 .debounce、.throttle 在 TCA 里也都有内置实现,不需要额外引入 Combine。
测试时的核心 API 是 TestStore,它强制你逐步断言状态变化:
@Test
func increment() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementTapped) { $0.count = 1 }
await store.send(.decrementTapped) { $0.count = 0 }
}
TestStore 会在断言不匹配、遗漏动作、状态不同步时直接失败,是 iOS 测试体系与持续集成
里那套 XCTest 流程的强力补充。代价是:每个动作都必须显式断言,写起来比「调用方法 + 断言属性」啰嗦,但换来了对状态变更路径的完整覆盖。
九、分层与模块边界
无论选 MVVM 还是 TCA,分层都是必须的。推荐的切法:
| 层 | 职责 | 依赖方向 |
|---|---|---|
| UI 层 | 视图、feature、ViewModel | 依赖 Domain |
| Domain 层 | 实体、用例、业务规则 | 依赖 Data 抽象 |
| Data 层 | 网络、持久化、缓存 | 依赖基础设施 |
| 基础设施 | 日志、埋点、配置 | 无 |
依赖必须单向:UI → Domain → Data。反过来(Data 依赖 UI)会导致模块无法单独测试。
模块边界的划分原则是按变更频率切,而不是按类型切。设计 token、数据模型、网络层这些变更频率低、被广泛依赖的,抽成底层模块;feature 模块之间不互相依赖,只依赖底层。
TCA 的 feature 天然适合按模块切分:一个 feature 就是一个 module,只暴露 State/Action/Store,内部实现(依赖、辅助类型)用 internal 藏起来。MVVM 则需要人为约束:ViewModel 的公开面尽量小,不要暴露内部状态。
跨 UI 框架的边界问题,在 UIKit 与 SwiftUI 混合开发
里已经讨论过;架构层要额外注意的是:不要让路由逻辑散落在两个框架里,用一层路由协议或 TCA 的 @Presents 统一管理。
十、选型与迁移
选型不看「先进程度」,看四个维度:
- 团队规模:3 人以下的小团队,TCA 的模板代码成本可能盖过收益;10 人以上、需要严格协作时,TCA 的约束力价值凸显。
- 页面复杂度:单一表单页、详情页用 MVVM 更快;有复杂状态机(多步骤流程、实时协作、撤销重做)的页面,TCA 明显占优。
- 测试要求:如果业务要求核心流程有完整单测覆盖,TCA 的
TestStore能省下大量 Mock 代码。 - 人员流动:TCA 的概念(Reducer、Effect、Scope)需要学习成本,新人上手慢;MVVM 几乎是 SwiftUI 开发者默认认知。
迁移路径建议自下而上:先把最复杂的那个页面(通常是列表 + 筛选 + 分页 + 缓存)用 TCA 重写,跑通测试,验证团队接受度;再逐步推广。不要一次全量重写,也不要为了「统一」把跑得稳的 MVVM 页面改掉。
一个务实的折中:MVVM 的 ViewModel 内部可以借用 UDF 的思想——状态只读、变更走方法、副作用显式声明。这样即使不引入 TCA,也能获得大部分可测试性收益。
权衡取舍
| 维度 | MVVM | TCA |
|---|---|---|
| 概念数量 | 少(ViewModel + 绑定) | 多(State/Action/Reducer/Store/Effect) |
| 模板代码 | 少 | 多 |
| 状态一致性 | 靠约定 | 由纯函数保证 |
| 可测试性 | 需手写 Mock | TestStore 内建 |
| 组合能力 | 弱 | 强(Scope / ifLet / forEach) |
| 副作用管理 | 无统一位置 | Effect 统一建模 |
| 学习曲线 | 平缓 | 陡 |
| 适合场景 | 中小型页面 | 复杂业务、多人协作 |
具体建议:
- 新项目的简单页面:
@Observable+@State的轻量 MVVM,不引入任何架构框架。 - 复杂状态机页面:TCA。
- 存量 UIKit 工程:MVVM + 路由协议,不要引入 TCA(跨框架边界成本高)。
- 框架/基础库:不依赖任何上层架构,只暴露协议。
常见坑清单
- ViewModel 暴露可写属性:视图直接改
model.posts,状态变更绕过方法,无法追踪。一律private(set)。 - 视图里写业务判断:
if model.items.count > 3 && model.isVip出现在body里,逻辑不可测。挪进 ViewModel。 - 副作用散落在
onChange/onAppear:导航、弹窗、埋点写在视图层,测试必须起真实视图。统一收进 ViewModel 或 Effect。 - TCA 里 state 直接赋值:在
Reduce之外改store.state,破坏单向数据流且 Swift 6 下会编译报错。 - Action 粒度过细或过粗:
case setLoading(Bool)、case setPosts([Post])这种把状态当动作,Reducer 变成 setter 集合。动作应描述「发生了什么」。 - 忘了
cancelInFlight:搜索/刷新类 effect 没有 id,快速连点会并发多个请求,后到的旧响应覆盖新状态。 - 依赖用单例而不是
@Dependency:APIClient.shared硬编码在 Reducer 里,测试无法替换,TestStore断言会挂。 - 模块边界按类型切:把「所有 ViewModel」放一个模块、「所有 Model」放一个模块,导致改一处牵动全局。应按 feature 或变更频率切。
- MVVM 里 ViewModel 互相引用:两个 ViewModel 直接持有对方,形成环且无法单独测试。用父级协调或路由协议解耦。
- 过早引入架构:三个页面的小 App 套 TCA,模板代码占了代码库一半。架构的复杂度要匹配业务的复杂度。
相关阅读
- SwiftUI 声明式界面与状态管理
—
@Observable与属性级追踪是两套架构的共同底座 - UIKit 与 SwiftUI 混合开发 — 跨框架的路由与状态共享边界
- iOS 测试体系与持续集成 — 把架构带来的可测试性接进 CI 流水线
- 前端状态管理方案 — Redux 式单向数据流在 Web 侧的对应实践
小结
MVVM 与 TCA 的分歧不在「模式」,而在约束强度。MVVM 把纪律交给开发者:状态只读、变更走方法、副作用有固定位置,全靠团队自觉;TCA 把纪律交给类型系统:状态只能由纯函数改、副作用必须返回 Effect、依赖必须显式声明。约束越强,可测试性与一致性越好,模板代码与学习成本越高。
选型的实用判据是:页面状态是否复杂到「忘记同步某一处就会出 bug」。如果会,上 TCA;如果不会,轻量 MVVM 是更经济的选择。无论选哪条路,分层与模块边界都是不能省的——它们决定的是长期维护成本,与框架无关。下一步建议把选定的架构与具体的持久化、网络层结合,验证依赖注入在真实工程里是否顺手。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。