-
- 6.1. AppState
- 6.2. View
- 6.3. Interactor
- 6.4. Repository
你能想象吗,UIKit已经有11年历史了! 自从2008年iOS SDK发布以来,我们就一直在使用它开发应用。在这段时间里,开发人员一直在为他们的应用程序寻找最佳的架构。从MVC开始,后来我们看到了MVP、MVVM、VIPER、RIBs、VIP的崛起。
但是最近发生了一些事情。 这个“东西”是如此重要,以至于大多数用于iOS的架构模式将很快成为历史。
我说的是SwiftUI。它哪儿也去不了。不管你喜不喜欢,这就是iOS开发的未来。它改变了我们在设计架构时所面临的挑战。
1. What are the conceptual changes?
UIKit是一个命令式的、事件驱动的框架。我们可以引用层次结构中的每个视图,当视图被加载时更新它的外观,或者作为对事件的反应(按钮的触摸或新的数据在UITableView中显示)。 我们使用**回调、委托、目标操作(callbacks, delegates, target-actions)**来处理这些事件。
现在,一切都过去了。SwiftUI是一个声明式的、状态驱动的框架。我们不能引用层次结构中的任何视图,也不能直接改变视图作为对事件的反应。相反,我们改变绑定到视图的状态。委托、目标-动作、响应链、KVO——所有的回调技术都被闭包和绑定所取代。
SwiftUI中的每个视图都是一个Struct,其创建速度比类似的UIView后代要快很多倍。该结构保持对它为呈现UI而提供给body函数的状态的引用。
SwiftUI中的视图只是一个编程函数。您为它提供输入(状态)——它绘制输出。而改变输出的唯一方法就是改变输入:我们不能通过添加或删除子视图来触及算法(body函数) - UI显示的所有可能的改变都必须在body中声明,不能在运行时更改。
在SwiftUI方面,我们没有添加或删除子视图,而是在预定义的流程图算法中启用或禁用UI的不同部分。
2. MVVM is the new standard architecture
SwiftUI天生契合MVVM。
在最简单的情况下,View不依赖于任何外部状态,它的本地@State变量充当ViewModel的角色,提供了订阅机制(Binding),以便在状态更改时刷新UI。
对于更复杂的场景,视图可以引用一个外部的ObservableObject,在这种情况下,它可以是一个不同的ViewModel。
无论如何,SwiftUI视图处理状态的方式非常类似于经典的MVVM(除非我们引入更复杂的编程实体图)。
让我们考虑一下这个针对SwiftUI应用程序的MVVM模块的快速示例。
- Model: a data container
struct Country {
let name: String
}
- View: a SwiftUI view
struct CountriesList: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
List(viewModel.countries) { country in
Text(country.name)
}
.onAppear {
self.viewModel.loadCountries()
}
}
}
- ViewModel: an ObservableObject that encapsulates the business logic and allows the View to observe changes of the state
extension CountriesList {
class ViewModel: ObservableObject {
@Published private(set) var countries: [Country] = []
private let service: WebService
func loadCountries() {
service.getCountries { [weak self] result in
self?.countries = result.value ?? []
}
}
}
}
在这个简化的例子中,当视图出现在屏幕上时,onAppear回调调用ViewModel上的loadCountries(),触发网络调用来加载WebService中的数据。ViewModel接收回调中的数据,并通过视图观察的@Published变量countries推送更新

虽然这篇文章是关于Clean Architecture的,但是我收到了很多关于在SwiftUI中应用MVVM的问题,所以我把最初的样例项目移植到MVVM的一个单独的分支中。你可以比较两者,选择最适合你需要的。项目的主要特点:
- 香草SwiftUI + Combine实现
- 解耦表示层、业务逻辑和数据访问层
- 完整的测试覆盖范围,包括UI(多亏了ViewInspector)
- 类似于redux的集中式AppState作为单一的真相来源
- 程序化导航(支持深度链接)
- 基于泛型的简单而灵活的网络层
- 处理系统事件(当应用程序处于非活动状态时模糊视图层次)
3. Under the hood, SwiftUI is based on ELM
我在ELM语言的网站上查看了ELM Architecture的描述,没有发现什么新的东西。SwiftUI基于与ELM相同的本质:
- 模型-应用程序的状态
- 一种将你的状态转换成HTML的方法
- 更新-一种基于消息更新你的状态的方法
我们在哪儿见过,不是吗?

