SerendipityEx

关注成长,记录生活

Swift 中的GCD

内容主要翻译自:

  1. Grand Central Dispatch Tutorial for Swift 3: Part 1

Grand Central Dispatch(GCD) 是管理并发操作的低级API,GCD 可以通过将计算量大的任务推迟到后台从而提高应用的响应速度。与锁和线程相比,它是一个更简单的并发模型。

在 Swift 3 中, GCD 得到了重大改进,从基于 C 的 API 转向包含新类和新的数据结构的 Swiftier API。

建立在名为 GooglyPuff 的现有应用程序上。 GooglyPuff 是一款未优化的,“线程不安全”的应用程序,它使用Core Image的人脸检测API,在检测到的人脸上覆盖Google眼睛。您可以从照片库中选择图像,或选择从互联网上下载图像应用此效果。

[ Part 1 ]

Getting Started

下载 starter project 并在 Xcode 中打开它。

主屏幕最初是空的,点击 + 然后选择 Le Internet 从网络下载预定义的图像,点击第一张图片,会看到添加在脸上的有趣的眼睛。

本教程中将主要介绍四个类:

  • PhotoCollectionViewController: 初始视图控制器,将所选照片显示为缩略图。
  • PhotoDetailViewController:显示从 PhotoCollectionViewController 选择的缩略图,并将 小眼睛尖添加到图片上。
  • Photo:这是描述照片属性的协议。它提供了图像,缩略图及其相应的状态。 两个实现协议的类:DownloadPhoto,用于从URL实例实例化照片; AssetPhoto,用于实例化来自PHAsset实例的照片。
  • PhotoManager: 管理所有的 Photo 对象。

该应用有几个问题。运行应用程序时可能会注意到的一点是,下载完成警告为时过早。您将在本系列的第二部分中解决这个问题。

在第一部分中,您将进行一些改进,包括优化googly-fying过程并使PhotoManager线程安全。

GCD 概念

要理解GCD,需要熟悉与并发和线程相关的几个概念。

Concurrency (并发)

在 iOS 中,一个进程或者应用程序由一个或多个线程组成,这些线程由操作系统调度程序独立管理。 每个线程可以同时执行,但是这种情况产生或者如何如何产生由系统决定。

单核设备可以通过time-slicing(时间切片)。他们将运行一个线程,执行上下文切换,然后运行另一个线程。

Concurrency_vs_Parallelism

多核设备通过并行执行同时执行多个线程。

GCD建立在线程之上。它下面管理一个共享线程池。使用GCD可以添加代码块或工作项来分派队列,GCD决定在哪个线程上执行它们。

在你构建你的代码时,你会发现有些代码块可以同时运行,有些不行。这就允许你使用GCD来利用并发执行。

请注意,GCD根据系统和可用系统资源决定需要多少并行性。重要的是要注意并行性需要并发性,但并发并不能保证并行性。

基本上,并发是关于结构,而并行则是关于执行。

Queues (队列)

GCD提供由 DispatchQueue 表示的调度队列来管理您提交的任务,并以来执行它们,以确保提交的第一项任务是第一个开始。

Dispatch queues 是线程安全的,这意味着你可以同时从多个线程访问它们。 当您了解调度队列如何为自己的代码的某些部分提供线程安全性时,GCD的好处就显而易见了。关键在于选择正确的调度队列和正确的调度功能将您的工作提交给队列。

队列可以是串行(serial)或并发(concurrent)的。 串行队列保证在任何给定的时间只有一个任务运行。 GCD控制执行时间。 你不会知道一个任务结束和下一个任务开始之间的时间长短:

Serial Queue Swift

并发队列允许同时运行多个任务。保证任务按照他们添加的顺序开始。任务可以按任意顺序完成,并且您不知道下次启动任务所需的时间,也不知道在任何给定时间运行的任务数量。

Concurrent-Queue-Swift

何时开始任务的决定完全取决于GCD。 如果一个任务的执行时间与另一个任务的执行时间重叠,则由GCD决定是否应该在不同的核心上运行(如果有),或者执行上下文切换以运行不同的任务。

GCD 提供三种主要类型的队列:

  • Main queue: 在主线程上运行并且是一个串行队列。
  • Global queues: 并发队列由整个系统共享。有四个这样的队列具有不同的优先级:high, default, low, and background。后台优先级队列被 I/O 限制。
  • Custom queues: 您创建的队列可以是串行或并发的。实际这些都是由全局队列控制的。

设置全局并发队列时,不要直接指定优先级。而是指定一个服务质量(QoS)类属性。这将表明任务的重要性,并指导GCD确定优先任务。

