(注意:对于基于 Swift Concurrency 的 WIP 2.0 版本,请查看 concurrency
分支。)
NSProgress (在 Swift 3 中重命名为 Progress) 是 Mac OS X 10.9 (“Mavericks”) 中引入的 Foundation 类,旨在简化 Mac 和 iOS 应用程序中的进度报告。它引入的概念非常棒,创建了一个进度对象树,所有这些对象都代表了待完成工作的一小部分,并且可以限定在该特定代码段中,从而减少了在复杂系统中表示进度所需的意大利面条式代码量。
不幸的是,执行效果很糟糕。
不幸的是,NSProgress 的性能简直糟糕透顶。虽然性能对于许多 Cocoa 类来说不是主要问题,但 NSProgress 的目的——跟踪耗时操作的进度——自然而然地使其适用于性能关键型代码。NSProgress 越慢,它就越有可能影响已经足够耗时以至于需要进度报告的操作的运行时间。在 Apple 2015 年的 “进度报告的最佳实践” 视频中,Apple 建议不要在紧密循环中更新 NSProgress,因为这会对性能产生影响。不幸的是,这种最佳实践实际上导致了使用 UI 代码污染后端,从而不利于关注点分离。
有许多因素导致了 NSProgress 的迟缓
这方面实际上无法避免,因为 NSProgress 是在 Objective-C 是开发 Apple 高级 API 的唯一主流语言时发明的。然而,NSProgress 基于 Objective-C 的事实意味着,每次更新它时,我们都会调用 objc_msgSend。虽然可以使用 IMP 缓存来解决这个问题,但这也没什么大不了的,对吧?好吧,不幸的是,还有更多。
这是最主要的问题。每次 NSProgress 对象更新时,它都会发布 KVO 通知。众所周知,KVO 具有糟糕的性能特征。并且每次您更新 NSProgress 对象上的更改计数时,不仅会为该进度对象发送 KVO 通知,还会为其父对象、祖父对象等等一直向上发送到树的根。此外,这些通知都在当前线程上发送,因此您的工作线程需要等待所有通知完成才能继续进行其正在执行的操作。这会显著拖慢速度。
NSProgress 是线程安全的,这很棒!不幸的是,这是使用 NSLock 实现的,NSLock 是 pthread 互斥锁的简单包装器,这增加了大量的开销。此外,没有原子方式来递增更改计数。要做到这一点,首先必须获取当前的 completedUnitCount,然后向其添加一些内容,最后将其发送回 completedUnitCount 的 setter,导致一次操作需要获取两次锁。除了性能较差之外,这还会引入竞争条件,因为在读取和写入之间,在另一个线程上运行的其他内容可能会更改 completedUnitCount 属性,从而导致单元计数变得不正确。
在更新其更改计数并发送其 KVO 通知的过程中,NSProgress 会生成大量自动释放的对象。在我的性能测试中,更新一百万次 NSProgress 会导致应用程序的内存大小膨胀到 4.8 GB (!)。当然,可以通过将整个过程包装在自动释放池中来缓解这种情况,但这往往会进一步降低性能。
所有这些性能警告至少会带来一个流畅的界面,对吧?好吧,并没有。
NSProgress 的所有报告都是通过 KVO 完成的。这很巧妙,对吧?您可以直接将您的 UI 元素(例如 NSProgressIndicator)绑定到其 fractionCompleted 属性,并在无需粘合代码的情况下进行设置。对吧?好吧,并没有,因为 UI 层中的大多数类都需要仅在主线程上访问,而 NSProgress 在当前线程上发送所有 KVO 通知。嗯。
不,要正确观察 NSProgress,您需要执行类似这样的操作
class MyWatcher: NSObject {
dynamic var fractionCompleted: Double = 0.0
private var progress: Progress
private var kvoContext = 0
init(progress: Progress) {
self.progress = progress
super.init()
progress.addObserver(self, forKeyPath: "fractionCompleted", options: [], context: &self.kvoContext)
progress.addObserver(self, forKeyPath: "cancelled", options: [], context: &self.kvoContext)
}
deinit {
progress.removeObserver(self, forKeyPath: "fractionCompleted", context: &self.kvoContext)
progress.removeObserver(self, forKeyPath: "cancelled", context: &self.kvoContext)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &self.kvoContext {
DispatchQueue.main.async {
switch keyPath {
case "fractionCompleted":
if let progress = object as? Progress {
self.fractionCompleted = progress.fractionCompleted
}
case "cancelled":
// handle cancellation somehow
default:
fatalError("Unexpected key path \(keyPath)")
}
}
} else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
}
漂亮,不是吗?我希望我在观察字符串中没有拼写错误(取消的那个应该是 "cancelled" 还是 "isCancelled"?我总是记不住)。
因此,我们非但没有享受到 KVO 的主要好处——能够将 UI 元素绑定到模型而无需粘合代码——反而拥有了比典型的基于块的方法中更多和更奇怪的粘合代码。
但是等等!NSProgress 确实有一些基于块的通知 API!例如,取消处理程序
var cancellationHandler: (() -> Void)?
不幸的是,每个 NSProgress 对象只允许有一个这样的处理程序。因此,如果您想在特定的 NSProgress 对象上设置取消处理程序,您最好确保没有其他人也想被告知该对象上的取消事件,否则您会覆盖它。对此有一些解决方法,但这不是一个好的 UI。
NSProgress 支持两种构建进度对象树的方法。不幸的是,它们都有缺陷
NSProgress 树是通过在 NSProgress 对象上调用 becomeCurrent(withPendingUnitCount:) 隐式构建的。这会导致所述对象作为“当前” NSProgress 存储在线程局部存储中。随后,使用 init(totalUnitCount:) 创建的下一个 NSProgress 对象将被添加到当前 NSProgress 作为子对象。这具有提供进度对象松散耦合的优点,使子任务不必知道它们是否是较大树的一部分,或者它们代表了整个任务的哪个部分。不幸的是,隐式树组合有很多问题,其中最不重要的一个问题是,如果不进行经验测试或查看源代码,就不可能知道任何给定的 API 是否支持隐式 NSProgress 组合。隐式树组合也很难与多线程代码一起使用,因为它依赖于线程局部变量。
在 OS X 10.11 (“El Capitan”) 中,NSProgress 引入了一个新的初始化器,允许显式构建树
init(totalUnitCount: Int64, parent: Progress, pendingUnitCount: Int64)
这种方法允许更高的清晰度,但不幸的是,它牺牲了隐式方法提供的松散耦合,因为它要求初始化器的调用者既要知道要创建的进度对象的总单元计数,也要知道其父对象的待处理单元计数。因此,要翻译使用隐式组合编写的函数
func foo() {
let progress = Progress(totalUnitCount: 10) // we don't know about the parent's pending unit count, or need to know it
... do something ...
}
必须包含不止一个,而是两个参数才能提供相同的功能
func foo(progress parentProgress: Progress, pendingUnitCount: Int64) {
let progress = Progress(totalUnitCount: 10, parent: parentProgress, pendingUnitCount: pendingUnitCount)
... do something ...
}
这给函数的签名增加了相当大的臃肿。
CSProgress 是一个 Swift 原生类,旨在作为 NSProgress 的直接替代品。它尚未支持 NSProgress 的所有功能,但可以作为测试用例,演示可以对 NSProgress 进行的改进。
CSProgress 支持以下功能
如您所见,拥有观察者对性能没有明显影响(在此测试中,带有观察者的版本实际上花费的时间略少)。因此,CSProgress 在未被观察时性能比 NSProgress 好大约两个数量级,而在涉及观察者时性能更好几倍。
CSProgress 还包括一个方便的结构体,用于封装父进度对象及其待处理单元计数,允许将两者作为一个参数传递
func foo(parentProgress: Progress.ParentReference) {
let progress = parentProgress.makeChild(totalUnitCount: 10)
... do something ...
}
let rootProgress = CSProgress(totalUnitCount: 10, parent: nil, pendingUnitCount: 0)
foo(parentProgress: rootProgress.pass(pendingUnitCount: 5)