精粹架构SwiftUI【译文】

2023-3-31|最后更新: 2023-4-27|
type
status
date
slug
summary
tags
category
icon
password
notion image
你能想象,UIKit 已经十一岁了!自从 2008 年 iOS SDK 发布以来,我们就一直在使用它构建应用程序。在此期间,开发人员一直在不懈地寻找最佳的应用程序架构。一切都始于 MVC,但后来我们见证了 MVP、MVVM、VIPER、RIBs 和 VIP 的崛起。
但是最近发生了一些事情。这个“某件事”非常重要,以至于用于 iOS 的大多数架构模式很快将成为历史。
我说的是 SwiftUI。它不会消失。无论你喜欢与否,这是 iOS 开发的未来。就设计架构而言,它是一个改变游戏规则的存在。

哪些概念上的变化了?

UIKit 是一种命令式的、事件驱动的框架。我们可以引用层级中的每个视图,在视图加载时或作为对事件的反应(例如在按钮上按下或者有新的数据可供在 UITableView 中显示)更新其外观。我们使用回调、委托、目标操作来处理这些事件。
现在,这一切都消失了。SwiftUI 是一种声明式的、状态驱动的框架。我们不能引用层级中的任何视图,也不能直接通过事件响应更改视图。相反,我们会更改绑定到视图的状态。委托、目标操作、响应链、KVO —— 所有的回调技术都被闭包和绑定所取代。
SwiftUI 中的每个视图都是一个结构体,可以比相似的 UIView 子类更快地创建很多倍。该结构体保留对其提供给渲染 UI 的 body函数的 state 的引用。
 
因此,在 SwiftUI 中,一个视图就只是一个编程函数。你提供它输入(状态)——它绘制输出。而且唯一改变输出的方式就是改变输入:我们不能通过添加或删除子视图来触及算法(即主体函数)——所有可能在显示的 UI 中进行的更改都必须在主体中声明,并且不能在运行时更改。
在 SwiftUI 中,我们不是添加或删除子视图,而是在预定义的流程图算法中启用或禁用不同的 UI 片段。
 

MVVM是新的标准架构

SwiftUI内置了MVVM。
在最简单的情况下,当视图不依赖于任何外部状态时,它的本地@State变量扮演了ViewModel的角色,提供了订阅机制(Binding),以便在状态改变时刷新UI。
对于更复杂的场景,视图可以引用外部的ObservableObject,在这种情况下,它可以是一个独立的ViewModel。
无论如何,SwiftUI视图与状态的工作方式非常类似于经典的MVVM(除非我们引入更复杂的编程实体图)
 
而且,你不需要再有ViewController了。
让我们考虑一下这个SwiftUI应用程序的MVVM模块的快速例子。
Model:一个数据容器
struct Country { let name: String }
View: 一个 SwiftUI 视图
struct CountriesList: View { @ObservedObject var viewModel: ViewModel var body: some View { List(viewModel.countries) { country in Text(country.name) } .onAppear { self.viewModel.loadCountries() } } }
ViewModel: 一个 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 ?? [] } } } }
在这个简化的例子中,当View出现在屏幕上时,onAppear回调调用ViewModel上的loadCountries(),触发网络调用以加载WebService中的数据。ViewModel在回调中接收数据,并通过@Published变量 countries 推送更新,由View观察。
notion image
 

SwiftUI的背后的实现是基于ELM(Elm架构)

SwiftUI基于与ELM相同的本质原则:
  • Model(模型)—— 应用程序的状态
  • View(视图)—— 将您的状态转换为HTML的方式
  • Update(更新)—— 根据消息来更新您的状态
我们在哪里看到过这个呢?
 
notion image
我们已经拥有了模型(Model),视图(View)会根据模型自动生成,唯一需要调整的是提供更新(Update)的方式。我们可以采用REDUX方式,使用命令模式来进行状态变更,而不是让SwiftUI的视图和其他模块直接写入状态。虽然我在之前的UIKit项目中更喜欢使用REDUX(如ReSwift ❤),但是否需要在SwiftUI应用程序中使用它还是有疑问的——数据流已经受到控制,并且很容易追踪。
 

SwiftUI是路由

在VIPER、RIBs和MVVM-R架构中,协调器(Coordinator,又称路由)是一个重要的组成部分。在UIKit应用程序中为屏幕导航分配单独的模块是有充分理由的——从一个ViewController直接进行路由会导致它们之间过于紧密地耦合,更不用说在ViewController层次结构中深度链接到屏幕时产生的编程地狱。
在UIKit中添加协调器非常容易,因为UIView(和UIViewController)是独立于环境的实例,您可以随时通过添加/删除来将它们扔进/出层次结构中。
但是,在SwiftUI中,这种动态性在设计上是不可能实现的:层次结构是静态的,并且所有可能的导航在编译时都已定义和固定。在运行时无法对层次结构结构进行调整:相反,导航完全由状态通过绑定(Bindings)进行控制。无论是Navigation View、TabView还是.sheet(),每次你看到需要Binding参数来进行路由的init方法。
“View是State的函数”,记住吗?关键词就是函数。这是一种将状态数据转换为渲染图片的算法。
这就解释了为什么将路由从SwiftUI视图中提取出来是非常具有挑战性的:路由是这个绘制算法的一个不可或缺的部分。
路由旨在解决两个问题:
  1. 解耦视图控制器之间的联系
  1. 程序化导航
