Combine 架构模式:构建响应式应用的蓝图

Combine 不仅是一套处理异步事件的工具箱,更是驱动整个应用数据流动的发动机。将 Combine 与合适的架构模式结合,可以显著提升代码的可测试性、可维护性和扩展性。本章将深入探讨 Combine 如何与 MVVM、Repository、状态机、MVI、Redux 以及依赖注入等主流架构模式无缝协作,帮助你构建出一套健壮、清晰且易于演进的 SwiftUI 应用骨架。

1. 架构模式概述

在 Combine 的世界里,数据流是单向的、可预测的。无论你采用何种上层架构,其底层本质都是相同的:State → View → Action → Reducer → New State。Combine 负责将状态变化发布出去,SwiftUI 视图自动响应这些变化。

本章将覆盖以下核心架构模式及其与 Combine 的集成:

  • MVVM (Model-View-ViewModel):最基础的 SwiftUI 架构,将视图状态与业务逻辑分离。
  • Repository 模式:抽象数据源,实现缓存策略与远程数据的无缝切换。
  • 状态机 (State Machine):严格定义有限状态及转换条件,避免非法状态。
  • MVI (Model-View-Intent):强调意图与单向数据流,适合复杂交互。
  • Redux:全局单一 Store + 纯函数 Reducer,极高可预测性。
  • 依赖注入 (DI):解耦组件,提升测试性。

2. MVVM + Combine:基石架构

MVVM 是 SwiftUI 应用的默认架构选择。Combine 在其中扮演着"粘合剂"的角色,让 ViewModel 的状态变化自动驱动 View 更新。

sql 复制代码
 用户操作 → View 调用 ViewModel → ViewModel 暴露 @Published 属性
                              → ViewModel 调用 Service / Repository
                              → Service 返回 Publisher 给 ViewModel
                              → ViewModel 更新 @Published,View 自动刷新

2.1 输入输出分离的 ViewModel

swift 复制代码
class LoginViewModel: ObservableObject {
    // 输入
    @Published var email = ""
    @Published var password = ""
    
    // 输出
    @Published var isLoginEnabled = false
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var isLoggedIn = false
    
    private let authService: AuthServiceProtocol
    private var cancellables = Set<AnyCancellable>()
    
    init(authService: AuthServiceProtocol = AuthService.shared) {
        self.authService = authService
        setupBindings()
    }
    
    private func setupBindings() {
        Publishers.CombineLatest($email, $password)
            .map { email, password in
                !email.isEmpty && email.contains("@") && password.count >= 6
            }
            .assign(to: \.isLoginEnabled, on: self)
            .store(in: &cancellables)
    }
    
    func login() {
        isLoading = true
        errorMessage = nil
        
        authService.login(email: email, password: password)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.errorMessage = error.localizedDescription
                }
            }, receiveValue: { [weak self] user in
                self?.isLoggedIn = true
            })
            .store(in: &cancellables)
    }
}

ViewModel 只负责将业务逻辑转化为视图可消费的状态,View 则完全被动地渲染这些状态:

swift 复制代码
struct LoginView: View {
    @StateObject private var viewModel = LoginViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            TextField("邮箱", text: $viewModel.email)
            SecureField("密码", text: $viewModel.password)
            
            if let error = viewModel.errorMessage {
                Text(error).foregroundColor(.red)
            }
            
            Button(action: viewModel.login) {
                if viewModel.isLoading { ProgressView() }
                else { Text("登录") }
            }
            .disabled(!viewModel.isLoginEnabled)
        }
        .padding()
    }
}

3. Repository 模式:抽象数据源

Repository 是数据层的抽象,让 ViewModel 不用关心数据来自远程 API、本地缓存还是 Mock。Combine 的 Publisher 使缓存策略可以优雅实现。

swift 复制代码
protocol UserRepositoryProtocol {
    func fetchUser(id: String) -> AnyPublisher<User, Error>
    func updateUser(_ user: User) -> AnyPublisher<Void, Error>
}

