概览

本文主要探讨一些常用后台任务的最佳实践:

  1. 如何做异步网络请求
  2. 如何异步处理大型文件,以保持较低的内存占用

操作队列 (Operation Queues) 还是 GCD ?

操作队列提供了在 GCD 中不那么容易复制的有用特性。其中最重要的一个就是可以取消在任务处理队列中的任务,而且操作队列在管理操作间的依赖关系方面也容易一些。 GCD 给予你更多的控制权力以及操作队列中所不能使用的底层函数。详细介绍可以参考底层并发 API 这篇文章。

后台 UI 代码

首先要强调:UIKit 只能在主线程上运行。而那部分不与 UIKit 直接相关,却会消耗大量时间的 UI 代码可以被移动到后台去处理,以避免其将主线程阻塞太久。

后台获取UI数据

例如使用操作队列隔离以下昂贵操作:

1
2
3
4
5
6
7
8
__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 引用。

这在 ARC 和 block 主导的编程范式中是解决 retain cycle 的一种常见也是最标准的方法。

后台绘制UI

如果你确定 drawRect: 是你的应用的性能瓶颈,那么你可以将这些绘制代码放到后台去做。但是在你这样做之前,检查下看看是不是有其他方法来解决,比如、考虑使用 core animation layers 或者预先渲染图片而不去做 Core Graphics 绘制。

如果你确实认为在后台执行绘制代码会是你的最好选择时再这么做。其实解决起来也很简单,把 drawRect: 中的代码放到一个后台操作中去做就可以了。然后将原本打算绘制的视图用一个 image view 来替换,等到操作执行完后再去更新。在绘制的方法中,使用 UIGraphicsBeginImageContextWithOptions 来取代 UIGraphicsGetCurrentContext

1
2
3
4
5
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// drawing code here
UIImage *i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return i;

通过在第三个参数中传入 0 ,设备的主屏幕的 scale 将被自动传入,这将使图片在普通设备和 retina 屏幕上都有良好的表现。

cell在操作队列中异步绘制

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

其他方案

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

异步网络请求处理

你的所有网络请求都应该采取异步的方式完成。

然而,在 GCD 下,有时候你可能会看到这样的代码

1
2
3
4
5
6
7
// 警告:不要使用这些代码。
dispatch_async(backgroundQueue, ^{
    NSData* contents = [NSData dataWithContentsOfURL:url]
    dispatch_async(dispatch_get_main_queue(), ^{
        // 处理取到的日期
    });
});

乍看起来没什么问题,但是这段代码却有致命缺陷。你没有办法去取消这个同步的网络请求。它将阻塞主线程直到它完成。如果请求一直没结果,那就只能干等到超时(比如 dataWithContentsOfURL: 的超时时间是 30 秒)。

分析状况

  1. 当队列是串行执行时,它将一直被阻塞住。
  2. 当队列是并行执行时,GCD 需要重开一个线程来补凑你阻塞住的线程。

两种结果都不太妙,所以最好还是不要阻塞线程。

方案一

要解决上面的困境,我们可以使用 NSURLConnection 的异步方法,并且把所有操作转化为 operation 来执行。通过这种方法,我们可以从操作队列的强大功能和便利中获益良多:我们能轻易地控制并发操作的数量,添加依赖,以及取消操作。 例如:在NSOperation子类DownloadOperation中重写start方法,并实现NSURLConnectionDelegate代理方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@interface DownloadOperation : NSOperation<NSURLConnectionDelegate>

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

然而,在这里还有一些事情值得注意: NSURLConnection 是通过 run loop 来发送事件的。因为发送事件不会花多少时间,因此最简单的是就只使用 main run loop 来做这个。然后,我们就可以用后台线程来处理输入的数据了。

方案二

另一种可能的方式是使用像 AFNetworking 这样的框架:建立一个独立的线程,为建立的线程设置自己的 run loop,然后在其中调度 URL 连接。但是并不推荐你自己去实现这些事情。

要处理URL 连接,我们重写自定义的 operation 子类中的 start 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- (void)start
{
    NSURLRequest* request = [NSURLRequest requestWithURL:self.url];
    self.isExecuting = YES;
    self.isFinished = NO;
    [[NSOperationQueue mainQueue] addOperationWithBlock:^
    {
        self.connection = [NSURLConnectionconnectionWithRequest:request
        delegate:self];
    }];
}

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

1
2
3
4
5
6
7
- (void)cancel
{
    [super cancel];
    [self.connection cancel];
    self.isFinished = YES;
    self.isExecuting = NO;
}

当连接完成加载后,它向代理发送回调:

1
2
3
4
5
6
7
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    self.data = self.buffer;
    self.buffer = nil;
    self.isExecuting = NO;
    self.isFinished = YES;
}

就这么多了。完整的代码可以参见GitHub上的示例工程

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

扩展阅读:

进阶:后台文件 I/O

构建一个类,负责一行一行读取文件而不是一次将整个文件读入内存,另外要在后台队列处理文件,以保持应用相应用户的操作。 为了达到这个目的,我们使用能让我们异步处理文件的 NSInputStream 。根据官方文档的描述:

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

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

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

