注:本人是翻译过来,并且加上本人的一点见解。
1. 开始
目前在 iOS中有两套先进的同步 API 可供我们使用:操作队列OperationQueue和 GCD 。其中 GCD 是基于 C 的底层的 API ,而操作队列OperationQueue则是 GCD 实现的 Objective-C API。
OperationQueue供了在 GCD 中不那么容易复制的有用特性。其中最重要的一个就是可以取消在任务处理队列中的任务,在稍后的例子中我们会看到这个。而且OperationQueue在管理操作间的依赖关系方面也容易一些。另一面,GCD 给予你更多的控制权力以及操作队列中所不能使用的底层函数。
扩展阅读:
2. 后台的 Core Data
在着手 Core Data 的并行处理之前,最好先打一些基础。我们强烈建议通读苹果的官方文档Concurrency with Core Data 。这个文档中罗列了基本规则,比如绝对不要在线程间传递 managed objects等。这并不单是说你绝不应该在另一个线程中去更改某个其他线程的 managed object ,甚至是读取其中的属性都是不能做的。要想传递这样的对象,正确做法是通过传递它的 object ID ,然后从其他对应线程所绑定的 context 中去获取这个对象。
其实只要你遵循那些规则,并使用这篇文章里所描述的方法的话,处理 Core Data 的并行编程还是比较容易的。
Xcode 所提供的 Core Data 标准模版中,所设立的是运行在主线程中的一个存储调度 (persistent store coordinator)和一个托管对象上下文 (managed object context) 的方式。在很多情况下,这种模式可以运行良好。创建新的对象和修改已存在的对象开销都非常小,也都能在主线程中没有困难地完成。然后,如果你想要做大量的处理,那么把它放到一个后台上下文来做会比较好。一个典型的应用场景是将大量数据导入到 Core Data 中。
我们的方式非常简单,并且可以被很好地描述:
1)我们为导入工作单独创建一个操作
2)我们创建一个 managed object context ,它和主 managed object context 使用同样的 persistent store coordinator
3)一旦导入 context 保存了,我们就通知 主 managed object context 并且合并这些改变
读者可以从这里下载实例app https://github.com/cjt321/QueueTransitDataImport
在示例app中,我们要导入一大组交通方面的数据。在导入的过程中,我们展示一个进度条,如果耗时太长,我们希望可以取消当前的导入操作。同时,我们显示一个随着数据加入可以自动更新的 table view 来展示目前可用的数据。
我们创建一个 NSOperation
的子类,将其叫做 ImportOperation
,我们通过重写 main
方法,用来处理所有的导入工作。
在代码中,我们看到判断如果用户中断操作,则return,不做回调和更新的操作。睡眠5秒钟为了演示此程序加载数据时间长。
/* 我们通过重写 main 方法,用来处理所有的导入工作. */ -(void)main { NSLog(@"--ImportOperation main--"); NSLog(@"处理导入数据开始"); sleep(5); [SingletonData shareInstance]; NSLog(@"处理导入数据结束"); if(self.isCancelled) { NSLog(@"取消运行Operation"); return; } NSLog(@"call processCallBack"); self.processCallBack(1); }
在 view controller 中通过以下代码来初始化OperationQueue:
_operationQueue = [[NSOperationQueue alloc] init];
然后导入与取消操作
#pragma mark 导入数据 - (IBAction)import:(id)sender { ImportOperation *importOperation = [[ImportOperation alloc] init]; __weak UITableView *tv = self.tableview; importOperation.processCallBack = ^(float f) { [[NSOperationQueue mainQueue] addOperationWithBlock:^{ _data = [SingletonData shareInstance].stopsData; __strong UITableView *tvs = tv; [tvs reloadData]; NSLog(@"刷新数据"); }]; }; [self.operationQueue addOperation: importOperation]; } #pragma mark 取消导入 - (IBAction)cancel:(id)sender { [self.operationQueue cancelAllOperations]; }
3. 后台 UI 代码
首先要强调:UIKit 只能在主线程上运行。而那部分不与 UIKit 直接相关,却会消耗大量时间的 UI 代码可以被移动到后台去处理,以避免其将主线程阻塞太久。但是在你将你的 UI 代码移到后台队列之前,你应该好好地测量哪一部分才是你代码中的瓶颈。这非常重要,否则你所做的优化根本是南辕北辙。
如果你找到了你能够隔离出的昂贵操作的话,可以将其放到操作队列中去:
- (IBAction)import:(id)sender { ImportOperation *importOperation = [[ImportOperation alloc] init]; __weak id weakSelf = self; importOperation.processCallBack = ^(float f) { _data = [SingletonData shareInstance].stopsData; [[NSOperationQueue mainQueue] addOperationWithBlock:^{ ViewController *strongSelf = weakSelf; [strongSelf.tableview reloadData]; NSLog(@"刷新数据"); }]; }; [self.operationQueue addOperation: importOperation]; }
如你所见,这些代码其实一点也不直接明了。我们首先声明了一个 weak 引用来参照 self,否则会形成循环引用( block 持有了 self,私有的 operationQueue
retain 了 block,而 self 又 retain 了 operationQueue
)。为了避免在运行 block 时访问到已被释放的对象,在 block 中我们又需要将其转回 strong 引用。
这在 ARC 和 block 主导的编程范式中是解决 retain cycle 的一种常见也是最标准的方法。
4. 后台绘制
如果你确定 drawRect:
是你的应用的性能瓶颈,那么你可以将这些绘制代码放到后台去做。但是在你这样做之前,检查下看看是不是有其他方法来解决,比如、考虑使用 core animation layers 或者预先渲染图片而不去做 Core Graphics 绘制。可以看看 Florian 对在真机上图像性能测量的帖子,或者可以看看来自 UIKit 工程师 Andy Matuschak 对个各种方式的权衡的评论。
如果你确实认为在后台执行绘制代码会是你的最好选择时再这么做。其实解决起来也很简单,把 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
属性。然而你需要精心衡量这样做的效果,因为有时候它能使绘制加速,有时候却适得其反。
5. 异步网络请求处理
你的所有网络请求都应该采取异步的方式完成。
然而,在 GCD 下,有时候你可能会看到这样的代码
// 警告:不要使用这些代码。 dispatch_async(backgroundQueue, ^{ NSData* contents = [NSData dataWithContentsOfURL:url] dispatch_async(dispatch_get_main_queue(), ^{ // 处理取到的日期 }); });
乍看起来没什么问题,但是这段代码却有致命缺陷。你没有办法去取消这个同步的网络请求。它将阻塞住线程直到它完成。如果请求一直没结果,那就只能干等到超时(比如 dataWithContentsOfURL:
的超时时间是 30 秒)。
如果队列是串行执行的话,它将一直被阻塞住。假如队列是并行执行的话,GCD 需要重开一个线程来补凑你阻塞住的线程。两种结果都不太妙,所以最好还是不要阻塞线程。
要解决上面的困境,我们可以使用 NSURLConnection
的异步方法,并且把所有操作转化为 operation 来执行。通过这种方法,我们可以从操作队列的强大功能和便利中获益良多:我们能轻易地控制并发操作的数量,添加依赖,以及取消操作。
然而,在这里还有一些事情值得注意: NSURLConnection
是通过 run loop 来发送事件的。因为时间发送不会花多少时间,因此最简单的是就只使用 main run loop 来做这个。然后,我们就可以用后台线程来处理输入的数据了。
另一种可能的方式是使用像 AFNetworking 这样的框架:建立一个独立的线程,为建立的线程设置自己的 run loop,然后在其中调度 URL 连接。但是并不推荐你自己去实现这些事情。
要处理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)cancel { [super cancel]; [self.connection cancel]; self.isFinished = YES; self.isExecuting = NO; }
当连接完成加载后,它向代理发送回调:
- (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 里使用的话,还能自动帮你处理取消加载操作,非常方便。
扩展阅读:
- Concurrency Programming Guide
- NSOperation Class Reference: Concurrent vs. Non-Concurrent Operations
- Blog: synchronous vs. asynchronous NSURLConnection
- GitHub:
SDWebImageDownloaderOperation.m
- Blog: Progressive image download with ImageIO
- WWDC 2012 Session 211: Building Concurrent User Interfaces on iOS
6. 进阶:后台文件 I/O
在之前我们的后台 Core Data 示例中,我们将一整个文件加载到了内存中。这种方式对于较小的文件没有问题,但是受限于 iOS 设备的内存容量,对于大文件来说的话就不那么友好了。要解决这个问题,我们将构建一个类,它负责一行一行读取文件而不是一次将整个文件读入内存,另外要在后台队列处理文件,以保持应用相应用户的操作。
为了达到这个目的,我们使用能让我们异步处理文件的 NSInputStream
。根据官方文档的描述:
如果你总是需要从头到尾来读/写文件的话,streams 提供了一个简单的接口来异步完成这个操作
不管你是否使用 streams,大体上逐行读取一个文件的模式是这样的:
- 建立一个中间缓冲层以提供,当没有找到换行符号的时候可以向其中添加数据
- 从 stream 中读取一块数据
- 对于这块数据中发现的每一个换行符,取中间缓冲层,向其中添加数据,直到(并包括)这个换行符,并将其输出
- 将剩余的字节添加到中间缓冲层去
- 回到 2,直到 stream 关闭
为了将其运用到实践中,我们又建立了一个示例应用,里面有一个 Reader
类完成了这件事情,它的接口十分简单
@interface Reader : NSObject - (void)enumerateLines:(void (^)(NSString*))block completion:(void (^)())completion; - (id)initWithFileAtPath:(NSString*)path; @end
注意,这个类不是 NSOperation 的子类。与 URL connections 类似,输入的 streams 通过 run loop 来传递它的事件。这里,我们仍然采用 main run loop 来分发事件,然后将数据处理过程派发至后台操作线程里去处理。
- (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; [self.inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [self.inputStream open]; }
现在,input stream 将(在主线程)向我们发送代理消息,然后我们可以在操作队列中加入一个 block 操作来执行处理了:
- (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中获取已读入的数据。然后把这些新读入的数据按行分开并存储。剩余的数据被再次存储到缓冲区中:
- (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; } }]; }
现在你运行示例应用的话,会发现它在响应事件时非常迅速,内存的开销也保持很低(在我们测试时,不论读入的文件有多大,堆所占用的内存量始终低于 800KB)。绝大部分时候,使用逐块读入的方式来处理大文件,是非常有用的技术。
延伸阅读:
- File System Programming Guide: Techniques for Reading and Writing Files Without File Coordinators
- StackOverflow: How to read data from NSFileHandle line by line?
7. 总结
通过我们所列举的几个示例,我们展示了如何异步地在后台执行一些常见任务。在所有的解决方案中,我们尽力保持了代码的简单,这是因为在并发编程中,稍不留神就会捅出篓子来。
很多时候为了避免麻烦,你可能更愿意在主线程中完成你的工作,在你能这么做事,这确实让你的工作轻松不少,但是当你发现性能瓶颈时,你可以尝试尽可能用最简单的策略将那些繁重任务放到后台去做。
我们在上面例子中所展示的方法对于其他任务来说也是安全的选择。在主队列中接收事件或者数据,然后用后台操作队列来执行实际操作,然后回到主队列去传递结果,遵循这样的原则来编写尽量简单的并行代码,将是保证高效正确的不二法则。