iOS多线程编程三:Operation和OperationQueue

3,744 阅读18分钟

概述

本文是多线程的第三篇文章,主要讲解Operation和OperationQueue。看资料的时候发现了一篇特别好的教程,随性就翻译一下,权当自己做个总结。本文主要内容翻译自raywenderlichoperation and operationqueue tutorial in swift。原文链接点这里

不管是在Mac还是iOS系统上,当用户使用你的app点击按钮或者编辑文本的时候突然你的app卡死,界面变得无法响应,这对用户来讲是非常糟糕的体验。 在Mac系统上用户不得不盯着彩色的加载框等待恢复。在iOS系统上连加载框都看不到。用户希望在他们触摸屏幕之后应用程序能立即做出响应。卡顿的app让用户感到这个app很笨重,反应太慢。这会造成用户在AppStore中给应用差评。

保持app的流畅响应说起来容易做起来可是相当不容易。一旦你的app需要处理多个任务,事情很快变得复杂起来。主线程的run loop循环中没有足够的时间来处理繁重的任务,同时还要响应用户的UI操作。

这种情况下,开发者该如何应对呢?解决办法就是通过多并行的手段将任务从主线程中剥离。并行的意思是你的应用程序同时执行多个工作流(或者多个线程)。这使得在执行任务的同时用户界面同时能够保持响应。

OperationOPerationQueue这两个类是iOS系统中并发执行任务的一种方式。本教程就是讲述的就是如何使用它们。我们将以一个没用并发的例子开始,它会非常的卡顿,然后你将逐步加入并发操作,使之变得流畅。

开始

我们的例子是展示一个滚动的图片列表,图片来自于网络,然后下载图片之后需要对其过滤,然后添加到表格上显示。应用的模型如下:

应用模型

第一次尝试

点击这里下载最初的项目,这是该教程项目的第一个版本。

注意: 所有的图片都来自stock.xchng 数据源中有些的图片名字被故意错误的命名,这是为了模拟图片下载失败的场景。

运行该项目,你讲看到滚动的图片列表如下,滑动该列表,是不是很不流畅。(国内需要给手机翻墙或者挂代理)。

运行效果