QoS 类:

  • User-interactive: 任务需要立即完成以提供良好的用户体验。 用于UI更新,事件处理和需要低延迟的小型工作负载。
  • User-initiatd: 任务从UI启动,可以异步执行。应在用户等待立即结果时使用,以及用于继续用户交互所需的任务。这将被映射到高优先级的全局队列中。
  • Utility: 代表长时间运行的任务,通常具有用户可见的进度指示器。将其用于计算,I / O,网络连接,连续数据馈送和类似任务。本课程旨在提高能源效率。这将被映射到低优先级的全局队列中。
  • Background: 这表示用户没有直接意识到的任务。将其用于预取,维护和其他不需要用户交互且时间不敏感的任务。这将被映射到后台优先级全局队列中。

同步 vs 异步

通过GCD,可以同步或者异步分派任务

同步函数在任务完成后将控制权返回调用者。

异步函数立即返回,命令完成任务但不等待它。因此,异步函数不会阻止当前的执行线程继续执行下一个函数。

管理任务

到目前为止,你已经听说过很多“任务”。为了本教程的目的,您可以将任务视为 closure。闭包是可以存储和传递的自包含,可调用的代码块。

你提交到 DispatchQueue 的任务被封装成 DispatchWorkItem。您可以配置 DispatchWorkItem 的行为,例如其QoS等级或是否产生新的分离线程(detached thread)。

处理后台任务

回到 app,从你的相册或者 使用 Le Internet 选项去下载一些图片。点击一个照片,注意照片线节显示花费了多长时间。在较慢的设备上查看较大图像时,延迟更加明显。

重载视图控制器的viewDidLoad()很容易造成视图出现之前的长时间等待。如果它在加载时不是绝对必要的,最好将工作移到后台。

听起来像 DispatchQueue 的 异步工作模式。

打开 PhotoDetailViewController.swiftviewDidLoad() 中替换下面两行:

let overlayImage = faceOverlayImageFromImage(image)
fadeInNewImage(overlayImage)

替换后:

DispatchQueue.global(qos: .userInitiated).async { // 1
  let overlayImage = self.faceOverlayImageFromImage(self.image)
    DispatchQueue.main.async { // 2
      self.fadeInNewImage(overlayImage) // 3
    }
}

一步一步来看代码做了什么:

  1. 将任务移动到后台全局队列,并且在闭包中执行。这使得 viewDidLoad() 在主线程中更早的完成,使加载感觉瞬间完成。同时,人脸检测处理开始并在稍后的时间结束。

  2. 人脸检测处理已完成,并且已生成新图像。既然你想使用这个新的图像来更新你的UIImageView,你可以在主队列中添加一个新的闭包。请记住 - 您必须始终访问主线程上的UIKit类!

  3. 最后,用fadeInNewImage(_ :)更新UI,执行新的googly眼睛图像的淡入转换。

构建并运行应用程序。通过Le Internet选项下载照片。选择一张照片,你会注意到视图控制器加载速度明显加快,并在短暂延迟后添加了googly眼睛

这增加了应用程序的前后效果,因为添加了googly眼睛。即使你试图加载一个非常庞大的图像,你的应用程序也不会像加载视图控制器时那样挂起。

一般来说,当你需要基于网络或CPU密集型任务,您会希望使用异步在后台执行,并且不阻止当前线程时。

下面是如何以及何时使用各种异步队列的快速指南:

  • Main Queue:常用的选择是在完成并发队列任务后更新 UI。 要做到这一点,你会在另一个代码中编写一个闭包。定位主队列并调用 async 保证此新任务将在当前方法结束后的某个时间执行。

  • Global Queue: 在后台执行 非 UI 任务。

  • Custom Serial Queue: 当你想要连续执行后台工作并跟踪它时,这是一个不错的选择。 这消除了资源争用,因为一次只有一个任务正在执行。 请注意,如果您需要方法中的数据,则必须内联另一个闭包以检索它或者考虑使用sync

Delaying Task Execution (延迟任务执行)

DispatchQueue 允许你延迟任务执行,应该注意不要使用它来解决竞争条件或其他时间BUG,比如引入延迟等黑科技。 当您想要任务在特定时间运行时使用此功能。

考虑一下你的App 的用户体验。用户可能会对第一次打开应用程序时应该怎么做感到困惑 - 不是吗?

如果没有任何照片,向用户显示提示是个不错的主意。 您还应该考虑用户的眼睛如何导航主屏幕。 如果您显示的提示太快,他们可能会错过它,因为他们的眼睛萦绕在视图的其他部分。显示提示前一秒延迟应足以吸引用户的注意力并引导他们。

打开 PhotoCollectionViewController.swift 填写 showOrHideNavPrompt() 方法的实现:

