学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

Swift花园笔记 - SwiftUI知识碎片 下

Hot Prospects 理解 Swift 的 Result 类型

让一个函数在执行成功时返回某些数据,执行失败时返回某个错误是很常见的做法。我们通常会利用抛出错误的函数来实现这个要求,一旦函数抛出错误运行 catch 块,这样就独立地处理成功和失败的逻辑。但是假如函数并不是立即返回的呢?

我们可以回顾一下之前使用过的 URLSession 的网络代码,然后在一个新的默认工程里看看下面这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
Text("Hello, World!")
.onAppear {
let url = URL(string: "https://www.apple.com")!
URLSession.shared.dataTask(with: url) { data, response, error in
if data != nil {
print("We got data!")
} else if let error = error {
print(error.localizedDescription)
}
}.resume()
}

文本视图呈现的时候,网络请求就会启动,从 http://apple.com 获取数据,然后根据网络请求的执行情况打印两条消息中的一条。

回忆一下,我们说过完成闭包要么会设置 data,要么会设置 error —— 不能两者都设置,也不能两者都不设置,因为这两种情况都不合理。但是由于 URLSession 并没有强制这个约束,我们不得不写代码处理不可能的情况,只是为了让所有的代码分支能被覆盖。

Swift 对此提供了一种解决方案,它是一个叫 Result 的专用数据类型。它能帮我们实现非此即彼的行为,同时也很好适用于非阻塞式的函数 —— 这是一种异步执行工作的函数,因此它们不会阻塞主要代码的执行。作为额外的好处,它允许我们返回特定类型的错误,这就让出错时排查错误变得更加容易。

我们要做的是给上面的网络代码添加一层封装,让它是利用 Swift 的 Result 类型,也就是说,你可以很清楚地看到改造前后的差异。

首先,我们要定义可能被抛出的错误的类型。如果你愿意,可以定义任意多,但在这里,我们假定只有 URL 错误,请求失败和未知错误三种情况。把下面这个枚举放到 ContentView 结构体外面:

1
2
3
enum NetworkError: Error {
case badURL, requestFailed, unknown
}

接下来,我们要写一个方法,这个方法能够返回一个 Result。记住,Result 是用于代表某种成功或者失败的情况。在这个例子里,我们说成功的情况是某个从网络返回的字符串,而错误的情况就是 NetworkError 的某一种。

我们要逐渐加大难度,把同一个方法的写法升级四次。系好安全带,你会看到东西是怎么建起来的。先从最简单的版本开始,我们直接返回一个 URL 错误的版本,像下面这样:

1
2
3
func fetchData(from urlString: String) -> Result<String, NetworkError> {
.failure(.badURL)
}

如你所见,方法的返回类型是 Result<String, NetworkError>,也就说,要么是一个代表成功的字符串,要么是代表失败的某个 NetworkError。注意,这个时候函数还是阻塞式的调用,一个非常快的调用。

但我们实际上要的是一个非阻塞式的函数,也就是说,我们不能返回一个 Result。取而代之的是,我们需要让我们的方法接收两个参数:一个用于 URL 请求,另一个是带一个执行参数的完成闭包。这意味着函数本身不返回任何东西,它的数据会被返回给完成闭包,这个闭包是在未来某个节点被调用。

这一次,为了让事情简化,我们还是直接使用 URL 错误的失败作为默认的实现:

1
2
3
func fetchData(from urlString: String, completion: (Result<String, NetworkError>) -> Void) {
completion(.failure(.badURL))
}

我们使用完成闭包的目的是让方法变成非阻塞式的:在方法里面,我们可以启动一些异步的工作,让方法直接返回,以便后面的代码能够继续运行,然后在未来某个时间调用完成闭包。