所有的响应代码都在ListViewController.swift这个类中,大部分在**tableView(_:cellForRowAtIndexPath:**里。

查看该方法里的代码,可以发现这里主要做了两件事:

  • 从网络上下载图片。
  • 使用Core Image 过滤图片。

还有,第一次加载的时候,你需要从网络上获取所有图片的url。

lazy var photos = NSDictionary(contentsOf:dataSourceURL)!

所有这些工作都发生在应用程序的主线程。由于主线程还担任着界面的交互,过多的在主线程上加载图片,过滤图片会给主线程造成压力,这必然会牺牲应用的响应流畅度。你可以在Xcode的debug 导航条上查看CPU的工作情况。

cpu执行情况

从图中我们可以看到,大部分的任务消耗在thread1, 它是应用程序的主线程。

接下来我们来优化用户体验。

任务,线程,进程

在进行下一步之前,有几个概念你需要了解一下:

  • 任务:你需要处理的一个简单的,独立的工作。
  • 线程:由操作系统提供的一种机制,可以使在一个app内让多个操作同时执行。
  • 进程:一个可执行的代码块,由多个线程组成。

在iOS和MacOS系统上,线程的功能是由POSIX线程API(或者pthreads)提供的,它们是操作系统的一部分。它们是很底层的,使用这些ÅPI编写代码及易出错,更糟糕的是由它们引发的错误非常难排查

Foundation框架包含了一个Thread的类,它是线程相对容易使用,但使用Thread管理多线程仍然令人头疼。Operation 和 OperationQueue 是更高层次的API,使用它们处理多线程非常的方便了很多。

下面展示了进程、线程、任务之间的关系。

进程、线程、任务之间的关系

如上图所示,一个进程和包含多个线程,同一时刻每个线程可以同时执行多个任务。

在上图中,Thread2 执行读取文件的操作,同时Thread1执行用户交互相关的代码。这和iOS代码结构十分相似:主线程应该执行和用户交互相关的操作,其他线程应该执行耗时操作,如读取文件,访问网络等等。

Operation 和 GCD的比较

你可能听说过GCD,简而言之,GCD由语言功能,运行时库和系统增强功能组成,可提供系统和全面的改进,以支持iOS和macOS中多核硬件的并发性。如果你想学习GCD可以参考GCD Tutorial

OperationOperationQueue 是构建在GCD之上的,通常来说,苹果建议使用最高级别的API来开发,必要的时候再使用低级API。

下面是这两套API的简单比较,以便帮助你决定什么时间什么地方使用GCD或者OPeration:

  • GCD 是一种轻量级的方式,用以表示即将并行执行的工作单元。你不用负责这些工作单元的执行,操作系统替你负责工作单元的调度。添加block之间的依赖是一件令人头疼的事,取消或者挂起一个block需要你编写额外的代码。

  • GCD操作增加了一些额外的开销,但您可以在各种操作之间添加依赖关系并重新使用,取消或暂停它们。

这篇教程使用Operation,因为你处理的是tableView,出于性能的原因,你需要在图片离开屏幕的时候取消图片的下载任务。即使这些操作实在后台线程中执行的,但如果大量的操作在队列中等待,性能依然会很糟糕。

重新定义App模型

是时候重新定义最初的没有额外线程的模型了。如果你仔细观察了最初的模型,你会发现它有三个地方可以提高。将这三个地方放在单独的线程中,主线程就可以安心的响应用户界面了。

重新定义模型

为了摆脱App的性能瓶颈,你需要一个线程来专门响应用户操作,一个线程来数据源和图片,一个线程来执行图片过滤。在新的模型中,App从主线程中启动,并加载一个空的tableView,同时,app开启一个线程来加载数据源。

一旦数据源下载成功,你讲告知tableView刷新表格,刷新表格的操作必须在主线程中执行,因为它涉及到界面操作。这时,tableView知道了它拥有多少个行和需要展示的图片的URL,但它还没有真实的图片,如果这时你立即开始下载所有的图片,这将是非常糟糕的,因为你不需要同时展示所有的图片。

那么,怎么做会好一点呢?

一个比较好的方式是仅仅下载那些屏幕内可见的cell的图片。因此你的代码应首先询问tableView那些行是可见的,然后再开启下载任务。与之相似,图片过滤操作也不能等待图片完全下载之后开始。因此,应用程序不应启动图像过滤任务,直到有未经过滤的图像等待处理。

为了使app看起来更加可响应,一旦图片下载之后就会展示它。然后开始图片过滤,然后将过滤后的图片显示在界面上。下图展示了新模型的调度控制情况:

控制流程

为了实现这些目标,你需要跟踪image的状态,下载中,已下载,已过滤。你也需要跟踪每个operation的状态和类型,以便于你在用户滚动的时候可以取消,暂停或者恢复它。

好啦,现在开始编码!

打开Xcode,新建一个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: URL
  var state = PhotoRecordState.new
  var image = UIImage(named: "Placeholder")
  
  init(name:String, url:URL) {
    self.name = name
    self.url = url
  }
}

这个类代表了app内展示的每个图片和它当前的状态,默认是 .new,图片默认情况下是一个占位图。

为了追踪每个opration的状态,你需要另一个类,在photoOperations.swift 的末尾添加如下类:

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

此类包含两个字典,用于跟踪表中每行的活动和挂起下载和过滤操作,以及每种操作类型的操作队列。

所有的操作都是懒加载,他们到第一次访问的时候才初始化,这会提升app的性能。

如你所见,创建一个OperationQueue非常简单,对queue进行命名有助于你调试代码。最大并发操作数量设置为1是为了教学所有,为了让你看到操作被一个接一个的完成。您可以将此部分保留,并允许队列决定它可以同时处理多少操作 - 这将进一步提高性能。

队列如何决定一次可以运行多少个操作?这是个好问题!这取决于硬件。默认情况下,OperationQueue会在幕后进行一些计算,确定它运行的特定平台的最佳值,并启动尽可能多的线程。

考虑如下情况:假设系统处于空闲状态,并且有大量可用资源。在这种情况下,队列可以同时启动八个线程。下次运行程序时,系统可能正忙于其他消耗资源操作。这次,队列可能只启动两个同时发生的线程。因为在此应用程序中设置了最大并发操作计数,所以一次只能执行一个操作。

你可能会好奇为什么要跟踪所有活动和挂起的oprations呢?queue有一个operations方法,这个方法返回operation的数组,为什么不利用他呢? 在这个项目中,因为他的效率并不高。你需要跟踪那个Operation和tableView的row相关,这将涉及到每次使用的时候遍历数组。但把他们存储在一个字典里,并使用Indexpath作为key,意味着查询的时候将更加快和高效。