为了将其运用到实践中,我们又建立了一个示例应用,里面有一个 Reader 类完成了这件事情,它的接口十分简单

1
2
3
4
@interface Reader : NSObject
- (void)enumerateLines:(void (^)(NSString*))block completion:(void (^)())completion;
- (id)initWithFileAtPath:(NSString*)path;
@end

runloop分发NSInputStream事件

注意,这个类不是 NSOperation 的子类。与 URL connections 类似,输入的 streams 通过 run loop 来传递它的事件。这里,我们仍然采用 main run loop 来分发事件,然后将数据处理过程派发至后台操作线程里去处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
- (void)enumerateLines:(void (^)(NSString*))block
completion:(void (^)())completion
{
    if (self.queue == nil) {
        self.queue = [[NSOperationQueue alloc] init];
        self.queue.maxConcurrentOperationCount = 1;  //串行队列
    }
    self.callback = block;
    self.completion = completion;
    self.inputStream = [NSInputStream inputStreamWithURL:self.fileURL];
    self.inputStream.delegate = self;
    //分发NSInputStream事件
    [self.inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop]
                                forMode:NSDefaultRunLoopMode];
    [self.inputStream open];
}

调用时的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
- (void)import:(id)sender
{
    NSURL *fileURL = [[NSBundle mainBundle] URLForResource:@"Clarissa Harlowe" withExtension:@"txt"];
    NSAssert([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]], @"Please download the sample data");

    self.reader = [[Reader alloc] initWithFileAtURL:fileURL];
    [self.reader enumerateLinesWithBlock:^(NSUInteger i, NSString *line){
    if ((i % 2000ull) == 0) {
        NSLog(@"i: %d", i);
        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            [self.button setTitle:line forState:UIControlStateNormal];
        }];
        }
    } completionHandler:^(NSUInteger numberOfLines){
        NSLog(@"lines: %d", numberOfLines);
        [self.button setTitle:@"Done" forState:UIControlStateNormal];
    }];
}

NSInputStream代理方法

现在,input stream 将(在主线程)向我们发送代理消息,然后我们可以在操作队列中加入一个 block 操作来执行处理了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)stream:(NSStream*)stream handleEvent:(NSStreamEvent)eventCode
{
    switch (eventCode) {
        ...
        case NSStreamEventHasBytesAvailable: {
            NSMutableData *buffer = [NSMutableData dataWithLength:4 * 1024];
            NSUInteger length = [self.inputStream read:[buffer mutableBytes]
                                             maxLength:[buffer length]];
            if (0 < length) {
                [buffer setLength:length];
                __weak id weakSelf = self;
                [self.queue addOperationWithBlock:^{
                    //在后台串行队列的线程池中逐行处理缓冲区
                    [weakSelf processDataChunk:buffer];
                }];
            }
            break;
        }
        ...
    }
}

缓冲区处理

处理数据块的过程是先查看当前已缓冲的数据,并将新加入的数据附加上去。接下来它将按照换行符分解成小的部分,并逐行处理。 数据处理过程中会不断的从buffer中获取已读入的数据。然后把这些新读入的数据按行分开并存储。剩余的数据被再次存储到缓冲区中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (void)processDataChunk:(NSMutableData *)buffer
{
    if (self.remainder != nil) {
        [self.remainder appendData:buffer];
    } else {
        self.remainder = buffer;
    }
    [self.remainder obj_enumerateComponentsSeparatedBy:self.delimiter
                            usingBlock:^(NSData* component, BOOL last) {
        if (!last) {
            [self emitLineWithData:component];
        } else if (0 < [component length]) {
            self.remainder = [component mutableCopy];
        } else {
            self.remainder = nil;
        }
    }];
}

- (void)emitLineWithData:(NSData *)data;
{
    NSUInteger lineNumber = self.lineNumber;
    self.lineNumber = lineNumber + 1;
    if (0 < data.length) {
        NSString *line = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
        self.callback(lineNumber, line);
    }
}

现在你运行示例应用的话,会发现它在响应事件时非常迅速,内存的开销也保持很低(在我们测试时,不论读入的文件有多大,堆所占用的内存量始终低于 800KB)。绝大部分时候,使用逐块读入的方式来处理大文件,是非常有用的技术。

延伸阅读:

总结

通过我们所列举的几个示例,我们展示了如何异步地在后台执行一些常见任务。在所有的解决方案中,我们尽力保持了代码的简单,这是因为在并发编程中,稍不留神就会捅出篓子来。

很多时候为了避免麻烦,你可能更愿意在主线程中完成你的工作,在你能这么做事,这确实让你的工作轻松不少,但是当你发现性能瓶颈时,你可以尝试尽可能用最简单的策略将那些繁重任务放到后台去做。

我们在上面例子中所展示的方法对于其他任务来说也是安全的选择。在主队列中接收事件或者数据,然后用后台操作队列来执行实际操作,然后回到主队列去传递结果,遵循这样的原则来编写尽量简单的并行代码,将是保证高效正确的不二法则。


话题 #2 下的更多文章

原文 Common Background Practices

译文 常见的后台实践