转 iOS开发中一些常见的并行处理
本文主要探讨一些常用多任务的最佳实践。包括Core Data的多线程访问,UI的并行绘制,异步网络请求以及一些在运行态内存吃紧的情况下处理大文件的方案等。 其实编写异步处理的程序有很多坑!所以,本文所涉及的样例都尽量采用简洁直观的处理方式。因为越是简单的逻辑结构,越能彰显代码的脉络清晰,越易于理解。打个比方,如果在程序中使用多层次的嵌套回调,基本上这个它会有很大的重构空间。
Operation Queues vs. Grand Central Dispatch
目前,在iOS和OS X 中,SDK主要提供了两类多任务处理的API:operation queues和Grand Central Dispatch(GCD)。其中GCD是基于C的更加底层的API,而operation queues被普遍认为是基于GCD而封装的面向对象(objective-c)的多任务处理API。关于并发处理API层面的比较,有很多相关的文章,如果感兴趣可以自行阅读。
相比于GCD,operation queues的优点是:提供了一些非常好用的便捷处理。其中最重要的一个就是可以取消在任务处理队列中的任务(稍后举例)。另外operation queues在处理任务之间的依赖关系方面也更加容易。而GCD的特长是:可以访问和操作那些operation queues所不能使用的低层函数。详情参考低层并发处理API相关文章。
延伸阅读:
Core Data in the Background
在着手Core Data的多线程处理之前,我们建议先通读一下苹果的官方文档”Concurrency with Core Data guide”。这个文档中罗列了诸多规则,比如:不要在不同线程间直接传递managed objects。注意这意味着线程间不但不能对不属于自己的managed object做修改操作,甚至连读其中的属性都不可以。正确做法是通过传object ID和从其他线程的context信息中获取object的方式来达到传递object的效果。其实只要遵循文档中的各种指导规则,那么处理 Core Data的并行编程问题就容易多了。
Xcode提供了一种创建Core Data的模版,工作原理是通过主线程作为persistent store coordinator(持久化协调者)来操作managed object context,进而实现对象的持久化。虽然这种方式很便捷并基本适用常规场景,但如果要操作的数据比较庞大,那就非常有必要将Core Data的操作分配到其他线程中去(注:大数据量的操作可能会阻塞主线程,长时间阻塞主线程用户体验很差并且有可能导致应用程序假死或崩溃)。
样例:向Core Data中导入大量的数据:
1.为引入数据创建一个单独的operation
2.创建一个和main object context相同persistent store coordinator的object context
3.引入操作的context保存完成后,通知main managed object context去合并数据。
在样例app中,要导入一大组柏林的运输线路数据。在导入的过程中会展示进度条并且用户可以随时取消当前导入操作。等待条下面再用一个table view来展示目前已导入的数据同时边导入边刷新界面。样例采用的数据署名Creative Commons license,可以在此下载。使用公开标准的General Transit Feed格式。
接下来创建NSOperation的子类ImportOperation,通过复写main方法来处理所有的导入工作。再创建一个private queue concurrency类型的独立的managed object context,这个context需要管理自己的queue,在其上的所有操作必须使用performBlock或者performBlockAndWait来触发。这点相当重要,这是保证这些操作能在正确的线程上执行的关键。
1
2
3
4
5
6
7
|
NSManagedObjectContext* context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType];
context.persistentStoreCoordinator = self.persistentStoreCoordinator;
context.undoManager = nil;
[self.context performBlockAndWait:^
{
[self import];
}];
|
注:在样例中复用了persistent store coordinator。正常情况下,需要初始化managed object contexts并且指定其类型:如NSPrivateQueueConcurrencyType,NSMainQueueConcurrencyType或者NSConfinementConcurrencyType,其中NSConfinementConcurrencyType不建议使用,因为它是给一些遗留的旧代码使用的。
导入前,按行迭代运输线路数据文件的内容,给每一个能解析的行数据创建一个managed object:
1
2
3
4
5
6
7
8
9
10
|
[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中来触发操作:
1
2
3
|
ImportOperation* operation = [[ImportOperation alloc]
initWithStore:self.store fileName:fileName];
[self.operationQueue addOperation:operation];
|
至此为止,多线程导入数据到Core Data部分已经完成。接下来,是取消导入部分,非常简单只需要在集合的快速枚举block中加个判断即可:
1
2
3
4
|
if(self.isCancelled) {
*shouldStop = YES;
return;
}
|
最后是增加进度条,在operation中创建一个progressCallback属性block。注意更新进度条必须在主线程中完成,否则会导致UIKit崩溃。
1
2
3
4
5
6
7
|
operation.progressCallback = ^(floatprogress)
{
[[NSOperationQueue mainQueue] addOperationWithBlock:^
{
self.progressIndicator.progress = progress;
}];
};
|
在快速枚举中加上下面这行去调用进度条更新block:
1
|
self.progressCallback(idx / (float) count);
|
然而,如果你执行样例app就会发现一切都特别慢而且取消操作也有迟滞。这是因为main opertation queue中塞满了要更新进度条的block。通过降低更新进度条的频度可以解决这个问题,
例如以百分之一的节奏更新进度条:
1
2
3
4
5
|
NSInteger progressGranularity = lines.count / 100;
if(idx % progressGranularity == 0) {
self.progressCallback(idx / (float) count);
}
|
Updating the Main Context
我们样例app中的table view后面挂接了一个专门在主线程上执行取数据任务的controller。如前面所述,在导入数据的过程中table view会同期展示数据。要达成这个任务,在数据导入的过程中,需要向main context发出广播,要在Store类的init方法中注册Core Data广播监听:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
[[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在main queue中作为参数传递,该block就会在main queue中执行。运行样例,此时table view是在导入结束后才会展示导入结果。大概那么几秒钟,用户的操作会被阻塞掉。因此,需要通过批量操作来解决这个问题。因为凡是导入较大的数据,都应该采用逐渐导入的方式,否则内存很快就会被耗光,效率会奇差。同时,渐进式的导入也会分散main thread 更新table view的压力。
至于说合理的保存的次数基本上就得靠试。存得太频繁,缺点是反复操作I/O。存得次数太少,应用会变得经常无响应。经过多次试验,我们认为本样例中存储250次比较合适。改进后,导入过程变得很平滑,更新了table view,整个过程也没有阻塞main context太久。
其他考量
在导入文件的时候,样例代码将整个文件直接读入内存后转成一个String对象接着再对其分行。这种方式非常适合操作那些小文件,但对于大文件应该采用逐行懒加载的方式。StackOverflow上Dave DeLong 提供了一段非常好的样例代码来实现逐行读取。本文的最后也会提供一个流方式读入文件的样例。
注:在app第一次运行时,也可以通过sqlite来替代将大量数据导入Core Data这个过程。sqlite可以放在bundle内,也可以从服务器下载或者动态生成。某些情况下,真机上使用sqlite的存储过程会非常快。
最后要提一下,最近关于child contexts的争论很多,并不建议在多线程中使用它。如果在非主线程中创建了一个context作为main context的child context,在这些非主线程中执行保存操作还是会阻塞主线程。反过来,要是将main context设置为其他非主线程context的child context,其效果与传统的创建两个有依赖关系的contexts类似,还是需要手动的将其他线程的context变化和main context做合并。
事实证明,除非有更好的选择,否则设置一个persistent store coordinator和两个独立的contexts才是对Core Data多线程操作的合理方式。
延伸阅读:
- Core Data Programming Guide: Efficiently importing data
- Core Data Programming Guide: Concurrency with Core Data
- StackOverflow: Rules for working with Core Data
- WWDC 2012 Video: Core Data Best Practices
- Book: Core Data by Marcus Zarra
UI Code in the Background
首先强调一点:UIKit只在主线程上执行。换句话说,为了不阻塞UI,那些和UIKit不相关的但是却非常耗时的任务最好放到其他线程上执行。另外也不能盲目的将任务分到其他线程队列中去,真正需要被优化的的是那些瓶颈任务。
独立的、耗时的操作最适合放在operation queue中:
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];
}];
}];
|
如上样例所见,里面的引用设置其实也并不简单。先要对self声明做weak弱引用,不然就会形成retain cycle循环引用(block对self做了retain,private operation queue又retain了block,接着self又retain了operation queue)。为了避免在运行block时出现访问已被自动释放的对象的情况,又需将对self的weak弱引用转换成strong强引用。
Drawing in the Background
如果drawRect:真的是应用的性能瓶颈,可以考虑使用core animation layers或者pretender预渲染图片的方式来取代原本的plain Core Graphics的绘制。详情见Florian对真机上图形处理性能分析的帖子,或者可以看看来自UIKit工程师Andy Matuschak对个中好处的评论。如果实在找不到其他好法子了,才有必要把绘制相关的工作放到其他线程中去执行。多线程绘制的处理方式也比较简单,直接把drawRect:中的代码丢到其他operation去执行即可。原本需要绘制的视图用image view占位等待,等到operation执行完毕,再去通知原来的视图进行更新。实现层面上,用UIGraphicsGetCurrentContext来取代原来绘制代码中的使用的UIGraphicsBeginImageContextWithOpertions:
1
2
3
4
5
|
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// drawing code here
UIImage *i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
returni;
|
上述代码中UIGraphicsBeginImageContextWithOpertion中的第三个参数表示对设备main screen的scale幅度,如果传0,那么表示自动填充,这么处理的话无论设备是否为视网膜屏幕,看起来都会很不错。
如果是在绘制table view或者collection view的cell,最好将他们都放进operation执行,再把这些operation添加到非main queue队列中去,这样一旦用户滑动触发了didEndDisplayingCell代理方法,就可以随时取消队列中的绘制operation。上述的内容,都在WWDC2012的Session211-Building Concurrenct User Interfaces on iOS中都有涵盖。当然除了多线程绘制还可以考虑尝试一下CALayer的drawsAsynchronously属性。但是需要自己评估一下使用它的效果,因为有时候它的性能表现不快反慢。
异步网络请求处理
切记,所有的网络请求都要采用异步的方式处理!
但是有些人运用GCD来处理网络请求的时候,代码是这个样子的:
1
2
3
4
5
6
7
|
// Warning: please don't use this code.
dispatch_async(backgroundQueue, ^{
NSData* contents = [NSData dataWithContentsOfURL:url]
dispatch_async(dispatch_get_main_queue(), ^{
// do something with the data.
});
});
|
咋看起来挺好,其实里面很有问题,这根本是一个没办法取消的同步网络请求!除非请求完成,否则会把线程卡住。如果请求一直没响应结果,那就只能干等到超时(比如dataWithContentsOfURL的超时时间是30秒)。
如果queue队列是线性执行,队列中网络请求线程其后的线程都会被阻塞。假如queue队列是并行执行的,由于网络请求线程受阻,GCD需要重新发放新的线程来做事。这两种结果都不好,最好是不要阻碍任何线程。
如何来解决上述问题呢?应该使用NSURLConnection的异步请求方式,并且把所有和请求相关的事情打包放到一个operation中去处理。这样可以随时控制这些并行operations,比如处理operation间的依赖关系,随时取消operation等,这便会发挥operation queue的便捷优势。这里还需要注意的是,URL connections通过run loop来发送事件,因为事件数据传递一般不怎么耗时,所以用main run loop来处理起来会很简单。然后我们用其他线程来处理返回的数据。当然还有其他的方式,比如很流行的第三方library AFNetworking的处理是:创建一个独立的线程,基于这个线程设置run loop,然后通过这个线程处理url connection。 但是不推荐读者自己采用这种方式。
复写样例中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方法,所以必须要自行处理operation的state属性状态:isExecuting和isFinished。如果想要取消operation,需要先取消connection然后再设置正确的flag,这样queue队列才知道这个operation已经结束了。
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这种第三方library。AFNetworking还提供了很多好用的uitities方法,比如说它对UIImageView做了category扩展,功能是根据指定URL异步加载网络图片资源,而且它会自动处理table view异步加载图片operation的取消逻辑等。
延伸阅读:
- 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
File I/O in the Background
在之前我们的Core Data多线程处理样例中,提到了将一整个大文件一次性读入内存的事情,我们说这种方式适合小文件,鉴于iOS设备的内存容量,大文件不适宜采用这种读入方式。我们建了一个只类来解决读入大文件的问题,这个类只做两件事:逐行读取文件,将对整个文件的处理放到其他线程中去。以此来保证应用能够同时响应用户的其他操作。我们使用NSInputStream来达到异步处理文件的目的。官方文档说:“如果总是需要从头到尾来读/写文件,streams提供了异步读写接口”。
大体上,逐行读取文件的过程是:
1.用一个中间buffer来缓存读入的数据
2.从stream读进一块文件数据
3.读进的数据不断堆入buffer中,对buffer所缓存数据进行处理,每发现一行数据(用换行符来判断),就把这行输出(样例中是输出到button title上)。
4.继续处理buffer中其他剩余数据
5.重新开始执行步骤2极其之后步骤,直到stream读取文件完毕
其中读文件的Reader接口类如下:
1
2
3
4
5
|
@interface Reader : NSObject
- (void)enumerateLines:(void(^)(NSString*))block
completion:(void(^)())completion;
- (id)initWithFileAtPath:(NSString*)path;
|
注意,这个类不是NSOperation的子类。与URL connections类似,streams通过run loop来分发事件。因此,我们还是采用main run loop来分发事件,但是将数据处理过程移至其他operation queue去处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
- (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通过主线程向代理发送消息,代理接受后再把数据处理任务添加到operation queue中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
- (void)stream:(NSStream*)stream handleEvent:(NSStreamEvent)eventCode
{
switch(eventCode) {
...
caseNSStreamEventHasBytesAvailable: {
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
|
- (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,BOOLlast) {
if(!last) {
[self emitLineWithData:component];
}elseif(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?
结论
上面举了几个例子来展示如何异步执行一些常见任务。需要强调的还是:在所涉及的所有方案中,我们都尽量采用清晰明了的代码实现,因为对于多线程编程,稍不留神就会搞出一堆麻烦来。大多数情况下,为了规避麻烦,你可能会选择让主线程打理一切活计。但是一旦出现了性能问题,建议还是尽量采用相对简单的多线程处理方法来解决问题。我们样例中提到的各种处理方式都是比较安全且不错的选择。总之,在main queue中接收事件或数据,在其他线程或队列中做详细的处理并且将处理结果回传给main queue。