iOS开发中一些常见的并行处理(转)

本文主要探讨一些经常使用多任务的最佳实践。包括Core Data的多线程访问,UI的并行绘制,异步网络请求以及一些在运行态内存吃紧的状况下处理大文件的方案等。

其实编写异步处理的程序有不少坑!因此,本文所涉及的样例都尽可能采用简洁直观的处理方式。由于越是简单的逻辑结构,越能彰显代码的脉络清晰,越易于理解。打个比方,若是在程序中使用多层次的嵌套回调,基本上这个它会有很大的重构空间。html

 

Operation Queues vs. Grand Central Dispatch

目前,在iOS和OS X 中,SDK主要提供了两类多任务处理的API:operation queuesGrand Central Dispatch(GCD)。其中GCD是基于C的更加底层的API,而operation queues被广泛认为是基于GCD而封装的面向对象(objective-c)的多任务处理API。关于并发处理API层面的比较,有不少相关的文章,若是感兴趣能够自行阅读。ios

相比于GCD,operation queues的优势是:提供了一些很是好用的便捷处理。其中最重要的一个就是能够取消在任务处理队列中的任务(稍后举例)。另外operation queues在处理任务之间的依赖关系方面也更加容易。而GCD的特长是:能够访问和操做那些operation queues所不能使用的低层函数。详情参考低层并发处理API相关文章git

延伸阅读:github

 

Core Data in the Background

在着手Core Data的多线程处理以前,咱们建议先通读一下苹果的官方文档”Concurrency with Core Data guide”。这个文档中罗列了诸多规则,好比:不要在不一样线程间直接传递managed objects。注意这意味着线程间不但不能对不属于本身的managed object作修改操做,甚至连读其中的属性都不能够。正确作法是经过传object ID和从其余线程的context信息中获取object的方式来达到传递object的效果。其实只要遵循文档中的各类指导规则,那么处理 Core Data的并行编程问题就容易多了。objective-c

Xcode提供了一种建立Core Data的模版,工做原理是经过主线程做为persistent store coordinator(持久化协调者)来操做managed object context,进而实现对象的持久化。虽然这种方式很便捷并基本适用常规场景,但若是要操做的数据比较庞大,那就很是有必要将Core Data的操做分配到其余线程中去(注:大数据量的操做可能会阻塞主线程,长时间阻塞主线程用户体验不好而且有可能致使应用程序假死或崩溃)。sql

样例:向Core Data中导入大量的数据:shell

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格式。api

接下来建立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 = ^( float progress)
{
     [[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多线程操做的合理方式。

延伸阅读:

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();
return i;

上述代码中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的取消逻辑等。

延伸阅读:

 

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;
@end

注意,这个类不是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) {
         ...
         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
- ( 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)。因此,处理大文件,仍是应该采用逐块处理的方式。

延伸阅读:

 

结论

上面举了几个例子来展现如何异步执行一些常见任务。须要强调的仍是:在所涉及的全部方案中,咱们都尽可能采用清晰明了的代码实现,由于对于多线程编程,稍不留神就会搞出一堆麻烦来。大多数状况下,为了规避麻烦,你可能会选择让主线程打理一切活计。可是一旦出现了性能问题,建议仍是尽可能采用相对简单的多线程处理方法来解决问题。咱们样例中提到的各类处理方式都是比较安全且不错的选择。总之,在main queue中接收事件或数据,在其余线程或队列中作详细的处理而且将处理结果回传给main queue。

 

 



原文连接: Chris Eidhof   翻译: 伯乐在线 sunset
译文连接: http://blog.jobbole.com/52557/
转载必须在正文中标注并保留原文连接、译文连接和译者等信息。]

相关文章
相关标签/搜索