如何使用 NSOperations 和 NSOperationQueues

2015年6月9日 · 9 years ago

本文为 www.raywenderlich.com 上的一篇教程,由枫影翻译。
原文地址 http://www.raywenderlich.com/76341/use-nsoperation-nsoperationqueue-swift

img

更新记录:本教程由 Richard Turton 针对 iOS 8, Xcode 6.1 和 Swift 进行了更新。原文由团队成员 Soheil Azarpour 编写。

相信大家都有过这样令人沮丧的经验:在 iOS/Mac 应用里按下一个按钮或者输入一些文字的时候,突然间整个界面就卡住不动了。

在 Mac 上,用户只能盯着那个彩色的球不停地转圈圈,好一会儿才能继续使用你的应用。在 iOS 上,用户则期望应用应该能实时地对自己的触摸动作进行反馈。这些不够流畅的应用看起来笨重而拖沓,一般都是差评如潮。

然而保持应用流畅度却是说起来容易做起来难。一旦你的应用需要执行繁重的任务,事情就会变得复杂起来。要在 Main Run Loop 里同时执行繁重的任务并且进行流畅的响应是不可能的事情。

一个可怜的开发者对此能做点什么呢?答案就是把繁重的任务从主线程挪到另一个并发的线程上。并发意味着你的应用可以同时多个操作流(或者线程)——以此达到界面响应用户输入的同时还能执行其他任务。

NSOperation 和 NSOperationQueue 是在 iOS 上实现并发操作的一种方法。在本教程中,你将会学习如何使用这些类。我们将从一个完全不使用并发的应用开始,这个应用会看起来很迟钝,然后你将对这个应用进行重构,加入并发操作让界面变得流畅起来!

开始吧!

本教程的示例工程主要用于展示一个 TableView,里面是加过滤镜的图片。图片要从网上下载下来,在本地加上滤镜效果,然后在展示在 TableView 里面。

下图是这个应用的模型:
model

初次尝试

请下载本教程的示例代码:http://cdn5.raywenderlich.com/wp-content/uploads/2014/10/ClassicPhotos-Starter63.zip

注意:所有的图片都来自stock.xchng。部分图片故意拼错名字,用于测试下载失败的情况

编译运行这个工程,然后(最终)你讲看到这个应用里展示了一个图片列表。试试看滚动一下这个列表,是不是菊花一紧?

所有的动作都在 ListViewController.swift 这个类里面,而且多数都写在 tableView(_:cellForRowAtIndexPath:) 方法里。

你会发现有两个操作是特别重的:

  1. 从网上下载图片。 虽然这是一件很简单的事情,但是你必须等待图片下载完成才能进行下一个任务。
  2. 使用 Core Image 对图片添加滤镜效果。 这个方法对图片使用了 sepia 滤镜。如果你对 Core Image 滤镜有兴趣,你可以参考 Beginning Core Image in Swift 一文。

此外,应用刚开始还从网上下载了一份图片列表:

lazy var photos = NSDictionary(contentsOfURL:dataSourceURL)

所有这些操作都是在主线程做的。由于主线程是用于更新UI,同用户交互的线程,阻塞了主线程就会让整个应用看起来很卡。你可以使用 Xcode 的 gauges view 查看应用的执行参数。

你可以看到 Thread 1 (也就是主线程)的 CPU 占用情况。如果想要查看应用的更多详情,你可以使用 Instruments,不过关于 Instruments 可以用一整篇文章来讲了。

OK,现在是时候进入优化部分了。

任务,线程和进程

开始教程正文之前,这里有几个技术概念需要明确一下:

  • 任务:一件需要完成的,简单、单一的工作。
  • 线程:由操作系统提供的,在一个应用里允许多条指令同时执行的机制。
  • 进程:一大块可执行代码(一般是一个可执行文件),可以由多个线程组成。

注意:在 iOS 和 OS X 中,多线程功能是由 POSIX Threads API(或者说 pthread)提供的,属于操作系统的一部分。这是一个比较底层的 API,很容易犯错,而且最糟糕的事这些错误是很难被察觉到的!
Foundation 框架里有一个类叫做 NSThread,相较而言要比底层接口好用多了,但是管理基于 NSThread 的多线程还是相当令人头疼。NSOperation 和 NSOperationQueue 则是更高级的类,提供更加简单的多线程管理接口。