let delayInSeconds = 1.0 // 1
DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { // 2
  let count = PhotoManager.sharedManager.photos.count
    if count > 0 {
      self.navigationItem.prompt = nil
    } else {
      self.navigationItem.prompt = "Add photos with faces to Googlyify them!"
    }
}
  1. 指定一个延迟时间的变量
  2. 等待指定的时间,然后异步运行更新照片数量的代码并且更新提示。

showOrHideNavPrompt()viewDidLoad()UICollectionView 重新加载时运行。

构建并运行应用程序。显示提示之前应该会有稍微延迟:

想知道什么时候适合使用 asyncAfter ?通常在主队列中使用它是一个不错的选择。 在其他队列(如全局背景队列或自定义串行队列)上使用 asyncAfter 时,需要谨慎。 最好坚持在主队列。

为什么不使用 Timer?你可以考虑使用它,如果你有重复的任务更容易使用 Timer 来安排。 这里有两个坚持使用调度队列的asyncAfter 的原因。

可读性。要使用Timer,您必须定义一个方法,然后使用选择器或调用定义的方法来创建定时器。有了 DispatchQueueasyncAfter ,你只需添加一个闭包。

Timer 在run loops (运行循环)中,因此还必须确保它在你希望启动的 run loop 上进行q触发(并且在某些情况下为正确的运行循环模式)。在这方面,使用调度队列更容易。

Managing Singletons(管理单例)

关于单例,一个常见的问题是他们并不是线程安全的。考虑到它们的使用,这种担心是合理的:singletons通常用于同时访问单例实例的多个控制器。你的PhotoManager类是一个单例,所以你需要考虑这个问题。

线程安全代码可以安全地从多线程或并发任务中调用,而不会导致任何问题,如数据损坏或应用程序崩溃。不是线程安全的代码一次只能在一个上下文中运行。

在单例实例的初始化期间以及读取和写入实例期间,需要考虑两个线程安全情况。

由于Swift初始化全局变量的方式,初始化变成了一种简单的情况。 全局变量在首次访问时被初始化,并且它们保证以原子方式初始化。 也就是说,执行初始化的代码被视为临界区,并且在任何其他线程访问全局变量之前保证完成。

关键部分是一段不能并发执行的代码,即一次执行两个线程。 这通常是因为代码操纵共享资源,例如变量,如果它被并发进程访问,它可能会被破坏。

打开 PhotoManager.swift 来看单例是如何初始化的:

private let _sharedManager = PhotoManager()

私有的全局变量 _sharedManager 被用于惰性地初始化PhotoManager。 这只发生在第一次访问:

class var sharedManager: PhotoManager {
  return _sharedManager
}

公开变量(public)sharedManager 返回 私有的 _sharedManager,Swift确保这个操作是线程安全的。

在访问处理共享内部数据的单例中的代码时,您仍然必须处理线程安全。您可以通过同步数据访问等方法来处理此问题。你会在下一节看到一种方法。

Handling the Readers-Writers Problems (处理读写问题)

在Swift中,任何用let关键字声明的变量都被认为是一个常量,并且是只读和线程安全的。 然而,用var关键字声明变量,除非数据类型被设计成这样,否则它变成可变的并且不是线程安全的。 当声明为可变时,像Array和Dictionary这样的Swift集合类型不是线程安全的。

虽然许多线程可以同时读取数组的可变实例而不会出现问题,但在另一个线程正在读取数组的同时让一个线程修改数组并不安全。 单例并不能阻止这种情况发生在目前的状态。

看看下面的问题,addPhoto(_:)PhotoManager.swift:

func addPhoto(_ photo: Photo) {
  _photos.append(photo)
    DispatchQueue.main.async {
      self.postContentAddedNotification()
    }
}

这是一个 write 方法,因为它修改了一个可变数组对象。

看看 photos 属性:

fileprivate var _photos: [Photo] = []
var photos: [Photo] {
  return _photos
}

该属性的getter在读取可变数组时被称为 read 方法。调用者获取数组的一个副本,并受到保护,以防止不适当地改变原始数组。 这不会对一个线程调用write方法 addPhoto(_:)提供任何保护,同时另一个线程调用photos属性的getter。

Note: 在上面的代码中,为什么调用者获得照片数组的 copy ?在Swift中,函数的参数和返回类型是通过引用或按值传递的。

按值传递会导致对象的 copy ,并且对 copy 的更改不会影响原始数据。 默认情况下,Swift类实例通过引用传递,结构通过值传递。 Swift的内置数据类型,如数组和字典,被实现为结构体。

它可能看起来像在代码中来回传递集合时有很多 coping 。 不要担心这个内存使用的影响。 Swift集合类型经过优化,只在必要时才进行复制,例如,在传递数值时第一次修改数组。

