通常在编写由Combine驱动的数据管道时,我们希望这些管道能够在每个操作完成后,尽快地发出数值。然而,有时我们可能也想引入某些延迟,以防止执行不必要的工作,或者在一定时间后能够重试失败的操作。
降级
当我们的操作是基于某种自由形式的用户输入时,有一种情况我们可能希望在触发一个特定的管道之前等待一小段时间。
例如,假设我们正在建立一个控制器,管理一个从Database ,用户可以选择对正在加载的项目应用基于字符串的filter 。
为了防止用户在快速输入我们的filter 值的文本字段时执行过多的数据库查询,我们可以在执行数据库调用之前应用debounce 操作符。这样,Combine只有在一定时间内(本例中为0,3秒)没有新值进入时才会继续执行我们的管道。
final class ItemListController: ObservableObject {
@Published private(set) var items = [Item]()
@Published var filter = ""
init(database: Database) {
$filter
.removeDuplicates()
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.map(database.loadItemsMatchingFilter)
.switchToLatest()
.assign(to: &$items)
}
}
延迟重试
就像它的名字一样,Combine内置的retry 操作符让我们在遇到错误时自动重试管道的操作。使用它时,我们只需指定我们想要执行的最大重试次数,Combine会处理剩下的事情。在这里,我们在标准的Combine-poweredURLSession 数据任务API的定制版本中使用该操作符,这将让我们自动重试任何失败的网络请求一定数量的次数。
extension URLSession {
func decodedDataTaskPublisher<T: Decodable>(
for url: URL,
retryCount: Int = 3,
decodingResultAs resultType: T.Type = T.self,
decoder: JSONDecoder = .init(),
returnQueue: DispatchQueue = .main
) -> AnyPublisher<T, Error> {
dataTaskPublisher(for: url)
.map(\.data)
.decode(type: T.self, decoder: decoder)
.retry(retryCount)
.receive(on: returnQueue)
.eraseToAnyPublisher()
}
}
然而,在上述实现中,每一次重试都会在遇到错误时立即执行,但如果我们想在每一次重试操作之间应用一定的延迟呢?
为此,让我们转向Combine的delay 操作符,它让我们在两个操作之间引入一个固定的延迟量。由于我们在这种情况下只想延迟重试,我们将使用catch 操作符来创建一个单独的管道,将我们想要的延迟应用于一个恒定的Void 输出值,然后调用flatMap 来再次触发我们的上游管道--像这样:
extension Publisher {
func retry<T: Scheduler>(
_ retries: Int,
delay: T.SchedulerTimeType.Stride,
scheduler: T
) -> AnyPublisher<Output, Failure> {
self.catch { _ in
Just(())
.delay(for: delay, scheduler: scheduler)
.flatMap { _ in self }
.retry(retries > 0 ? retries - 1 : 0)
}
.eraseToAnyPublisher()
}
}
请注意,我们必须从retries 的传递次数中减去1 ,因为我们的flatMap 操作符将总是至少运行一次。然而,我们也要注意不要把这个数字变成负数,因为这将导致Combine执行无限次的重试。
有了上述内容,我们就可以更新我们之前的自定义数据任务API,现在看起来是这样的:
extension URLSession {
func decodedDataTaskPublisher<T: Decodable>(
for url: URL,
retryCount: Int = 3,
decodingResultAs resultType: T.Type = T.self,
decoder: JSONDecoder = .init(),
returnQueue: DispatchQueue = .main
) -> AnyPublisher<T, Error> {
dataTaskPublisher(for: url)
.map(\.data)
.decode(type: T.self, decoder: decoder)
.retry(retryCount, delay: 3, scheduler: returnQueue)
.receive(on: returnQueue)
.eraseToAnyPublisher()
}
}
现在,每次重试之间会有三秒钟的延迟,这在这种情况下特别有用,因为当我们第一次启动网络调用时,用户的连接可能已经暂时离线。
然而,值得指出的是,上面的代码样本并不意味着是一个完整的随时可用的网络实现,因为我们可能只想在遇到某些错误时重试。例如,如果我们正在执行一个经过验证的网络调用,而用户的访问令牌已经过期,用同样的参数重试这样的调用只会浪费电池和带宽。
为了实现这种每一个错误的逻辑,我们可以使用tryCatch ,然后throw ,我们不希望执行重试的错误。当这样做时,我们的管道将立即失败,并触发我们在调用地点添加的任何错误处理。
就像我们有时可能想在某些管道中引入人为的延迟一样,也有一些情况下,我们可能想完全推迟发布者的执行,直到有用户连接到它。这正是特殊的Deferred publisher让我们做的,这在使用Combine的 Future类型的时候特别有用。
例如,假设我们目前正在使用Future 来改造具有Combine支持的FeaturedItemsLoader -- 通过将传递到我们未来的promise 闭包作为完成处理程序发送到我们以前的、基于闭包的API。
extension FeaturedItemsLoader {
var itemsPublisher: Future<[Item], Error> {
Future { [weak self] promise in
self?.loadItems(then: promise)
}
}
}
如果我们想让每个加载操作立即开始,并且我们不想重试这样的操作,那么上述做法就完全可以。然而,做下面这样的事情实际上不会像预期的那样工作:
featuredItemsLoader.itemsPublisher
.retry(5)
.replaceError(with: [])
.sink { items in
// Handle items
...
}
上述方法不能工作的原因是,一个Future 总是只运行一次,然后不管是成功还是失败,都会缓存其结果--这意味着,即使上述管道在遇到错误时确实会重试5次,每次重试都会从我们基于Future-的itemsPublisher 。
为了解决这个问题,让我们把我们的Future 创建代码包在一个Deferred 发布器中--这将推迟我们底层发布器的创建,直到用户开始从它那里请求值,也将让我们正确地重试我们的管道,因为现在这样做将导致每次重试都要创建一个新的Future 实例。
extension FeaturedItemsLoader {
var itemsPublisher: AnyPublisher<[Item], Error> {
Deferred {
Future { [weak self] promise in
self?.loadItems(then: promise)
}
}
.eraseToAnyPublisher()
}
}
然而,虽然Deferred 是非常有用的,但我们不应该在每次使用Future 的时候都使用它。把Deferred 看作是懒惰属性的组合等价物--让每个属性都变得懒惰是没有意义的,但它是我们工具箱中的一个有用的工具,当我们需要懒惰评估给我们带来的特性时。
结论
就像Combine的面向管道的设计在设置反应式数据流和观察方面是非常棒的,它也会使实现精确的定时和重试等事情变得有点挑战性,但希望这篇文章能为你提供一些关于如何做到这一点的见解。
谢谢你的阅读!