【翻译】iOS上的多线程和GCD入门教程

原文链接
由于作者文章11年写的,给的demo以及文中错误地方,在翻译的时候我已经改正!你也可以在Github上看到!

写在前面的话:建议通篇先看完,不要一开始就一步一步读下去,并且按照作者提供的链接下载阅读或者尝试,整篇文章讲的很好,个人很喜欢作者幽默风趣,善举例子说明的风格,而相关代码,由于作者链接的网站改版,就算运行也获取不到想要的效果,所以没必要都下载下来看。为此,我用自己最简短的总结概括下:

  1. tableview上加载既有图片又有文字的数据(所有数据是基于HTML的,需要解析HTML,拉取图片ZIP文件,并解压ZIP文件);
  2. 最开始作者处理的方案是所有的解析HTML,下载ZIP文件解压ZIP文件都放在主线程,(结果卡顿很久,影响UI和用户交互);
  3. 作者开始使用ASI异步下载,并使用通知回调,但是这时上上下下来回滑动,界面就卡死,于是作者加入了dispatch_async,保证所有耗时操作(比如:解析HTML,下载ZIP,解压ZIP)都放后台处理,而更新UI以及显示图片都放主线程处理。
  4. 最后提及NSOperations以及operation queues,而前者是基于GCD的。

你有没有遇到这种情况:当你开发一个app时,某个地方你想处理一 些事情,但是由于UI长时间没有反应而停顿了好长时间?
通常,这种迹象说明,你的app需要多线程处理下!
在这个教程,你将获得关于iOS上可用的核心多线程API:GCD的相关经验!
我们为你提供一个根本没使用多线程的app,然后使用多线程修改它,你会为前后的不同感到震惊的!
该教程假设你已经熟悉基本的iOS开发。如果你完全是个iOS开发新手,你可以看看其它教程

废话少说,痛饮一番碳酸饮料或者嚼嚼泡泡糖,开始该教程吧!你已经踏上了多线程之路啦!

为什么我应该在乎?

“呃,为什么你要告诉我这呢?为啥我应该在乎呢?我才不在乎。你中午吃的啥饭呀?(我关心这,哈哈)”
如果你像一个木偶人,你可能仍在怀疑你为什么应该关心这些多线程业务,那么让我们通过一个根本不用多线程的app的实例来告诉你为什么。
下载最原始工程,用XCode打开,然后编译运行。你会看到来自vickiwenderlich.com的一个游戏艺术包展示在屏幕上:

ImageGrabber.jpg

这个APP叫ImageGrabber,它主要是通过这个web页面的HTML并且检索其中所有相关的图像,显示在表视图,这样你就可以更仔细地看到他们。酷的是它甚至下载zip文件并查找zip中所有图片,比如vickiwenderlich.com上的free game art zip
接下来,点击按钮Grab!,看是否有反应。

…waiting…

…waiting…

…waiting…

Tomato-San is angry!.jpg

哇!它终于有效果了,但是等了太久!这App解析HTML,下载所有图片和zip文件,以及解压zip文件,都在主线程。最终的结果是用户不得不花费大量宝贵时间等待,还不一定确定这个App是否还在加载!这样后果是非常可怕的:用户可能会退出App,系统会在等了太久而终止App,或者生气的Tomato先生会攻击你的树屋。
幸运的是有了多线程的营救!我们把这些繁重的工作通过苹果提供的简单的APIs放到后台处理,而不再试都放在主线程中。

多线程和群猫们

如果你已经熟悉多线程的概念,可以随时跳到下一节,否则,继续读吧,骚年!
当你想到一个程序正在运行时,你可以想象它就像(下图)一只猫要移动那个箭头。猫移动箭头和程序按照它的逻辑运行一样,都是同一时间只移动一步。

MultithreadCat-500x158.jpg

多线程就像一群猫和一个箭头。(一群猫移动一个箭头!)
ImageGrabber的问题是在主线程中使得我们可怜的猫精疲力尽地去做所有的工作。因此,在这个App绘制UI或者相应用户交互事件之前,不得不先完成所有的耗时操作,比如下载文件,解析HTML等。
OverworkedCat.jpg