是时候关心下载和过滤的操作了,将下面代码添加到PhotoOperations.swift 的尾部。

class ImageDownloader: Operation {
  //1
  let photoRecord: PhotoRecord
  
  //2
  init(_ photoRecord: PhotoRecord) {
    self.photoRecord = photoRecord
  }
  
  //3
  override func main() {
    //4
    if isCancelled {
      return
    }

    //5
    guard let imageData = try? Data(contentsOf: photoRecord.url) else { return }
    
    //6
    if isCancelled {
      return
    }
    
    //7
    if !imageData.isEmpty {
      photoRecord.image = UIImage(data:imageData)
      photoRecord.state = .downloaded
    } else {
      photoRecord.state = .failed
      photoRecord.image = UIImage(named: "Failed")
    }
  }
}

Operation是一个抽象类,专为子类化而设计。每个子类代表一个特定的任务,如前面的图所示。

以下是上述代码中每个编号注释的内容:

  1. 添加一个常量,引用与operation相关的PhotoRecord。
  2. 创建初始化方法,并传入PhotoRecord。
  3. main() 方法是重写自operation, 是真正执行任务的地方。
  4. 在开始之前检查是否处于取消状态。在尝试耗时的操作之前,应定期检查是否处于取消状态。
  5. 下载图片数据。
  6. 重新检测是否处于取消状态。
  7. 如果图片成功下载,创建图片并添加到record中,然后设置状态。如果下载失败,标记record为失败,并设置相应的图片。

下一步,你需要创建另一个operation 来处理图片过滤。把下面的代码添加到PhotoOperations.swift的尾部。

class ImageFiltration: Operation {
  let photoRecord: PhotoRecord
  
  init(_ photoRecord: PhotoRecord) {
    self.photoRecord = photoRecord
  }
  
  override func main () {
    if isCancelled {
        return
    }
      
    guard self.photoRecord.state == .downloaded else {
      return
    }
      
    if let image = photoRecord.image, 
       let filteredImage = applySepiaFilter(image) {
      photoRecord.image = filteredImage
      photoRecord.state = .filtered
    }
  }
}

这看起来与下载操作非常相似,只是使用了图像过滤操作(使用尚未实现的方法,因此编译器错误)替换了下载操作。

在ImageFiltration类中添加过滤方法

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

  return UIImage(cgImage: outImage)
}

这个方法和之前ListViewController中提供的过滤方法基本一致,移到这里是为了让其可以在operation中执行。同样的你需要频繁的检查operation的是否处于取消状态。一旦你的过滤执行完毕,需要重置record中的值。

到这里我们已经有了所有的工具和方法来在后台处理任务。现在,我们回到viewcontroller来修改代码以提高性能。

切换到ListViewController.swift文件,删除lazy var photos 属性,添加如下代码:

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

这两个属相持有了PhotoRecord组成的数组和PendingOperations对象来管理operations。

添加一个新的方法来下载photos数组的值

func fetchPhotoDetails() {
  let request = URLRequest(url: dataSourceURL)
  UIApplication.shared.isNetworkActivityIndicatorVisible = true

  // 1
  let task = URLSession(configuration: .default).dataTask(with: request) { data, response, error in

    // 2
    let alertController = UIAlertController(title: "Oops!",
                                            message: "There was an error fetching photo details.",
                                            preferredStyle: .alert)
    let okAction = UIAlertAction(title: "OK", style: .default)
    alertController.addAction(okAction)

    if let data = data {
      do {
        // 3
        let datasourceDictionary =
          try PropertyListSerialization.propertyList(from: data,
                                                     options: [],
                                                     format: nil) as! [String: String]

        // 4
        for (name, value) in datasourceDictionary {
          let url = URL(string: value)
          if let url = url {
            let photoRecord = PhotoRecord(name: name, url: url)
            self.photos.append(photoRecord)
          }
        }

        // 5
        DispatchQueue.main.async {
          UIApplication.shared.isNetworkActivityIndicatorVisible = false
          self.tableView.reloadData()
        }
        // 6
      } catch {
        DispatchQueue.main.async {
          self.present(alertController, animated: true, completion: nil)
        }
      }
    }

    // 6
    if error != nil {
      DispatchQueue.main.async {
        UIApplication.shared.isNetworkActivityIndicatorVisible = false
        self.present(alertController, animated: true, completion: nil)
      }
    }
  }
  // 7
  task.resume()
}
  1. 创建URLSession任务在后台下载image列表。
  2. 配置UIAlertController,在错误的时候使用它。
  3. 如果请求成功,请从属性列表中创建字典。字典使用图像名称作为键,其URL作为值。
  4. 从字典构建PhotoRecord对象数组。
  5. 返回主线程以重新加载表视图并显示图像。
  6. 发生错误时显示警告控制器。请记住,URLSession任务在后台线程上运行,并且必须在主线程中显示屏幕上的任何消息。
  7. 运行下载任务。

