在 SwiftUI 中实现应用程序架构的现代方法有哪些?本文从 SwiftUI 的发展过程切入,进而对 SwiftUI 的前沿状况进行了分析,并解答了一些关于 SwiftUI 微服务的问题。
SwiftUI 在 iOS、macOS 和其他所有苹果设备上,为应用程序开发带来了一种新的声明式、状态驱动和基于组件的方法。与此同时,我们的应用程序架构方法也应该向前发展了。但在我们前进之前,先来简要回顾一下历史和现在的前沿状况。
开发 iOS 应用程序的经典方法是站在 MVC(模型 - 视图 - 控制器)的肩膀之上的。在 MVC 中,控制器(controller)在模型(model)和代表我们接口的各种视图(view)之间来回传递信息。
在 iOS 中,控制器将自身显示为单个对象,UIViewController。视图控制器管理所有用户交互和状态更改——包括信息加载、操作和数据更新,它还处理用户在我们的应用中各个屏幕和页面之间的来回跳转。
这种方法意味着控制器在我们的应用程序体系结构中负担着过于繁重的任务。实际上,它的作用是如此之大,以至于人们普遍称其为"巨型视图控制器"(Massive View Controller)。
显然,这种方法并不是最佳的。
为了取代巨型视图控制器,人们提出了很多解决方案,其中大多数方案都能归结为某种形式的模型 - 视图 - 视图模型(MVVM)。
在这种方法中,模型和视图以及视图控制器仍然存在。但应用程序的内部结构、数据处理和业务逻辑已经从视图控制器提取出来,移到了视图模型(ViewModel)中。
为什么这样做呢?一方面,它简化了视图控制器,但之所以从视图控制器中提取所有逻辑,主要目的是让这些逻辑可测试。我们可以实例化视图模型,并向其提供信息并调用其方法,还能直接观察被视图模型呈现给视图控制器的状态更改。
由于视图控制器的工作已简化为,只把这些状态更改传递给构成我们应用程序的视图,因此我们可以确信,只要视图模型的输出正确,我们的应用程序也将正确。
这种方法有多种变体:模型 - 视图 - 呈现器(MVP,model-view-presenter)、VIPER、Clean。但它们都是基于相同的基本概念,主要区别在于它们如何在一组组件之间划分职责。
但所有人都认同一件事,那就是视图控制器应该尽可能简单。
苹果公司显然同意这一点,并在 WWDC 19 大会上推出了 SwiftUI,其一大特性就是取消了大多数用户定义和托管视图的控制器。
在 SwiftUI 中,你可以使用一种简单的语法来声明你的用户界面。
此外,该接口完全由任意给定时间点的应用程序状态驱动。更改应用程序状态时,应用程序界面将立即更新以反映这些更改。
苹果将此概念称为“单一事实来源”(Single Source of Truth)。
WWDC19“通过 SwiftUI 的数据流”讲座
但是,应用程序的任何给定部分都应有一个单一事实来源,并不一定意味着整个应用程序也应该有一个单一事实来源。
搞糊涂了?下面具体解释。
正如我在文章“SwiftUI 中的 View Composition[1]”中所写的那样,苹果鼓励你将视图分解为许多小的、紧凑的、独立的组件,其中每个视图控制用户界面的一个特定部分。
我们再来看一下那篇文章中的一个组件,是一个收藏按钮(下图右上),用于指示给定项目应该已经被记录了,并显示在应用的“收藏夹”(favorites)列表中。
收藏按钮背后的代码如下:
struct FavoritesButton: View {
let item: MenuItem
@EnvironmentObject var favorites: FavoritesService
var imageName: String {
favorites.isFavorite(item) ? "star.fill" : "star"
}
var body: some View {
Image(systemName: imageName)
.foregroundColor(.accentColor)
.scaleEffect(1.2)
.onTapGesture {
self.favorites.toggleFavorite(self.item)
}
}
}
收藏按钮的界面和行为是完全自包含的,可以用在我们应用程序中任何视图的任何位置。如屏幕截图所示,我们甚至可以将其放入导航栏中。
struct DetailView: View {
let item: MenuItem
var body: some View {
ScrollView(.vertical) {
VStack {
...
}
}
.navigationBarTitle("Details", displayMode: .inline)
.navigationBarItems(trailing: FavoritesButton(item: item))
}
}
点击导航栏中的收藏按钮,当前项目会被标记为收藏状态。再点一下会移除收藏。无论如何,DetailView 都不了解按钮的内部细节或实现。
尽管收藏按钮界面背后的代码是自包含的,但视图的基本功能在内部依赖 FaovritesService,这是一个已定义的环境对象,已插入视图层次结构中的较高层级上。
FavoritesService 是一个 SwiftUI ObservableObject(可观察对象),它向我们的视图暴露一个发布的值和两个方法。一个是 isFavorite(item) 方法,该方法确定该项目是否已收入收藏夹;另一个是 toggleFavorite(item),该方法根据收藏情况切换项目的状态。
请注意,从此处或应用程序中的任何位置调用 toggleFavorite(item) 时,我们的收藏夹项目列表都会更新,进而依赖 FavoritesService 的任何视图都会被要求更新其视图表示。
class FavoritesService: ObservableObject {
@Published var items: [MenuItem] = []
func isFavorite(_ menuItem: MenuItem) -> Bool {
items.firstIndex(where: { $0.id == menuItem.id }) != nil
}
func toggleFavorite(_ menuItem: MenuItem) {
if let index = items.firstIndex(where: { $0.id == menuItem.id }) {
items.remove(at: index)
} else {
items.append(menuItem)
}
}
}
FavoritesService 是此特定视图的单一事实来源。它对于其他视图也可能是一个事实来源,但 FavoritesButton 不关心这个。FavoritesService 还遵守“单一责任原则”。它的目的是管理收藏夹菜单项列表,仅此而已。
我们看一下另一种服务,是一个非常简单的服务。
enum AppTabs: Int {
case favorites
case menu
case order
}
class AppState: ObservableObject {
@Published var currentTab = AppTabs.favorites
}
我们在这里跟踪应用程序的当前标签页状态,这样就可以根据需要在程序中转到特定标签页。
struct AppTabView: View {
@EnvironmentObject var appState: AppState
var body: some View {
TabView(selection: $appState.currentTab) {
FavoritesView()
.tabItem {
Image(systemName: "star")
Text("Favorites")
}
.tag(AppTabs.favorites)
...
}
}
}
还有一个服务。这里是来自同一应用的 OrderService,用于跟踪已订购的商品。
class OrderService: ObservableObject {
@Published var items = [MenuItem]()
var total: Int {
items.reduce(0) { $0 + $1.price }
}
func isInCart(_ menuItem: MenuItem) -> Bool {
items.firstIndex(where: { $0.id == menuItem.id }) != nil
}
func add(item: MenuItem) {
items.append(item)
}
func remove(item: MenuItem) {
if let index = items.firstIndex(of: item) {
items.remove(at: index)
}
}
}
由于应用程序的每个组件都应该有一个单一事实来源,因此有人提议 SwiftUI 应转向 Redux 风格的状态模型,整个应用程序应该有一个单一事实来源。
class AppState: ObservableObject {
@Published var currentTab = AppTabs.favorites
@Published var menuItems: [MenuItem] = []
@Published var favoriteItems: [MenuItem] = []
@Published var orderItems: [MenuItem] = []
}
或者,如果你想维护组件行之间的功能,则可以尝试以下操作:
class AppState: ObservableObject {
@Published var currentTab = AppTabs.favorites
@Published var menu = MenuService()
@Published var favorites = FavoritesService()
@Published var order = OrderService()
}
将全局 AppState 导入到各个需要数据的视图中,就完成了。
单个 AppState 的优点主要在于简单性。如前所述,你只需要处理一个 environmentObject 导入即可。
但对我来说,它的缺点有很多。
首先,它们会影响性能。对应用程序状态进行单个更改(例如,将单个项目标记为收藏状态),现在需要遍历应用程序中的每个单一视图树并检查更改。为什么?因为每个视图依赖的单一环境对象都发出了信号,表示一个更新已经发生了。
对于较小的应用程序,这里的性能影响可能不大。但是对于更大的应用呢?
(应该注意,这也是大型 React/Redux Web 应用程序面临的问题。)
我认为的第二大缺点涉及应用程序数据的全局暴露。
将 AppState 导入到单个视图中,然后所有内容都会暴露给所有人查看。既然如此,如果不仔细检查视图的每行代码,你如何确定特定视图可能正在访问或操纵的信息是什么?
上面的 FavoritesButton 就是一个很好的反例。只要看一下代码的开头部分,我就能看出这段代码可以看到或更改的唯一内容就是 FavoritesService,因为这是从应用程序环境中导入的唯一对象。
此外,如果我想在其他应用程序中使用 FavoritesButton,也很容易看出我还需要转移哪些内容到其他应用程序中。
第三个缺点涉及测试。我们将代码分解为视图模型和服务的主要动机之一,就是让代码更容易测试。
在 SwiftUI 中,我们的应用程序完全由其状态控制。因此,如果我们将该状态放入模型或服务,并且该状态由于用户触发的操作而更改,那么在测试中我们就可以触发这些操作并观察状态的变化。
如果状态针对每个可能的更改或动作能正确更新,那么我们就能对我们的应用程序是否正确具有很高的信心。
但将所有状态放到一个容器中,就很难单独测试各个模型或服务。这样做很适合集成测试,但不适合单元测试。
即便如此,因为全局状态的关系,出现未测试内容并因此产生预期之外的副作用的可能性也非常高。“哦,我没意识到它也在改变 那个 变量!”
正如我在 view-composition 那篇文章中所指出的,另一个 SwiftUI 最佳实践是将状态绑定到层次结构中尽可能低的位置上。
WWDC 19“通过 SwiftUI 的数据流”讲座
当我们绑定在层次结构中较低的位置时,由于任意给定更新只会影响视图树的一小部分,因而极大地减少了所需的接口更新和重新渲染的次数。
所有这些都显著提高了应用程序性能。
在上面的例子中,FavoritesService 直接绑定到需要它的对象上。要显示收藏按钮的 DetailView 既不知道也不在乎此事。当然,应该有较高级别的事物来提供它,但这是其他事物的另一种职责。
有人可能会问为什么我们称它们为服务,而不只是视图模型。
在这里,关键的区别因素在于,视图模型通常是为驱动单个屏幕、页面或视图而编写的,并且该视图拥有该视图模型。
另一方面,服务会被注入应用程序环境中视图层次结构的某个级别,供较低级别的元素使用,从而在 SwiftUI 应用程序中的多个视图和组件之间共享。只要这个级别持续存在,对应的服务就会持续存在。
实际上,当在 SceneDelegate 中创建初始内容视图时,往往会创建许多服务并将它们注入到视图层次结构的最顶层。
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// 创建 SwiftUI 视图,用来提供窗口内容。
let contentView = AppTabView()
.environmentObject(AppState())
.environmentObject(MenuService())
.environmentObject(MessageService())
.environmentObject(FavoritesService())
.environmentObject(OrderService())
.environmentObject(RatingsService())
// 使用一个 UIHostingController 作为窗口根视图控制器。
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
尽管更好的解决方案可能是使用一个系统服务修饰符,如“SwiftUI 和缺少的环境对象[2]”这篇文章中所述。
let contentView = AppTabView()
.modifier(SystemServices())
使用服务修饰符时如下所示:
struct SystemServices: ViewModifier {
private static var appState: AppState = AppState()
private static var menu = MenuService()
private static var messages = MessageService()
private static var favorites = FavoritesService()
private static var ratings = RatingsService()
private static var order = OrderService()
func body(content: Content) -> some View {
content
// defaults
.accentColor(.red)
// messages
.overlay(MessageOverlayView(), alignment: .top)
// services
.environmentObject(Self.appState)
.environmentObject(Self.menu)
.environmentObject(Self.messages)
.environmentObject(Self.favorites)
.environmentObject(Self.order)
.environmentObject(Self.ratings)
}
}
请注意,我们的 SystemServices 修饰符仅用来在需要时(例如当我们提供新的模态视图或动作表时)将服务注入 SwiftUI 环境。这就是为什么其成员是私有的原因。
微服务架构的含义是将应用程序安排为一组松散耦合的服务。这些服务是细粒度的,它们之间的协议是轻量级的。
在微服务架构中,服务是可独立部署的。拿上面的 FavoritesService 的例子来说,我们看到了我们可以轻松地在另一个应用程序中重新部署这个服务和对应的接口组件。
最后,将它们称为"微"服务进一步强化了这样的理念,也就是说我们的服务应该小巧、定义明确,并且每个服务的实现都应针对性管理我们应用程序的某个方面。
单一事实来源。
如果 SwiftUI 背后的主要目标是使用结构良好、独立且可复用的视图来构建应用程序,那么我们是否应该考虑以相同的方式实现内部服务架构?
这是我的观点,但如果你有其他意见,我也想听听。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8