那么我们该怎样让劳累过度的猫喘口气呢?最简单的解决方案就是买更多的猫(事实上,我有一个朋友相当在行这)。于是,主猫来响应更新UI和用户的交互事件,而其他的猫则绕着后台去下载文件,解析HTML,然后传表视图(这个猫就退下,等待新的任务)!
这就是多线程技术的核心。就像群猫(在后台)执行各种任务,这程序被放在不同的线程执行。
iOS开发,你习惯用的函数方法(比如viewDidLoad,button点击回调等)都在主线程,你不想在主线程执行耗时操作,这样的话你的UI会很卡顿并且主猫会劳累过度。

孩子们,别再这样做了!

让我们一起来看看当前的代码并且讨论它是怎么执行的,以及为什么这样不好!
ImageGrabber这个App的rootViewControllerWebViewController,当你点击buttonGrab!后,它会获取当前页的HTML,并且传递给ImageListViewController
ImageListViewControllerviewDidLoad里,创建了一个新的ImageManager对象并执行它的process方法。ImageListViewController这个类,不仅处理ImageInfo信息,还包含所有的耗时操作代码,比如:解析HTML,从网络拉取图片,以及解压文件。
下面我们来看看ImageManagerImageInfo是干什么用的:

ImageManager.mprocessHTML方法 : 使用正则表达式匹配去搜索HTML中链接,但这可能是耗时的,主要还是看HTML有多大。当它每发现一个zip文件,就去调用retrieveZip:方法。当它每发现一张图片(image),就去用initWithSourceURL创建一个ImageInfo对象。
_____------
ImageInfoinitWithSourceURL:方法 : 调用getImage方法,用[NSData dataWithContentsOfURL:...];同步地去网络拉取image.就像[NSString stringWithContentsOfURL:…]方法一样,会阻碍程序继续执行,除非该方法执行完毕,当然了,这会花费很长时间的!你几乎从来没有想要在你的应用程序中使用这种方法。
_-----
ImageManager.mretrieveZip方法 : 和上面的相似,用令人畏惧的[NSData dataWithContentsOfURL:...]方法,会使得当前线程停滞不前,直到它自己完成任务结束(不要这样用!)。该方法结束时,它会调用processZip方法。
_-----
ImageManager.mprocessZip方法 : 用第三方库ZipArchive来保存下载的数据到本地磁盘,并且解压数据,以及查找其中的图片。像这样的写入磁盘和解压文件是相当慢的操作,所以这是另外一个不应该在主线程操作的实例。

你可能还注意到了一些ImageManagerDelegateimageInfosAvailable方法的调用,这就是当有新的数据要展示在tableView上时,ImageManager怎么通知到tableView的。

现在停下了看一看,确保你理解了当前操作的执行,以及为什么这样不好。你也许会觉得这样是有用的,并且可以看到控制台打印以及一些NSLog描述信息,当程序运行时。
一旦你知道了该程序当前如何运行,让我们用多线程继续前进和提升它(性能更好,效率更高,交互反应时间更短等)。

异步下载

首先,替换同步下载文件这种最慢的操作。虽然苹果内置的NSURLRequestNSURLConnection类与封装的类ASIHTTPRequest没什么不同,但由于我更喜欢封装好的类,并且ASIHTTPRequest会使得异步下载更简单。所以,我们将用这个类库来下载文件,就让我们把它加入到ImageGrabber这个工程中吧。

如果你还没有ASIHTTPRequest,请先下载ASIHTTPRequest,一旦你下载成功,右击ImageGrabber工程,选择New Group,并且给这new group 命名为ASIHTTPRequest,然后拖拽ASIHTTPRequest\Classes目录(ASIAuthenticationDialog.h和其它一些, 但是不要添加 ASIWebPageRequest, CloudFiles, S3, and Tests.)到ASIHTTPRequestgroup。确保“Copy items into destination group’s folder (if needed)”选中, 然后再点击完成
重复上面的操作,导入ASIHTTPRequest\External\Reachability,它也是工程需要的。

最后一步是添加ASIHTTPRequest,你需要在你的工程链接必须的frameWorks,具体操作:* Build Phases* ---> Link Binary with Libraries,添加CFNetwork.framework,SystemConfiguration.framework,MobileCoreServices.framework.