我们已经有了模型,视图从模型中自动生成,我们唯一能调整的是更新交付的方式。们可以采用REDUX的方式,使用Command模式来改变状态,而不是让SwiftUI的视图和其他模块直接写入状态。
虽然我更喜欢在我之前的UIKit项目中使用REDUX (ReSwift❤),但对于swifti应用程序是否需要REDUX还是有疑问的——数据流已经在控制之下,而且很容易追踪。
4. Coordinator is SwiftUI
Coordinator(也就是Router)是VIPER、RIBs和MVVM-R体系结构的重要组成部分。为屏幕导航分配一个单独的模块在UIKit应用程序中是很合理的——从一个ViewController到另一个ViewController的直接路由导致了它们的紧密耦合,更不用说在ViewController层次结构中深入链接到屏幕的代码地狱了。
在UIKit中添加一个协调器是相当容易的,因为UIView(和UIViewController)是环境独立的实例,你可以随时从层次结构中添加/删除它们。
对于SwiftUI来说,这样的动态在设计上是不可能的:层次结构是静态的,所有可能的导航都在编译时定义和固定。没有办法在运行时调整层次结构:相反,导航完全由通过Bindings改变的状态控制:看看你的NavigationView, TabView或.sheet(),每次你都会看到一个init,它接受路由的Binding参数。
"视图是状态的函数"还记得吗? 这里的关键词是function,一种将状态数据转换为渲染图像的算法。
这就解释了为什么从SwiftUI视图中提取路由是一个相当大的挑战:路由(routing)是绘图算法的一个组成部分。
Coordinator的目标是解决两个问题:
- 将ViewControllers彼此解耦
- 程序化的导航
SwiftUI有一个内置的机制,可以通过前面提到的Bindings进行程序化导航。我有一篇专门的文章。
至于SwiftUI中视图的脱钩,这很容易实现。如果你不希望视图A直接引用视图B,你可以简单地将B转换为A的泛型参数,然后就结束了。
你也可以使用相同的方法来抽象视图A可以打开B的事实方式(使用TabView, NavigationView等),尽管我不认为在你的视图中陈述这一点有什么问题。如果需要,您可以轻松地就地更改路由模型,而无需接触视图B。
不要忘记@ViewBuilder和AnyView -另外两种方法使类型B对A来说是隐藏的。
鉴于上述情况,我认为SwiftUI让Coordinator变得不必要了:我们可以使用泛型参数或@ViewBuilder隔离视图,并使用标准工具实现编程导航。
quickbirdstudios有一个在SwiftUI中使用coordinator的实际例子,然而,在我看来,这有点过头了。另外,这种方法有几个缺点,比如授予Coordinators对所有ViewModels的完全访问权,但是您应该检查一下并自己决定。
5. Are VIPER, RIBs and VIP applicable for SwiftUI?
我们可以从这些架构中借鉴很多伟大的想法和概念,但最终任何一种架构的规范实现对SwiftUI应用都没有意义。
- 首先,正如我刚才所阐述的,实际上并不需要有一个Coordinator。
- 其次,SwiftUI中数据流的全新设计,加上对视图状态绑定的本地支持,将所需的设置代码压缩到Presenter变成了一个愚蠢的实体的程度,不做任何有用的事情。
- 随着模式中模块数量的减少,我们发现我们也不需要Builder。 所以基本上,整个模式就分崩离析了,因为它旨在解决的问题不再存在了。
- SwiftUI在系统设计中引入了自己的一套挑战,所以我们必须从头开始重新设计UIKit的模式。
无论如何,都有人试图坚持心爱的架构,但请不要这样做。
6. Clean Architecture
让我们参考Uncle Bob的Clean Architecture,即VIP的前身。
通过将软件分层,并遵循依赖规则,您将创建一个本质上可测试的系统,并具有它所暗示的所有好处。
Clean Architecture对于我们应该引入的层的数量非常自由,因为这取决于应用程序。
但对于手机应用来说,最常见的情况是,我们需要有三个层:
- Presentation layer (呈现层)
- Business Logic layer(业务层)
- Data Access layer (数据层)
因此,如果我们通过SwiftUI的特性来提炼Clean架构的需求,我们会得出如下结论:

6.1. AppState
AppState是模式中唯一需要是对象(特别是ObservableObject)的实体。 或者,它可以是一个封装在来自Combine的CurrentValueSubject中的结构。
就像Redux一样,AppState作为唯一的真相来源,并保存整个应用的状态,包括用户数据、认证令牌、屏幕导航状态(选中的选项卡、呈现的表单)和系统状态(是活动的/是后台的,等等)。
AppState不知道任何其他层,也不包含任何业务逻辑。
国家演示项目中的AppState示例:
class AppState: ObservableObject, Equatable {
@Published var userData = UserData()
@Published var routing = ViewRouting()
@Published var system = System()
}
6.2. View
这是SwiftUI的常识。它可以是无状态的,也可以有本地的@State变量。
没有其他层知道View层的存在,所以没有必要将它隐藏在协议后面。
当视图被初始化时,它通过SwiftUI的标准依赖注入来接收AppState和Interactor,注入的变量是@Environment, @EnvironmentObject或@ observetobject。
副作用是由用户的操作(如点击按钮)或视图生命周期事件onAppear触发的,并被转发到Interactor。
struct CountriesList: View {
@EnvironmentObject var appState: AppState
@Environment(\.interactors) var interactors: InteractorsContainer
var body: some View {
...
.onAppear {
self.interactors.countriesInteractor.loadCountries()
}
}
}
6.3. Interactor
Interactor(交互器)封装特定视图或一组视图的业务逻辑。 与AppState一起形成业务逻辑层,它完全独立于表示层和外部资源。
它是完全无状态的,作为构造函数参数注入的,只引用AppState对象。
Interactor应该用协议“伪装”,以便视图可以在测试中与模拟交互者对话。
Interactor接收执行工作的请求,例如从外部源获取数据或进行计算,但它们从不直接返回数据,例如在闭包中。
相反,它们将结果转发给AppState或视图提供的Binding。
当工作结果(数据)在本地属于一个视图而不属于中心的AppState时,Binding被使用,也就是说,它不需要被持久化或与应用程序的其他屏幕共享。
演示项目中的CountriesInteractor:
protocol CountriesInteractor {
func loadCountries()
func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country)
}
// MARK: - Implemetation
struct RealCountriesInteractor: CountriesInteractor {
let webRepository: CountriesWebRepository
let appState: AppState
init(webRepository: CountriesWebRepository, appState: AppState) {
self.webRepository = webRepository
self.appState = appState
}
func loadCountries() {
appState.userData.countries = .isLoading(last: appState.userData.countries.value)
weak var weakAppState = appState
_ = webRepository.loadCountries()
.sinkToLoadable { weakAppState?.userData.countries = $0 }
}
func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country) {
countryDetails.wrappedValue = .isLoading(last: countryDetails.wrappedValue.value)
_ = webRepository.loadCountryDetails(country: country)
.sinkToLoadable { countryDetails.wrappedValue = $0 }
}
}
6.4. Repository
Repository(存储库)是一个用于读写数据的抽象网关。提供对单个数据服务的访问,无论是web服务器还是本地数据库。
我有一篇专门的文章解释为什么提取存储库是必要的。
例如,如果应用程序使用它的后端,Google Maps APIs并写入一些到本地数据库,将有三个存储库:两个用于不同的web API提供商,一个用于数据库IO操作。
存储库也是无状态的,没有对AppState的写访问权,只包含与处理数据相关的逻辑。它对View或Interactor一无所知。
实际的存储库应该隐藏在协议后面,以便Interactor可以在测试中与模拟的存储库对话。
来自演示项目的国家webrepository:
protocol CountriesWebRepository: WebRepository {
func loadCountries() -> AnyPublisher<[Country], Error>
func loadCountryDetails(country: Country) -> AnyPublisher<Country.Details.Intermediate, Error>
}
// MARK: - Implemetation
struct RealCountriesWebRepository: CountriesWebRepository {
let session: URLSession
let baseURL: String
let bgQueue = DispatchQueue(label: "bg_parse_queue")
init(session: URLSession, baseURL: String) {
self.session = session
self.baseURL = baseURL
}
func loadCountries() -> AnyPublisher<[Country], Error> {
return call(endpoint: API.allCountries)
}
func loadCountryDetails(country: Country) -> AnyPublisher<Country.Details, Error> {
return call(endpoint: API.countryDetails(country))
}
}
// MARK: - API
extension RealCountriesWebRepository {
enum API: APICall {
case allCountries
case countryDetails(Country)
var path: String { ... }
var httpMethod: String { ... }
var headers: [String: String]? { ... }
}
}
因为WebRepository将URLSession作为构造函数参数,所以很容易通过使用定制的URLProtocol模拟网络调用来测试它