下图中,你可以看到任务,线程和进程三者的关系:

可以看到,一个进程可以包含多个可以执行的线程,一个线程则可以同时执行多个人物。

在上图中,线程 2 执行读文件的操作,同时线程 1 则执行界面的展示工作。这跟 iOS 代码的结构很相似——主线程执行界面相关操作,子线程则负责耗时操作,比如读文件,网络操作等等。

NSOperation vs. Grand Central Dispatch (GCD)

你可能早已听过 Grand Central Dispatch (GCD) 的大名了。GCD 包含了语言特性,运行时库和系统优化,给 iOS 和 OS X 的并发和多核编程提供了一套系统而且强大的接口。想要了解更多关于 GCD 的内容,可以参考我们的文章:Multithreading and Grand Central Dispatch on iOS for Beginners Tutorial

NSOperation 和 NSOperationQueue 是基于 GCD 之上的。一个通用的规则,苹果建议尽量使用最高级别的抽象接口,只有经过仔细权衡后觉得确实更底层的接口,才往下层走。

下面是一个 NSOperation 和 GCD 的一个简单的比较,便于你选择应该使用哪一种接口:

  • GCD 是一种轻量级的方法,用于一组将被并发执行的任务。你不需要计划这些任务的时间,系统帮你做了。但是使用 Blocks 来添加依赖会是令人头疼的事情,而且作为一个开发者,取消或者挂起一个 block 也需要大量额外的工作。
  • NSOperation 相比 GCD 要多了一点额外的开销,但是你可以对多个 operation 添加依赖,复用 operation,取消或者挂起 operation。

由于本文的示例中将对 Table View 进行优化,基于提高性能和减少功耗的考虑,你需要在一个图片 cell 被滚动出屏幕外的时候能够取消对这个图片正在执行的操作,所以我们将使用 NSOperation 来做这个优化。即使所有的操作都是在后台线程做的,如果有几十个耗时操作一直在后台队列中无法取消,对性能也是一种打击。

完善应用模型

现在是时候完善一下那个没有多线程的应用模型了!如果你仔细观察这个一开始的模型,你会发现有三个地方可以被多线程优化。只要把这三个地方拆解到其他线程,主线程就可以减轻不少压力,提高流畅度。

为了突破应用的性能瓶颈,你将有一个主线程用于实时反馈用户输入,一个子线程专门用来下载图片,还有一个子线程用来对图片添加滤镜效果。在新的模型中,这个应用会从主线程启动,然后先加载一个空的 Table View。与此同时,应用将启动一个子线程用于下载数据源。

一旦数据源下载完,你将通知 Table View 进行 Reload。Reload Table View 是 UI 操作,所以一定要在主线程执行。在这时, Table View 就知道有多少行数据要进行展示,也知道每一行的图片对应的 URL。但是 Table View 还没拿到真正的图片数据!所以这个时候去下载所有图片是不明智的,你只需要看得到的那几张图片就可以了。

这里有什么可以优化的点呢?

一个更好的模型是,只下载显示在屏幕上的那几张图片。所以你的代码首先要问 Table View 要能看到的那些行,然后开始下载的工作。同时,滤镜线程在没有真实图片之前也无法开始工作。所以,在图片还没有下载完之前,滤镜线程不应该被启动。

要让应用看起来更流畅,你的代码应该要能在图片下载完就立刻显示出来。然后再启动滤镜线程,等滤镜加载完毕再更新 UI 显示添加过滤镜的图片。下面这个图片展示了完整的流程:

为了达成这些目标,你首先需要跟踪正在下载、已经下载完的图片,还要知道图片是否已经加过滤镜了。然后你还需要跟踪每一个操作的状态,以便在用户滚动界面时可以取消、暂停或者继续之前的操作。

Okay!现在我们已经准备好开始写代码了!:]