这是经典的软件开发 Reader-Writers Problem。 GCD 通过使用 dispatch barriers 创建一个 read/write lock 提供了一个优雅的解决方式。 Dispatch barriers 是一组函数,当处理并发队列,作为串行式的瓶颈(serial-style bottleneck)

DispatchWorkItem 提交到调度队列时,可以设置标志来指示它应该是在特定时间内在指定队列上执行的唯一项目。 这意味着在 dispatch barrier 之前提交给队列的所有项目必须在 DispatchWorkItem 执行之前完成。

当轮到 DispatchWorkItem 执行时,barrier 执行它并确保队列在此期间不执行任何其他任务。完成后,队列返回到其默认实现。

下图说明了 barrier 对各种异步任务的影响:

注意,在正常操作中,队列的行为如同正常的并发队列。 但是,当 barrier 执行时,它本质上就像一个串行队列。 也就是说,barrier 是唯一执行的事情。barrier 完成后,队列返回正常并发队列。

在全局后台并发队列中使用 barriers 时要小心,因为这些队列是共享资源。 在自定义串行队列中使用 barrier 是多余的,因为它已经是连续执行。 在自定义并发队列中使用 barrier 是处理关键代码线程安全性的绝佳选择。

使用自定义并发队列来处理 barrier 功能并分离读取和写入功能。并发队列将允许同时进行多个读取操作。

打开 PhotoManager.swift 并且在_photos 声明之上添加一个私有属性.

fileprivate let concurrentPhotoQueue =
DispatchQueue(
    label: "com.raywenderlich.GooglyPuff.photoQueue", // 1
    attributes: .concurrent) // 2
  1. 您使用描述性名称设置 label,该标签在调试过程中很有帮助。通常,使用反转的DNS样式命名约定。

  2. 指定一个并发队列。

使用下面的代码替换 addPhoto(_:) :

func addPhoto(_ photo: Photo) {
  concurrentPhotoQueue.async(flags: .barrier) { // 1
    self._photos.append(photo) // 2
      DispatchQueue.main.async { // 3
        self.postContentAddedNotification()
      }
  }
}
  1. 使用 barrier 异步分派写入操作。它执行时,它将成为队列中唯一的项目。
  2. 添加对象到数组中。
  3. 最后,发布一条通知,说明你已经添加了照片。这个通知应该发布在主线程上,因为它会做UI工作。因此,你将另一个任务异步分派到主队列以触发通知。

这需要照顾写入,但是你还需要实现照片读取方法。

为确保写入时线程安全,你需要在 concurrentPhotoQueue 上执行读取操作。 你需要从函数调用返回数据,所以异步调度不会削减它。 在这种情况下, sync 将是一个很好的候选人。

使用 sync 使用来跟踪工作中的 dispatch barrier ,或者在需要等待操作完成时才能使用由闭包处理的数据。

你需要小心!想象一下,如果您调用 sync 并且你已经在运行的当前队列。这将导致死锁情况。

两个(或有时更多)项目(大多数情况下是线程)被阻塞,如果他们都等待对方完成或执行另一个操作。 第一个无法完成,因为它正在等待第二个完成。但第二个也无法完成,因为它正在等待第一个完成。

在上面的情况, sync 将一直等到闭包完成,但闭包不能完成(它甚至不能启动!),直到当前正在执行的闭包完成,这不能! 这应该强制你意识到你在呼叫哪个队列 - 以及你通过哪个队列。