在viewDidLoad()方法中调用这个方法:

fetchPhotoDetails()

下一步,找到tableView(_:cellForRowAtIndexPath:)方法,用以下代码替换它

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier", for: indexPath)
  
  //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()
    startOperations(for: photoDetails, at: indexPath)
  }
  
  return cell
}
  1. 创建UIActivityIndicatorView并将其设置为单元的附件视图, 为提供反馈。
  2. 根据indexpath从数据源中取出PhotoRecord。
  3. 单元格的文本标签(几乎)始终相同,PhotoRecord的图片在状态变更后设置,因此无论record的状态如何,您都可以在此处设置它们。
  4. 检查Record。根据需要设置活动指示符和文本,然后启动操作(尚未实现)。

实现启动操作方法

func startOperations(for photoRecord: PhotoRecord, at indexPath: IndexPath) {
  switch (photoRecord.state) {
  case .new:
    startDownload(for: photoRecord, at: indexPath)
  case .downloaded:
    startFiltration(for: photoRecord, at: indexPath)
  default:
    NSLog("do nothing")
  }
}

在这里,您传入PhotoRecord的实例及其索引路径。根据照片记录的状态,您可以启动下载或过滤操作。

下载和过滤图像的方法是分开实现的。因为有可能在下载图像时用户可以滚动,所以你并不需要应用图像过滤器。下次用户来到同一行时,您无需重新下载图像;你只需要应用图像过滤器!

现在,您需要实现在上面的方法中调用的方法。前面我们创建了一个自定义类PendingOperations来跟踪操作; 现在你真的开始使用它了!将以下方法添加到类中:

func startDownload(for photoRecord: PhotoRecord, at indexPath: IndexPath) {
  //1
  guard pendingOperations.downloadsInProgress[indexPath] == nil else {
    return
  }
      
  //2
  let downloader = ImageDownloader(photoRecord)
  
  //3
  downloader.completionBlock = {
    if downloader.isCancelled {
      return
    }

    DispatchQueue.main.async {
      self.pendingOperations.downloadsInProgress.removeValue(forKey: indexPath)
      self.tableView.reloadRows(at: [indexPath], with: .fade)
    }
  }
  
  //4
  pendingOperations.downloadsInProgress[indexPath] = downloader
  
  //5
  pendingOperations.downloadQueue.addOperation(downloader)
}
    
func startFiltration(for photoRecord: PhotoRecord, at indexPath: IndexPath) {
  guard pendingOperations.filtrationsInProgress[indexPath] == nil else {
      return
  }
      
  let filterer = ImageFiltration(photoRecord)
  filterer.completionBlock = {
    if filterer.isCancelled {
      return
    }
    
    DispatchQueue.main.async {
      self.pendingOperations.filtrationsInProgress.removeValue(forKey: indexPath)
      self.tableView.reloadRows(at: [indexPath], with: .fade)
    }
  }
  
  pendingOperations.filtrationsInProgress[indexPath] = filterer
  pendingOperations.filtrationQueue.addOperation(filterer)
}
  1. 从downloadsInProgress中查找指定的IndexPath中是否有operation,如果有就返回。
  2. 如果没有就创建ImageDownloader。
  3. 添加一个完成的block,它将在operation执行完毕之后执行。这是让您的应用程序的其余部分知道操作已完成的好地方。要注意如果操作被取消也会执行完成块,因此您必须在执行任何操作之前检查此属性。你也无法保证调用完成块的线程,因此需要使用GCD触发主线程上表视图的重新加载。
  4. 将operation添加到downloadsInProgress以帮助跟踪。
  5. 将操作添加到下载队列。这是开始执行任务的方式。一旦你将operation添加到队列,队列开始处理任务的调度。

startFiltration 方法遵循同样的原则,只是使用了ImageFiltration和filtrationsInProgress来追踪operation。

到这里,我们已经完成了。运行该项目,查看效果。你滚动浏览表格视图,应用程序不再停止并开始下载图像并在它们变得可见时对其进行过滤。

效果

