Hot Prospects 理解 Swift 的 Result 类型
让一个函数在执行成功时返回某些数据,执行失败时返回某个错误是很常见的做法。我们通常会利用抛出错误的函数来实现这个要求,一旦函数抛出错误运行 catch
块,这样就独立地处理成功和失败的逻辑。但是假如函数并不是立即返回的呢?
我们可以回顾一下之前使用过的 URLSession
的网络代码,然后在一个新的默认工程里看看下面这样一个例子:
1 | Text("Hello, World!") |
文本视图呈现的时候,网络请求就会启动,从 http://apple.com
获取数据,然后根据网络请求的执行情况打印两条消息中的一条。
回忆一下,我们说过完成闭包要么会设置 data
,要么会设置 error
—— 不能两者都设置,也不能两者都不设置,因为这两种情况都不合理。但是由于 URLSession
并没有强制这个约束,我们不得不写代码处理不可能的情况,只是为了让所有的代码分支能被覆盖。
Swift 对此提供了一种解决方案,它是一个叫 Result
的专用数据类型。它能帮我们实现非此即彼的行为,同时也很好适用于非阻塞式的函数 —— 这是一种异步执行工作的函数,因此它们不会阻塞主要代码的执行。作为额外的好处,它允许我们返回特定类型的错误,这就让出错时排查错误变得更加容易。
我们要做的是给上面的网络代码添加一层封装,让它是利用 Swift 的 Result
类型,也就是说,你可以很清楚地看到改造前后的差异。
首先,我们要定义可能被抛出的错误的类型。如果你愿意,可以定义任意多,但在这里,我们假定只有 URL 错误,请求失败和未知错误三种情况。把下面这个枚举放到 ContentView
结构体外面:
1 | enum NetworkError: Error { |
接下来,我们要写一个方法,这个方法能够返回一个 Result
。记住,Result
是用于代表某种成功或者失败的情况。在这个例子里,我们说成功的情况是某个从网络返回的字符串,而错误的情况就是 NetworkError
的某一种。
我们要逐渐加大难度,把同一个方法的写法升级四次。系好安全带,你会看到东西是怎么建起来的。先从最简单的版本开始,我们直接返回一个 URL
错误的版本,像下面这样:
1 | func fetchData(from urlString: String) -> Result<String, NetworkError> { |
如你所见,方法的返回类型是 Result<String, NetworkError>
,也就说,要么是一个代表成功的字符串,要么是代表失败的某个 NetworkError
。注意,这个时候函数还是阻塞式的调用,一个非常快的调用。
但我们实际上要的是一个非阻塞式的函数,也就是说,我们不能返回一个 Result
。取而代之的是,我们需要让我们的方法接收两个参数:一个用于 URL
请求,另一个是带一个执行参数的完成闭包。这意味着函数本身不返回任何东西,它的数据会被返回给完成闭包,这个闭包是在未来某个节点被调用。
这一次,为了让事情简化,我们还是直接使用 URL 错误的失败作为默认的实现:
1 | func fetchData(from urlString: String, completion: (Result<String, NetworkError>) -> Void) { |
我们使用完成闭包的目的是让方法变成非阻塞式的:在方法里面,我们可以启动一些异步的工作,让方法直接返回,以便后面的代码能够继续运行,然后在未来某个时间调用完成闭包。
这里面有一个难点,我之前简要提过,现在变得很重要了。当我们把一个闭包传给一个函数时,Swift 需要知道这个闭包是被立刻使用还是可能稍后才被使用。如果它是被立即使用的 —— 也就是默认的情况 —— Swift 很欣然接受代码,然后运行闭包。但如果它是稍后才使用的,那么很有可能创建闭包的东西在闭包被调用时已经被销毁掉,不再存在于内存中,这个时候闭包也会被销毁,不被执行。为了处理这种情况,Swift 允许我们给闭包参数标记 @escaping
(逃逸闭包),它的意思是“这个闭包可能会脱离当前方法的运行周期被使用,所以请在内存中保留它,直到我们把事情做完。”
以我们的方法为例,我们将先执行一个异步的工作,然后调用在该工作做完时调用你闭包。这个调用动作可能立即发生,也可能需要几分钟。但我们不关心这一点,关键是闭包在方法返回之后还需要保留,因此我们必须把它标记为@escaping
。你可能会担心自己遗漏这一点,大可不必担心:如果你不加 @escaping
属性的话 Swift 实际上回拒绝编译。
下面是函数的第三个版本,使用了 @escaping
的闭包,以便我们可以异步调用:
1 | func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) { |
记住,完成闭包是在未来某个时点被调用的。
最后是第四个版本:我们要讲 URLSession
的 code
合入之前的 Result
。这个版本的函数签名不变 —— 仍是接收一个字符串和一个闭包,不返回任何东西 —— 但这次我们调用完成闭包的方式不同:
- 如果
URL
非法,我们调用completion(.failure(.badURL))
。 - 如果我们从请求的返回中得到合法的数据,则将其转换成字符串并调用
completion(.success(stringData))
。 - 如果我们从请求得到错误,则调用
completion(.failure(.requestFailed))
。 - 如果既没有得到数据,也没有得到错误,则调用
completion(.failure(.unknown))
。
这里头唯一的新知识点是将 Data
实例转换成字符串。回忆一下,你知道如何从字符串构建 Data: let data = Data(someString.utf8)
,而从 Data
转 String
的代码是相似的:
1 | let stringData = String(decoding: data, as: UTF8.self) |
好了,下面是完整的代码:
1 | func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) { |
讲完四个版本的函数费了不少篇幅,之所以一步一步解释的原因在于需要理解消化的内容着实不少。 最后的代码实现了一个更清爽的 API,借助它我们可以确保要么得到字符串,要么得到某个错误 —— 不可能同时得到两者或两者都得不到,这正是 Result
的特点。更棒的是,我们得到错误的话,必定是 NetworkError
的某一条 case,这使得错误处理更加容易。
目前为止我们实现了使用 Result
的函数,但还没有编写处理 Result
的函数。无论何种情况,Result
总是携带两部分的信息:结果的类型(成功或者失败),以及内部包含的东西。对于我们而言,这东西就是字符串或者某个 NetworkError
。在幕后,Result
实际上是一个有关联值的枚举,Swift 对此提供了特别的语法:我们可以对 Result
使用 switch
,编写像 .success(let str)
这样的代码来表示 “如果成功,取出字符串放进一个叫 str 的新常量中。” 这样的意思。
在实例中更容易明白我的意思,就让我们在文本视图的 onAppear
闭包里处理所有可能的情况:
1 | Text("Hello, World!") |
希望你能发现这么做的益处:我们不仅消除了对于返回的数据做检查的不确定因素,也完全消除了可选性。对于错误处理,甚至不再需要 default
的 case
,因为 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 | class DelayedUpdater: ObservableObject { |
使用这个类只需要在 ContentView
的某个地方用 ObservedObject
注解某个 DelayedUpdate
类型的属性,然后在 body 中显示它的值,如下:
1 | struct ContentView: View { |
运行代码,你会看到数值一直往上增加,知道 10,正如你预期的那样。现在,移除 @Published
,你会看到 UI 不再变化了。虽然幕后的 asyncAfter()
仍然在执行,但由于没有变化通知发出,UI 不再刷新。我们可以通过手动发送我前面提到的 objectWillChange
来解决。这种方式能让我们在任意时刻发送变化通知,而不用依赖于 @Published
的自动行为。
把 value 属性改成下面这样:
1 | var value = 0 { |
这样改完你又获得和之前一样的应用行为 —— UI 会计数增加到 10。但这回,我们有机会在 willSet
观察者中添加额外的功能。也许你需要打日志,或者调用另一个方法,又或者你要 clamp
整数,确保它永远不会超出某个范围 —— 一切尽在掌握之中。
薛定谔的 @State
value
,$value
和_value
代表三个完全不同的东西!
这几个 @Things
其实只是 SwiftUI
框架中的几个结构体,并非 Swift 语言的一部分。
而真正属于语言的一部分的是 Swift 5.1 引入的一个新特性:属性包装器.
当我们用 SwiftUI 里的 @Something
给变量标注属性时,比如 @State var value: Int = 0
,Swift 编译器将为我们生成三个变量!(其中有两个是计算属性):
- value —— 被包装的由我们声明类型的原始值(wrappedValue),比如例子中的 Int。
- $value —— 一个 “额外的”
projectedValue
,它的类型由我们使用的属性包装器决定。@State
的projectedValue
的类型是Binding
,因此我们的例子中就是 Binding 类型。 - _value —— 属性包装器本身的引用,在视图初始化过程中可能用到:
1 | struct MyView: View { |
根据 projectedValue 进行分类
让我们浏览一下 SwiftUI 中最常用的 @Things
,看看他们的 projectedValue
分别都是些什么:
- @State —— Binding
- @Binding —— Binding
- @ObservedObject —— Binding
(*) - @EnvironmentObject - Binding
(*) - @Published - Publisher<Value, Never>
技术上来讲,(*) 给到我们的是 Wrapper
类型的中间值,一旦我们为该对象中的实际值指定了 keyPath
,就会变成一个 Binding
。
如你所见,SwiftUI 中大部分的属性包装器,其职能都是跟视图的状态有关,并且被投射为 Binding
,用于在视图之间传递状态。唯一的跟大多数包装器不同的是 @Published
,不过请注意:
- 它是在 Combine 框架而不是 SwiftUI 里声明的
- 它的用途是让值变为可观察的
- 它不用于视图的变量声明,只用在 ObservableObject 内部。
考虑一个在 SwiftUI 中相当常见的场景:声明一个 ObservableObject
,并在某个视图中以 @ObservedObject
属性使用它:
1 | class ViewModel: ObservableObject { |
MyView 可以引用 $viewModel.value
和 viewModel.$value
—— 两个表达式都是合法的。有点犯迷糊了是不是?
其实这两个表达式分别代表了完全不同的两个类型:Binding
和 Publisher
。
两者都有实际的用途:
1 | var body: some View { |
薛定谔的 @State
我们都知道包含在一个不可变的 struct 内部的 struct 也是不可变的。
在 SwiftUI 中,多数情况下我们面对是一个不可修改的 self
,例如,在某个 Button
的回调中。基于这种上下文,每个实例变量,包括 @State
结构体也都是不可变的。
那么,你能解释一下为什么下面的代码是完全合法的吗?
1 | struct MyView: View { |
@State
有什么魔法?
这里有一份关于 SwiftUI 如何处理这种场景下的值的变化的详细解释,但这里我想强调一个事实:对于 @State 变量实际的值,SwiftUI 使用了隐藏的外部存储。
@State
其实是一个代理:它拥有一个内部变量 _location
,用于访问外部存储。
让我给你出道面试题:下面这个例子会打印出什么内容?
1 | func test() { |
上面的代码相当直观;直觉告诉我们打印的值应该是 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 | struct MyView: View { |
这是视图只是显示了由 Publisher 生产的字符串,并且在视图出现在屏幕时设置 didAppear 标记 ,就这么简单而已。
现在,试着回答我,你认为在下面这两个用例中,print(“onReceive”) 会被触发几次?
1 | struct TestView: View { |
让我们先考虑 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
的值擦掉你刚刚赋的值。