CSProgress

简介

NSProgress(在 Swift 3 中重命名为 Progress)是 Mac OS X 10.9 (“Mavericks”) 中引入的一个 Foundation 类,旨在简化 Mac 和 iOS 应用程序中的进度报告。它引入的概念非常棒,创建了一个进度对象树,所有对象都代表一小部分待完成的工作,并且可以限制在该特定代码段中,从而减少了在复杂系统中表示进度所需的意大利面条式代码的数量。

不幸的是,它的执行效果很糟糕。

NSProgress 的性能非常糟糕

不幸的是,NSProgress 的性能简直糟糕透顶。虽然对于许多 Cocoa 类来说,性能并不是主要问题,但 NSProgress 的目的——跟踪需要很长时间的操作的进度——自然而然地使其用于对性能要求很高的代码中。NSProgress 越慢,它就越有可能影响那些已经足够长、需要进度报告的操作的运行时间。在 Apple 2015 年的 "进度报告的最佳实践" 视频中,Apple 建议不要在紧密循环中更新 NSProgress,因为这会对性能产生影响。不幸的是,这种最佳实践实际上导致 UI 代码污染了后端,对关注点分离产生了不利影响。

导致 NSProgress 运行缓慢的原因有很多

Objective-C

这一方面实际上无法避免,因为 NSProgress 是在 Objective-C 是开发 Apple 高级 API 的唯一主流语言时发明的。然而,NSProgress 基于 Objective-C 意味着每次更新它时,都会调用 objc_msgSend。虽然可以使用 IMP 缓存来解决这个问题,但这并不是什么大问题,对吧?嗯,不幸的是,还有更多问题。

KVO

这是最重要的一点。每次更新 NSProgress 对象时,它都会发布 KVO 通知。众所周知,KVO 具有非常糟糕的性能特征。每次更新 NSProgress 对象上的更改计数时,不仅会为该进度对象发送 KVO 通知,还会为其父对象、祖父对象等等,一直发送到树的根对象。此外,这些通知都在当前线程上发送,因此你的工作线程需要等待所有通知完成,才能继续处理它正在做的事情。这会大大降低速度。

NSLock

NSProgress 是线程安全的,这很棒!不幸的是,这是使用 NSLock 实现的,NSLock 是 pthread 互斥锁的简单包装器,这增加了大量的开销。此外,没有原子方式来增加更改计数。要这样做,首先必须获得当前的 completedUnitCount,然后向它添加一些内容,最后将其发送回 completedUnitCount 的 setter,从而导致一次操作需要锁定两次。除了性能较差之外,这还会引入竞争条件,因为在读取和写入之间,在另一个线程上运行的其他程序可能会更改 completedUnitCount 属性,从而导致单元计数不正确。

它非常消耗内存

在更新其更改计数并发送 KVO 通知的过程中,NSProgress 会生成大量自动释放的对象。在我的性能测试中,更新一百万次 NSProgress 会导致应用程序膨胀到 4.8 GB 的内存大小 (!)。当然,可以通过将整个过程包装在自动释放池中来缓解这种情况,但这往往会进一步降低性能。

所有这些性能警告至少会带来一个流畅的界面,对吧?嗯,不是的。

NSProgress 的界面非常糟糕

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 对象上调用 becomeCurrent(withPendingUnitCount:) 隐式构建 NSProgress 树。这会导致该对象存储在线程本地存储中,作为“当前” 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

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)

待办事项