打开示例工程,添加一个新的 Swift 文件,名为:PhotoOperations.swift。添加以下代码:

import UIKit
 
// This enum contains all the possible states a photo record can be in
enum PhotoRecordState {
  case New, Downloaded, Filtered, Failed
}
 
class PhotoRecord {
  let name:String
  let url:NSURL
  var state = PhotoRecordState.New
  var image = UIImage(named: "Placeholder")
 
  init(name:String, url:NSURL) {
    self.name = name
    self.url = url
  }
}

注意:请确保 import UIKit 这句在文件的顶部。默认情况下,Xcode 会帮你引入 Foundation 文件的。

这个简单的类将代表每一个展示在应用里的图片,同时包含图片的状态(初始状态为 .New)。图片默认是一张占位图。

为了追踪每一个操作的状态,你需要另一个类。把下面这个类的定义加到 PhotoOperations.swift 底部:

class PendingOperations {
  lazy var downloadsInProgress = [NSIndexPath:NSOperation]()
  lazy var downloadQueue:NSOperationQueue = {
    var queue = NSOperationQueue()
    queue.name = "Download queue"
    queue.maxConcurrentOperationCount = 1
    return queue
    }()
 
  lazy var filtrationsInProgress = [NSIndexPath:NSOperation]()
  lazy var filtrationQueue:NSOperationQueue = {
    var queue = NSOperationQueue()
    queue.name = "Image Filtration queue"
    queue.maxConcurrentOperationCount = 1
    return queue
    }()
}

这个类包含了两个 Dictionary,用来追踪 Table View 里每一行的下载和滤镜状态,同时还有两个 Operation Queue 用于图片下载和滤镜操作管理。

所有的值都被声明为 lazy create,也就是说只有等到第一次用到这个值它才会真正被初始化。这也将是应用的性能得到提升。

创建一个 NSOperationQueue 也是挺直观的。给 NSOperationQueue 命名是很有用的,这个名字会在 Instruments 或者 debugger 里面显示出来。出于本文的教学目的,这里最大并发数 maxConcurrentOperationCount 被设为 1,这样你可以清楚地看到这些操作一个接一个地往下走。你也可以改成让队列本身去决定最大并发数,这将更大地提升你的应用性能。

那么队列本身是怎么决定最大并发数的呢?好问题!:]这是由硬件来决定的。默认情况下,NSOperationQueue 会自己计算出对当前平台来说最好的决策,然后启动尽可能多的线程。

试想一下下面这个例子。假设这时候系统出于空闲状态,有很多可用资源,那么这个队列可能会启动大概 8 个并发线程。下一次你启动这个 App 的时候,有可能系统正在处理其他操作,那这个队列可能就只有 2 个并发线程。在这个示例工程里,由于你设置了最大并发数为 1,所以同时只会有一个操作在执行。

注意:你可能觉得奇怪,为什么我们要跟踪所有下载中和等待下载的操作,队列本身有一个 operations 属性可以返回所有操作,为什么不直接用这个就好了?在这个项目中,这么做可能不太高效。因为你必须把 table view 的行数和操作关联起来,这么做意味着你每次都要循环一遍整个数组去查找属于哪一行,使用 Dictionary 把 indexPath 作为 Key 存起来则可以很快地找到对应的操作。

接下来我们要对下载和滤镜操作进行管理了。把下面这些代码加到 PhotoOperations.swift 文件底部:

class ImageDownloader: NSOperation {
  //1
  let photoRecord: PhotoRecord
 
  //2
  init(photoRecord: PhotoRecord) {
    self.photoRecord = photoRecord
  }
 
  //3
  override func main() {
    //4
    if self.cancelled {
      return
    }
    //5
    let imageData = NSData(contentsOfURL:self.photoRecord.url)
 
    //6
    if self.cancelled {
      return
    }
 
    //7
    if imageData?.length > 0 {
      self.photoRecord.image = UIImage(data:imageData!)
      self.photoRecord.state = .Downloaded
    }
    else
    {
      self.photoRecord.state = .Failed
      self.photoRecord.image = UIImage(named: "Failed")
    }
  }
}

