原文链接
由于作者文章11年写的,给的demo以及文中错误地方,在翻译的时候我已经改正!你也可以在Github上看到!
写在前面的话:建议通篇先看完,不要一开始就一步一步读下去,并且按照作者提供的链接下载阅读或者尝试,整篇文章讲的很好,个人很喜欢作者幽默风趣,善举例子说明的风格,而相关代码,由于作者链接的网站改版,就算运行也获取不到想要的效果,所以没必要都下载下来看。为此,我用自己最简短的总结概括下:
- tableview上加载既有图片又有文字的数据(所有数据是基于HTML的,需要解析HTML,拉取图片ZIP文件,并解压ZIP文件);
- 最开始作者处理的方案是所有的解析HTML,下载ZIP文件解压ZIP文件都放在主线程,(结果卡顿很久,影响UI和用户交互);
- 作者开始使用ASI异步下载,并使用通知回调,但是这时上上下下来回滑动,界面就卡死,于是作者加入了dispatch_async,保证所有耗时操作(比如:解析HTML,下载ZIP,解压ZIP)都放后台处理,而更新UI以及显示图片都放主线程处理。
- 最后提及NSOperations以及operation queues,而前者是基于GCD的。
你有没有遇到这种情况:当你开发一个app时,某个地方你想处理一 些事情,但是由于UI长时间没有反应而停顿了好长时间?
通常,这种迹象说明,你的app需要多线程处理下!
在这个教程,你将获得关于iOS上可用的核心多线程API:GCD的相关经验!
我们为你提供一个根本没使用多线程的app,然后使用多线程修改它,你会为前后的不同感到震惊的!
该教程假设你已经熟悉基本的iOS开发。如果你完全是个iOS开发新手,你可以看看其它教程。
废话少说,痛饮一番碳酸饮料或者嚼嚼泡泡糖,开始该教程吧!你已经踏上了多线程之路啦!
为什么我应该在乎?
“呃,为什么你要告诉我这呢?为啥我应该在乎呢?我才不在乎。你中午吃的啥饭呀?(我关心这,哈哈)”
如果你像一个木偶人,你可能仍在怀疑你为什么应该关心这些多线程业务,那么让我们通过一个根本不用多线程的app的实例来告诉你为什么。
下载最原始工程,用XCode打开,然后编译运行。你会看到来自vickiwenderlich.com的一个游戏艺术包展示在屏幕上:
这个APP叫
ImageGrabber
,它主要是通过这个web页面的HTML并且检索其中所有相关的图像,显示在表视图,这样你就可以更仔细地看到他们。酷的是它甚至下载zip文件并查找zip中所有图片,比如 vickiwenderlich.com上的 free game art zip。
接下来,点击按钮
Grab!
,看是否有反应。
…
…waiting…
…
…waiting…
…
…waiting…
…
哇!它终于有效果了,但是等了太久!这App解析HTML,下载所有图片和zip文件,以及解压zip文件,都在主线程。最终的结果是用户不得不花费大量宝贵时间等待,还不一定确定这个App是否还在加载!这样后果是非常可怕的:用户可能会退出App,系统会在等了太久而终止App,或者生气的Tomato先生会攻击你的树屋。
幸运的是有了多线程的营救!我们把这些繁重的工作通过苹果提供的简单的APIs放到后台处理,而不再试都放在主线程中。
多线程和群猫们
如果你已经熟悉多线程的概念,可以随时跳到下一节,否则,继续读吧,骚年!
当你想到一个程序正在运行时,你可以想象它就像(下图)一只猫要移动那个箭头。猫移动箭头和程序按照它的逻辑运行一样,都是同一时间只移动一步。
多线程就像一群猫和一个箭头。(一群猫移动一个箭头!)
ImageGrabber
的问题是在主线程中使得我们可怜的猫精疲力尽地去做所有的工作。因此,在这个App绘制UI或者相应用户交互事件之前,不得不先完成所有的耗时操作,比如下载文件,解析HTML等。
那么我们该怎样让劳累过度的猫喘口气呢?最简单的解决方案就是买更多的猫(事实上,我有一个朋友相当在行这)。于是,
主猫
来响应更新UI和用户的交互事件,而其他的猫则绕着后台去下载文件,解析HTML,然后传表视图(这个猫就退下,等待新的任务)!
这就是多线程技术的核心。就像群猫(在后台)执行各种任务,这程序被放在不同的线程执行。
iOS开发,你习惯用的函数方法(比如
viewDidLoad
,
button点击回调
等)都在主线程,你不想在主线程执行耗时操作,这样的话你的UI会很卡顿并且
主猫
会劳累过度。
孩子们,别再这样做了!
让我们一起来看看当前的代码并且讨论它是怎么执行的,以及为什么这样不好!
ImageGrabber
这个App的rootViewController
是WebViewController
,当你点击buttonGrab!
后,它会获取当前页的HTML,并且传递给ImageListViewController
。
在ImageListViewController
的viewDidLoad
里,创建了一个新的ImageManager
对象并执行它的process
方法。ImageListViewController
这个类,不仅处理ImageInfo
信息,还包含所有的耗时操作代码,比如:解析HTML,从网络拉取图片,以及解压文件。
下面我们来看看ImageManager
和ImageInfo
是干什么用的:
ImageManager.m
的processHTML
方法 : 使用正则表达式匹配去搜索HTML中链接,但这可能是耗时的,主要还是看HTML有多大。当它每发现一个zip文件,就去调用retrieveZip:
方法。当它每发现一张图片(image),就去用initWithSourceURL
创建一个ImageInfo
对象。
_____------
ImageInfo
的initWithSourceURL:
方法 : 调用getImage
方法,用[NSData dataWithContentsOfURL:...];
同步地去网络拉取image.就像[NSString stringWithContentsOfURL:…]
方法一样,会阻碍程序继续执行,除非该方法执行完毕,当然了,这会花费很长时间的!你几乎从来没有想要在你的应用程序中使用这种方法。
_-----
ImageManager.m
的retrieveZip
方法 : 和上面的相似,用令人畏惧的[NSData dataWithContentsOfURL:...]
方法,会使得当前线程停滞不前,直到它自己完成任务结束(不要这样用!)。该方法结束时,它会调用processZip
方法。
_-----
ImageManager.m
的processZip
方法 : 用第三方库ZipArchive
来保存下载的数据到本地磁盘,并且解压数据,以及查找其中的图片。像这样的写入磁盘和解压文件是相当慢的操作,所以这是另外一个不应该在主线程操作的实例。
你可能还注意到了一些ImageManagerDelegate
的imageInfosAvailable
方法的调用,这就是当有新的数据要展示在tableView上时,ImageManager
怎么通知到tableView的。
现在停下了看一看,确保你理解了当前操作的执行,以及为什么这样不好。你也许会觉得这样是有用的,并且可以看到控制台打印以及一些NSLog
描述信息,当程序运行时。
一旦你知道了该程序当前如何运行,让我们用多线程继续前进和提升它(性能更好,效率更高,交互反应时间更短等)。
异步下载
首先,替换同步下载文件这种最慢的操作。虽然苹果内置的NSURLRequest
和NSURLConnection
类与封装的类ASIHTTPRequest没什么不同,但由于我更喜欢封装好的类,并且ASIHTTPRequest
会使得异步下载更简单。所以,我们将用这个类库来下载文件,就让我们把它加入到ImageGrabber
这个工程中吧。
如果你还没有ASIHTTPRequest
,请先下载ASIHTTPRequest,一旦你下载成功,右击ImageGrabber
工程,选择New Group
,并且给这new group 命名为ASIHTTPRequest
,然后拖拽ASIHTTPRequest\Classes
目录(ASIAuthenticationDialog.h和其它一些, 但是不要添加 ASIWebPageRequest, CloudFiles, S3, and Tests.)到ASIHTTPRequest
group。确保“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 {
if (!data) {
NSLog(@"Error retrieving %@", sourceURL);
return;
}
}
与此相似,找到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!
,在表视图上很快显示细节标签文字,而不是等待很长时间,但是出现了一个主要的问题:
表视图上的图片下载成功后并不显示!你可以通过上下滑动来让它们显示出来(这时候能显示出来是因为它超过屏幕后会reloadData),这是一个问题。我们该怎样去解决它呢?
介绍NSNotifications
一种简单的办法是用苹果的NSNotifications系统,发送更新信息从一个地方到另一个地方。这样做事相当简单的,你获取到NSNotificationCenter单例(用[NSNotificationCenter defaultCenter])并且:
- 如果你有一个想要发送的更新,你调用
postNotificationName
.你仅仅需要给它一个你自己创建的唯一字符串表示(s如“com.razeware.imagegrabber.imageupdated”)和一个对象(如:一个刚下载完图片的ImageInfo
对象)。
- 如果你想知道更新什么时候发生,你可以调用
addObserver:selector:name:object
方法。一旦ImageListViewController
知道更新发生,它就会reload恰当的tableViewCell。最好把addObserver:selector:name:object
方法放在viewDidLoad
中。 - 当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.
现在编译运行,你会看到那些图片被下载完后时不时或者突然出现在表视图。
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)默认情况下是串行的,回想我们最早关于猫的举例,如果两只猫同时想得到猫食盘会发生什么?这是个大问题。但是我们把所有的猫放在一条线上,并且告诉它们“如果它们想接近猫食盘,你们不得不排成一队”,要是生活如此简单锁好。
这也是最基本是想法使用调度队列(dispatch queue)来保护数据。你设置你的代码以致于特殊的数据只能被一个特殊的调度队列(dispatch queue)访问。这样既然调度队列(dispatch queue)串行运行blocks,就能保证同一时间只有一个调度队列(dispatch queue)能访问这个数据结构。
在这个App中,我们有2个数据结构我们必须要保护:
ImageListViewController
里的imageInfos数组。为了保护它,我们将重构我们的代码以致于它只能在主线程中触发;ImageManager
里的pendingZips。为了保护它,我将重构我们的代码以致于它只能在backgroundQueue中触发。
图片信息在主线程展示,而图片获取以及解压在后台处理。
关于GCD我们已经谈论不少了,现在我们来尝试尝试它。
Grand Central Dispatch in Practice
打开ImageManager.h
并且做如下修改:
// Add to top of file
#import
// 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)];
});
这些都是简单的但是重要的调用,让我们依次讨论每一个:
- 创建一个队列。当你创建一个队列时你需要给它一个唯一字符串标示,创建唯一标示的一个好的方法是用反向DNS表示法,像这样。
- 当你创建一个队列的时候不要忘了释放它。对这个队列,我们在
ImageManager
deallocated的时候释放。- 老的
process
方法直接运行processHTML
方法,因此,在主线程运行它,当遇到解析HTML时,UI就会卡顿。现在我们在我们自己创建的backgroundQueue后台运行,用dispatch_async简单地调用。- 与3相似,之前我们在zip文件下载完成通过
ASIHTTPRequeset
回调在主线程处理zip文件,现在我们把处理zip文件放到后台,就不会出现之前保存和解压zip文件时UI卡顿的现象。确保变量pendingZips是受保护的是很重要的。- 我们要确保在主线程的上下文调用代理方法。第一,确保
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