转自:http://cleexiang.github.io/blog/2013/09/19/concurrent-programming-2-yi/
在本文中,我们将描述在后台做常见的任务的最佳实践。我们将看看怎么并发的使用CoreData,怎么并发绘制,并且怎么异步地操作网络。最后,我们将看下怎么在保持低耗内存下异步地处理大文件。
异步编程非常容易出错。因此本文中的所有例子将使用很简单的方法。使用简单的结构有助于我们我们思考代码并保持概述。如果你以复杂的嵌套调用结束,你应该修订一些你的设计决策。
目前,有两种现代的并发API用于iOS和OS X:操作队列(Operation Queues)和GCD。GCD是底层的C接口,然而操作队列是在GCD基础上实现的并以Objective-c的接口提供。想要在这个问题上对可用的并发API有一个综合的概述,可以看看这篇文章:concurrency APIs and challenges。
操作队列提供一些有用的方便特性,这是GCD不可复制的。在实际情况中,一个重要的情况就是可能会在一个队列中取消操作,我们下面会证明。操作队列也让操作之间依赖性的管理变得更简单一点,另一方面,GCD让你有更多的控持和底层功能,这也是操作队列所没有的。更多详情请参考low level concurrency APIs.
更多文章:
在你用Core Data做一些并发的事情之前,得到一些基本的权力是恨重要的。强烈推荐阅读苹果的Concurrency with Core Data指南。这个文档奠定了基本的规则,如绝不在多个线程间传递对象。这并仅不意味着你绝不应该在其他线程上修改一个被管理对象,而且你也决不能从它上面读取任何属性。要传递一个对象,使用对象ID,并且从关联到其他线程的上下文中获取对象。
用Core Data进行并发编程是很简单的只要你坚持那些规则并且使用本文中描述的方法、
在Xcode模板里面,Core Data的标准配置是一个运行在主线程的,拥有一个上下文管理对象的,持久化存储管理者。在很多用例中,都是这样的。在主线程中创建一些新的对象并且修改已经存在的对象是很方便的,也没什么问题。然而,如果你想操作大块儿的数据,在后台回话中去做是讲得通的。一个典型的例子,就是将大数据集导入进Core Data。
我们的方法很简单,并在已有的文献中又提到:
1. 我们为导入的任务创建一个单独的操作。
2. 我们用同样的一个持久化存储协调者创建一个被管理对象上下文作为主要的被管理对象上下文。
3. 一旦导入的回话保存了,我们就通知主要的被管理对象上下文并且合并改变。
在示例中,我们将导入柏林这个城市的交通大数据集合。在导入期间,我们显示了一个进度条,并且我们可以取消它,如果它花的时间太长了。同时,我们显示了一拥有迄今可用的数据的表视图,当新数据进来的时候自动更新。示例数据集是在知识共享许可下的公开可用的,并且你可以在这里下载。它符合General Transit Feed格式,一个开放的传输数据标准。
我们创建一个NSOperation的子类ImportOperation,处理导入的操作。我们复写了要做所有事情的方法,main方法。我们在这里使用私有队列并发类型创建了一个单独的被管理对象上下文。这就意味着上下文将管理自己的队列,并且所有的在它之上的操作需要使用performBlock或者performBlockAndWait来执行。确定他们在正确的线程上执行是恨重要的。
NSManagedObjectContext* context = [[NSManagedObjectContext alloc]
initWithConcurrencyType:NSPrivateQueueConcurrencyType];
context.persistentStoreCoordinator = self.persistentStoreCoordinator;
context.undoManager = nil;
[self.context performBlockAndWait:^
{
[self import];
}];
注意我们重利用了已存在的持久存储协调器。在现在的代码里,你应该用NSPrivateQueueConcurrencyType或者NSMainQueueConcurrencyType初始化被管理对象上下文。第三个并发类型NSConfinementConcurrencyType不变,用于遗留代码,我们的建议是不再使用它。
为了导入数据,我们迭代文件中的每一行,并且为可以解析的每一行创建一个被管理对象。
[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];
}];
我们在试图控制器里面执行以下代码开始这个操作:
ImportOperation* operation = [[ImportOperation alloc]
initWithStore:self.store fileName:fileName];
[self.operationQueue addOperation:operation];
为了后台导入,你不得不做这些。现在,我们将添加对取消的支持,并且幸运的是,在枚举块中添加检查就那么简单:
if(self.isCancelled) {
*shouldStop = YES;
return;
}
最后,为了支持进度提示,我们在操作里面创建了一个progressCallback的属性。至关重要的是我们要在主线程中更新进度条,否则UIKit会崩溃。
operation.progressCallback = ^(float progress)
{
[[NSOperationQueue mainQueue] addOperationWithBlock:^
{
self.progressIndicator.progress = progress;
}];
};
我们在枚举块中添加以下行来调用进程块:
self.progressCallback(idx / (float) count);
然而,如果你运行这个代码,你会发现一切都极大的减缓了。看起来好像操作不能立即取消。这样的原因是主操作队列填满了想要更新进度条的块。一个简单的解决办法是减少更新的粒度,即我们只调用导入的1%的进度回调:
NSInteger progressGranularity = lines.count / 100;
if (idx % progressGranularity == 0) {
self.progressCallback(idx / (float) count);
}
我们app里面的表视图在主线程上被一个获取结果的控制器所支持。在导入数据的期间和之后,我们在表视图里面显示导入的数据。
做这个事情缺失的一块是;导入到后台回话的数据不会传送到主会话除非我们明确地告诉它这样做。我们添加以下几行到Store的init方法中,我们在这里设置Core Data堆栈。
[[NSNotificationCenter defaultCenter]
addObserverForName:NSManagedObjectContextDidSaveNotification
object:nil
queue:nil
usingBlock:^(NSNotification* note)
{
NSManagedObjectContext *moc = self.mainManagedObjectContext;
if (note.object != moc)
[moc performBlock:^(){
[moc mergeChangesFromContextDidSaveNotification:note];
}];
}];
}];
注意在主线程中块作为一个参数传递,将在主线程中被调用。如果你启动app,你将注意到在导入完成之后表视图重新加载了它的数据。然而,这样阻塞了用户界面几秒钟。
要解决这个问题,我们需要这样去做:分批保存。当做大数据导入时,要确定你是定期地保存,否则你可能耗尽内存,并且性能变得很差。此外,定期保存分散了在主线程上更新表视图的超时。
你多久保存一次是一个重要的试错。保存的太频繁,你将花很多时间在I/O上。保存间隔太长,app会变得迟钝。在尝试了一些不同的数目之后,我们设置批量大小为250。现在导入数据很平滑,并且更新表视图也不阻塞主会话太长时间。
在导入操作中,我们将整个文件读取到一个字符串,然后分割成多行。相对较小的文件工作正常,但是对于较大的文件,逐行读取文件看起来很缓慢的。正确的做法是本文中最后使用输入流的例子。Dave DeLong在write-up on StackOverflow中也很好的展示了怎么去做。
取代在app启动时传入大数据集到Core Data,你也可以在你的app bundle里面放置一个sqlite文件,或者从服务器下载它,这样你可以动态生成它。如果你特殊的用例用到这个解决方案,在设备上运行的更快,并且节约处理时间,
最后,这期间有很多关于子会话的问题。我们的建议是别用他们做后台操作。如果你创建一个后台会话作为主会话的子会话,在后台会话保存仍将阻塞主线程。如果你创建一个主会话作为一个后台会话的子会话,你事实上没有增加什么比起传统的设置两个独立的会话,因为你仍不得不手动地合并从后台到主会话的改变。
在后台操作核心数据,通过用一个持久存储协调者和两个独立的会话设置是行之有效的方法。坚持这么做,除非你有更好的理由不这么做。
更多文章:
首先:UIKit只工作于主线程。据说,有一些UI相关的代码不直接关联于UIKit,而且UIKit会消耗大量的时间,这些任务可以交给后台而不会阻塞主线程太久。但在你开始移动UI代码到后台队列之前,衡量那部分代码真正需要这么做是非常重要的,否则你可能优化了错误的代码。
如果你已经鉴定一个耗时的操作是可以隔离的,就把它放到一个操作队列里:
__weak id weakSelf = self;
[self.operationQueue addOperationWithBlock:^{
NSNumber* result = findLargestMersennePrime();
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
MyClass* strongSelf = weakSelf;
strongSelf.textLabel.text = [result stringValue];
}];
}];
就像你看到的,这不是一件容易完成的事情;我们需要创建一个自己弱引用,否则我们创建一个retain循环(块retain自己,私有操作队列又retain了块,并且自己retain了操作队列)。在块里面我们又把它转换为了强引用,以确保在运行块的时候不被销毁。
如果你的测量值显示drawRect:是性能瓶颈,你可以将绘制代码移动到后台。在你这样做之前,检查是否有其他的方式来实现相同的效果,如通过使用core animation层或者预渲染图像而不是简单的Core Graphics绘制。看看Florian关于在当前设备商图形性能测量值的链接,或者一个叫Andy Matuschak的UIKit工程师的评论,为了获得更好的体验在所有涉及到的微妙之处。
如果你决定你的最好的选择是在后台执行绘制代码,这个解决办法非常的简单。把代码放到你的drawRect:方法里面并把它放到一个操作里。用一张图片替换原始视图,一旦操作完成立即更新。在你的绘制代码里,使用UIGraphicsBeginImageContextWithOptions代替UIGraphicsGetCurrentContext。
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// drawing code here
UIImage *i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return i;
通过传0作为第三个参数,设备主屏幕的比例将被自动填充,并且图像在retina或者非retina设备上看起来都很不错。
如果你在表视图或者集合视图单元格里面自定义绘制,把它们都放到操作的子类里去是讲得通的。你可以将他们添加到后台操作队列里面,并且当用户将单元格从边缘滑出的时候,在didEndDisplayingCell代理方法里面取消他们。所有对这些细节的描述在https://developer.apple.com/videos/wwdc/2012/。
代替在后台调度绘制代码,你也应该试试CALayer的drawsAsynchronously属性。然后确报测量这样的的影响。有时它可以提升速度,而有时是事与愿违的。
所有你的异步操作都应该以异步的方式完成。然而,使用GCD,你的代码有时候看起来像这样:
// 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秒超时)。
如果队列是串行的队列,它将一直阻塞。如果是并发的队列,为了正阻塞的线程,GCD不得不加速一个新线程。这两种情况下都不好。所以最好完全避免阻塞。
为了改进这种情况,我们将使用NSURLConnection的异步方法并且把所有事情封装到一个operation对象里。这样我们就得到了操作队列强大的力量和方便性;我们可以方便地控制并发操作的数量,依赖性和取消操作。
然而,需要小心这样做:URL连接传递事件到run loop里面。最简单的是在main run loop做这件事,这样数据传递不会花费太多时间。那时我们能分配输入数据的处理到后台线程上。
另一个可能的方法是像AFNetworking库那样:创建一个拆分的线程,设置一个run loop在这个线程上,并且调度url连接。但是你可能自己去做这件事。
启动URL连接,我们在自定义的操作子类里复写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。要取消一个操作,我们需要取消网络连接并设置正确的标志,让操作队列知道操作完成了。
- (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的库。他们提供了一些便利的工具像UIImageView的分类可以异步的从URL加载一个图片。在你的表视图代码里使用将自动注意取消加载图片的操作。
更多文章:
在我们的后台core data例子里,我们读取整个文件导入到内存。这对较小的文件可以,但是对较大的文件是不可行的,因为在iOS设备里内存是有限制的。为了解决这个问题,我们将创建一个类来做两件事:逐行读取文件到内存而非整个文件,并在后台队列处理文件以至于app是保持响应的。
为了这个目标我们使用了NSInputStream,可以让我们对文件进行异步地处理。就像文档里说的:“如果你总是读或写文件从开始到完成,流提供了一个简单的接口去异步做这件事。”。
无论你是否用流,一般逐行读取的模式是下面这样的:
将这一想法付诸实践,我们创建了一个简单的示例应用,用一个Reader类做这件事情。接口非常的简单:
@interface Reader : NSObject
- (void)enumerateLines:(void (^)(NSString*))block
completion:(void (^)())completion;
- (id)initWithFileAtPath:(NSString*)path;
@end
注意这不是NSOperation的子类。像URL链接一样,输入流用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];
}
现在输入流将向我们发送代理信息(在主线程上),并且我们通过添加一个block operation在操作队列上做处理。
- (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;
}
...
}
}
处理数据块看着当前缓冲的数据并且追加新的数据流块。那时以逐行分成很多部分,发出每一行。又把剩下的存起来:
- (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;
}
}];
}
如果你运行示例应用,你将看到app保持高响应度,并且内存消耗很低(在我们测试运行中,不管文件多大,堆大小保持在800KB以下)。一块一块的处理大文件,这种技术可能是你想要的。
更多阅读:
在以上的例子中,我们阐明了怎样在后台异步的执行公共的任务。在所有这些解决方案中,我们力图让我们的代码保持简单,因为并发编程很容易不注意就犯错。
有时你也许能在主线程上侥幸完成你的工作,它会是很简单的事情。但是如果你发现性能瓶颈了,尽可能用最简单的方法把这些任务放到后台去。
我们在上面的例子中展示的模式对其他任务来说也是一个安全的选择。在主线程接收事件或者数据,在向主线程传递结果之前使用后台操作队列执行实际的任务。