跳转至

第10章:调试

了解异步代码中的事件流一直是一个挑战。在Combine中尤其如此,因为发布者中的操作符链可能不会立即发出事件。例如,像throttle(for:scheduler:latest:)这样的运算符不会发送他们收到的所有事件,因此您需要了解发生了什么。Combine提供了一些运算符来帮助调试您的被动流。了解它们将帮助您对令人困惑的情况进行故障排除。

打印活动

当您不确定是否有任何东西通过您的发布者时,print(_:to:)运算符是您应该使用的第一个运算符。这是一个直通式发布者,可以打印大量关于正在发生的事情的信息。

即使有像这样的简单情况:

let subscription = (1...3).publisher
  .print("publisher")
  .sink { _ in }

输出非常详细:

publisher: receive subscription: (1...3)
publisher: request unlimited
publisher: receive value: (1)
publisher: receive value: (2)
publisher: receive value: (3)
publisher: receive finished

在这里,您看到print(_:to:)运算符显示了很多信息,因为它:

  • 当它收到订阅时打印,并显示其上游发布者的描述。
  • 打印订阅者的需求请求,以便您可以查看请求的项目数量。
  • 打印上游发布者发出的每个值。
  • 最后,打印完成事件。

还有一个额外的参数,它需要一个TextOutputStream对象。您可以使用它来重定向字符串以打印到记录器。您还可以向日志添加信息,例如当前日期和时间等。可能性是无穷无尽的!

例如,您可以创建一个简单的记录器,显示每个字符串之间的时间间隔,以便了解发布者发送值的速度:

class TimeLogger: TextOutputStream {
  private var previous = Date()
  private let formatter = NumberFormatter()

  init() {
    formatter.maximumFractionDigits = 5
    formatter.minimumFractionDigits = 5
  }

  func write(_ string: String) {
    let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return }
    let now = Date()
    print("+\(formatter.string(for: now.timeIntervalSince(previous))!)s: \(string)")
    previous = now
  }
}

在您的代码中使用非常简单:

let subscription = (1...3).publisher
  .print("publisher", to: TimeLogger())
  .sink { _ in }

结果显示每行打印之间的时间:

+0.00111s: publisher: receive subscription: (1...3)
+0.03485s: publisher: request unlimited
+0.00035s: publisher: receive value: (1)
+0.00025s: publisher: receive value: (2)
+0.00027s: publisher: receive value: (3)
+0.00024s: publisher: receive finished

如上所述,这里的可能性是无穷无尽的。

Note

根据计算机和您运行此代码的Xcode版本,上面打印的间隔可能会略有不同。

对事件采取行动-执行副作用

除了打印信息外,对特定事件执行操作通常也很有用。我们称之为执行副作用,因为您“侧面”采取的行动不会直接影响流中的更多发布者,但可能会产生修改外部变量等效果。

handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:)(哇,多么好的签名!)让您可以拦截发布者生命周期中的所有事件,然后在每个步骤中采取行动。

想象一下,您正在跟踪发布者必须执行网络请求,然后发送一些数据的问题。当您运行它时,它永远不会收到任何数据。发生什么事了?这个请求真的有效吗?你甚至看到有什么回来了吗?

考虑以下代码:

let request = URLSession.shared
  .dataTaskPublisher(for: URL(string: "https://www.raywenderlich.com/")!)

request
  .sink(receiveCompletion: { completion in
    print("Sink received completion: \(completion)")
  }) { (data, _) in
    print("Sink received data: \(data)")
  }

你运行它,永远不会看到任何打印。你能通过查看代码来看出问题吗?

如果没有,请使用 handleEvents 来跟踪正在发生的事情。 你可以在发布者和sink之间插入这个操作符:

.handleEvents(receiveSubscription: { _ in
  print("Network request will start")
}, receiveOutput: { _ in
  print("Network request data received")
}, receiveCancel: {
  print("Network request cancelled")
})

然后,再次运行代码。这次您看到一些调试输出:

Network request will start
Network request cancelled

那里!你找到了:你忘了把Cancellable放在身边。因此,订阅开始,但会立即取消。现在,您可以通过保留Cancellable来修复代码:

let subscription = request
  .handleEvents...

然后,再次运行代码,您现在会看到它运行正常:

Network request will start
Network request data received
Sink received data: 303094 bytes
Sink received completion: finished

使用调试器作为最后手段

最后的操作符是您在调试器中的某些时候真正需要反省的情况,因为没有其他东西能帮助您弄清楚出了什么问题。

第一个简单的运算符是breakpointOnError()。顾名思义,当您使用此运算符时,如果任何上游发布者发出错误,Xcode将中断调试器,让您查看堆栈,并希望找到发布者出错的原因和位置。

一个更完整的变体是breakpoint(receiveSubscription:receiveOutput:receiveCompletion:)它允许您拦截所有事件,并根据具体情况决定是否要暂停调试器。

例如,只有当某些值通过发布者时,您才能中断:

.breakpoint(receiveOutput: { value in
  return value > 10 && value < 15
})

假设上游发布者发出整数值,但值1114永远不会发生,您可以将breakpoint配置为仅在这种情况中断裂,并让您进行调查!您还可以有条件地中断订阅和完成时间,但不能像handleEvents操作符那样拦截取消。

Note

没有一个断点发布者会在Xcode playground中工作。您将看到一个错误,表明执行已中断,但它不会落入调试器,因为Playground通常不支持断点调试。

关键点

  • print操作符一起跟踪发布者的生命周期,
  • 创建您自己的TextOutputStream来自定义输出字符串,
  • 使用 handleEvents 操作符拦截生命周期事件并执行操作,
  • 使用breakpointOnErrorbreakpoint运算符在特定事件上中断。

接下来去哪?

你发现了如何跟踪你的发布者在做什么,现在是时候......计时器了!转到下一章,了解如何使用Combine定期触发事件。