iOS 架构模式:MVVM 与 TCA

面向 SwiftUI 时代的状态架构选型,对比 MVVM 与 The Composable Architecture 两套方案,讲清 SwiftUI 下的绑定与副作用处理、单向数据流的必要性、TCA 的 State/Action/Reducer/Store 构件与作用域组合、依赖注入与环境、副作用的可测试性,并给出分层与模块边界的落地建议。

开篇

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)是对上述问题的直接回应。它只有三条规则:

  1. 状态(State)是唯一真相,只读。
  2. 所有变更由**事件(Action)**描述,不允许直接改状态。
  3. 状态变更由一个纯函数完成: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,也能获得大部分可测试性收益。

权衡取舍

维度MVVMTCA
概念数量少(ViewModel + 绑定)多(State/Action/Reducer/Store/Effect)
模板代码少多
状态一致性靠约定由纯函数保证
可测试性需手写 MockTestStore 内建
组合能力弱强(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,模板代码占了代码库一半。架构的复杂度要匹配业务的复杂度。

相关阅读

小结

MVVM 与 TCA 的分歧不在「模式」,而在约束强度。MVVM 把纪律交给开发者:状态只读、变更走方法、副作用有固定位置,全靠团队自觉;TCA 把纪律交给类型系统:状态只能由纯函数改、副作用必须返回 Effect、依赖必须显式声明。约束越强,可测试性与一致性越好,模板代码与学习成本越高。

选型的实用判据是:页面状态是否复杂到「忘记同步某一处就会出 bug」。如果会,上 TCA;如果不会,轻量 MVVM 是更经济的选择。无论选哪条路,分层与模块边界都是不能省的——它们决定的是长期维护成本,与框架无关。下一步建议把选定的架构与具体的持久化、网络层结合,验证依赖注入在真实工程里是否顺手。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全