NSOperation 是一个抽象类,是为了继承而设计的。每一个子类代表一个特殊的任务(如前所述)。

上面代码里各行注释的含义如下:

  1. 用一个常量来记住当前操作对应的 PhotoRecord
  2. 创建一个初始化接口,接收一个图片记录作为初始化参数
  3. 你需要重载 main 函数,这里是实际要执行的操作
  4. 开始任务之前先检查是否已经被取消了。在开始耗时操作之前,所有的 Operation 都应该检查是否已被取消。
  5. 下载图片数据。
  6. 再检查一次是否被取消
  7. 如果有图片数据,则创建一个 UIImage 然后赋值给 photoRecord,并且修改状态。如果没有,则标记为下载失败。

接下来,你需要创建另一个 Operation,用于执行滤镜操作!把下面代码添加到 PhotoOperations.swift 文件里:

class ImageFiltration: NSOperation {
  let photoRecord: PhotoRecord
 
  init(photoRecord: PhotoRecord) {
    self.photoRecord = photoRecord
  }
 
  override func main () {
    if self.cancelled {
      return
    }
 
    if self.photoRecord.state != .Downloaded {
      return
    }
 
    if let filteredImage = self.applySepiaFilter(self.photoRecord.image!) {
      self.photoRecord.image = filteredImage
      self.photoRecord.state = .Filtered
    }
  }
}

这个看上去很像下载 Operation,只是用图片添加滤镜(这里用了一个还没实现的函数,所以会有一个编译错误)代替了下载操作。

下面我们给 ImageFiltration 类添加图片滤镜操作的代码:

func applySepiaFilter(image:UIImage) -> UIImage? {
  let inputImage = CIImage(data:UIImagePNGRepresentation(image))
 
  if self.cancelled {
    return nil
  }
  let context = CIContext(options:nil)
  let filter = CIFilter(name:"CISepiaTone")
  filter.setValue(inputImage, forKey: kCIInputImageKey)
  filter.setValue(0.8, forKey: "inputIntensity")
  let outputImage = filter.outputImage
 
  if self.cancelled {
    return nil
  }
 
  let outImage = context.createCGImage(outputImage, fromRect: outputImage.extent())
  let returnImage = UIImage(CGImage: outImage)
  return returnImage
}

这里的滤镜实现跟之前 ListViewController 中的实现是一样的,只是搬到了一个 Operation,从而可以在后台执行这个操作。同样的,你还是要经常检查任务是否已经被取消了,最佳实践是在耗时操作之前和之后各检查一次。一旦滤镜添加完成,你就可以设置 photo record 的状态了。

牛逼!现在你已经拥有执行后台任务的所有必备工具了,是时候回到 View Controller 中修改你的旧代码了。

回到 ListViewController.swift 文件,删掉 lazy var photos 声明,添加下面的声明:

    var photos = [PhotoRecord]()
    let pendingOperations = PendingOperations()

如此你将有一个照片对象的数组和一个等待任务的队列。

下面我们添加一个新的方法用于下载图片信息的 plist 文件。

func fetchPhotoDetails() {
  let request = NSURLRequest(URL:dataSourceURL!)
  UIApplication.sharedApplication().networkActivityIndicatorVisible = true
 
  NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.mainQueue()) {response,data,error in
    if data != nil {
      let datasourceDictionary = NSPropertyListSerialization.propertyListWithData(data, options: Int(NSPropertyListMutabilityOptions.Immutable.rawValue), format: nil, error: nil) as! NSDictionary
 
      for(key : AnyObject,value : AnyObject) in datasourceDictionary {
        let name = key as? String
        let url = NSURL(string:value as? String ?? "")
        if name != nil && url != nil {
          let photoRecord = PhotoRecord(name:name!, url:url!)
          self.photos.append(photoRecord)
        }
      }
 
      self.tableView.reloadData()
    }
 
    if error != nil {
      let alert = UIAlertView(title:"Oops!",message:error.localizedDescription, delegate:nil, cancelButtonTitle:"OK")
      alert.show()
    }
    UIApplication.sharedApplication().networkActivityIndicatorVisible = false
  }
}

