阅读 120
第三章 Combine的错误处理 (Part. 1)

第三章 Combine的错误处理 (Part. 1)

正如在第 1 章“Hello,Combine”中提到的,Combine 发布者声明了两个通用约束:Output,它定义了发布者发出的值的类型,以及 Failure,它定义了这个发布者可以以何种失败结束。

到目前为止,您一直将精力集中在发布者的输出类型上,但未能深入探讨失败在出版商中的作用。好吧,别担心,这一章会改变的!

img2141.jpg

Never

失败类型为 Never 的发布者表示发布者永远不会失败。 虽然这乍一看似乎有点奇怪,但它为这些发布商提供了一些非常强大的保证。具有从不失败类型的发布者可让您专注于使用发布者的值,同时绝对确保发布者永远不会失败。只有完成后才能成功完成。

img2147.jpg

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 上,创建了一个保留周期,导致内存泄漏👊

img2186.jpg

幸运的是,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)”
复制代码
  1. 使用 Just 创建一个Publisher并将其失败类型设置为 MyError。
  2. 如果发布者以失败事件完成,则使用 assertNoFailure 以致命错误崩溃。这会将发布者的失败类型恢复为Never
  3. 使用 sink 打印出接收到的值。请注意,由于 assertNoFailure 将故障类型设置回 Never,因此 可以使用sink(receiveValue:)

我们在assertNoFailure()前输入.sink,可以看到

截屏2021-10-08 下午9.29.03.png

我们只能使用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)") }
)
复制代码
  1. 定义一个 NameError 错误枚举。
  2. 创建一个发布三个不同字符串的发布者。
  3. 将每个字符串映射为它的长度。

运行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)
复制代码
  1. 定义一个 NameError 用于此示例。
  2. 创建一个只发出字符串 Hello 的 Just。
  3. 使用 setFailureType 将失败类型设置为 NameError。
  4. 使用 map 将另一个字符串附加到发布的字符串。
  5. 最后,使用 sink 的 receiveCompletion 为 NameError 的每个失败案例打印出适当的消息。

运行playground:

Hello is too short!

当我们在switch completion这行,按住Option点击completion,会发现

截屏2021-10-08 下午10.07.30.png

请注意,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!

文章分类
iOS
文章标签