是不是很酷?你可以看到一点点努力可以大大提高您的应用程序响应速度 - 并为用户带来更多乐趣!

微调

你在本教程中已经走了很长的路!你的小项目具有响应性,并且与原始版本相比有很多改进。但是,仍有一些小细节需要处理。

你可能已经注意到,当你在表格视图中滚动时,这些屏幕外单元格仍处于下载和过滤的过程中。如果您快速滚动,应用程序将忙于下载并过滤列表中更靠后的单元格中的图像,即使它们不可见。理想情况下,应用程序应取消对屏幕外单元格的过滤,并优先显示当前显示的单元格。

你可以在你的代码中添加取消规则,来进一步优化。

打开 ListViewController.swift 找tableView(_:cellForRowAtIndexPath:) 方法,在调用 startOperationsForPhotoRecord 方法的地方添加判断

if !tableView.isDragging && !tableView.isDecelerating {
  startOperations(for: photoDetails, at: indexPath)
}

只有在表视图不滚动时,才能告诉表视图启动操作。这些实际上是UIScrollView的属性,因为UITableView是UIScrollView的子类,所以表视图会自动继承这些属性。

接下来,将以下UIScrollView委托方法的实现添加到类中:

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. 在用户开始滚动时,你将希望暂停所有操作并查看用户想要查看的内容。您将在稍后实现suspendAllOperations。
  2. 如果decelerate的值为false,则表示用户停止拖动表视图。因此,你希望恢复暂停操作,取消屏幕外单元格的操作,以及启动屏幕单元格的操作。之后实现loadImagesForOnscreenCells和resumeAllOperations。
  3. 此委托方法告诉你表视图停止滚动,因此需要执行与操作2相同的操作。

接下来,将下面方法添加到ListViewController中。

func suspendAllOperations() {
  pendingOperations.downloadQueue.isSuspended = true
  pendingOperations.filtrationQueue.isSuspended = true
}

func resumeAllOperations() {
  pendingOperations.downloadQueue.isSuspended = false
  pendingOperations.filtrationQueue.isSuspended = false
}

func loadImagesForOnscreenCells() {
  //1
  if let pathsArray = tableView.indexPathsForVisibleRows {
    //2
    var allPendingOperations = Set(pendingOperations.downloadsInProgress.keys)
    allPendingOperations.formUnion(pendingOperations.filtrationsInProgress.keys)
      
    //3
    var toBeCancelled = allPendingOperations
    let visiblePaths = Set(pathsArray)
    toBeCancelled.subtract(visiblePaths)
      
    //4
    var toBeStarted = visiblePaths
    toBeStarted.subtract(allPendingOperations)
      
    // 5
    for indexPath in toBeCancelled {
      if let pendingDownload = pendingOperations.downloadsInProgress[indexPath] {
        pendingDownload.cancel()
      }
      pendingOperations.downloadsInProgress.removeValue(forKey: indexPath)
      if let pendingFiltration = pendingOperations.filtrationsInProgress[indexPath] {
        pendingFiltration.cancel()
      }
      pendingOperations.filtrationsInProgress.removeValue(forKey: indexPath)
    }
      
    // 6
    for indexPath in toBeStarted {
      let recordToProcess = photos[indexPath.row]
      startOperations(for: recordToProcess, at: indexPath)
    }
  }
}

suspendAllOperations() and resumeAllOperations() 实现起来非常简单,OperationQueue可以通过将suspended属性设置为true来挂起queque里面的所有的操作。

loadImagesForOnscreenCells() 有一点复杂。

  1. 从包含表视图中所有当前可见行的索引路径的数组开始。
  2. 过组合正在进行的所有下载和正在进行的所有过滤器,构建一组所有待处理操作。
  3. 构造一组包含要取消的操作的索引路径。从所有操作开始,然后删除可见行的索引路径。这将使一组操作涉及屏幕外行。
  4. 构造一组需要启动其操作的索引路径。从索引路径开始所有可见行,然后删除操作已挂起的那些行。
  5. 循环访问要取消的那些,取消它们,并从PendingOperations中删除它们的引用。
  6. 遍历那些要启动的,并为每个调用startOperations(for:at :)。

运行应用,这是一个响应更快,资源管理更好的应用程序。

运行效果

请注意,当您完成滚动表视图时,可见行上的图像将立即开始处理。

其他

本文首发于RiverLi的个人公众号,转载请注明出处。
欢迎关注我公众号与我交流。

RiverLi的公众号