这个方法创建了一个异步的网络请求,请求结束后在主线程回调。文件下载后会被解析成一个 NSDictionary 对象,然后再解出来一个 PhotoRecord 数组。这里你不会直接使用到 NSOperation,而是用到主队列 NSOperationQueue.mainQueue()。

在 ViewDidLoad 方法中调用这个下载函数。

    fetchPhotoDetails()

接下来,找到 tableView(_:cellForRowAtIndexPath:) 然后替换成下面的实现:

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCellWithIdentifier("CellIdentifier", forIndexPath: indexPath) as! UITableViewCell
 
  //1
  if cell.accessoryView == nil {
    let indicator = UIActivityIndicatorView(activityIndicatorStyle: .Gray)
    cell.accessoryView = indicator
  }
  let indicator = cell.accessoryView as! UIActivityIndicatorView
 
  //2
  let photoDetails = photos[indexPath.row]
 
  //3
  cell.textLabel?.text = photoDetails.name
  cell.imageView?.image = photoDetails.image
 
  //4
  switch (photoDetails.state){
  case .Filtered:
    indicator.stopAnimating()
  case .Failed:
    indicator.stopAnimating()
    cell.textLabel?.text = "Failed to load"
  case .New, .Downloaded:
    indicator.startAnimating()
    self.startOperationsForPhotoRecord(photoDetails,indexPath:indexPath)
  }
 
  return cell
}

请花点时间通读一下下面的注释详解:

  1. 我们使用 UIActivityIndicatorView 来告诉反馈用户当前的图片状态
  2. Data Source 包含了 PhotoRecord 的实例,根据当前行的 IndexPath 找到正确的那一个。
  3. Cell 的 Text Label 将跟 PhotoRecord 保持一致。
  4. 检查当前图片记录,设置正确的 Indicator 状态,然后启动对应的 Operation(还没实现)。

现在你可以把 ViewController 里面的 applySepiaFilter 删掉了,添加以下代码,用于启动一个 Operation:

func startOperationsForPhotoRecord(photoDetails: PhotoRecord, indexPath: NSIndexPath){
  switch (photoDetails.state) {
  case .New:
    startDownloadForRecord(photoDetails, indexPath: indexPath)
  case .Downloaded:
    startFiltrationForRecord(photoDetails, indexPath: indexPath)
  default:
    NSLog("do nothing")
  }
}

这里,你将把一个 PhotoRecord 和 IndexPath 作为参数传入,基于图片的状态,你可以选择启动下载或者滤镜 Operation。

注意:用来下载和添加滤镜的方法是分开实现的,有可能有些图片已经下载完了,然后被滚出屏幕外了,这时候图片滤镜还没加上。所以下一次用户滚动到同一行的时候,你不需要重新下载图片,只要加上滤镜就行了!牛逼烘烘!:]

现在你要实现上面调用到的方法了。还记住你创建了一个自定义的类PendingOperations 用来跟踪 operation吗?现在你可以用上这个类了!添加下面的代码:

func startDownloadForRecord(photoDetails: PhotoRecord, indexPath: NSIndexPath){
  //1
  if let downloadOperation = pendingOperations.downloadsInProgress[indexPath] {
    return
  }
 
  //2
  let downloader = ImageDownloader(photoRecord: photoDetails)
  //3
  downloader.completionBlock = {
    if downloader.cancelled {
      return
    }
    dispatch_async(dispatch_get_main_queue(), {
      self.pendingOperations.downloadsInProgress.removeValueForKey(indexPath)
      self.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
    })
  }
  //4
  pendingOperations.downloadsInProgress[indexPath] = downloader
  //5
  pendingOperations.downloadQueue.addOperation(downloader)
}
 
func startFiltrationForRecord(photoDetails: PhotoRecord, indexPath: NSIndexPath){
  if let filterOperation = pendingOperations.filtrationsInProgress[indexPath]{
    return
  }
 
  let filterer = ImageFiltration(photoRecord: photoDetails)
  filterer.completionBlock = {
    if filterer.cancelled {
      return
    }
    dispatch_async(dispatch_get_main_queue(), {
      self.pendingOperations.filtrationsInProgress.removeValueForKey(indexPath)
      self.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
      })
  }
  pendingOperations.filtrationsInProgress[indexPath] = filterer
  pendingOperations.filtrationQueue.addOperation(filterer)

}