SwiftUI具有通过上述Bindings实现编程式导航的内置机制。我写了一篇专门的文章来介绍它。
在SwiftUI中,要实现视图之间的解耦非常容易。如果您不希望视图A直接引用视图B,您可以将B设置为A的泛型参数,然后完成。
您同样可以使用这种方法来抽象视图A打开视图B的方式(使用TabView、NavigationView等),尽管我认为实际上将此声明在视图中并不会产生问题。如果需要,在原地轻松更改路由模型,而无需触及B视图。
另外,别忘了@ViewBuilder和AnyView——这两种方法也可用于使B类型对A视图隐式。
基于上述原因,我认为SwiftUI已经使协调器变得不再必要:我们可以使用泛型参数或@ViewBuilder来隔离视图,并使用标准工具实现编程式导航。
quickbirdstudios的一篇实践性文章中提供了在SwiftUI中使用协调器的实际示例,但是在我看来,这种做法过于冗余。此外,这种方法也存在一些缺点,例如授予协调器对所有ViewModel的完全访问权限。您可以去看一看并自行决定。

VIPER、RIBs和VIP架构是否适用于SwiftUI?

我们可以从这些架构中借鉴很多好的想法和概念,但最终这些经典实现并不适用于SwiftUI应用程序。
首先,正如我刚才详细阐述的那样,没有必要再使用协调器(路由)了。
其次,SwiftUI中完全新的数据流设计与本地支持的视图状态绑定使所需的设置代码缩小到了Presenter变成了一个无用的傻瓜实体。
随着模式中模块数量的减少,我们发现也不再需要Builder。因此,整个模式就像它旨在解决的问题一样破碎了。
SwiftUI在系统设计中引入了一系列挑战,因此,我们必须从头开始重新设计UIKit中的模式。
虽然有人试图不考虑任何情况坚持使用喜爱的架构,但请不要这样做。
 

Clean Architecture

Clean Architecture(清晰架构)是Uncle Bob创建的,也是VIP的前身。
通过将软件分为层,并遵循依赖规则,您将创建一个本质上可测试的系统,具有所有相关好处。
Clean Architecture对我们应该引入多少层非常宽容,因为这取决于应用程序域。
但在移动应用程序的最常见场景中,我们需要三个层:
  • 展示层
  • 业务逻辑层
  • 数据访问层
因此,如果我们通过SwiftUI的特性来提炼出Clean Architecture的要求,我们会得到以下内容:
notion image
我创建了一个演示项目来说明使用这种模式的方法。该应用程序通过restcountries.eu REST API获取国家列表和详细信息。

AppState

AppState(应用程序状态)是模式中唯一需要成为对象的实体,具体来说是ObservableObject。或者它可以是一个由Combine中的CurrentValueSubject包装的结构体。
Redux一样,AppState作为单一真相来源,保持整个应用程序的状态,包括用户数据、身份验证令牌、屏幕导航状态(选定的标签、呈现的sheet等)和系统状态(处于活动状态/处于后台状态等)。
AppState对其他任何层都不知道,并不包含任何业务逻辑。
以下是Countries演示项目中AppState的示例:
class AppState: ObservableObject, Equatable { @Published var userData = UserData() @Published var routing = ViewRouting() @Published var system = System() }
 

View

视图(View)是常规的SwiftUI视图,可以是无状态的,也可以具有本地的@State变量。
其他层不知道视图层的存在,因此无需将其隐藏在协议后面。
当视图实例化时,它通过SwiftUI标准的依赖注入方式(使用@Environment、@EnvironmentObject或@ObservedObject属性)接收AppState和 Interactor。
副作用由用户的操作(例如点击按钮)或视图生命周期事件onAppear触发,需要转发给Interactor。
struct CountriesList: View { @EnvironmentObject var appState: AppState @Environment(\.interactors) var interactors: InteractorsContainer var body: some View { ... .onAppear { self.interactors.countriesInteractor.loadCountries() } } }
 

Interactor

Interactor(交互器)封装了特定视图或一组视图的业务逻辑。与AppState一起形成业务逻辑层,该层完全独立于展示和外部资源。
它是完全无状态的,只引用了作为构造参数注入的AppState对象。
为了使视图可以与模拟的交互器进行通信,在测试中应该为交互器“创建一个接口”。
交互器接收执行工作的请求,例如从外部源获取数据或进行计算,但它们从不直接返回数据,例如在闭包中。
相反,它们将结果转发到AppState或由View提供的Binding中。
当工作的结果(数据)仅由一个View本地拥有,而不属于全局的AppState时,使用Binding。也就是说,它不需要持久保存或与应用程序的其他屏幕共享。
CountriesInteractor from the demo project:
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 } } }
 

Repository

Repository(仓库)是用于读取/写入数据的抽象网关。提供对单个数据服务的访问,可以是Web服务器或本地数据库。
我有一篇专门的文章解释了为什么提取Repository是必要的。
例如,如果应用程序正在使用其后端、Google Maps API并将某些内容写入本地数据库,则将有三个Repository:两个用于不同的Web API提供程序和一个用于数据库IO操作。
仓库也是无状态的,没有对AppState的写访问权限,只包含与处理数据相关的逻辑。它不知道View或Interactor的存在。
实际的Repository应该被隐藏在一个协议后面,以便Interactor可以在测试中与模拟的Repository进行通信。
CountriesWebRepository from the demo project:
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来模拟网络调用,从而非常容易地对其进行测试。
 

结语

由于Clean Architecture的“依赖规则”和将应用程序分为多个层,演示项目现在具有97%的测试覆盖率。
它提供了完全设置的CoreData持久性层、来自推送通知的深度链接以及其他非凡但实用的示例
 
新婚旅行之西游记如何选择开源协议