理解异步代码中的事件流,对于初学者来说一直是一个挑战。在Combine的上下文中尤其如此,因为事件流中的操作符链可能不会立即发出事件。
例如,throttle(for:scheduler:latest:)
操作符就不会发出接收到的任何事件,要了解这个过程中发生了什么,就需要借助Combine提供的一些操作符来进行调试,以帮助我们解决遇到的困难。
当你不确定流中发生的事件时,首先可以考虑使用print(_:to:)
操作符来进行打印操作,它是一个passthrough publisher,可以打印大量事件流信息,帮助我们了解事件传输过程中发生了什么,比如用它来了解事件流的生命周期。
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
对象,我们可以使用它来重定向字符串并打印出来。通过自定义的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 }
handleEvents
除了打印事件信息之外,使用handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:)
对特定事件进行操作也很有用,它不会直接影响下游其他publisher,但会产生类似于修改外部变量的效果。所以可以称其为“执行副作用”。
handleEvents
可以让你拦截一个publisher生命周期内的任何事件,并且可以在每一步对它们进行操作。
想象一下,你正在跟踪的publisher必须执行网络请求,然后发出一些数据。但当你运行它时,却怎么也收不到数据,比如下面的代码就是如此,
let request = URLSession.shared.dataTaskPublisher(for: URL(string: "https://kodeco.com/")!)
request.sink(receiveCompletion: { completion in
print("Sink received completion: \(completion)")
}) { data, _ in
print("Sink received data: \(data)")
}
运行之后,控制台没有任何输出。你是否能发现这段代码存在的问题呢?
如果问题你没找到,那么就可以使用 handleEvents
来跟踪并查找问题所在。
request.handleEvents(receiveSubscription: { _ in
print("请求开始了")
}, receiveOutput: { _ in
print("请求到数据了")
}, receiveCancel: {
print("请求取消了")
}).sink(receiveCompletion: { completion in
print("Sink received completion: \(completion)")
}, receiveValue: { data, _ in
print("Sink received data: \(data)")
})
再次执行,可以看到打印:
请求开始了
请求取消了
因为Subscriber
返回的是一个AnyCancellable
对象,如果不持有这个对象,那么它会马上被取消(释放),这里的问题就是没有持有 Cancellable
对象,导致publisher被提前释放了, 修改代码:
let subscription = request.handleEvents(receiveSubscription: { _ in
print("请求开始了")
}, receiveOutput: { _ in
print("请求到数据了")
}, receiveCancel: {
print("请求取消了")
}).sink(receiveCompletion: { completion in
print("Sink received completion: \(completion)")
}, receiveValue: { data, _ in
print("Sink received data: \(data)")
})
再次运行,打印如下:
请求开始了
请求到数据了
Sink received data: 266785 bytes
Sink received completion: finished
终极大招
当你用尽浑身解数也无法找到问题所在时,“万不得已”操作符是帮助你解决问题的终极方案。
简单的“万不得已”操作符: breakpointOnError()
。 顾名思义,使用此运算符时,如果任何上游publisher发出错误,Xcode 将中断调试器,让你从堆栈中找出publisher出错的原因和位置。
完整的变体是 breakpoint(receiveSubscription:receiveOutput:receiveCompletion:)
。 它允许你拦截所有事件并根据具体情况决定是否要暂停调试器。比如,只有当某些值通过publisher时才可以中断:
.breakpoint(receiveOutput: { value in
value > 10 && value < 15
})
假设上游publisher发出整数值,但值 11 到 14 永远不会发生,就可以将断点配置为仅在这种情况下中断,以进行检查。 你还可以有条件地中断订阅和完成时间,但不能像 handleEvents
运算符那样拦截取消。
总结
以上我们已经介绍了几种Combine的调试方法,下面做一个简单的总结:
- 使用
print
操作符跟踪publisher的生命周期 - 创建自定义的
TextOutputStream
来输出需要的调试信息 - 使用
handleEvents
操作符拦截生命周期事件,并执行操作 - 使用
breakpointOnError
和breakpoint
操作符来中断特定事件