class UserRepository: UserRepositoryProtocol {
    private let remote: RemoteAPIProtocol
    private let cache: LocalCacheProtocol
    
    func fetchUser(id: String) -> AnyPublisher<User, Error> {
        // 先从缓存取,同时请求网络更新
        let cached = cache.fetchUser(id: id).catch { _ in Empty() }
        let remote = remote.fetchUser(id: id)
            .handleEvents(receiveOutput: { [weak self] user in
                try? self?.cache.saveUser(user)
            })
        return cached.merge(with: remote).eraseToAnyPublisher()
    }
}

ViewModel 依赖 Repository 协议,可在测试时注入 Mock。

4. 状态机模式:消除非法状态

当页面状态复杂时(如 idleloadingloaded / error),使用枚举定义状态机可以保证状态的唯一性与合法性,避免组合爆炸。

swift 复制代码
enum LoadableState<T: Equatable>: Equatable {
    case idle
    case loading
    case loaded(T)
    case error(Error)

    static func == (lhs: LoadableState, rhs: LoadableState) -> Bool {
        switch (lhs, rhs) {
        case (.idle, .idle), (.loading, .loading):
            return true
        case (.loaded(let l), .loaded(let r)):
            return l == r
        case (.error, .error):
            return true
        default: return false
        }
    }
}

class ItemsViewModel: ObservableObject {
    @Published private(set) var state = LoadableState<[Item]>.idle
    private let repo: ItemRepositoryProtocol
    private var bag = Set<AnyCancellable>()
    
    func load() {
        state = .loading
        repo.fetchItems()
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.state = .error(error)
                }
            }, receiveValue: { [weak self] items in
                self?.state = .loaded(items)
            })
            .store(in: &bag)
    }
}

视图中使用 switch state 渲染不同界面,所有状态均被覆盖。

5. MVI:意图与副作用管理

MVI (Model-View-Intent) 进一步约束了状态变更的唯一入口:通过 Intent(意图)表达用户操作,通过 Effect 管理副作用。Combine 的 PassthroughSubject 非常适合作为 Intent 通道。

swift 复制代码
enum ViewState { case idle, loading, loaded(User), error(Error) }
enum Intent { case loadUser(id: Int), reload }

class MVIViewModel: ObservableObject {
    @Published private(set) var state = ViewState.idle
    private let intents = PassthroughSubject<Intent, Never>()
    private var bag = Set<AnyCancellable>()
    
    init() {
        intents.sink { [weak self] intent in
            switch intent {
            case .loadUser(let id): self?.loadUser(id: id)
            case .reload: /* 根据当前状态重载 */
            }
        }.store(in: &bag)
    }
    
    func send(_ intent: Intent) { intents.send(intent) }
    
    private func loadUser(id: Int) {
        state = .loading
        // 网络请求...
    }
}

View 只需要调用 viewModel.send(.loadUser(id: 1)),所有状态变更都流经单向管道。

6. Redux:全局单一 Store

Redux 将整个应用的状态集中到单一的 Store 中,通过 Reducer 纯函数生成新状态。Combine 在这里充当中间件的角色,处理异步 Action 流。

swift 复制代码
struct AppState { var user: User?; var items: [Item] = [] }
enum Action { case loadUser(id: Int); case userLoaded(User); case userFailed(Error) }

typealias Reducer = (inout AppState, Action) -> AnyPublisher<Action, Never>?

func appReducer(state: inout AppState, action: Action) -> AnyPublisher<Action, Never>? {
    switch action {
    case .loadUser(let id):
        state.isLoading = true
        return fetchUser(id: id)
            .map { Action.userLoaded($0) }
            .catch { Just(Action.userFailed($0)) }
            .eraseToAnyPublisher()
    case .userLoaded(let user):
        state.user = user; return nil
    case .userFailed(let error):
        state.errorMessage = error.localizedDescription; return nil
    }
}

class Store: ObservableObject {
    @Published var state = AppState()
    private let reducer: Reducer; private var bag = Set<AnyCancellable>()
    