Okay! 下面这个列表会告诉你这段代码干了些啥:

  1. 首先,检查对应的 indexPath 看看是否有 Operation 在 downloadsInProgress 中,如果有,则忽略之。
  2. 如果没有,则创建一个 ImageDownloader。
  3. 添加一个 completion block,这个 block 将在 operation 结束后被执行。这里是接收 operation completed 的最佳位置,这个 block 在 operation 被取消的时候也会回调,所以需要先检查是否被取消了。而且这里并不保证一定在主线程回调,所以你还得用 GCD 来确保 UI 操作是在主线程做的。
  4. 把 Operation 加进 downloadsInProgress 里面以便追踪。
  5. 把 operation 加到下载队列。这里是触发队列启动的入口,队列会自己去管理任务的执行时间。

给图片添加滤镜的操作也是一样的模式,除了使用 ImageFiltration 和 filtrationsInProgress 来追踪任务之外。作为练习,你可以自己尝试重构这部分代码,提取重用部分 :]

最后,你成功了!现在你的工程已经完整了。编译运行一下看看!现在滚动列表将不再卡顿,App 在看得见图片的时候才开始下载和添加滤镜,体验流畅无比!

这难道不是很酷吗?你可以看到小小的努力可以让你的应用变得更加流畅——用户将获得更多乐趣!

微调

能看到这里已经很不容易了!你已经让这个项目优化了许多。但是,我们还是有一些细节可以进行调整。你想要的是变成一个伟大的程序员,而不只是一个好程序员。( You want to be a great programmer, not just a good one!)

也可能已经注意到当你滚动列表把图片滚出屏幕外的时候,那些任务还在不停地下载和添加滤镜。如果你滚得很快,队列将被塞满,滚到底部的时候那些图片要等很久才能出现。理想情况下,我们应该取消那些不在屏幕里的任务。

你不是已经添加了“取消”的代码了吗?现在是时候用上他们了!:]

回到 Xcode,打开 ListViewController.swift。找到 tableView(_:cellForRowAtIndexPath:) 的实现,给 startOperationsForPhotoRecord 的调用包上一个条件判断:

    if (!tableView.dragging && !tableView.decelerating) {
      self.startOperationsForPhotoRecord(photoDetails, indexPath: indexPath)
    }

这里你让 Table View 只在不滚动的时候才开始下载。这两个属性其实是 UIScrollView 的属性,UITableView 是 UIScrollView 的子类所以可以直接用。

接下来,要实现 UIScrollView 的 Delegate 方法:

override func scrollViewWillBeginDragging(scrollView: UIScrollView) {
  //1
  suspendAllOperations()
}
 
override func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
  // 2
  if !decelerate {
    loadImagesForOnscreenCells()
    resumeAllOperations()
  }
}
 
override func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
  // 3
  loadImagesForOnscreenCells()
  resumeAllOperations()
}

这段代码做了这么件事:

  1. UIScrollView 开始滚动的时候,就要挂起所有的 Operation,看看哪些是用户真正要看到的。后面我们会实现 suspendAllOperations 方法。
  2. 如果 Delcelrate 是 false,说明用户在拖动 Table View,这里我们要恢复被挂起的任务,取消掉已经不在屏幕中的任务,开始新的任务。我们也会实现 loadImagesForOnscreenCells 和 resumeAllOperations 方法。
  3. 这个 Delegate 回调告诉你 Table View 停止滚动了,这里我们会做跟 #2 一样的操作。

现在,我们把漏掉的实现加回 ListViewController.swift 文件中:

func suspendAllOperations () {
  pendingOperations.downloadQueue.suspended = true
  pendingOperations.filtrationQueue.suspended = true
}
 