这里面有一个难点,我之前简要提过,现在变得很重要了。当我们把一个闭包传给一个函数时,Swift 需要知道这个闭包是被立刻使用还是可能稍后才被使用。如果它是被立即使用的 —— 也就是默认的情况 —— Swift 很欣然接受代码,然后运行闭包。但如果它是稍后才使用的,那么很有可能创建闭包的东西在闭包被调用时已经被销毁掉,不再存在于内存中,这个时候闭包也会被销毁,不被执行。为了处理这种情况,Swift 允许我们给闭包参数标记 @escaping(逃逸闭包),它的意思是“这个闭包可能会脱离当前方法的运行周期被使用,所以请在内存中保留它,直到我们把事情做完。”

以我们的方法为例,我们将先执行一个异步的工作,然后调用在该工作做完时调用你闭包。这个调用动作可能立即发生,也可能需要几分钟。但我们不关心这一点,关键是闭包在方法返回之后还需要保留,因此我们必须把它标记为@escaping。你可能会担心自己遗漏这一点,大可不必担心:如果你不加 @escaping 属性的话 Swift 实际上回拒绝编译。

下面是函数的第三个版本,使用了 @escaping 的闭包,以便我们可以异步调用:

1
2
3
4
5
func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
DispatchQueue.main.async {
completion(.failure(.badURL))
}
}

记住,完成闭包是在未来某个时点被调用的。

最后是第四个版本:我们要讲 URLSessioncode 合入之前的 Result。这个版本的函数签名不变 —— 仍是接收一个字符串和一个闭包,不返回任何东西 —— 但这次我们调用完成闭包的方式不同:

  1. 如果 URL 非法,我们调用 completion(.failure(.badURL))
  2. 如果我们从请求的返回中得到合法的数据,则将其转换成字符串并调用 completion(.success(stringData))
  3. 如果我们从请求得到错误,则调用 completion(.failure(.requestFailed))
  4. 如果既没有得到数据,也没有得到错误,则调用 completion(.failure(.unknown))

这里头唯一的新知识点是将 Data 实例转换成字符串。回忆一下,你知道如何从字符串构建 Data: let data = Data(someString.utf8),而从 DataString 的代码是相似的:

1
let stringData = String(decoding: data, as: UTF8.self)

好了,下面是完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
// check the URL is OK, otherwise return with a failure
guard let url = URL(string: urlString) else {
completion(.failure(.badURL))
return
}

URLSession.shared.dataTask(with: url) { data, response, error in
// the task has completed – push our work back to the main thread
DispatchQueue.main.async {
if let data = data {
// success: convert the data to a string and send it back
let stringData = String(decoding: data, as: UTF8.self)
completion(.success(stringData))
} else if error != nil {
// any sort of network failure
completion(.failure(.requestFailed))
} else {
// this ought not to be possible, yet here we are
completion(.failure(.unknown))
}
}
}.resume()
}

讲完四个版本的函数费了不少篇幅,之所以一步一步解释的原因在于需要理解消化的内容着实不少。 最后的代码实现了一个更清爽的 API,借助它我们可以确保要么得到字符串,要么得到某个错误 —— 不可能同时得到两者或两者都得不到,这正是 Result 的特点。更棒的是,我们得到错误的话,必定是 NetworkError 的某一条 case,这使得错误处理更加容易。

目前为止我们实现了使用 Result 的函数,但还没有编写处理 Result 的函数。无论何种情况,Result 总是携带两部分的信息:结果的类型(成功或者失败),以及内部包含的东西。对于我们而言,这东西就是字符串或者某个 NetworkError。在幕后,Result 实际上是一个有关联值的枚举,Swift 对此提供了特别的语法:我们可以对 Result 使用 switch,编写像 .success(let str) 这样的代码来表示 “如果成功,取出字符串放进一个叫 str 的新常量中。” 这样的意思。