    func dispatch(_ action: Action) {
        if let effect = reducer(&state, action) {
            effect.sink { self.dispatch($0) }.store(in: &bag)
        }
    }
}

Redux 的优势在于严格的状态可预测性与时间旅行调试能力,适合大型复杂应用。

7. 依赖注入:架构的润滑剂

无论是 ViewModel 依赖 Service,还是 Service 依赖 API,通过协议进行依赖注入是保证测试性与灵活性的关键。

swift 复制代码
protocol NetworkServiceProtocol { ... }
protocol CacheServiceProtocol { ... }

class DIContainer {
    static let shared = DIContainer()
    
    lazy var networkService: NetworkServiceProtocol = NetworkService()
    lazy var cacheService: CacheServiceProtocol = CacheService()
    lazy var userRepo: UserRepositoryProtocol = UserRepository(remote: networkService, cache: cacheService)
    
    func makeLoginViewModel() -> LoginViewModel {
        LoginViewModel(authService: authService)
    }
}

在 SwiftUI 中,@EnvironmentObject 可以传递容器,但更推荐在 View 初始化时手动注入 ViewModel,以保持显式依赖。

8. 架构选择指南与混合实践

架构 复杂度 适用场景 优势 劣势
MVVM 简单到中等 上手快,SwiftUI 原生支持 状态可能分散
Repository 需缓存/多数据源 数据层分离清晰 增加一层抽象
State Machine 页面状态多、复杂 消除非法状态 需维护枚举
MVI 中高 交互密集、副作用多 单向流,意图清晰 概念较多
Redux 大型应用、多人协作 单一数据源,可预测 样板代码多

实际项目中往往混合使用:以 MVVM 为基础,View 的复杂状态用 State Machine 管理,数据层采用 Repository 模式,跨组件通信借助 Redux 或 @EnvironmentObject。依赖注入则贯穿始终。

9. 测试的收益

上述架构都高度依赖协议和依赖注入,这使得 Combine 的 ViewModel、Service、Reducer 都能被轻松测试。使用 XCTestExpectation 等待异步流的完成,或使用 ImmediateScheduler 消除时间依赖。

swift 复制代码
func testViewModelState() {
    let mockService = MockAuthService()
    let vm = LoginViewModel(authService: mockService)
    vm.email = "test@test.com"
    vm.password = "123456"
    vm.login()
    let exp = XCTestExpectation()
    vm.$isLoggedIn.sink { loggedIn in
        if loggedIn { exp.fulfill() }
    }.store(in: &bag)
    wait(for: [exp], timeout: 1)
}

10. 总结

Combine 是响应式架构的天然搭档。无论你选择 MVVM、MVI、Redux 还是自定义状态机,核心思想都是将异步数据流的管理 从 View 中剥离,交由 Combine 的 Publisher/Subscriber 体系处理。本章覆盖了与 Combine 结合最紧密的几种架构模式及其典型实现,并给出了混合使用的建议。最终目标是构建出层次清晰、职责单一、易于测试的 SwiftUI 应用。

相关推荐
90后的晨仔2 小时前
Combine 高级实践:多线程调度、调试与测试
ios
人月神话Lee4 小时前
【图像处理】饱和度——颜色的浓淡与灰度化
ios·ai编程·图像识别
王飞飞不会飞5 小时前
iOS卡顿查找和定位-ProFile
ios·性能优化
敲代码的鱼5 小时前
NFC读卡能力 支持安卓/iOS/鸿蒙 UTS插件
android·ios·uni-app
sweet丶9 小时前
iOS应用启动过程深度分析与优化实践
ios
largecode12 小时前
企业名称能在来电显示吗?号码显示公司名服务打通多终端展示
android·xml·ios·iphone·xcode·webview·phonegap
MonkeyKing12 小时前
iOS Core Animation 渲染架构详解:Render Server 与 Commit Transaction
ios
MonkeyKing12 小时前
iOS Auto Layout 原理详解:Cassowary 算法、性能问题与优化
ios
运维之美@12 小时前
Nginx性能优化(二):HTTP/2升级指南,让你的网站开启极速模式
ios·iphone