写点什么

借鉴 Kotlin/Android 技术架构,构建可扩展的 SwiftUI iOS 应用

作者:Ivan Bliznyuk
  • 2026-03-05
    北京
  • 本文字数:9356 字

    阅读完需:约 31 分钟

对于 iOS 开发者来说,基于苹果提供的简单的单页示例应用创建一个可扩展的架构往往很难。当然,如果是要创建一个简单的应用,那些例子就够了,但当你想要构建一个可扩展的东西时,难免会陷入挣扎。

几经寻找后,我发现了 Android 世界。与苹果相比,谷歌为开发者提供的东西让我感到惊讶。Android 开发者有清晰的指南和模式,最重要的是,有真实世界的例子展示如何构建生产级应用的结构,而不仅仅是玩具项目。

Android 社区受益于:

有清晰文档的官方架构组件 Now in Android 这样的示例应用展示了大规模应用的最佳实践跨生态系统模式一致(Repository、ViewModel)

相比之下,iOS 开发者则经常需要根据博文和苹果的示例应用来拼凑解决方案。单独来看,这些解决方案有用,但却很少能代表真实世界应用架构的演变,让我们只能祈祷我们的架构不会随着应用的增长而崩溃。

但令人鼓舞的是,好的架构是平台无关的,使 Android 应用具有可维护性的原则同样适用于 iOS。

本文探讨了如何参考现代 Kotlin 和 Android 开发的架构模式来构建 iOS 应用,并展示了如何将这些模式运用到 Swift 和 SwiftUI 开发中。

我们将从一个基本问题开始:视图中的状态管理。这个问题包括确保所有状态变更只有一个入口点,并实现诸如日志记录和调试等横切关注点。

接下来,我们将上移一层,将视图与视图模型分离,从而提高可重用性、可测试性和可预览性。

最后,我们将引入一个 Active Repository,将“单一数据源”的理念付诸实践,并展示数据如何在应用程序中自动传播。

传统 iOS ViewModel 存在的问题

如果你使用 SwiftUI 构建过 iOS 应用,那么你可能写过类似这样的东西:

class DashboardViewModel: ObservableObject {@Published var workouts: [Workout] = []@Published var isLoading = false@Published var error: Error?func loadWorkouts() {isLoading = trueerror = nilTask {do {workouts = try await api.fetchWorkouts()isLoading = false} catch {self.error = errorisLoading = false}}}}

这段代码适用于简单的界面,但请思考一下当视图模型变得复杂时会发生什么。

状态问题

多个属性相互矛盾,没有什么能防止这种情况:

viewModel.isLoading = trueviewModel.workouts = cachedWorkouts // 现在我们有数据但还在“加载”viewModel.error = NetworkError.timeout // 并且还出错了?

UI 应该显示哪种状态?在这里,编译器无法提供什么帮助。开发者做出了不同的选择,Bug 便随之产生了。

变更问题

你将添加更多方法,如 loadMore(),然后 refresh(),然后 deleteWorkout()、filterWorkout()和 selectWorkout()。现在,你有多个方法可以改变状态,而且各有各的方式。想要记录每个状态变化,就要在多个地方添加日志。想要通过调试来确定为什么 isLoading 卡在 true 上,可能得在十个地方设置断点。想要编写测试,弄清楚哪种方法调用组合可以重现用户流程,却没有哪一个地方可以集中处理。ViewModel 有一堆方法,你需要记住它们之间是如何相互作用的。

假如你正在处理一个功能,涉及到一个你六个月来没有看过的 ViewModel,或者一个你从未见过的新的 ViewModel。你打开文件,有六百行代码和二十个方法。这个东西是做什么的?哪些方法是从视图调用的,哪些是内部的辅助方法?你必须读完整个类才能理解它。没有概要介绍,没有契约,没有“这个 ViewModel 能做什么”的清单。而这样的 ViewModel 另外还有 100 多个。

