聊聊iOS下block + GCD (Grand Central Dispatch)实现异步非阻塞
本文用示例来说明一下iOS下用block+GCD来在程序中实现非阻塞式执行耗时任 务。先说明一下,严格说来“异步”、“后台线程”、“非阻塞”这些概念是有一些小区别的。有些系统API特别是网络和文件I/O是通过系统底层中断来实 现”非阻塞”,而一般用户任务比如耗时计算是通过后台线程完成的。但具体到app这一层,开发人员并不关心具体的实现是用了硬件中断还是一个线程,所以在 本文的上下文中,没有特意区分这几个概念点,甚至有些混用。本文中的“非阻塞”可以简章理解为,开发人员只需要知道“我的程序执行耗时任务时,UI仍然可 以响应用户操作”。
示范代码在附件。可用xcode 4编译,在ios 4及以上运行。
写过程序的都知道,要让程序对用户输入响应及时,避免程序在某个操作时僵死的情况,那就要把耗时操作放到后台去做,然后通过异步的通知或者回调来接着流程往下走。否则的话耗时操作会把主线程阻塞,导致程序很长时间不回到主事件循环。
这
在移动平台上尤其重要,一般移动平台上系统都会有一个专门的检查机制,看程序有没有很长时间被阻塞住,没有回来检查主消息队列。发现这种情况一般都是把程
序作为“无响应”干掉。iOS一般情况下是10秒为上限。10秒内程序没有回到主消息循环就被干掉。在前台后台切换时更严格,大概是5秒左右。(在一般的
PC编程中,对这种情况的容忍度高一些,程序本身会僵死,UI画屏会停止(所以常常会看到空白或者破碎的窗口),有时系统还会弹出“停止响应”警告。但一
般来说系统不会主动杀掉这些程序)
但对很多开发人员,尤其是新手来说,这种非阻塞方式是比较违反人类直观思维的做法。比如,当用户点击某个按纽时我想在程序中计算100万位的PI值。从最直观的思维出发,一般都会先想到顺序式的编程方式:
// 示例1:阻塞方式 // 用户点击了按纽,触发计算操作 - (void) didTapCalcButton { // 显示“请等待”提示 [self showWaitingView]; // 计算PI值到100万位。运算结束后才返回。 NSString *result = [self calcPI:1000000]; // 关闭“请等待”提示 [self hideWaitingView]; // 显示结果(当然,这里可能只显示前N位,不然又变成耗时操作了) [self displayResult:result]; }
这样做有很多问题。一是前面提到的程序不响应用户输入,甚至被系统判定为失去响应而杀掉的问题。二是“请等待”这个提示根本不会出现。因为任何对UI的操 作,在iOS中实际上并不是立刻执行,只是做了个标记,在当前事件循环(runloop)完成后,在下一个事件循环开始前,系统根据做的标记来决定屏幕哪 一块需要更新,并进行重绘。照上面这个写法,showLoadingView只是打个标记,但当前runloop要在这个函数返回后才会结束。而结束前我 们又调用了hideLoadingView,所以根本不会显示。
要解决这些问题,需要把计算PI值这个操作放到后台异步执行。具体有很多
方法。传统的方法无非是自己开线程,或者用iOS提供的高级线程封装NSOperation来完成这样做。(扯远点:在没有线程支持的低端移动平台上还有
一种方式,就是每次做很少的计算以避免阻塞,比如计算100位,然后把剩下的工作重新排程到事件队列尾巴上。重复进行,最终结果是分一万次做完。这样的做
法非常低效,而且开发人员需要自己保存若干状态,很麻烦)
无论用哪种方法,传统的异步方式来实现这个例子的程序结构大概都是这么一个样子:
// 示例2:传统的后台任务实现异步 // 用户点击了按纽,触发计算操作 - (void) didTapCalcButton { // 显示“请等待”提示 [self showWaitingView]; // 计算PI值到100万位。这里只是创建一个后台任务并启动它,然后立刻返回,并不等待任务本身完成 [self startCalcPI:1000000]; // 然后程序什么也不干,等着。 } // 不论哪种异步方式,最后一定要有一个办法通知主线程任务已完成。具体到iOS,有若干方法可以使用,比如: // delegate, KVO, NSNotification, performSelectorOnMainThread:等。 // 假设以下是回调函数,在主线程上被调用: - (void) calculationDidFinishWithResult:(NSString *)result { // 关闭“请等待”提示 [self hideWaitingView]; // 把结果显示在屏幕上 [self displayResult:result]; } // 以下示范用NSOperation + KVO来做后台运算。 - (void) startCalcPI:(NSInteger)digits { // MyPICalcOperation是一个NSOperation子类。其main方法中直接进行PI的运算,相当于阻塞示例中的calcPI:。 NSOperation *calcOpeation = [[[MyPICalcOpeartion alloc] init] autorelease]; // 假设我们选择用KVO方式观察后台任务的结束 [calcOperation addObserver:self keyPath:@"isFinished" …]; // 提交任务,开始执行。 [self.operationQueue addOperation:calcOperation]; } // 观察后台计算任务的完成。这是一个标准KVO函数,简单说当calcOperation的isFinished属性从FALSE变TRUE后会被调用 - (void) observeValueForKey:keyPath ofObject:object ... { if([@"isFinished" isEqualToString:keyPath] && [object isKindOfClass:[MyPICalcOperation class]]) { // 观察到了我们想要的状态变化,即运算结束。这里我们调用回调处理结果。确保回调在主线程上进行 MyPICalcOpeartion *op = (MyPICalcOperation *)object; [self performSelectorOnMainThread:@selector(calculationDidFinishWithResult:) withObject:op.result waitUntilDone:FALSE]; } else { [super observeValueForKey:...]; } }
差不多就这样。当然具体代码量的多少和你选用的具体异步实现相关,但总是要有额外的代码去做后台的事情,来判定运算的结束,以及回调。从这方面说没有根本的区别。
对
熟练的开发人员来说,这是非常自然的事情,尤其是一个合格的移动平台开发人员,他会认为这是写好一个程序必要的方式。但是现在的问题是,随着
android/iOS的出现,越来越多的非专业人士开始写程序。他们有一个很棒的创意,可以做出很有用的东西,但他们毕竟没有受过正统的编程训练,所以
很多人会认为异步方式难于理解且代码复杂(看看上面两个例子的代码量比较。第二个例子我还省略了MyCalcPIOperation的实现,不然更长)。
所以很多人会自然的选择用同步阻塞的方式来写程序。这样造成的结果就是AppStore上有大量不稳定的程序,莫名其妙的崩溃。或者在iPhone4上能
够正常工作,但在慢一点的3GS上就崩溃,因为计算速度变慢导致了阻塞时间过长。
而传统的异步方式需要一些时间才能掌握,而且很容易出现
一些常见错误。比如KVO的注册和反注册没有匹配;没有搞清楚观察函数是在主线程还是后台线程上执行,导致UI操作无效;而delegate方式也常会引
发内存问题,比如retain delegate造成循环引用;或者assign
delegate没有管理好,出现野指针。这一类的问题会让普通开发人员望而却步。
iOS4对这个问题的解决办法,就是引入了block
块编程方式以及GCD (Grand Central
Dispatch)任务队列管理。这里我们不去花版面介绍枯燥的语法。有需要的同学请自己查阅文档。我们先试着用block+GCD来重写这个计算100
万位PI的程序片段:
// 示例3:block+GCD异步 // 用户点击了按纽,触发计算操作 - (void) didTapCalcButton { // 显示“请等待”提示 [self showWaitingView]; // 以下两行将任务排程到一个后台线程执行。dispatch_get_global_queue会取得一个系统分配的后台任务队列。 dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_async(queue, ^{ // 计算PI值到100万位。和示例1的calcPI:完全一样,唯一区别是现在它在后台线程上执行了。 NSString *result = [self calcPI:1000000]; // 计算完成后,因为有UI操作,所以需要切换回主线程。一般原则: // 1. UI操作必须在主线程上完成。2. 耗时的同步网络、同步IO、运算等操作不要在主线程上跑,以避免阻塞 // dispatch_get_main_queue()会返回关联到主线程的那个任务队列。 dispatch_async(dispatch_get_main_queue(), ^{ // 关闭“请等待”提示 [self hideWaitingView]; // 显示结果 [self displayResult:result]; }); }); }
然后……然后就没有然后了。就这样。
可以比较一下示例1和3。基本上是完全一样的代码,3只是加了两行dispatch指令,把任务在前
台后台间切换来切换去。但3完全不会造成主线程阻塞,哪怕计算PI值要花一个小时都不会有问题。“请等待”提示也可以正确显示和消失。block+GCD
可以说是既保留了顺序编程的直观和简洁,又在技术上实现了异步特征以提高程序响应。可以说是一种比较完美的方式,代码也非常好理解。
这里对示例3有几点需要说明:
1. didTapCalcButton并没有等到计算完成才返回。当计算任务被扔到后台队列(甚至都未必开始执行)后就立刻返回了。后续的操作由系统自己记住并完成
2.
block的一大特征是自动管理变量的生存期。传统的异步做法一般都要把计算状态或者结果保留为类的成员变量。但示例3中我们直接把NSString
*result申请成局部变量,然后在另外一个块中可以直接使用。这是比较颠覆的一种做法,因为从传统的变量生存周期来看,result这个变量只在第一
个块中有效。在最后这个displayResult所在的块中应该已经出了scope,不再有效。但针对block,编译器做了一些特别的事情,它会自动
分析出变量的跨块引用并进行跨块的传址(需要使用__block方式)、传值、或者retain(对object或者其属性及方法的调用)。所以对开发人
员来说,块间的变量生存周期是很灵活的,基本上是“前面有定义后面就可用”。
如果大家熟悉java inner
class,其实第二点是很象的。non-static inner
class允许访问外层的局部变量,但外层必须申请为final即传值模式。但java是没有传址模式(相当于__block)的,所以inner
class不能修改外部局部变量的值(假如我没记错的话)。inner
class对外层class的成员变量和方法的引用,编译器也是通过创建一系列Outter.access$100等匿名方法实现的。从这一点
看,block借鉴了相当多的java inner
class的概念。而GCD只是管理一堆前台后台任务队列,并允许程序把任务在队列间切来切去而已。GCD选择block作为任务定义的语法,是因为
block这种自动跨块生存周期管理很适合这种切换。
另外要提醒的是,这种方式也并非万能:
1. 一个好的程序,对任何耗时操作都要给用户提供半路取消的选择。要做到这一点,还是需要增加一些代码
2.
block就象一个object,也有自己的生存周期问题,也会出现类似野指针和内存泄漏的情况。如果你自己做一个基于block的异步库供别人使用,非
常容易产生循环引用的错误(对方的app class
retain了你的异步库,你的异步库retain了app提供的回调block,而block中一般又通过self引用了app
class本身),需要特别小心。
3. 假如在运算完成前用户就退出这个页面(比如回退到上一页),运算还是会进行,view
controller的销毁被延后到运算结束的时候。假如不想要这个效果的话,一是要实现1中的取消机制,二是要在块中避免引用self(否则会被自动
retain)。具体看文档。
个人浅见。错漏难免。欢迎讨论。
附件是示范代码。
NBExample1,2,3ViewController三个类分别示范三种做法。可以看出,Example
1的同步方式体验很差,而且程序很可能被系统中止。2 & 3都做到了非阻塞,任务进行中UI还可以响应(列表可以滚动),但3的代码简洁得多。
参考:
http://developer.apple.com/library/m...8091-CH102-SW1