在实例中更容易明白我的意思,就让我们在文本视图的 onAppear 闭包里处理所有可能的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Text("Hello, World!")
.onAppear {
self.fetchData(from: "https://www.apple.com") { result in
switch result {
case .success(let str):
print(str)
case .failure(let error):
switch error {
case .badURL:
print("Bad URL")
case .requestFailed:
print("Network problems")
case .unknown:
print("Unknown error")
}
}
}
}

希望你能发现这么做的益处:我们不仅消除了对于返回的数据做检查的不确定因素,也完全消除了可选性。对于错误处理,甚至不再需要 defaultcase,因为 NetworkError 的所有 case 都会被覆盖到。

allowsHitTesting()

SwiftUI 可以让我们通过设置 allowsHitTesting() 为 false 来禁用交互,在项目中我们可以用它来在倒计时消耗完时禁用卡片的轻扫操作。

在内层的 ZStack 里添加这个 modifier —— 显示卡片的那个 stack:
.allowsHitTesting(timeRemaining > 0)
只有在 timeRemaining 大于等于 1 时才可以接收拖拽手势。

手动发布ObservedObject的变化

遵循 ObservableObject 协议的类可以使用 SwiftUI 的 @Published 属性包装器来自动发布属性的变化,以便使用该类的实例的任何视图能够自动重新调用 body 属性,保持界面与数据的一致。多数情况下,这个机制都可以很好 地工作,不过有时候你可能会需要更多的控制,SwiftUI 对此的解决方式是 objectWillChange

每个遵循 ObservableObject 的类都自动获得一个叫 objectWillChange 的属性。它是一个 publisher,也就是说它做的是和 @Published 属性包装器一样的事情:通知正在观察的视图被观察的对象即将有重要的事情发生。正如它的名字所暗示的,这个 publisher 是在我们即将做出改变的时候发出,这能让 SwiftUI 检查 UI 的状态,并未动画化改变做好准备。

为了演示这一点,我们将构建一个会更新自己 10 次的 ObservableObject 子类。你之前已经见过用 DispatchQueue.main.async() 将工作推回主线程的做法,这一次我们要认识一个类似的方法,叫 DispatchQueue.main.asyncAfter()。它能指定附加的闭包在何时运行,也就是说,我们可以要求闭包 “1 秒后运行”,而不是立刻运行。

在测试的例子中, 我们将在一个从 1 到 10 的循环中使用 asyncAfter() 增加一个整数。这个整数会用 @Published 包装,这样它的所有变化都会被发布给观察该对象的视图。

在代码的某个地方添加下面这个类:

1
2
3
4
5
6
7
8
9
10
11
class DelayedUpdater: ObservableObject {
@Published var value = 0

init() {
for i in 1...10 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i)) {
self.value += 1
}
}
}
}

使用这个类只需要在 ContentView 的某个地方用 ObservedObject 注解某个 DelayedUpdate 类型的属性,然后在 body 中显示它的值,如下:

1
2
3
4
5
6
7
struct ContentView: View {
@ObservedObject var updater = DelayedUpdater()

var body: some View {
Text("Value is: \(updater.value)")
}
}

运行代码,你会看到数值一直往上增加,知道 10,正如你预期的那样。现在,移除 @Published,你会看到 UI 不再变化了。虽然幕后的 asyncAfter() 仍然在执行,但由于没有变化通知发出,UI 不再刷新。我们可以通过手动发送我前面提到的 objectWillChange 来解决。这种方式能让我们在任意时刻发送变化通知,而不用依赖于 @Published 的自动行为。

把 value 属性改成下面这样:

1
2
3
4
5
var value = 0 {
willSet {
objectWillChange.send()
}
}

这样改完你又获得和之前一样的应用行为 —— UI 会计数增加到 10。但这回,我们有机会在 willSet 观察者中添加额外的功能。也许你需要打日志,或者调用另一个方法,又或者你要 clamp 整数,确保它永远不会超出某个范围 —— 一切尽在掌握之中。

薛定谔的 @State

value$value_value代表三个完全不同的东西!

