轉自: 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.resultwaitUntilDone: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 http://developer.apple.com/library/i...roduction.html 上傳的附件
|
NonBlockingTasks.zip (80.8 KB, 10 次查看) |
|