我们需要对加载界面进行一些更改,其中之一是允许重置。
重置是获取加载链的当前状态并将其清除的想法。 您可以将其视为类似于“注销”。 你可能想知道为什么我们需要这个。 如果我们要“重新开始”,那么我们不能简单地扔掉加载链并创建一个新的吗?
这是一个很好的问题。 在许多情况下,扔掉加载链并创建一个新的就足够了。 但是,有几个关键案例不是:
- 维护持久状态(即,将状态保存在磁盘上)的加载程序需要有机会在请求时丢弃该状态。 保存的数据(用户名、密码、身份验证令牌)或缓存的数据都算作“持久化”。
- 任何进行中的
HTTPRequest
将继续执行,即使它的链也是如此,这可能是不受欢迎的行为。
重置
允许重置的第一步可能如下所示:
open class HTTPLoader {
...
open func reset(completionHandler: @escaping () -> Void) {
if let next = nextLoader {
next.reset(completionHandler: completionHandler)
} else {
completionHandler()
}
}
}
这对于简单的情况很有效,但随着加载程序变得更加复杂,它很快就会崩溃。 当我们问自己时,问题就变得很清楚了:链中的哪个加载器负责执行完成处理程序?
它不一定是终端加载器(通常是基于 URLSession
的加载器),因为链中可能还有另一个加载器尚未完成其重置工作。 它也可能不是链中的第一个加载器,因为它不能(轻易地)知道它下面的所有其他加载器是否也已完成。
拥有一个这样的完成处理程序很复杂。 我们可以想象这样一种情况,加载程序 (A) 要求其下游加载程序 (B) 使用新的完成处理程序进行重置。 一旦 A 完成了自己的重置逻辑(如果有的话),A 将只执行给定的完成处理程序,并且 B 也通过调用给定的完成处理程序来指示自己完成了重置。 我们绝对可以做到这一点,但有一种更简单的方法:DispatchGroup
。
DispatchGroup 是一种将相关工作“分组”在一起的方法,无需提前知道实际有多少工作。 你所知道的是,事物可以在开始工作时“加入”(或进入())组,并在完成后离开()组。 对于群组创建者,它会分配一个闭包,以便在所有内容都离开群组后执行。 这正是我们想要建模的行为:我们希望允许未知数量的加载器每个执行未知数量的任务,作为单个重置“组”的一部分。 当一切都完成后,我们希望得到通知。
因此,我们将根据 DispatchGroup
定义 API
,而不是使用完成处理程序:
open class HTTPLoader {
...
open func reset(with group: DispatchGroup) {
nextLoader?.reset(with: group)
}
}
因为我们的用户值得拥有美好的事物,我们将提供一种方便的方法来为我们处理群组创建:
extension HTTPLoader {
...
public final func reset(on queue: DispatchQueue = .main, completionHandler: @escaping () -> Void) {
let group = DispatchGroup()
self.reset(with: group)
group.notify(queue: queue, execute: completionHandler)
}
}
在自定义加载器中采用此逻辑非常简单:
class MyCustomLoader: HTTPLoader {
...
override func reset(with group: DispatchGroup) {
group.enter() // this loader has work to include in this group
DispatchQueue.global(qos: .userInitiated).async {
// do whatever cleanup this loader needs
group.leave() // we are done with the work
}
// make sure loaders beneath us can reset as well
super.reset(with: group)
}
}
即使我们在完成工作之前调用 super
,顶级 DispatchGroup
在我们离开组之前也不会完成。 DispatchGroup
有一个硬性规定,即每次对 enter()
的调用都必须通过对 leave()
的匹配调用来平衡。 如果我们忘记调用 leave(),我们的组将永远不会通知它已经完成。 如果我们 leave()
太多次,Dispatch
框架将使我们的应用程序崩溃。 我们必须努力适当地平衡我们的 enter()
和 leave()
调用。
ResetGuard 加载器
有一个非常有用的加载器,我们可以将其与此行为结合使用; 我称之为“Reset Guard”装载机。 这个加载器的基本思想是它阻止人们在另一个重置调用已经发生时重置加载器链。 这可能是因为允许用户多次点击“注销”按钮的客户端错误,但在某些情况下,我们可以编写具有重置保护的加载程序也很有用。
总体思路很简单:
- 如果加载程序未重置,则允许加载请求。
- 如果装载机未重置,则允许开始重置。
- 如果加载程序正在重置,则失败尝试加载请求
- 如果加载程序正在重置,则尝试启动另一个重置应该不会执行任何操作
- 有了这些简单的要求,我们就可以开始构建一个实现(忽略线程安全问题):
public class ResetGuard: HTTPLoader {
private var isResetting = false
public override func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) {
// TODO: make this thread-safe
if isResetting == false {
super.load(request: request, completion: completion)
} else {
let error = HTTPError(code: .resetInProgress, request: request)
completion(.failure(error))
}
}
...
}
reset(with:)
方法的实现有点棘手,因为我们需要知道下面的加载器何时完成加载。 我们的 reset(..)
方法可以告诉我们这一点,但这里的技巧是意识到我们将需要第二个 DispatchGroup
:
public class ResetGuard: HTTPLoader {
...
public override func reset(with group: DispatchGroup) {
// TODO: make this thread-safe
if isResetting == true { return }
guard let next = nextLoader else { return }
group.enter()
isResetting = true
next.reset {
self.isResetting = false
group.leave()
}
}
}
为了使此类线程安全,我们需要在读写 isResetting
属性时添加某种屏障。 DispatchQueue
或 NSLock
似乎是合理的选择。
我想在这里指出的一件事是第二次调用 reset(...)
的行为,而一个已经在进行中。 按照目前的实现方式,第二次调用 reset(with:)
什么都不做。 这意味着第二次重置请求几乎肯定会在第一次重置请求之前完成。
这个 ResetGuard
类是一个方便的加载器,可以帮助我避免犯错,它通常最终成为我的链中的第一个加载器:
let chain = resetGuard --> applyEnvironment --> ... --> urlSessionLoader
在下一篇文章中,我们将(希望如此)对 HTTPLoader API
进行最终修改,以允许取消请求和自定义加载程序来使用它。