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

小新 正九品 (县丞) 2026-03-07 17:48 0 0
小新 正九品 (县丞) 楼主
2026-03-07 17:48
第1楼

摘要:用Swift实现等价的代码简单且直观:enum Loadable {case loadingcase finished(T)case error(U)}class DashboardViewModel: ObservableObject {@Published var workouts: Loadable<[Workout]> = .loading}请看下面这个标准的视图:struct DashboardView: View {@StateObject private var viewModel = DashboardViewModel()var body: some View {ScrollView {s@Composable fun DashboardScreen(viewModel: DashboardViewModel = hiltViewModel()) {val state by viewModel.


对于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> {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/

  • 1 / 1 页
敬请注意:文中内容观点和各种评论不代表本网立场!若有违规侵权,请联系我们.