正如在第 1 章“Hello,Combine”中提到的,Combine 发布者声明了两个通用约束:Output,它定义了发布者发出的值的类型,以及 Failure,它定义了这个发布者可以以何种失败结束。
到目前为止,您一直将精力集中在发布者的输出类型上,但未能深入探讨失败在出版商中的作用。好吧,别担心,这一章会改变的!
Never
失败类型为 Never 的发布者表示发布者永远不会失败。 虽然这乍一看似乎有点奇怪,但它为这些发布商提供了一些非常强大的保证。具有从不失败类型的发布者可让您专注于使用发布者的值,同时绝对确保发布者永远不会失败。只有完成后才能成功完成。
setFailureType
将绝对可靠的发布者转变为容易出错的发布者的第一种方法是使用 setFailureType。这是另一个仅适用于失败类型为 Never 的发布者的运算符
首先定义示例范围之外的 MyError 错误类型。稍后您将重用此错误类型。然后,您通过创建一个与您之前使用的类似的 Just 来开始示例 现在,您可以使用 setFailureType 将发布者的失败类型更改为 MyError。在 Just 之后立即添加以下行
enum MyError: Error {
case ohNo
}
Just("Hello")
.setFailureType(to: MyError.self)
要确认这确实改变了发布者的失败类型,请开始输入 .eraseToAnyPublisher(),自动完成将显示已擦除的发布者类型
现在是时候使用 sink 来消费发布者了。在最后一次调用 setFailureType 后立即添加以下代码
.sink(
receiveCompletion: { completion in
switch completion {
// 2
case .failure(.ohNo):
print("Finished with Oh No!")
case .finished:
print("Finished successfully!")
}
},
receiveValue: { value in
print("Got value: \(value)")
}
)
.store(in: &subscriptions)”
你可能已经注意到关于上述代码的两个有趣的事实:
1、他使用了 sink(receiveCompletion:receiveValue:),而 sink(receiveValue:)
不再可用,因为此发布者可能会以失败事件完成。结合迫使您处理此类publisher的完成事件
2、失败类型严格输入为 MyError,这让您可以针对 .failure(.ohNo) 情况,而无需进行不必要的转换来处理该特定错误。
当然,setFailureType 的效果只是一个类型系统定义。由于原始发布者是 Just,因此实际上不会抛出任何错误。 在本章后面,您将了解更多关于如何从您自己的发布者那里实际产生错误的信息。但首先,还有一些运营商专门针对永不失败的出版商。
assign(to:on:)
您在第 2 章“发布者和订阅者”中学到的分配运算符仅适用于不会失败的发布者,与 setFailureType 相同。如果你仔细想想,这完全是有道理的。将错误发送到提供的键路径会导致未处理的错误或未定义的行为
example(of: "assign(to:on:)") {
// 1
class Person {
let id = UUID()
var name = "Unknown"
}
// 2
let person = Person()
print("1", person.name)
Just("Shai")
.handleEvents( // 3
receiveCompletion: { _ in print("2", person.name) }
)
.assign(to: \.name, on: person) // 4
.store(in: &subscriptions)
}
1、定义一个具有 id 和 name 属性的 Person 类。 2、创建一个 Person 实例并立即打印其名称。 3、一旦发布者发送完成事件,使用您之前了解的 handleEvents 再次打印此人的姓名。 4、最后,使用assign 将人名设置为发布者发出的任何内容
运行结果: ——— Example of: assign(to:on:) ——— 1 Unknown 2 Shai
正如预期的那样,一旦 Just 发出它的值,assign 就会更新这个人的名字,这是有效的,因为 Just 不会失败。相比之下,如果Publihser有一个非Never的类型,你认为会发生什么?
在 Just("Shai") 下方添加以下行:
.setFailureType(to: Error.self)
在这段代码中,你将失败类型设置为标准的 Swift 错误。这意味着它现在不是 Publisher<String, Never>,而是 Publisher<String, Error>。 尝试运行您的Playground。会发现有报错信息:
referencing instance method 'assign(to:on:)' on 'Publisher' requires the types 'Error' and 'Never' be equivalent
删除刚刚添加的对 setFailureType 的调用,并确保 Playground 运行时没有编译错误
assign(to:)
assign(to:on:) 有一个棘手的部分——它会强烈捕获提供给 on 参数的对象。 让我们探讨为什么这是有问题的。 在上一个示例之后立即添加以下代码
example(of: "assign(to:)") {
class MyViewModel: ObservableObject {
// 1
@Published var currentDate = Date()
init() {
Timer.publish(every: 1, on: .main, in: .common) // 2
.autoconnect()
.prefix(3) // 3
.assign(to: \.currentDate, on: self) // 4
.store(in: &subscriptions)”
}
}
// 5
let vm = MyViewModel()
vm.$currentDate
.sink(receiveValue: { print($0) })
.store(in: &subscriptions)
}
这段代码有点长,让我们分解一下:
1、在ViewModel中定义一个 @Published 属性。它的初始值是当前日期。
2、创建一个定时器的Publisher,它每秒发出当前日期。
3、使用prefix操作符设定只接受 3 个发布数据。
4、应用assign(to:on:) 运算符将每个日期更新分配到@Published 属性。
5、实例化ViewModel,用sink开始订阅,并打印出每个值。
运行结果:
2021-08-21 12:43:32 +0000
2021-08-21 12:43:33 +0000
2021-08-21 12:43:34 +0000
2021-08-21 12:43:35 +0000
正如预期的那样,上面的代码打印了分配给已发布属性的初始日期,然后连续更新了 3 次(受前缀运算符限制)。 表面上看一切正常,到底哪里出了问题?
👊对assign(to:on:) 的调用创建了一个强引用的订阅。 本质上——self 挂在订阅上,而订阅挂在 self 上,创建了一个保留周期,导致内存泄漏👊
幸运的是,Apple引入了该运算符的另一个重载 assign(to:)。
该运算符专门处理通过提供对其预计发布者的 inout 引用,将发布的值重新分配给 @Published 属性。
将原本的assign行改为
.assign(to: &$currentDate)
使用assign(to:) 操作符并将其传递给预计发布者的inout 引用打破了保留周期,让您轻松处理上述问题。 此外,它会在内部自动处理订阅的内存管理,这让您可以省略 store(in: &subscriptions) 行。
assertNoFailure
当您想在开发过程中保护自己并确认发布者无法完成失败事件时,assertNoFailure 运算符非常有用。 它不会阻止上游发出失败事件。但是,如果它检测到错误,它会因致命错误而崩溃。在开发环境中可以使用它来发现错误。
在playground中实现如下代码:
// 1
Just("Hello")
.setFailureType(to: MyError.self)
.assertNoFailure() // 2
.sink(receiveValue: { print("Got value: \($0) ")}) // 3
.store(in: &subscriptions)”
- 使用 Just 创建一个Publisher并将其失败类型设置为 MyError。
- 如果发布者以失败事件完成,则使用 assertNoFailure 以致命错误崩溃。这会将发布者的失败类型恢复为Never
- 使用 sink 打印出接收到的值。请注意,由于 assertNoFailure 将故障类型设置回 Never,因此 可以使用sink(receiveValue:)
我们在assertNoFailure()前输入.sink,可以看到
我们只能使用sink(receiveCompletion:receiveValue:)的sink方法,因为我们将Publisher的错误类型设置为了非Never类型,所以只能使用带有receiveCompletion的sink方法。
当我们在加入了.assertNoFailure()操作符后,Publihser的错误类型又变回了Never,所以可以使用
sink(receiveValue:)的方法了。
现在我们在.setFailureType后增加一行代码
.tryMap { _ in throw MyError.ohNo }
我们用tryMap来强制抛出一个错误,看Combine如何处理
这时运行playground会报错
error: Execution was interrupted, reason: EXC_BREAKPOINT (code=1, subcode=0x1a833abc0).
The process has been left at the point where it was interrupted, use "thread return -x" to return to the state before expression evaluation.
playground崩溃是因为Publisher发布了错误,这就是assertFailureType所做的。在某种程度上,您可以将 assertFailure() 视为代码的保护机制。虽然不是您应该在生产环境中使用的东西,但对在开发过程中尽早发现错误非常有用。
在继续下一部分之前,注释掉对 tryMap 的调用。
处理错误
Combine 提供了一些技术和工具来处理发布错误信息的Publisher。这包括内置发布者和您自己的发布者。 但首先,您如何实际产生故障事件?如上一节所述,有几种方法可以做到这一点。您刚刚使用了 tryMap,那么为什么不了解更多有关这些 try 运算符如何工作的信息呢?
try* 操作符
在第 II 节“运算符”中,您了解了大部分 Combine 的运算符以及如何使用它们来操纵发布者发出的值和事件。您还学习了如何组合多个运算符的逻辑链以生成所需的输出。 在这些章节中,您将了解到大多数运算符都有以 try 为前缀的并行运算符 组合提供了一个有趣的区别,可能会抛出错误的操作符和可能不会抛出错误的操作符
当涉及到错误时,Combine 中所有带有 try 前缀的运算符的行为方式都相同。您将在本章中只试验 tryMap 运算符
在playground中增加如下代码:
// 1
enum NameError: Error {
case tooShort(String)
case unknown
}
// 2
let names = ["Marin", "Shai", "Florent"].publisher
names
// 3
.map { value in
return value.count
}
.sink(
receiveCompletion: { print("Completed with \($0)") },
receiveValue: { print("Got value: \($0)") }
)
- 定义一个 NameError 错误枚举。
- 创建一个发布三个不同字符串的发布者。
- 将每个字符串映射为它的长度。
运行playground,得到如下信息:
Got value: 5 Got value: 4 Got value: 7 Completed with finished
现在我们为代码增加一些功能:当字符串长度小于5的时候,抛出一个错误
我们将代码中map部分替换为
.tryMap { value -> Int in
// 1
let length = value.count
// 2
guard length >= 5 else {
throw NameError.tooShort(value)
}
// 3
return value.count
}
注意这里我们不能用
map,因为map不能抛出错误,所以只能使用tryMap
运行playground:
Got value: 5 Completed with failure(__lldb_expr_261.NameError.tooShort("Shai"))
可以看到“Shai”字符串长度小于5,所以抛出了错误,而且Combine的流程也停止了
映射错误 (Mapping Error)
map 和 tryMap 之间的差异不止是后者可以抛出错误。虽然 map 继承了现有的失败类型并且只操作发布者的值,但 tryMap 不会——它实际上将错误类型擦除为一个普通的 Swift 错误。与带有 try 前缀的对应物相比,所有运算符都是如此。
在playground中实现如下代码:
// 1
enum NameError: Error {
case tooShort(String)
case unknown
}
// 2
Just("Hello")
.setFailureType(to: NameError.self) // 3
.map { $0 + " World!" } // 4
.sink(
receiveCompletion: { completion in
// 5
switch completion {
case .finished:
print("Done!")
case .failure(.tooShort(let name)):
print("\(name) is too short!")
case .failure(.unknown):
print("An unknown name error occurred")
}
},
receiveValue: { print("Got value \($0)") }
)
.store(in: &subscriptions)
- 定义一个 NameError 用于此示例。
- 创建一个只发出字符串 Hello 的 Just。
- 使用 setFailureType 将失败类型设置为 NameError。
- 使用 map 将另一个字符串附加到发布的字符串。
- 最后,使用 sink 的 receiveCompletion 为 NameError 的每个失败案例打印出适当的消息。
运行playground:
Hello is too short!
当我们在switch completion这行,按住Option点击completion,会发现
请注意,Completion 的失败类型是 NameError,这正是您想要的。 setFailureType 运算符允许您专门针对 NameError 失败,例如 failure(.tooShort(let name))。
接下来,将map更改为 tryMap。您会立即注意到playground不再编译。再次观察competion的类型
你会发现他的类型现在是Subscribers.Completion<Error>
这是我们需要重点注意的
tryMap删除了严格类型的错误,并用通用的 Swift.Error 类型替换了它。即使您实际上并未从 tryMap 中抛出错误,也会发生这种情况——您只是使用了它!这是为什么? 仔细想想,原因很简单:Swift 尚不支持类型化抛出,尽管自 2015 年以来 Swift Evolution 中一直围绕这个话题进行讨论。这意味着当您使用带有 try 前缀的运算符时,您的错误类型将总是被删除到最常见的祖先:Swift.Error。 所以你能对它做点啥?对于发布者来说,严格类型的故障的全部意义在于让您处理——在本例中——专门处理 NameError,而不是任何其他类型的错误
一种幼稚的方法是手动将通用错误转换为特定的错误类型,但这并不理想。它打破了严格类型错误的整个目的。幸运的是,Combine 为这个问题提供了一个很好的解决方案,称为 mapError。 在调用 tryMap 之后,立即添加以下行
.mapError { $0 as? NameError ?? .unknown }
mapError 接收从上游发布者抛出的任何错误,并让您将其映射到您想要的任何错误。在这种情况下,您可以利用它将错误转换回 NameError 或回退到 NameError.unknown 错误。在这种情况下,你必须提供一个回退错误,因为理论上强制转换可能会失败——即使它不会在这里——你必须从这个运算符返回一个 NameError。
这会将失败恢复到其原始类型,并将您的发布者恢复为 Publisher<String, NameError>。 构建并运行playground。它应该最终编译并按预期工作:
Got value Hello World! Done!
最后将tryMap改进为
.tryMap { throw NameError.tooShort($0) }
这个调用会立即从 tryMap 中抛出一个错误。再次检查控制台输出,并确保您得到正确键入的 NameError
Hello is too short!