是时候,用新的异步代码替换之前的同步代码了!
打开ImageManager.m做以下改变:

// Add to top of file
#import "ASIHTTPRequest.h"
 
// Replace retrieveZip with the following
- (void)retrieveZip:(NSURL *)sourceURL {
 
    NSLog(@"Getting %@...", sourceURL);
 
    __block ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:sourceURL];
    [request setCompletionBlock:^{
        NSLog(@"Zip file downloaded.");
        NSData *data = [request responseData];
        [self processZip:data sourceURL:sourceURL];        
    }];
    [request setFailedBlock:^{
        NSError *error = [request error];
        NSLog(@"Error downloading zip file: %@", error.localizedDescription);
    }];
    [request startAsynchronous];    
}

这种改进方法,通过一个URL,创建一个ASIHTTPRequest对象,这个对象在请求结束会回调,并且因为某些原因请求失败也会回调。然后调用startAsynchronous方法,这个方法立即返回以致于主线程可以继续处理自己的业务,比如:UI做动画,相应用户输入。与此同时,OS系统会自动运行代码在后台下载zip文件,并且在任务完成或者失败时立即回调!

参考:最初的代码为:

pragma mark --- pp最初的

  • (void)retrieveZip:(NSURL *)sourceURL {
    NSLog(@"Getting %@...", sourceURL);
    NSData * data = [NSData dataWithContentsOfURL:sourceURL];
    if (!data) {
    NSLog(@"Error retrieving %@", sourceURL);
    return;
    }
    [self processZip:data sourceURL:sourceURL];

}

与此相似,找到ImageInfo.m并且做类似改变:

// Add to top of file
#import "ASIHTTPRequest.h"
 
// Replace getImage with the following
- (void)getImage {
 
    NSLog(@"Getting %@...", sourceURL);
 
    __block ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:sourceURL];
    [request setCompletionBlock:^{
        NSLog(@"Image downloaded.");
        NSData *data = [request responseData];
        image = [[UIImage alloc] initWithData:data];
    }];
    [request setFailedBlock:^{
        NSError *error = [request error];
        NSLog(@"Error downloading image: %@", error.localizedDescription);
    }];
    [request startAsynchronous];    
}

这几乎和ImageManager.m中刚才的代码一样,都是在后台下载,下载完成后,设置图像为可用的结果。

参考:最初的代码为:

#pragma mark --- 原始的方法(没有使用多线程)
-(void)getImage
{
    NSLog(@"Getting %@...", _sourceURL);
    >
    NSData * data = [NSData dataWithContentsOfURL:_sourceURL];
    if (!data) {
        NSLog(@"Error retrieving %@", _sourceURL);
        return;
    }
    _image = [[UIImage alloc] initWithData:data];
}

现在,我们一起看看这样修改后是不是有效果!编译运行后点击Grab!,在表视图上很快显示细节标签文字,而不是等待很长时间,但是出现了一个主要的问题:

UpdatingRowInTableView.jpg

表视图上的图片下载成功后并不显示!你可以通过上下滑动来让它们显示出来(这时候能显示出来是因为它超过屏幕后会reloadData),这是一个问题。我们该怎样去解决它呢?

介绍NSNotifications

一种简单的办法是用苹果的NSNotifications系统,发送更新信息从一个地方到另一个地方。这样做事相当简单的,你获取到NSNotificationCenter单例(用[NSNotificationCenter defaultCenter])并且:

  1. 如果你有一个想要发送的更新,你调用postNotificationName.你仅仅需要给它一个你自己创建的唯一字符串表示(s如“com.razeware.imagegrabber.imageupdated”)和一个对象(如:一个刚下载完图片的ImageInfo对象)。
  1. 如果你想知道更新什么时候发生,你可以调用addObserver:selector:name:object方法。一旦ImageListViewController知道更新发生,它就会reload恰当的tableViewCell。最好把addObserver:selector:name:object方法放在viewDidLoad中。
  2. 当VC的view unloaded时,不要忘记调用removeObserver:name:object方法,否则,通知会在一个unloaded view(或者 unallocated object)中调用某个方法,而这将是一个不好的事情!

那么就让我们试试这!打开ImageInfo.m并且做以下修改:

// Add inside getImage, right after image = [[UIImage alloc] initWithData:data];
[[NSNotificationCenter defaultCenter] postNotificationName:@"com.razeware.imagegrabber.imageupdated" object:self];

这样一旦图片下载成功,我们就发一个通知并且传递一个已经更新的对象(self).
接下来,跳到ImageListViewController.m并且做以下修改:

// At end of viewDidLoad
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(imageUpdated:) name:@"com.razeware.imagegrabber.imageupdated" object:nil];
 
// At end of viewDidUnload
[[NSNotificationCenter defaultCenter] removeObserver:self name:@"com.razeware.imagegrabber.imageupdated" object:nil];
 
// Add new method
- (void)imageUpdated:(NSNotification *)notif {
 
    ImageInfo * info = [notif object];
    int row = [imageInfos indexOfObject:info];
    NSIndexPath * indexPath = [NSIndexPath indexPathForRow:row inSection:0];
 
    NSLog(@"Image for row %d updated!", row);
 
    [self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationNone];
 
}

imageUpdated方法通过通知传递过来的ImageInfo对象去imageInfos数组查找,一旦找到,获取对应的row,并且告诉tableView刷新该row.
现在编译运行,你会看到那些图片被下载完后时不时或者突然出现在表视图。

AsynchImageLoading.jpg

Grand Central Dispatch and Dispatch Queues, Oh My!

目前为止,我们的App任然有一个问题。只要详情页一加载,如果你点击Grab!按钮并且一直上下滑动,在zip文件下载后,UI界面像冰冻一样如果正在保存和解压文件。这是因为,ASIHTTPRequest的完成回调虽然是在主线程,但是我们处理zip文件也在主线程:

[request setCompletionBlock:^{
    NSLog(@"Zip file downloaded.");
    NSData *data = [request responseData];
    [self processZip:data sourceURL:sourceURL]; // Ack - heavy work on main thread!
}];

那么我们该怎样让这繁重的工作在后台处理呢?
好吧,iOS3.2介绍了一种简单的(非常有效的)方法来解决这个问题,通过GCD。基本的,无论什么时候你想在后台跑一些东西,你只需要调用dispatch_async并且传入对应参数即可。GCD会为你处理所有---在它需要的时候它会创建新的线程,并且会重用那些过去的可用的(已经创建过,并且已经使用过,但是截至目前又是空闲的)线程。

当你调用dispatch_async,你传入一个dispatch queue参数,你可以认为这是一个存储你传入的所有blocks的列表,遵循先进先出原则。
你也可以自己创建dispatch queue(通过dispatch_create),或者你也可以(通过dispatch_get_main_queue)得到一个特殊的主线程的dispatch queue。这里我们将创建一个用来在后台执行任务(解析HTML以及保存/解压zip文件)的名叫“backgroundQueue”的dispatch queue

Dispatch Queues, Locks, and Cat Food

调度队列(dispatch queue)默认情况下是串行的,回想我们最早关于猫的举例,如果两只猫同时想得到猫食盘会发生什么?这是个大问题。但是我们把所有的猫放在一条线上,并且告诉它们“如果它们想接近猫食盘,你们不得不排成一队”,要是生活如此简单锁好。


CatsInLine.jpg

这也是最基本是想法使用调度队列(dispatch queue)来保护数据。你设置你的代码以致于特殊的数据只能被一个特殊的调度队列(dispatch queue)访问。这样既然调度队列(dispatch queue)串行运行blocks,就能保证同一时间只有一个调度队列(dispatch queue)能访问这个数据结构。

在这个App中,我们有2个数据结构我们必须要保护:

  1. ImageListViewController里的imageInfos数组。为了保护它,我们将重构我们的代码以致于它只能在主线程中触发;
  2. ImageManager里的pendingZips。为了保护它,我将重构我们的代码以致于它只能在backgroundQueue中触发。
    图片信息在主线程展示,而图片获取以及解压在后台处理。
    关于GCD我们已经谈论不少了,现在我们来尝试尝试它。

Grand Central Dispatch in Practice

打开ImageManager.h并且做如下修改:

// Add to top of file
#import <dispatch/dispatch.h>
 