这几个 @Things 其实只是 SwiftUI 框架中的几个结构体,并非 Swift 语言的一部分。

而真正属于语言的一部分的是 Swift 5.1 引入的一个新特性:属性包装器.

当我们用 SwiftUI 里的 @Something 给变量标注属性时,比如 @State var value: Int = 0,Swift 编译器将为我们生成三个变量!(其中有两个是计算属性):

  1. value —— 被包装的由我们声明类型的原始值(wrappedValue),比如例子中的 Int。
  2. $value —— 一个 “额外的” projectedValue,它的类型由我们使用的属性包装器决定。@State projectedValue 的类型是 Binding,因此我们的例子中就是 Binding 类型。
  3. _value —— 属性包装器本身的引用,在视图初始化过程中可能用到:
1
2
3
4
5
6
7
struct MyView: View {
@Binding var flag: Bool

init(flag: Binding<Bool>) {
self._flag = flag
}
}

根据 projectedValue 进行分类

让我们浏览一下 SwiftUI 中最常用的 @Things,看看他们的 projectedValue 分别都是些什么:

  • @State —— Binding
  • @Binding —— Binding
  • @ObservedObject —— Binding (*)
  • @EnvironmentObject - Binding (*)
  • @Published - Publisher<Value, Never>

技术上来讲,(*) 给到我们的是 Wrapper 类型的中间值,一旦我们为该对象中的实际值指定了 keyPath,就会变成一个 Binding

如你所见,SwiftUI 中大部分的属性包装器,其职能都是跟视图的状态有关,并且被投射为 Binding,用于在视图之间传递状态。唯一的跟大多数包装器不同的是 @Published,不过请注意:

  1. 它是在 Combine 框架而不是 SwiftUI 里声明的
  2. 它的用途是让值变为可观察的
  3. 它不用于视图的变量声明,只用在 ObservableObject 内部。

考虑一个在 SwiftUI 中相当常见的场景:声明一个 ObservableObject,并在某个视图中以 @ObservedObject 属性使用它:

1
2
3
4
5
6
7
8
9
class ViewModel: ObservableObject {
@Published var value: Int = 0
}

struct MyView: View {
@ObservedObject var viewModel = ViewModel()

var body: some View { ... }
}

MyView 可以引用 $viewModel.valueviewModel.$value —— 两个表达式都是合法的。有点犯迷糊了是不是?

其实这两个表达式分别代表了完全不同的两个类型:BindingPublisher

两者都有实际的用途:

1
2
3
4
5
6
var body: some View {
OtherView(binding: $viewModel.value) // Binding
.onReceive(viewModel.$value) { value // Publisher
// 执行某些不需要视图更新的操作
}
}

薛定谔的 @State

我们都知道包含在一个不可变的 struct 内部的 struct 也是不可变的。

在 SwiftUI 中,多数情况下我们面对是一个不可修改的 self,例如,在某个 Button 的回调中。基于这种上下文,每个实例变量,包括 @State 结构体也都是不可变的。

那么,你能解释一下为什么下面的代码是完全合法的吗?

1
2
3
4
5
6
7
8
9
struct MyView: View {
@State var counter: Int = 0

var body: some View {
Button(action: {
self.counter += 1 // 修改一个不可变的结构体!
}, label: { Text("Tap me!") })
}
}
  • @State 有什么魔法?

这里有一份关于 SwiftUI 如何处理这种场景下的值的变化的详细解释,但这里我想强调一个事实:对于 @State 变量实际的值,SwiftUI 使用了隐藏的外部存储。

@State 其实是一个代理:它拥有一个内部变量 _location,用于访问外部存储。

让我给你出道面试题:下面这个例子会打印出什么内容?

1
2
3
4
5
func test() {
var view = MyView()
view.counter = 10
print("\(view.counter)")
}

上面的代码相当直观;直觉告诉我们打印的值应该是 10。

然而并不是 —— 输出是 0。

