1. Introduction
今天我们将开始讨论一些应用程序架构的想法,在Point-Free发布的一年半时间里,不知怎么的,我们还没有真正正面讨论过这些想法。关于如何使应用程序更易于理解和测试,我们已经讨论了许多广泛的概念,例如将副作用推到极限,尽可能多地使用纯函数,以及将函数和组合置于所有其他抽象之上。
因此,随着函数式编程的基础牢固地扎根于每个人的头脑中,我们现在可以开始讨论如何将所有这些想法粘合到构建大型、复杂应用程序的内聚故事中。但是,在此之前,我们需要探索一些问题空间,并看看当我们试图创建一个中等复杂的应用程序时,我们会遇到什么样的挑战。 在接下来的几集中不会有太多函数式编程,因为我们需要做好准备,这样我们就可以看到函数式编程对架构有什么影响。
因此,在下一节中,我们将开始构建一个应用程序,它将演示应用程序开发中出现的许多复杂情况。它将有一些需要被操作和持久化的状态,它将涉及从API请求加载数据并显示警报,它将有多个界面,所有这些都需要对全局应用程序状态进行更改。无论您是独立开发人员还是在大型团队中,拥有一种坚持己见和一致的方式来完成这些任务对于创建一个可伸缩的、可以长期维护的体系结构是至关重要的。
我们已经决定使用Swift 5.1和Xcode 11测试版在SwiftUI上做这个应用,但我们讨论的所有问题也同样适用于UIKit应用。我们之所以选择SwiftUI,是因为苹果在如何构建应用方面采取了比以往UIKit更强硬的立场。这给了我们一个绝佳的机会去理解为什么架构是重要的,以及我们如何利用苹果提供的工具来采用适合我们需求的架构。
同样值得注意的是,我们并没有对SwiftUI的工作方式进行全面的探索,我们只会描述我们需要让应用程序运行的内容。我们将详细解释我们使用的部分SwiftUI,但有些内容将在未来的章节中进行更深入的探讨。
2. A tour of the application
这是一款计数应用,可以让你检查一个数是否为质数(也就是说,一个数只能被1和它本身整除),如果是,你可以保存或从喜爱的质数列表中删除它。你也可以让它计算第n个素数,这实际上是对Wolfram Alpha做一个API请求,一个科学计算API。我们还可以回去查看我们喜欢的质数的整个列表,如果我们愿意,可以删除任何质数。我们在这个应用程序中没有进行任何样式设置,因为我们想完全关注数据如何在这个应用程序中移动的复杂性。
当然,这不是一个超级真实的应用程序的例子,它更像是一个玩具,但它触及了我们在任何应用程序的架构中需要解决的很多问题的核心:
-
有很多状态会改变UI的显示。 例如,按下一个按钮会导致一个新的界面出现,而新界面的内容取决于前一个界面上发生的事情,并且我们的这个按钮,它会触发一个网络请求,当请求正在运行时,我们想要禁用这个按钮。
-
状态有时必须跨越多个界面(在本例中它是最喜欢的质数的列表,因为我们需要它来表示是否添加或删除,我们需要在列表视图中所有的质数都是我们最喜欢的)。
-
有许多小的子组件需要拼凑在一起,使应用成为一个整体。我们希望这些界面可以在不了解更大的应用程序的情况下独立开发,甚至可以将它们放在自己的Swift包中。
-
特别是当我们想要计算“n”素数时发生的API请求,会有一些副作用会发生。我们希望有一种自以为是的方式将这种影响引入到我们的视图中,这样我们就不会到处散布网络请求了。这样做会使我们很难理解数据是如何在应用程序中流动的,并使视图更难测试。
3. The navigation screen
让我们通过做一些简单的事情来接触SwiftUI。 我们要创建这个根视图。它只有一个标题视图和两个垂直堆叠的按钮。 无论如何,当你开始在SwiftUI创建一个视图时,你总是先创建一个符合view协议的结构,我喜欢使用EmptyView来实现基本的一致性:
import SwiftUI
struct ContentView: View {
var body: some View {
EmptyView()
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(
rootView: ContentView()
)
现在我们已经遇到了一些有趣的Swift 5.1的新东西:
-
body属性的类型中有some关键字。幸运的是,为了在这个界面上取得进展,我们不需要深入了解它是如何工作的。我们将在以后的章节中更深入地讨论一些概念,但现在只需要知道,在这个属性的主体中,我们只需要返回一些符合View协议的值。
-
我们可以忽略computed属性体的return,因为它只是一个一行代码块
为了在playground中获得这个视图渲染,我们需要将它包装在UIHostingController中,它是SwiftUI世界和UIKit世界之间的桥梁。
接下来,我们要开始填充这个视图的主体。因为我们想从这个视图导航到其他子视图,我们的根视图将是NavigationView:
struct ContentView: View {
var body: some View {
NavigationView {
}
}
}
这将允许我们在新界面上创建按钮。说到这里,我们可以使用NavigationLink元素来创建其中一些:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: EmptyView()) {
Text("Counter demo")
}
NavigationLink(destination: EmptyView()) {
Text("Favorite primes")
}
}
}
}
只显示了第一个导航链接,因为我们还没有告诉SwiftUI这两个元素应该如何流动:它们应该并排还是堆叠在一起? 如果我们将它们包装在一个List中,我们可以让它们都很好地呈现。
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: EmptyView()) {
Text("Counter demo")
}
NavigationLink(destination: EmptyView()) {
Text("Favorite primes")
}
}
}
}
}
最后,我们可以通过在NavigationView的根级视图中设置navigationBarTitle来为这个视图添加标题:
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: EmptyView()) {
Text("Counter demo")
}
NavigationLink(destination: EmptyView()) {
Text("Favorite primes")
}
}
.navigationBarTitle("State management")
}
}
}
值得指出的是,我们使用EmptyView作为目的地,因为我们还没有这些视图的内容。这是一个方便的技巧来慢慢填充UI。如果有什么东西需要视图,但你手边没有任何东西,你可以只使用EmptyView。这类似于我们在本系列中多次使用的实现函数,只不过我们使用了fatalError,这是一个Never返回的函数。
就这样,我们有了第一个界面! 点击按钮只会导致空白屏幕,但我们能够如此迅速地将内容呈现在屏幕上。
4. The counter screen
接下来,我们将开始建造计数器界面。我们可以开始建立一些脚手架和切换到我们的实时视图:
struct CounterView: View {
var body: some View {
EmptyView()
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(
// rootView: ContentView()
rootView: CounterView()
)
现在我们需要使用HStacks和VStacks来构建counter视图的内容,以便水平和垂直堆叠视图。
struct CounterView: View {
var body: some View {
VStack {
HStack {
Button(action: {}) {
Text("-")
}
Text("0")
Button(action: {}) {
Text("+")
}
}
Button(action: {}) {
Text("Is this prime?")
}
Button(action: {}) {
Text("What is the 0th prime?")
}
}
}
}
创建Button需要指定一个操作闭包,该闭包在按下按钮时执行。我们很快就会想在那里做一些工作,但现在我们将提供一个无操作闭包
在本集中,我们有意不讨论样式,但为了使内容更容易阅读,让我们将该视图的字体增大到与标题大小相同。
.font(.title)
现在所有的核心UI都就位了,但我们需要更新我们的根内容视图,这样点击一个按钮就会让我们深入到这个视图:
NavigationLink(destination: CounterView()) {
现在我们可以向下进入到这个视图。
为了让标题就位,我们需要添加一个导航标题到计数器视图。
.navigationBarTitle("Counter demo")
这个屏幕上没有任何连接。当点击任何一个按钮,什么都不会发生。
SwiftUI为我们提供了一些将状态引入视图的选项,只要状态发生变化,视图就会用更新状态重新呈现。 让我们从最简单的选项开始,首先在视图中引入一个var属性,用@State属性标记:
@State var count: Int = 0
这是Swift的一项新功能,称为“property wrapper”。它是一种机制,允许您将一个类型封装到另一个提供一些功能的类型中,同时仍然直接向我们公开底层封装的值。 这个@State属性用一个新对象包装了一个整数,它为我们做两件事:
它为我们提供了一个count变量,我们可以在视图中使用该变量来基于该值更新其表示。
self.count // Int
最简单的就是把计数的值放在TextView里面:
Text("\(self.count)")
非常容易。稍微复杂一点的是更新负责计算“第n个素数”的按钮的标签。 我们想要将count值转换为顺序(例如,1、2、3等),我们可以通过一个数字格式化程序来实现:
private func ordinal(_ n: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter.string(for: n) ?? ""
}
现在我们可以在按钮的标签中使用这个助手函数。
Button(action: {}) {
Text("What's the \(ordinal(self.count)) prime?")
}
到目前为止,@State似乎没有做什么。我们只是直接访问整数计数。然而,在幕后,@State属性也把count包装进binding:
self.$count // Binding<Int>
这是SwiftUI用来知道值何时被更新的一种类型,这样它就可以用更新的信息重新计算它的视图。 这真的很强大,因为在视图中,我们可以以简单、熟悉的方式改变这个变量,但在内部,SwiftUI会做一些工作,以确保变化传播到适当的位置。
例如,在+/-按钮中,我们可以对count变量做一个非常简单的改变:
Button(action: { self.count -= 1 }) {
Text("-")
}
Button(action: { self.count += 1 }) {
Text("+")
}
就像这样,我们已经有了一个半功能的应用程序。点击这些按钮现在导致计数器值上升和下降,甚至改变按钮的标签。
5. Persisting the counter screen
站起来跑起来很容易,几乎太容易了。事实上,这是一个相当严重的问题。使用@State属性,我们指定视图有一些它关心的本地状态,但是没有简单的方法允许该状态传播到其他屏幕。我们可以通过简单地改变计数器来看到这一点,回到主界面,然后回到计数器。它被重置为0,而不是保持以前的值。
这样做的原因是@State专门用于本地的、非持久化的状态,只有这个视图才会关心和想要控制这些状态。 典型的例子是按钮的高亮状态,它完全由用户的触摸控制,因此不需要移动到比按钮本身更远的地方。
对于您确实希望状态持久存在的情况,SwiftUI提供了另一个称为@ObjectBinding的概念。这与@State的工作方式基本相同,不同的是它让您负责描述状态的变化如何发生,以及这些变化如何通知SwiftUI系统。有了这种权限,你就可以将状态表示为一个更全局的对象,而不是视图的局部对象,这样状态的变化就可以影响到整个应用程序。
首先,我们将@State改为@ObjectBinding:
@ObjectBinding var count: Int = 0
这里有两个问题:
-
首先,我们不应该将这个值默认为0,因为每次这个屏幕打开时计数器都从0开始,这不再是正确的。我们希望它引用一个持久化的全局值,因此应该去掉默认值,只指定类型:var count: Int。
-
其次,@ObjectBinding的值的类型必须符合BindableObject协议。 正是实现了这个协议,我们才能控制持久性状态的变化以及如何通知系统的其他部分。
这第二点意味着我们需要将计数值包装在符合此协议的新类型中。我们可能会尝试使用结构体,因为我们知道值类型很棒:
struct AppState: BindableObject {
var count: Int
}
但我们马上就遇到了一个问题:
🛑 Non-class type ‘AppState’ cannot conform to class protocol ‘BindableObject’
这是因为BindableObject协议继承自AnyObject,这意味着它必须是一个类。 这是有意义的,因为我们想要一个单一的,持久的应用状态源,所以复制我们的状态并对其进行操作是没有意义的。我们希望所有操作都发生在单一的真实数据上。
让我们换成一个类:
class AppState: BindableObject {
var count: Int
}
现在有几个编译器错误告诉我们count没有初始化,我们还没有满足协议的所有要求。
我们可以默认count为0。
class AppState: BindableObject {
var count = 0
}
然后为了符合协议,我们必须提供一个didChange属性。自动完成有点让人困惑,但这里真正要做的是我们需要提供一个didChange发布者:
var didChange: AppState.PublisherType
Correction
In Xcode 11 beta 5 and later versions, SwiftUI’s BindableObject protocol was deprecated in favor of an ObservableObject protocol that was introduced to the Combine framework. This protocol utilizes an objectWillChange property of ObservableObjectPublisher, which is pinged before (not after) any mutations are made to your model:
let objectDidChange = ObservableObjectPublisher()
This boilerplate is also not necessary, as the ObservableObject protocol will synthesize a default publisher for you automatically.
现在publishers是与SwiftUI一起发布的Combine框架中的一个概念,我们将在未来的章节中对它进行大量介绍,但现在我们可以将其视为一种机制,当有什么变化时,我们可以通知感兴趣的订阅户。出于我们的目的,我们可以使用PassthroughSubject,它有两个泛型:一个用于它可以发出的值,另一个用于它可以完成的错误。 再次简化,我们将使用Void和Never来代表一个主题,当一些事情发生变化时,它不会发出任何感兴趣的东西,而且永远不会失败:
var didChange = PassthroughSubject<Void, Never>()
这种机制的工作原理与通知中心非常相似,但本地化程度更高。任何对监听这个对象的更改感兴趣的人都可以很容易地订阅,我们可以通过简单地点击这个值的send方法来通知更改。
这就足够让编译器满意了,但它还没有做任何事情。 每次我们的模型改变时,我们都需要ping这个publisher,幸运的是Swift让这变得非常简单:我们只需要给我们的属性附加一个didSet处理程序:
var count = 0 {
didSet {
self.didChange.send()
}
}
Correction
With Xcode 11 beta 5 and later, willSet should be used instead of didSet:
var count = 0 {
willSet {
self.objectWillChange.send()
}
}
Or you can remove this boilerplate entirely by using a @Published property wrapper:
@Published var count = 0
这就是我们为应用程序获得持久状态所需要做的一切。为了将它连接到视图,我们将更新状态变量:
@ObjectBinding var state: AppState
Correction
With Xcode 11 beta 5 and later, SwiftUI’s @ObjectBinding property wrapper was deprecated in favor of the @ObservedObject wrapper introduced to the Combine framework.
这将破坏我们视图中的一些东西,因为我们不再有count字段,而是必须通过state字段访问它:
Button(action: { self.state.count -= 1 }) {
…
Text("\(self.state.count)")
…
Button(action: { self.state.count += 1 }) {
…
Text("What's the \(ordinal(self.state.count)) prime?")
最后,唯一需要解决的事情是,当我们深入时,显式地将app状态传递给计数器视图:
NavigationLink(destination: CounterView(state: <#AppState#>)) {
但为了做到这一点,我们的ContentView也需要访问应用状态,所以我想我们应该添加它:
struct ContentView: View {
@ObjectBinding var state: AppState
然后我们可以把它传递下去:
NavigationLink(destination: CounterView(state: self.state)) {
最后,当它第一次实例化到playground实时视图时,我们需要提供应用的状态给ContentView:
PlayergroundPage.current.live = UIHostingController(
rootView: ContentView(state: AppState())
)
好的,所以我们必须做一些管道,以在我们的每个视图适当地得到我们的全局应用状态,但做这项工作的好处是,现在计数值将持续在所有屏幕上。我们可以向下深入计数器,更改它,回到主屏幕,再向下深入,一切都恢复到以前的状态。所以我们用SwiftUI中的@ObjectBinding只需要很少的工作就实现了一致性。
6. Next time: prime checking
现在我们知道了如何在视图中表达状态,如何让视图对状态的变化做出反应,甚至如何在整个应用程序中一致化状态,让我们在应用程序中构建另一个界面。我们来做质数检查模态视图。 当你点击“这是质数吗?”按钮,它会显示一个标签,让你知道当前计数器是否为素数,它还会给你一个按钮,用于从收藏列表中保存或删除该数字。