// Add new instance variable
dispatch_queue_t backgroundQueue;

用GCD前要先导入头文件,并且我们也声明了backgroundQueue用来在后台处理任务。
接下来打开ImageManager.m并且做如下修改:

// 1) Add to bottom of initWithHTML:delegate
backgroundQueue = dispatch_queue_create("com.razeware.imagegrabber.bgqueue", NULL);        
 
// 2) Add to top of dealloc
dispatch_release(backgroundQueue);
 
// 3) Modify process to be the following
- (void)process {    
    dispatch_async(backgroundQueue, ^(void) {
        [self processHtml];
    });    
}
 
// 4) Modify call to processZip inside retrieveZip to be the following
dispatch_async(backgroundQueue, ^(void) {
    [self processZip:data sourceURL:sourceURL];
});
 
// 5) Modify call to delegate at the end of processHTML **AND** processZip to be the following
dispatch_async(dispatch_get_main_queue(), ^(void) {
    [delegate imageInfosAvailable:imageInfos done:(pendingZips==0)];
});

这些都是简单的但是重要的调用,让我们依次讨论每一个:

  1. 创建一个队列。当你创建一个队列时你需要给它一个唯一字符串标示,创建唯一标示的一个好的方法是用反向DNS表示法,像这样。
  2. 当你创建一个队列的时候不要忘了释放它。对这个队列,我们在ImageManagerdeallocated的时候释放。
  3. 老的process方法直接运行processHTML方法,因此,在主线程运行它,当遇到解析HTML时,UI就会卡顿。现在我们在我们自己创建的backgroundQueue后台运行,用dispatch_async简单地调用。
  4. 与3相似,之前我们在zip文件下载完成通过ASIHTTPRequeset回调在主线程处理zip文件,现在我们把处理zip文件放到后台,就不会出现之前保存和解压zip文件时UI卡顿的现象。确保变量pendingZips是受保护的是很重要的。
  5. 我们要确保在主线程的上下文调用代理方法。第一,确保ImageListViewController里的imageInfos数组只能通过主线程访问,根据我们之前的战略分析;第二,因为代理方法与UIKit对象有交互,而UIKit对象只能在主线程使用。

就这样,编译运行你的代码,ImageGrabber应该有更好更快的响应!

But Wait!

如果你有iOS编程经验,你可能听说过叫做NSOperations的神奇的东西,以及操作队列(operation queues)。你可能好奇什么时候你应该用它们,什么时候你应该用GCD。实际上,NSOperations 是基于GCD的简单API。这样,当你使用 NSOperations 时,你实际上也是在使用GCD。NSOperations 仅仅是提供给你一些你可能喜欢的神奇的特性。你可以创建一些operations依赖于其它的operations,在你提交items后重新排列队列,还有其它的像这样的事情。
事实上,ImageGrabber已经使用了NSOperations 和 operation queues!ASIHTTPRequest在底层使用它们,如果你喜欢,你可以自己配置operationsy用作处理不同行为。

所以你应该使用哪一个?哪个适合你的应用程序。对这个程序我们直接使用GCD是相当简单,不需要NSOperation的神奇的功能。但是如果你的App需要它们,就去使用吧!

Where To Go From Here?

这有一个简单的工程,包含上面教程的所有代码。
现在为止,你已经有了在iOS上使用异步操作和GCD的实践经验。但本教程还远远不够——还有很多你可以学习!
我首先建议听大苹果关于GCD的视频WWDC2010年和WWDC2011年都有一些视频,介绍的很不错。
如果你真的想学习GCD相关知识,Mike Ash 有一些好的关于GCD的文章你可以去看看.

如果你有任何问题、意见或建议,请在下方留言加入论坛的讨论!

团队

www.raywenderlich.com上的每个教程都是由专门的团队开发人员创建,以此来符合我们的高质量的标准。团队成员曾参与本教程是:
Ray Wenderlich
Follow Ray Wenderlich on Twitter

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,646评论 4 366
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,979评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,391评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,356评论 0 215
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,740评论 3 293
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,836评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,022评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,764评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,487评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,728评论 2 252
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,200评论 1 263
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,548评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,217评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,134评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,921评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,919评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,766评论 2 274

推荐阅读更多精彩内容