下面简要介绍在什么情况下使用 sync

  • Main Queue: 出于与上述相同的原因, 要"非常"小心。这种情况也有可能造成死锁。
  • Global Queue: 这是一个好的选择,通过 dispatch barrier` 做同步工作,或等待任务完成时同步工作,然后可以执行进一步处理。
  • Custom Serial Queue : 您正在队列中运行并以同一队列为目标调用同步,则肯定会造成死锁。

仍然在 PhotoManager.swift 中改动 photos 属性的 getter :

var photos: [Photo] {
  var photosCopy: [Photo]!
    concurrentPhotoQueue.sync { // 1
      photosCopy = self._photos // 2
    }
  return photosCopy
}
  1. 同步发送到 concurrentPhotoQueue 以执行读取。
  2. 将照片数组的 copy 存储在 photosCopy 中并将其返回。

构建并运行应用程序。 通过 Le Internet 选项下载照片。 它应该像以前一样行动,但是在引擎盖下,你有一些非常快乐的线程。

恭喜 - PhotoManager单例 现在是线程安全的。无论你在哪里或如何阅读或写照片,你都可以确信,这将以安全的方式完成,不会有任何意外。

[ Part 2 ]

Getting Started

运行 app,点击 + ,然后选择 Le Internet 来添加网络图片,你可能注意到下载完成警报消息在图像下载完成之前弹出:

这是你要解决的第一件事.

Dispatch Groups

打开 PhotoManager.swift 并查看 downloadPhotosWithCompletion(\_:):

func downloadPhotosWithCompletion(_ completion: BatchPhotoDownloadingCompletionClosure?) {
  var storedError: NSError?
    for address in [overlyAttachedGirlfriendURLString, successKidURLString, lotsOfFacesURLString] {
          let url = URL(string: address)
            let photo = DownloadPhoto(url: url!) { _, error in
                if error != nil {
                  storedError = error
                }
            }
          PhotoManager.sharedManager.addPhoto(photo)
        }
  completion?(storedError)
}

该警告由传入该方法的 completion 闭包触发。 这是在下载照片的for循环之后调用的。错误地认为在调用闭包之前下载已完成。

通过调用 DownloadPhoto(url:) 启动照片下载。调用立刻返回,但实际下载是异步发生的。 因此,当 completion 调用时,不能保证所有下载都完成。

你想要 downloadPhotosWithCompletion(_:) 在所有照片下载完成后调用它的 completion 闭包,如何监控这些并发异步事件以实现这一目标?使用当前的方法,你不知道任务何时完成,并且可以按任何顺序完成。

好消息!,这正是 dispatch groups 要处理的。 使用 dispatch groups 可以将多个任务分组在一起,并等待它们完成或在完成后通知他们。任务可以是异步或者同步,甚至可以在不同的队列上运行。

DispatchGroup 管理着 dispatch groups. 先看它的 wait 方法。 这会阻止当前线程,直到所有组的入队任务完成。

PhotoManager.swift 并且替换 downloadPhotosWithCompletion(_:) 代码:

DispatchQueue.global(qos: .userInitiated).async { // 1
  var storedError: NSError?
  let downloadGroup = DispatchGroup() // 2
  for address in [overlyAttachedGirlfriendURLString,
                  successKidURLString,
                  lotsOfFacesURLString] {
    let url = URL(string: address)
    downloadGroup.enter() // 3
    let photo = DownloadPhoto(url: url!) {
      _, error in
      if error != nil {
        storedError = error
      }
      downloadGroup.leave() // 4
    }
    PhotoManager.sharedManager.addPhoto(photo)
  }

  downloadGroup.wait() // 5
  DispatchQueue.main.async { // 6
    completion?(storedError)
  }
}
  1. 因为使用同步方法 wait ,它会阻塞当前线程,所以可以使用 async 将整个方法放入后台队列中,来确保不会阻塞主线程。
  2. 这里创建一个新的 dispatch group
  3. 调用 enter() 来手动通知 group 任务已经开始了。你必须调用 leave() 来平衡 enter() 的调用次数,否则程序会崩溃。
  4. 这里通知 group 任务已经完成。
  5. 当等待任务完成时,调用 call() 阻塞当前线程。永远等待,因为照片创建问题完成的。 你可以使用 wait(timeout:) 指定一个超时时间,当超过时间后,会从等待状态中脱离出来。
  6. 此时,可以保证所有图像任务已经完成或者超时,然后,回到主线程运行 completion 闭包。

构建并运行应用程序。通过 Le Internet 选项下载照片并确认在下载所有图像之前警告不会显示。

Dispatch group 适合所有类型的队列。如果你正在等待完成所有的工作,因为你不想阻塞主线程,你应该警惕在主线程上使用 dispatch queue。 但是异步模型是一个很有吸引力的方式,当长时间运行的任务完成后更新UI,如网络请求。

目前的解决方案是好的,但总的来说,如果可能的话最好避免阻塞线程。下一个任务是重写相同的方法,以便在所有下载完成后异步通知您。

Dispatch Groups, Task 2

使用 wait 方法异步调度到另一个队列是笨拙的。幸好有一个更好的方法。DispatchGroup 可以在所有 group 的任务完成时改为通知。

仍然在__PhotoManager.swift__ 中,downloadPhotosWithCompletion(_:)

// 1
var storedError: NSError?
let downloadGroup = DispatchGroup()
for address in [overlyAttachedGirlfriendURLString,
                successKidURLString,
                lotsOfFacesURLString] {
  let url = URL(string: address)
  downloadGroup.enter()
  let photo = DownloadPhoto(url: url!) {
    _, error in
    if error != nil {
      storedError = error
    }   
    downloadGroup.leave()
  }   
  PhotoManager.sharedManager.addPhoto(photo)
}   
    
downloadGroup.notify(queue: DispatchQueue.main) { // 2
  completion?(storedError)
}
  1. 在这个新的实现中,你不需要将方法包围在一个 async 中调用,因为你没有阻塞主线程。
  2. notify(queue:work:) 作为异步完成闭包。它可以在 item 离开 group 时调用,你还指定在主队列上运行完成工作。

这是处理这个特定工作的更简洁的方式,因为它不会阻塞任何线程。

构建并运行应用程序。确认所有互联网照片下载完成后都仍显示完成警告.

Concurrency Looping(并发循环)

看一看 PhotoManagerdownloadPhotosWithCompletion(_:) 方法,你会注意到有一个for 循环,循环三次迭代并下载三个单独的图像。 你的工作是看看你是否可以同时运行这个循环来尝试和加快速度。

这是 DispatchQueue.concurrentPerform(iterations:execute:) 的工作。 它与for 循环的工作方式类似,它同时执行不同的迭代。它是同步的并且只有在所有工作完成后才返回。

在为给定工作量计算最佳迭代次数时必须小心。 许多迭代和每次迭代的少量工作可能会产生太多的开销,从而使得调用并发的任何收益都无效。 这种称为“跨步”的技术可以帮助你在这里。 这是每次迭代都要完成多项工作的地方。

什么时候去使用 DispatchQueue.concurrentPerform(iterations:execute:)呢? 你可以排除串行队列,因为没有任何好处 - 你也可以使用正常的循环。 对于包含循环的并发队列来说,这是一个很好的选择,尤其是在需要跟踪进度时。

还是在 downloadPhotosWithCompletion(_:) 方法中:

var storedError: NSError?
let downloadGroup = DispatchGroup()
let addresses = [overlyAttachedGirlfriendURLString,
                 successKidURLString,
                 lotsOfFacesURLString]
let _ = DispatchQueue.global(qos: .userInitiated)
DispatchQueue.concurrentPerform(iterations: addresses.count) {
  i in
  let index = Int(i)
  let address = addresses[index]
  let url = URL(string: address)
  downloadGroup.enter()
  let photo = DownloadPhoto(url: url!) {
    _, error in
    if error != nil {
      storedError = error
    }
    downloadGroup.leave()
  }
  PhotoManager.sharedManager.addPhoto(photo)
}
downloadGroup.notify(queue: DispatchQueue.main) {
  completion?(storedError)
}

前一个 for 循环已经被替换为 DispatchQueue.concurrentPerform 来处理并发循环。

在设备上运行此新代码偶尔会产生稍微更快的结果。但是,所有这些工作值得吗?

其实在这种情况下不值得。原因如下:

  • 你可能已经创建了更多的并行运行线程,而不是只是首先运行for循环。你应该使用 DispatchQueue.concurrentPerform(iterations:execute:) 用于迭代非常大的集合以及适当的步长。
  • 您创建应用程序的时间有限 - 不要浪费时间预先优化您不知道的代码已损坏的代码。如果你要优化某些东西,优化一些值得注意并值得花时间的事情。 通过 Instruments 分析你的应用来查找执行时间最长的方法。
  • 通常情况下,优化代码会使您的代码对于您和其他开发者而言更加复杂。确保增加的复杂功能是值得的。

请记住,不要为优化而优化。你只会让自己和其他必须通过代码遍历的人变得更加困难。

Cancelling Dispatch Blocks

到目前为止,您还没有看到允许您取消入队任务的代码。 这里是 dispatch block objects由 DispatchWorkItem 表示成为焦点的地方。 请注意,你只能在 DispatchWorkItem 到达队列头并开始执行之前取消它。

让我们通过从Le Internet的几个图像开始下载任务,然后取消其中的一些来演示这一点。

还是 downloadPhotosWithCompletion(_:)

var storedError: NSError?
let downloadGroup = DispatchGroup()
var addresses = [overlyAttachedGirlfriendURLString,
                 successKidURLString,
                 lotsOfFacesURLString]
addresses += addresses + addresses // 1
var blocks: [DispatchWorkItem] = [] // 2

for i in 0 ..< addresses.count {
  downloadGroup.enter()
  let block = DispatchWorkItem(flags: .inheritQoS) { // 3
    let index = Int(i)
    let address = addresses[index]
    let url = URL(string: address)
    let photo = DownloadPhoto(url: url!) {
      _, error in
      if error != nil {
        storedError = error
      }
      downloadGroup.leave()
    }
    PhotoManager.sharedManager.addPhoto(photo)
  }
  blocks.append(block)
  DispatchQueue.main.async(execute: block) // 4
}

for block in blocks[3 ..< blocks.count] { // 5
  let cancel = arc4random_uniform(2) // 6
  if cancel == 1 {
    block.cancel() // 7
    downloadGroup.leave() // 8
  }
}

downloadGroup.notify(queue: DispatchQueue.main) {
  completion?(storedError)
}
  1. 展开 addresses 数组以保存每个图像的三个副本
  2. 初始化一个 blocks 数组以保存 dispatch block objects 代以后使用。
  3. 创建一个新 DispatchWorkItem. 你传递一个flags参数来指定块应该从它被分派到的队列继承它的Quality of Service类。 然后你可以定义要在闭包中完成的工作。
  4. 你将该块异步地分派给主队列。在这个例子中,使用主队列可以更容易地取消选中的块,因为它是一个串行队列。设置调度块的代码已经在主队列上执行,因此你可以保证下次执行的时间不会超过。
  5. 通过切分块数组可以跳过前三个下载块。
  6. 在这里,你使用arc4random_uniform()来随机选择一个介于0和1之间的数字。它就像一个硬币投掷。
  7. 如果随机数是1,则取消该块。这只能取消仍在队列中但尚未开始执行的块。块在执行过程中不能取消。
  8. 在这里,您记得从调度组中删除取消的块。

构建并运行应用程序,然后从Le Internet添加图像。会看到该应用程序现在下载了三个以上的图像。每次重新运行应用程序时,额外图像的数量都会发生变化。队列中的某些其他图像下载在开始之前会被取消。

这是一个人为的例子,但它很好地说明了如何使用和取消分派块对象。

Dispatch block 可以做的事情有很多,Apple’s documentation

Miscellaneous GCD Fun (其它GCD乐趣)

Testing Asynchronous Code

Xcode 中的测试是在 XCTestCase 的子类中上执行的,并在方法签名中运行任何以 test 开头的方法。 测试是在主线程上进行测量的,因此您可以假定每个测试都以串行方式进行。 只要给定的测试方法完成,XCTest方法就会考虑完成测试并转到下一个测试。 这意味着在下一次测试运行时,来自先前测试的任何异步代码将继续运行。

网络相关的代码通常是异步的,因为您不想在执行网络请求时阻塞主线程。再加上当测试方法结束时测试完成的事实,可能使测试网络代码变得困难。

我们来简单介绍一下测试异步代码的两种常用技术:一种使用(semaphores)信号量,另一种使用(expectations)期望值。

Semaphores

信号量是一种古老的线程概念,由 Edsger W. Dijkstra 提出。信号量是一个复杂的话题,因为它建立在操作系统功能的提杂性之上。

如果你想要学习更多信号量的知识,请查看 detail discussionDining Philosophers Problem

打开 GooglyPuffTests.swfitdownloadImageURLWithString(_:) 中替换如下代码:

let url = URL(string: urlString)
let semaphore = DispatchSemaphore(value: 0) // 1
let _ = DownloadPhoto(url: url!) {
  _, error in
  if let error = error {
    XCTFail("\(urlString) failed. \(error.localizedDescription)")
  }   
  semaphore.signal() // 2
}
let timeout = DispatchTime.now() + .seconds(defaultTimeoutLengthInSeconds)
if semaphore.wait(timeout: timeout) == .timedOut { // 3
  XCTFail("\(urlString) timed out")
}
  1. 创建一个信号量并设置一个起始值,这表示可以访问信号量的事物的数量,而不需要增加信号量。 (注意递增信号量被称为发送信号)
  2. 在 compnletion 中发送一个信号,这会增加信号计数,并指示信号量可用于其他需要它的资源。
  3. 在给定的超时时间内等待信号,这个调用会阻塞当前的线程,直到信号被发出。此函数的非零返回码表示达到了超时。在这种情况下,测试失败,因为它认为网络不应该超过10秒才能返回 - 这是一个公平的点!

Expectations

XCTest 框架以 expectations 的形式为异步代码测试问题提供了另一种解决方案。 这个功能可以让你设置一个 期望 - 你期望的事情会发生 - 然后开始一个异步任务。 然后,您可以让测试运行人员等待,直到异步任务将预期标记为已完成。

仍然是 GooglyPuffTests.swfitdownloadImageURLWithString(_:) 中替换如下代码:

let url = URL(string: urlString)
let downloadExpectation = 
  expectation(description: "Image downloaded from \(urlString)") // 1
let _ = DownloadPhoto(url: url!) {
  _, error in
  if let error = error {
    XCTFail("\(urlString) failed. \(error.localizedDescription)")
  }   
  downloadExpectation.fulfill() // 2
}   
waitForExpectations(timeout: 10) { // 3
  error in
  if let error = error {
    XCTFail(error.localizedDescription)
  }   
} 
  1. 使用 `expectation(description:) 创建一个期望。测试运行器将在失败时在测试日志中显示字符串参数,因此请描述你期望发生的情况。
  2. 在异步执行的闭包中调用 fulfill() 将预期标记为已完成。
  3. 调用 waitForExpectations(timeout:handler:) 等待 期望完成。如果超时,会被视为错误。