这其中的玄机在于视图并非总是同状态存储连接:SwiftUI 会在视图需要重绘或者视图接收来自 SwiftUI 的回调的时候接通连接,而在之后又断开。

与此同时,在 DispatchQueue.main.async 中对 State 做出的修改将不能保证成功:某些时候可能是工作的。但假如你引入某个延迟,而存储连接在闭包执行时已经被断开了,那么状态修改就不会生效了。

对于 SwiftUI 视图来说,传统的异步分发是不安全的 —— 不要引火烧身。

幽灵般的状态更新

在用了多年的 RxSwift 和 ReactiveSwift 之后,对于数据流通过响应式绑定和视图的属性建立连接这件事,我认为是理所当然的。

但是当我尝试将 SwiftUI 和 Combine 放在一起协作的时候,我震惊了。这两个框架之间表现得相当异质:一方并不能很轻松地把某个 Publisher 连接到某个 Binding,或者把某个 CurrentValueSubject 转换成 ObservableObject。两种框架之间互操作的方式只有几种。

第一个接触点是 ObservableObject —— 它是一个声明在 Combine 里的协议,但已经广泛地用于 SwiftUI 的视图。

第二个是 .onReceive() 视图 modifier,它是让你将视图和任意数据连接的唯一 API。我的下一个大大的疑惑正是和这个 modifier 有关。看一下这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct MyView: View {

let publisher: AnyPublisher<String, Never>

@State var text: String = ""
@State var didAppear: Bool = false

var body: some View {
Text(text)
.onAppear { self.didAppear = true }
.onReceive(publisher) {
print("onReceive")
self.text = $0
}
}
}

这是视图只是显示了由 Publisher 生产的字符串,并且在视图出现在屏幕时设置 didAppear 标记 ,就这么简单而已。

现在,试着回答我,你认为在下面这两个用例中,print(“onReceive”) 会被触发几次?

1
2
3
4
5
6
7
8
9
struct TestView: View {

let publisher = PassthroughSubject<String, Never>() // 1
let publisher = CurrentValueSubject<String, Never>("") // 2

var body: some View {
MyView(publisher: publisher.eraseToAnyPublisher())
}
}

让我们先考虑 PassthroughSubject。

如果你的答案是 0,那么恭喜你,回答正确。PassthroughSubject 从未接收到任何值,因此没有东西会被提交到 onReceive 闭包。

第二用例有一点欺骗性。请认真点,仔细分析其中的猫腻。

当试图被创建时,onReceive modifier 将订阅 Publisher,提供无限制的值“要求” (参考 Combine 中的说明)。

由于 CurrentValueSubject 拥有初始值 “” ,它会立即将值推送给它的新订阅者,触发 onReceive 回调。

然后,当视图即将第一次显示在屏幕上时,SwiftUI 会调用它的 onAppear 回调,在我们的例子,这个回调会通过设置 didAppear 为 true 来修改视图的状态。

那么接下来会发生什么? 你猜的没错!onReceive 闭包再次调用了!为什么会这样?

当 MyView 修改 onAppear 中的状态时,SwiftUI 需要创建一个新的视图,以便和状态改变之前的视图做对比! 这是给视图层级打上合适的补丁所要求的步骤。

由于第二次创建过程的视图也订阅了 Publisher,后者欢欢喜喜地又推送了自己的值。

正确答案是 2。

你能想象我在调试这些被传递给 onReceive 的幽灵般的更新调用时的困惑吗?当我试图去过滤掉这些重复的更新调用时,我的脑门上挂满了问号。

最后一个测验:如果我们在 onAppear 里设置 self.text = “abc”,那最后会显示什么文本?

如果你不知道上面这个故事,那合乎逻辑的答案应当是 “abc”,但是当你已经用新知识升级了自己:无论何时何地你给 text 赋值,onReceive 回调都会如影随形,用 CurrentValueSubject 的值擦掉你刚刚赋的值。