聊聊iOS下block + GCD (Grand Central Dispatch)实现异步非阻塞

转自:  http://bbs.et8.net/bbs/showthread.php?t=1019931
 

本文用示例来说明一下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

posted @ 2013-03-14 11:38  rui90102  阅读(286)  评论(0编辑  收藏  举报