。。。

最终结果与使用信号量没有太大区别,但是利用XCTest框架是一个更清晰和更可读的解决方案。

Dispatch Sources

Dispatch sources 是 GCD 的一个特别有趣的功能。 一个 dispatch source 可以用于监视某种类型的事件。事件可以包括 Unix 信号,文件描述符,Mach端口,VFS节点和其它不明确的东西。

在设置 dispatch source 时,可以告诉它要监视的事件类型以及应该执行其事件处理程序块的 dispatch queue, 然后你将一个事件处理程序分配给 dispatch source

创建时,dispatch source 以暂停状态开始。这允许进行额外的配置步骤,例如设置事件处理程序,一旦你配置了 dispatch source,你应该继续它来处理事件。

task:监视应用程序何时进入调试模式。

打开 PhtotCollectionViewController.swift 在 backgroundImageOpacity 全局属性声明下面添加以下内容:

#if DEBUG // 1
  var signal: DispatchSourceSignal? // 2
  private let setupSignalHandlerFor = { (_ object: AnyObject) -> Void in // 3
    let queue = DispatchQueue.main
    signal =
      DispatchSource.makeSignalSource(signal: Int32(SIGSTOP), queue: queue) // 4
    signal?.setEventHandler { // 5
      print("Hi, I am: \(object.description!)")
    }
    signal?.resume() // 6
  }