解决状态问题:显式状态

在 Kotlin 中,状态问题在类型层面就得到了解决:

sealed interface UiState {data object Loading : UiState()data class Success(val data: T) : UiState()data class Error(val message: String) : UiState()}val workouts: StateFlow>> = ...

状态由单一数据源定义。其类型使得可能的状态相互排斥,编译器强制执行这一特性。同时处于 Loading 和 Success 状态是不可能的。

用 Swift 实现等价的代码简单且直观:

enum Loadable {case loadingcase finished(T)case error(U)}class DashboardViewModel: ObservableObject {@Published var workouts: Loadable<[Workout]> = .loading}

解决变更问题:单一入口点

显式状态可以防止出现相互矛盾的状态,但多个变更方法的问题怎么办?Kotlin 的解决方案是将所有操作都通过单一入口点进行处理:

fun onAction(action: DashboardAction) {when (action) {is DashboardAction.Refresh -> loadWorkouts()is DashboardAction.SelectWorkout -> selectWorkout(action.id)is DashboardAction.Delete -> deleteWorkout(action.id)}}

每一次变更都会经过 onAction(),不是部分变更,而是全部。

让我们单独看一下 DashboardAction 类:

sealed class DashboardAction {object Refresh : DashboardAction()data class SelectWorkout(val id: String) : DashboardAction()data class Delete(val id: String) : DashboardAction()data class FilterBy(val type: WorkoutType) : DashboardAction()}

这是 ViewModel 中所有动作的完整列表。一个新来的工程师打开这个文件,阅读这个类,就可以立即理解这个 ViewModel 所具备的能力。他不需要滚动阅读六百行代码,不需要猜测哪些方法是公开的,也不需要猜测一个方法是从视图调用的,还是仅在内部使用。

密封类是契约。如果一个动作没有在那里声明,ViewModel 就不能执行。这个策略也迫使你思考 ViewModel 的责任。当添加一个新的动作时,你首先将它添加到密封类中。这是一个有意识的决定,而不是一个在文件中某个地方悄悄出现的的方法。

但是,DashboardAction 中到底应该放入什么?如果视图可以触发一个动作,那么它就应该被声明为一个动作。用户是否通过点击删除一个项?用户是否选择一个项?哪些项会被保留?内部辅助函数,比如 loadWorkouts(),只从 perform()内部调用。它是一个私有方法,不是一个 Action。Action 是.refresh。内部发生什么属于实现细节。

enum Action {case refreshcase selectWorkout(String)case delete(String)}// 不是动作 —— 内部实现 private func loadWorkouts() async {...}private func updateCache(_ workouts: [Workout]) {...}

如果你已经编写 iOS 应用程序多年,会感觉这种模式没有必要。明明可以直接调用那个方法,为什么还要将所有操作都通过一个方法来进行。当团队很小,只有三五个 Screen 时,这并不重要。但随着团队和代码库的增长,这会变得很重要。传统的 iOS 模式优化了简单的情况,比如使用 @StateObject、@Published,以及直接调用方法。这样既便于理解,又能加快编码速度。苹果的示例代码就是这样的,因为代码示例很小。

但是,当你想进行扩展时,那些直接调用的方法就有问题了。每个方法都是一个潜在的入口点。每个入口点都是可以改变状态的地方。入口点越多,你的 ViewModel 就越难理解。

随着代码库规模的增长,将动作集中处理会便于实现一些难以管理的任务,包括日志记录、调试、测试和分析。

日志记录

只需在基类中添加一行代码,你就可以监控所有 ViewModel 的动作,而无需在多个方法中添加打印语句。

