并发编程实践

1.GCD OR 操作队列

这两个是目前我们在iOS中用的最多的两套API

2.后台的Core Data

在着手 Core Data 的并行处理之前,最好先打一些基础。我们强烈建议通读苹果的官方文档 Concurrency with Core Data

Xcode 所提供的 Core Data 标准模版中,所设立的是运行在主线程中的一个存储调度 (persistent store coordinator)和一个托管对象上下文 (managed object context) 的方式。在很多情况下,这种模式可以运行良好。创建新的对象和修改已存在的对象开销都非常小,也都能在主线程中没有困难地完成。然后,如果你想要做大量的处理,那么把它放到一个后台上下文来做会比较好。一个典型的应用场景是将大量数据导入到 Core Data 中。

示例:
我们要导入一大组柏林的交通数据。在导入的过程中,我们展示一个进度条,如果耗时太长,我们希望可以取消当前的导入操作。同时,我们显示一个随着数据加入可以自动更新的 table view 来展示目前可用的数据。

解决方案:
我们创建一个NSOperation 的子类,将其叫做ImportOperation,我们通过重写 main方法,用来处理所有的导入工作。这里我们使用NSPrivateQueueConcurrencyType来创建一个独立并拥有自己的私有dispatch queue的 managed object context,这个 context 需要管理自己的队列。在队列中的所有操作必须使用performBlock或者performBlockAndWait来进行触发。

NSManagedObjectContext* context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType];
context.persistentStoreCoordinator = self.persistentStoreCoordinator;
context.undoManager = nil;
//performBlockAndWait:Synchronously performs a given block on the receiver’s queue.
[self.context performBlockAndWait:^
{
    [self import];
}];

在这里我们重用了已经存在的persistent store coordinator。一般来说,初始化 managed object contexts 要么使用NSPrivateQueueConcurrencyType,要么使用 NSMainQueueConcurrencyType

导入文件内容

/**
    在导入前,我们枚举文件中的各行,并对可以解析的每一行创建 managed object :
*/
[lines enumerateObjectsUsingBlock:
  ^(NSString* line, NSUInteger idx, BOOL* shouldStop)
  {
      NSArray* components = [line csvComponents];
      if(components.count < 5) {
          NSLog(@"couldn't parse: %@", components);
          return;
      }
      [Stop importCSVComponents:components intoContext:context];
  }];
/**
    在 view controller 中通过以下代码来开始操作:
*/
ImportOperation* operation = [[ImportOperation alloc]
     initWithStore:self.store fileName:fileName];
[self.operationQueue addOperation:operation];
/**
    至此为止,后台导入部分已经完成。接下来,我们要加入取消功能,这其实非常简单,只需要枚举的 block 中加一个判断就行了:
*/
if(self.isCancelled) {
    *shouldStop = YES;
    return;
}
/**
    最后为了支持进度条,我们在 operation 中创建一个叫做 progressCallback 的属性。需要注意的是,更新进度条必须在主线程中完成,否则会导致 UIKit 崩溃。
*/
operation.progressCallback = ^(float progress)
{
    [[NSOperationQueue mainQueue] addOperationWithBlock:^
    {
        self.progressIndicator.progress = progress;
    }];
};
//我们在枚举中来调用这个进度条更新的 block 的操作:

self.progressCallback(idx / (float) count);

  //  然而,如果你执行示例代码的话,你会发现它运行逐渐变得很慢,取消操作也有迟滞。这是因为主操作队列中塞满了要更新进度条的 block 操作。一个简单的解决方法是降低更新的频度,比如只在每导入一百行时更新一次:

NSInteger progressGranularity = 100;

if (idx % progressGranularity == 0) {
    self.progressCallback(idx / (float) count);
}

更新 Main Context

在让一切运转起来之前之前,还有一件事情要做。现在在后台 context 中导入的数据还不能传送到主 context 中,除非我们显式地让它这么去做。我们在Store类的设置Core Data stackinit方法中加入下面的代码:

[NSNotificationCenter defaultCenter]
    addObserverForName:NSManagedObjectContextDidSaveNotification
                object:nil
                 queue:nil
            usingBlock:^(NSNotification* note)
{
    NSManagedObjectContext *moc = self.mainManagedObjectContext;
    if (note.object != moc)
        [moc performBlock:^(){
            [moc mergeChangesFromContextDidSaveNotification:note];
        }];
    }];
}];
/**
    如果 block 在主队列中被作为参数传递的话,那么这个 block 也会在主队列中被执行。如果现在你运行程序的话,你会注意到 table view 会在完成导入数据后刷新数据,但是这个行为会阻塞用户大概几秒钟。
    要修正这个问题,我们需要做一些无论如何都应该做的事情:批量保存。在导入较大的数据时,我们需要定期保存,逐渐导入,否则内存很可能就会被耗光,性能一般也会更坏。而且,定期保存也可以分散主线程在更新 table view 时的工作压力。
    合理的保存的次数可以通过试错得到。保存太频繁的话,可能会在 I/O 操作上花太多时间;保存次数太少的话,应用会变得无响应。在经过一些尝试后,我们设定每 250 次导入就保存一次。改进后,导入过程变得很平滑,它可以适时更新 table view,也没有阻塞主 context 太久。
*/
其他考虑

在导入操作时,我们将整个文件都读入到一个字符串中,然后将其分割成行。这种处理方式对于相对小的文件来说没有问题,但是对于大文件,最好采用惰性读取 (lazily read) 的方式逐行读入。本文最后的示例将使用输入流的方式来实现这个特性,在 StackOverflow 上 Dave DeLong 也提供了一段非常好的示例代码来说明这个问题。

在 app 第一次运行时,除开将大量数据导入 Core Data 这一选择以外,你也可以在你的 app bundle 中直接放一个 sqlite 文件,或者从一个可以动态生成数据的服务器下载。如果使用这些方式的话,可以节省不少在设备上的处理时间。

最后,最近对于 child contexts 有很多争议。我们的建议是不要在后台操作中使用它。如果你以主 context 的 child 的方式创建了一个后台 context 的话,保存这个后台 context 将阻塞主线程。而要是将主 context 作为后台 context 的 child 的话,实际上和与创建两个传统的独立 contexts 来说是没有区别的。因为你仍然需要手动将后台的改变合并回主 context 中去。

设置一个 persistent store coordinator 和两个独立的 contexts 被证明了是在后台处理 Core Data 的好方法。除非你有足够好的理由,否则在处理时你应该坚持使用这种方式。

扩展阅读

3.后台 UI 代码

首先要强调:UIKit 只能在主线程上运行。而那部分不与 UIKit 直接相关,却会消耗大量时间的 UI 代码可以被移动到后台去处理,以避免其将主线程阻塞太久。但是在你将你的 UI 代码移到后台队列之前,你应该好好地测量哪一部分才是你代码中的瓶颈。这非常重要,否则你所做的优化根本是南辕北辙。

如果你找到了你能够隔离出的昂贵操作的话,可以将其放到操作队列中去:

__weak id weakSelf = self;
[self.operationQueue addOperationWithBlock:^{
    NSNumber* result = findLargestMersennePrime();
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        MyClass* strongSelf = weakSelf;
        strongSelf.textLabel.text = [result stringValue];
    }];
}];

如你所见,这些代码其实一点也不直接明了。我们首先声明了一个 weak 引用来参照 self,否则会形成循环引用( block 持有了 self,私有的 operationQueue retain 了 block,而 self 又 retain 了 operationQueue )。为了避免在运行 block 时访问到已被释放的对象,在 block 中我们又需要将其转回 strong 引用。

4.后台绘制

如果你确定drawRect:是你的应用的性能瓶颈,那么你可以将这些绘制代码放到后台去做。但是在你这样做之前,检查下看看是不是有其他方法来解决,比如、考虑使用core animation layers或者预先渲染图片而不去做Core Graphics绘制。
解决方案:
其实解决起来也很简单,把drawRect:中的代码放到一个后台操作中去做就可以了。然后将原本打算绘制的视图用一个 image view 来替换,等到操作执行完后再去更新。在绘制的方法中,使用 UIGraphicsBeginImageContextWithOptions来取代UIGraphicsGetCurrentContext :

UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// drawing code here
UIImage *i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return i;
/**
    通过在第三个参数中传入 0 ,设备的主屏幕的 scale 将被自动传入,这将使图片在普通设备和
     retina 屏幕上都有良好的表现。
*/

注意:
如果你在 table view 或者是 collection view 的 cell 上做了自定义绘制的话,最好将它们放入 operation 的子类中去。你可以将它们添加到后台操作队列,也可以在用户将 cell 滚动出边界时的 didEndDisplayingCell 委托方法中进行取消。这些技巧都在 2012 年的WWDC Session 211 -- Building Concurrent User Interfaces on iOS中有详细阐述。

除了在后台自己调度绘制代码,以也可以试试看使用 CALayer 的 drawsAsynchronously 属性。然而你需要精心衡量这样做的效果,因为有时候它能使绘制加速,有时候却适得其反。

4.异步网络请求处理

所有的网络请求都应该通过异步的方式进行

// 警告:不要使用这些代码。
dispatch_async(backgroundQueue, ^{
   NSData* contents = [NSData dataWithContentsOfURL:url]
   dispatch_async(dispatch_get_main_queue(), ^{
      // 处理取到的日期
   });
});
//这段代码问题:没有办法去取消这个同步的网络请求。它将阻塞住线程直到它完成。如果请求一直没结果,那就只能干等到超时

要解决上面的困境,我们可以使用NSURLConnection的异步方法,并且把所有操作转化为 operation 来执行. NSURLConnection 是通过run loop来发送事件的。因为事件发送不会花多少时间,因此最简单的是就只使用 main run loop 来做这个。然后,我们就可以用后台线程来处理输入的数据了。
要处理URL 连接,我们重写自定义的 operation 子类中的 start 方法:

- (void)start
{
    NSURLRequest* request = [NSURLRequest requestWithURL:self.url];
    self.isExecuting = YES;
    self.isFinished = NO;
    [[NSOperationQueue mainQueue] addOperationWithBlock:^
    {
        self.connection = [NSURLConnectionconnectionWithRequest:request
                                                       delegate:self];
    }];
}

由于重写的是 start 方法,所以我们需要自己要管理操作的 isExecuting 和 isFinished 状态。要取消一个操作,我们需要取消 connection ,并且设定合适的标记,这样操作队列才知道操作已经完成。

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    self.data = self.buffer;
    self.buffer = nil;
    self.isExecuting = NO;
    self.isFinished = YES;
}

Demo下载
因此:我们总结如下:建议要么你花时间来把事情做对做好,要么就直接使用像AFNetworking这样的框架
其实 AFNetworking 还提供了不少好用的小工具,比如有个 UIImageView 的 category,来负责异步地从一个 URL 加载图片。在你的 table view 里使用的话,还能自动帮你处理取消加载操作,非常方便。

5.进阶:后台文件 I/O

一次性将文件读入内存中对于较小的文件可能没什么问题,但是对于大文件来说就并不友好。
我们将构建一个类,它负责一行一行读取文件而不是一次将整个文件读入内存,另外要在后台队列处理文件,以保持应用相应用户的操作。
为了达到这个目的,我们使用能让我们异步处理文件的NSInputStream

如果你总是需要从头到尾来读/写文件的话,streams 提供了一个简单的接口来异步完成这个操作

不管你是否使用 streams,大体上逐行读取一个文件的模式是这样的:

  1. 建立一个中间缓冲层以提供,当没有找到换行符号的时候可以向其中添加数据
  2. 从 stream 中读取一块数据
  3. 对于这块数据中发现的每一个换行符,取中间缓冲层,向其中添加数据,直到(并包括)这个换行符,并将其输出
  4. 将剩余的字节添加到中间缓冲层去

绝大部分时候,使用逐块读入的方式来处理大文件,是非常有用的技术。

总结

在主队列中接收事件或者数据,然后用后台操作队列来执行实际操作,然后回到主队列去传递结果,遵循这样的原则来编写尽量简单的并行代码,将是保证高效正确的不二法则。