#endif
  1. 你只能在DEBUG模式下编译此代码,防止“感兴趣的各方” 对你的应用进行大量的探查。 DEBUG 通过 在 Project Settings -> Build Settings -> Swift Compiler - Custom Flags -> Other Swift Flags -> Debug 添加 -D DEBUG 来定义。 它应该已经在启动器项目中设置。
  2. 定义一个 DispatchSourceSingal 类型的 singal 变量来监控 Unix 信号。
  3. 创建一个 block 分配给 全局变量 setupSignalHandlerFor 用于 dispatch source 的一次性设置。
  4. 设置信号。您指出您有兴趣监控SIGSTOP Unix信号并处理主队列中接收到的事件 - 您会很快发现为什么。
  5. 如果调度源成功创建,则注册一个事件处理程序闭包,每当您收到SIGSTOP信号时都会调用它。您的处理程序打印包含类描述的消息。
  6. 所有源默认情况下都处于暂停状态。在这里,您告诉调度源恢复,以便它可以开始监视事件。

添加下面的代码在 viewDidLoad() 调用 super.viewDidLoad() 后:

#if DEBUG
  _ = setupSignalHandlerFor(self)
#endif

构建并运行应用程序。暂停程序执行并通过点击暂停然后在Xcode调试器中播放按钮来立即恢复应用程序:

查处控制台,会发现一些类似下面的东西:

Hi, I am: <GooglyPuff.PhotoCollectionViewController: 0x7fbf0af08a10>

你的应用程序现在可以调试!这真是太棒了,但你如何在实际生活中使用它?

无论何时恢复应用程序,您都可以使用它来调试对象并显示数据。 当恶意攻击者将调试器附加到您的应用程序时,您也可以为您的应用程序定制安全逻辑以保护自己(或用户的数据)。

一个有趣的想法是将此方法用作堆栈跟踪工具来查找要在调试器中操作的对象。

考虑一下这种情况。当你彻底停止调试器时,你几乎不会在期望的堆栈帧上。 现在你可以随时停止调试器并在您想要的位置执行代码。如果您想要在应用程序中的某个点执行代码,而这些代码很难从调试器访问,那么这非常有用。试试看!

在刚刚添加的 setupSignalHandlerFor 块内的 print() 语句上放置一个断点。

在调试器中暂停,然后重新开始。该应用程序将达到您添加的断点。 你现在深入了 PhotoCollectionViewController 方法的深处。现在可以访问PhotoCollectionViewController 的实例来查看内容。非常方便!

在调试器控制台中,输入以下内容:

(lldb) expr object.navigationItem.prompt = "WOOT!"

Xcode调试器有时可能不合作。如果您收到消息:

error: use of unresolved identifier 'self'

然后,你必须努力解决LLDB中的错误。首先记下调试区域中对象的地址:

(lldb) po object

然后手动将值转换为所需的类型:

(lldb) expr let $vc = unsafeBitCast(0x7fbf0af08a10, to: GooglyPuff.PhotoCollectionViewController.self)
(lldb) expr $vc.navigationItem.prompt = "WOOT!"

现在恢复执行应用程序。你会看到以下内容:

使用这种方法,您可以更新UI,查询类的属性,甚至执行方法 - 而无需重新启动应用程序即可进入该特殊工作流程状态。很简约。

End

Gem: 使用 --user-install 时的问题