func perform(_ action: Action) {print("[(Self.self)] Action: (action)")// 处理动作……}

调试

如果状态错误,那么你只需要在 perform()中设置一个断点,就可以看到产生当前状态的确切动作序列。将这种方法与在十个不同的方法中设置断点做下比较,孰优孰劣就一目了然了。

测试

测试变得可读。因为每个动作都通过相同的执行路径,你正在测试的就是真实应用程序使用的代码路径。

viewModel.perform(.refresh)viewModel.perform(.selectWorkout("123"))viewModel.perform(.delete("123"))XCTAssertEqual(viewModel.state.workouts, .finished([]))

分析

每个用户交互都会被自动捕获。

func perform(_ action: Action) {analytics.track(action)// 处理动作...}

这个函数本身并不是什么新鲜东西。这是 Android 开发中的标准实践,也是谷歌官方架构指南中推荐的写法。Android 开发人员称之为单向数据流事件下行(View → ViewModel → Repository)和状态上行(Repository → ViewModel → View)。onAction()方法是向下流动的入口点。

谷歌提供的“Now in Android”示例应用就使用了这种模式。大多数 Kotlin 社区也是如此。当 Android 开发人员加入一个新项目时,他们就会想到要找一个 Action 枚举和一个 onAction()方法。

Swift 实现

以下是将这种模式带到 iOS 的方法:

class ViewModel: ObservableObject {@Published private(set) var state: Stateinit(state: State) {self.state = state}func perform(_ action: Action) {// 在子类中覆盖}func updateState(changing keyPath: WritableKeyPath, to value: some Any) {state[keyPath: keyPath] = value}}

状态可以从任何地方读取,但只能从 ViewModel 内部写入。这种方法强制执行单向数据流,View 可以读取状态,但不能直接修改它。所有状态变更都通过 perform()进行。

你可以将这种方法定义为一个扩展,但基类为你提供了一个放置共享逻辑的地方,比如日志记录、分析和常见的状态更新模式。每个 ViewModel 都会继承那个行为。

以下是使用了该模式的一个完整的 ViewModel:

class DashboardViewModel: ViewModel {struct State {var workouts: Loadable<[Workout]> = .loadingvar selectedTab: Tab = .dashboard}

enum Action {    case refresh    case selectTab(Tab)    case deleteWorkout(String)}override func perform(_ action: Action) {    switch action {    case .refresh:        Task {            await loadWorkouts()        }    case .selectTab(let tab):        updateState(\.selectedTab, to: tab)    case .deleteWorkout(let id):        Task {            await deleteWorkout(id)        }    }}private func loadWorkouts() async {    updateState(\.workouts, to: .loading)    do {        let workouts = try await repository.fetchWorkouts()        updateState(\.workouts, to: .finished(workouts))    } catch {        updateState(\.workouts, to: .error(error))    }}private func deleteWorkout(_ id: String) async {    // 实现}
复制代码

}

注意代码结构:

State 是一个包含所有 ViewModel 数据的结构体 Action 是一个包含用户所有可能意图的枚举 perform()是可以路由到不同私有方法的单一入口点私有方法执行实际的工作

Action 枚举是公共契约。私有方法是实现细节。看到这个文件时,你立即就知道它的作用。

Screen vs. View:缺失的层

我们已经解决了状态管理和动作路由,但还有另外一个问题:紧耦合。视图拥有 ViewModel,这破坏了预览并限制了可重用性。

请看下面这个标准的视图:

struct DashboardView: View {@StateObject private var viewModel = DashboardViewModel()var body: some View {ScrollView {switch viewModel.workouts {case .loading:ProgressView()case .finished(let data):WorkoutList(data)case .error(let error):ErrorView(error)}}}}

这个视图做了两项工作:拥有一个 ViewModel(创建它,持有引用,并观察变化)和渲染 UI(布局视图和处理 switch 语句)。

预览问题

尝试在 Xcode 中预览这个视图:

#Preview {DashboardView()}

视图创建了一个真实的 ViewModel。ViewModel 可能会访问网络。它可能需要预览上下文中不存在的依赖项。因此,它可能会崩溃。

所以你开始分析导致问题的原因:

#Preview {DashboardView(viewModel: MockDashboardViewModel())}

但是,现在你需要一个模拟的 ViewModel,但因为你已经修改了初始化器,所以模拟的 ViewModel 维护起来很繁琐,或者你完全放弃预览。许多 iOS 开发者确实放弃了预览。

预览变成了一个你尝试过一次,发现不可靠,然后就放弃的功能。

可重用性问题

假设你希望在两个位置(仪表板和搜索结果界面)显示相同的训练列表。在当前架构下,你无法复用 DashboardView,因为它会创建专属的 DashboardViewModel。

你可以只提取列表:

struct WorkoutList: View {let workouts: [Workout]var body: some View {...}}

但现在你失去了正在加载和错误状态,所以你得多提取一些内容:

struct WorkoutListContainer: View {let state: Loadable<[Workout]>var body: some View {switch state {case .loading:ProgressView()case .finished(let data):WorkoutList(data)case .error(let error):ErrorView(error)}}}

现在,你有了 DashboardView、WorkoutList 和 WorkoutListContainer。提取什么并没有明确的原则。当另一名开发者查看这个试图时,不知道应该遵循哪种模式。

Kotlin 是如何解决这个问题的

在应用示例 Now in Android 中,有一个标准模式:将 Screen 与 Content 分开。Screen 是一个封装器,拥有 ViewModel,而 Content 是一个只渲染 UI 的可组合组件。

@Composable fun DashboardScreen(viewModel: DashboardViewModel = hiltViewModel()) {val state by viewModel.state.collectAsState()DashboardContent(state = state, onAction = viewModel::onAction)}

@Composable fun DashboardContent(state: DashboardState, onAction: (DashboardAction) -> Unit) {// 纯 UI 渲染 Column {when (state.workouts) {is Loading -> CircularProgressIndicator()is Success -> WorkoutList(state.workouts.data)is Error -> ErrorMessage(state.workouts.message)}}}

在 Kotlin 中,可组合组件 Contenth 很容易预览:

@Preview @Composable fun DashboardContentPreview() {DashboardContent(state = DashboardState(workouts = Success(sampleWorkouts)), onAction = {})}

谷歌在 Android 应用示例 Now in Android 中始终使用了该模式。

将这个模式带到 iOS

我们可以在 SwiftUI 中做相同的分离。首先是 Content,一个接受状态和动作处理器的视图:

struct DashboardContent: View {let state: DashboardViewModel.Statelet onAction: (DashboardViewModel.Action) -> Voidvar body: some View {ScrollView {switch state.workouts {case .loading:ProgressView()case .finished(let workouts):WorkoutList(workouts, onAction: onAction)case .error(let error):ErrorView(error, onRetry: { onAction(.refresh) })}}}}

请注意,这里没有 @ObservedObject,没有 @StateObject,也没有 ViewModel 引用,只是输入数据,输出 UI。

现在是 Screen,一个拥有 ViewModel 的封装器:struct DashboardScreen: View {@StateObject private var viewModel: DashboardViewModelinit(viewModel: DashboardViewModel) {_viewModel = StateObject(wrappedValue: viewModel)}var body: some View {DashboardContent(state: viewModel.state,onAction: viewModel.perform).onAppear {viewModel.perform(.refresh)}}}

Screen 监视 ViewModel 并传递状态,而 Content 并不知道其存在。

一个通用的 Screen 封装器

Screen 可以是一个可重用的通用组件:

protocol ViewModeling: ObservableObject {associatedtype Statevar state: State { get }}struct Screen: View {@ObservedObject var viewModel: VMlet content: (VM.State) -> Contentvar body: some View {content(viewModel.state)}}

现在,任何 Screen 都变成了这样:

struct DashboardScreen: View {@ObservedObject var viewModel: DashboardViewModelvar body: some View {Screen(viewModel: viewModel) { state, onAction inWorkoutList(state.workouts) { viewModel.perform(.action) }}}}

数据流链

我们已经介绍了单个的模式。现在,让我们看看如何把它们组合成一个包含不同层次的完整系统。图片: https://uploader.shimo.im/f/h3rogRowE3edWA1j.png!thumbnail?accessToken=eyJhbGciOiJIUzI1NiIsImtpZCI6ImRlZmF1bHQiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3NzI2NzM4NzUsImZpbGVHVUlEIjoielc0N1B2WElKV2lWbVZIRyIsImlhdCI6MTc3MjY3MzU3NSwiaXNzIjoidXBsb2FkZXJfYWNjZXNzX3Jlc291cmNlIiwicGFhIjoiYWxsOmFsbDoiLCJ1c2VySWQiOjk5Mjg1NjY5fQ.IGTJOsC9pm9pSdRX5qFhibMlFxdeMaH7-89CUpcEGFs 图 1:从视图到 API 的关系。

图 1 显示了从视图到 API 的下行关系:

View → ViewModel → Repository → RemoteSource → API

反过来则是状态回流:

API → RemoteSource → Repository → ViewModel → View

每一层都只知道它下面的一层。视图不知道 Repository 的存在。Repository 也不知道视图的存在。依赖仅指向一个方向。

为什么要分这么多层?因为每一层都可以独立测试、模拟和替换。将 RemoteSource 替换为假的源,Repository 就可以离线工作。将 Repository 替换为模拟库,ViewModel 测试就不会触及网络。

// ViewButton(onClick = { viewModel.onAction(Action.Refresh) })

// ViewModelfun onAction(action: Action) {when (action) {is Action.Refresh -> viewModelScope.launch {_state.value = State(workouts = Resource.Loading)_state.value = State(workouts = repository.getWorkouts())}}}

// Repositorysuspend fun getWorkouts() = remoteSource.fetchWorkouts()

// RemoteSourcesuspend fun fetchWorkouts() = api.getWorkouts().map { it.toDomain() }

iOS 等效实现// ViewButton("Refresh") { onAction(.refresh) }

// ViewModelclass DashboardViewModel: ViewModel {private let meRepo: MeRepository

init(dependencies: Resolver) {    self.meRepo = dependencies.resolve(MeRepository.self)!    super.init(dependencies: dependencies, state: State())}override func perform(_ action: Action) {    switch action {    case .refresh:        updateState(changing: \.workouts, to: .loading)        Task {            let data = try await meRepo.fetchTrainingLoad()            updateState(changing: \.workouts, to: .finished(data))        }    }}
复制代码

}

// Repository Layerprotocol MeRepository {func fetchTrainingLoad() async throws -> [TrainingLoad]}

class MeRepositoryImpl: MeRepository {private let remoteSource: MeRemoteSource

init(dependencies: Resolver) {    self.remoteSource = dependencies.resolve(MeRemoteSource.self)!}func fetchTrainingLoad() async throws -> [TrainingLoad] {    try await remoteSource.fetchTrainingLoad()}
复制代码

}

// Remote Source Layerprotocol MeRemoteSource {func fetchTrainingLoad() async throws -> [TrainingLoad]}

class MeRemoteSourceImpl: MeRemoteSource {private let api: API

init(api: API) {    self.api = api}func fetchTrainingLoad() async throws -> [TrainingLoad] {    let response = try await api.query(TrainingLoadQuery())    return response.me?.trainingLoad.map { TrainingLoad(from: $0) } ?? []}
复制代码

}

反应式 Repository 模式

这里才是前面介绍的架构真正亮眼的地方。

想象这样一个场景。你的应用有两个 Screen:一个是锻炼列表和一个是锻炼详情。用户打开一个锻炼项目,编辑名称,然后返回列表,而列表上显示的仍然是旧名称。为什么?因为每个 Screen 都有自己的数据副本。详情 Screen 修改了它的副本,但列表 Screen 不知道那里发生的任何变化。SwiftUI 的 @Binding 解决了简单的父子关系。你也可以传递一个回调,发布一个通知,或者在 onAppear 中刷新。但是,一旦你有三个 Screen,或者相互独立的特性需要相同的数据,这些就都不适用了。你需要一个唯一的数据源。

Repository 拥有数据

如果有且仅有一个数据副本呢?每个 Screen 都观察那一个副本。更新一次,每个 Screen 都能看到变化。

这就是反应式 Repository 模式的作用。以下是它在 Swift 中的实现:

class WorkoutRepository {protocol WorkoutRepository {var workoutsPublisher: AnyPublisher<[Workout], Never> { get }func updateWorkoutName(id: String, newName: String) async throws}final class WorkoutRepositoryImpl: WorkoutRepository {private let remoteSource: WorkoutRemoteSource@Published private var workouts: [Workout] = []var workoutsPublisher: AnyPublisher<[Workout], Never> {$workouts.eraseToAnyPublisher()}init(remoteSource: WorkoutRemoteSource) {self.remoteSource = remoteSource}func updateWorkoutName(id: String, newName: String) async throws {// 1. 更新后台try await remoteSource.updateWorkout(id: id, name: newName)// 2. 更新本地状态if let index = workouts.firstIndex(where: { $0.id == id }) {workouts[index].name = newName}// 所有观察者将通过 @Published 自动收到通知}}

Repository 保存数据,ViewModel 订阅它:

class WorkoutListViewModel: ViewModel {private let repository: WorkoutRepositoryprivate var cancellables = Set()init(repository: WorkoutRepository) {self.repository = repositorysuper.init(state: State())repository.workoutsPublisher.sink { [weak self] workouts inself?.updateState(.workouts, to: .finished(workouts))}.store(in: &cancellables)}}

两个 ViewModel 观察相同的数据源。当 Repository 更新时,两者都会接收到新数据,无需回调、通知或手动刷新。测试时只需注入模拟数据。

单一数据源原则

谷歌的架构指南将此称为“单一数据源”原则。对于任何数据片段,都有一个唯一的所有者,其他人都观察它的变化。

锻炼数据?由WorkoutRepository拥有用户资料?由UserRepository拥有设置?由SettingsRepository拥有

ViewModel 不拥有数据。它们观察数据并将其暴露给 View。当它们需要更改某些内容时,它们会向 Repository 发起请求。Repository 更新其状态,而更改会传播给所有观察者。

完整流程

用户编辑锻炼项目名称:

DetailViewModel.perform(.updateName("New Name"))DetailViewModel 调用 repository.updateWorkoutName(...)Repository 连接远程源并更新后台 Repository 更新其 @Published 锻炼项目数据 ListViewModel 通过订阅接收新的锻炼项目数据 DetailViewModel 通过订阅接收新的锻炼项目数据两个视图都使用新名称重新渲染

一次更新即可实现自动传播。如果需第三个显示锻炼数据的 Screen,只需订阅该 Repository 即可,而无需修改其他任何地方的代码。

正是这种模式让大型应用变得可控。如果没有它,你就要在数十个界面间与过时数据玩打地鼠游戏。

结论

好的架构可以超越平台。通过采用已经在 Android 生态系统中得到验证的模式,包括显式状态管理、基于动作的更新、Screen 模式、分层数据流和反应式 Repository,我们构建出的 iOS 应用将具备如下特性:

可维护性,具有清晰的关注点分离可测试性,每一层都可以模拟可扩展性,无论有五个 Screen 还是五十个 Screen,该模式都适用可预测性,单向数据流使调试变得简单

我们不需要重新发明轮子。我们可以学习其他地方的有效做法,并将其适配到我们的平台上,获得更清晰的代码,使应用程序不会因自身的规模增长而崩溃。

声明:本文为 InfoQ 翻译,未经许可禁止转载。

原文链接:https://www.infoq.com/articles/kotlin-scalable-swiftui-patterns/