func resumeAllOperations () {
  pendingOperations.downloadQueue.suspended = false
  pendingOperations.filtrationQueue.suspended = false
}
 
func loadImagesForOnscreenCells () {
  //1
  if let pathsArray = tableView.indexPathsForVisibleRows() {
    //2
    var allPendingOperations = Set(pendingOperations.downloadsInProgress.keys.array)
    allPendingOperations.unionInPlace(pendingOperations.filtrationsInProgress.keys.array)
 
    //3
    var toBeCancelled = allPendingOperations
    let visiblePaths = Set(pathsArray as! [NSIndexPath])
    toBeCancelled.subtractInPlace(visiblePaths)
 
    //4
    var toBeStarted = visiblePaths
    toBeStarted.subtractInPlace(allPendingOperations)
 
    // 5
    for indexPath in toBeCancelled {
      if let pendingDownload = pendingOperations.downloadsInProgress[indexPath] {
        pendingDownload.cancel()
      }
      pendingOperations.downloadsInProgress.removeValueForKey(indexPath)
      if let pendingFiltration = pendingOperations.filtrationsInProgress[indexPath] {
        pendingFiltration.cancel()
      }
      pendingOperations.filtrationsInProgress.removeValueForKey(indexPath)
    }
 
    // 6
    for indexPath in toBeStarted {
      let indexPath = indexPath as NSIndexPath
      let recordToProcess = self.photos[indexPath.row]
      startOperationsForPhotoRecord(recordToProcess, indexPath: indexPath)
    }
  }
}

suspendAllOperations 和 resumeAllOperations 的实现都很直观了。NSOperationQueues 是可以被挂起的,设置 suspended 为 true 就行了。这样整个队列里所有的任务都会被挂起——你没有办法只挂起单个任务。

loadImagesForOnscreenCells 稍微复杂一点:

  1. 首先有一个数组包含了 Table View 所有可见 Row 的 IndexPath。
  2. 构建一个由下载中和添加滤镜中的所有操作组成的集合。
  3. 构建一个要被取消的 Operation 对应的 IndexPath 的集合。在所有 IndexPath 里面去掉看得见的那些就是了。
  4. 遍历所有需要取消的 Operation,取消并从 PendingOperations 删掉。
  5. 遍历所有要启动的 Operation,启动之。

编译运行一下,现在你应该有一个更加流畅,资源管理更加合理的应用了!你们的掌声在哪里!

注意到现在只要你停止滚动 Table View,当前屏幕上的 Cell 就会立刻开始下载了。

下一步去哪里?

这里有一个完整的项目工程可供大家下载。

如果你已经完成了这个项目而且确实花时间理解了所有的东西,那么恭喜你!相比你刚开始看这篇文章,你已经是一个更有价值的 iOS 开发者了!多数开发团队都会为拥有一个或两个真正懂得这些东西的开发者而感到幸运。

但是要注意——就像多层嵌套的 block 一样,无理由地滥用多线程也会让你的代码变得难以维护。多线程可能会引入很多难以察觉的bug,只在网络差的时候,设备性能特别好的时候,或者跟你写代码的机器的 CPU 数不一样的时候才会出现。你必须很小心地测试这些代码,经常使用 Instruments(或者你自己喜欢的工具)来确认引入多线程确实让性能得到了提高。

这篇文章里没有提到,Operation 还有一个很有用的特性,就是依赖(dependency)。你可以创建一个依赖于其他 Operation 的 Operation。这个 Operation 只有在那个依赖的 Operation 全部执行完了它才会启动。举个例子:

// MyDownloadOperation is a subclass of NSOperation
let downloadOperation = MyDownloadOperation()
// MyFilterOperation  is a subclass of NSOperation
let filterOperation = MyFilterOperation()
 
filterOperation.addDependency(downloadOperation)

要解除依赖关系只要一句代码:

filterOperation.removeDependency(downloadOperation)

如果用上依赖关系,是否可以简化本文介绍的这个工程呢?你可以用上这个新技能自己试一下 :] 值得注意的是,有依赖关系的 Operation 即使被依赖的 Operation 被取消了,它也依然会被启动,你需要随时牢记这一点。