Combine: Debugging

9,159 阅读4分钟

理解异步代码中的事件流,对于初学者来说一直是一个挑战。在Combine的上下文中尤其如此,因为事件流中的操作符链可能不会立即发出事件。

例如,throttle(for:scheduler:latest:) 操作符就不会发出接收到的任何事件,要了解这个过程中发生了什么,就需要借助Combine提供的一些操作符来进行调试,以帮助我们解决遇到的困难。

Print

当你不确定流中发生的事件时,首先可以考虑使用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操作符拦截生命周期事件,并执行操作
  • 使用breakpointOnErrorbreakpoint操作符来中断特定事件

参考

Combine: Asynchronous Programming with Swift