本文主要探討一些常用多工的最佳實踐。包括Core Data的多執行緒訪問,UI的並行繪製,非同步網路請求以及一些在執行態記憶體吃緊的情況下處理大檔案的方案等。 其實編寫非同步處理的程式有很多坑!所以,本文所涉及的樣例都儘量採用簡潔直觀的處理方式。因為越是簡單的邏輯結構,越能彰顯程式碼的脈絡清晰,越易於理解。打個比方,如果在程式中使用多層次的巢狀回撥,基本上這個它會有很大的重構空間。
Operation Queues vs. Grand Central Dispatch
目前,在iOS和OS X 中,SDK主要提供了兩類多工處理的API:operation queues和Grand Central Dispatch(GCD)。其中GCD是基於C的更加底層的API,而operation queues被普遍認為是基於GCD而封裝的物件導向(objective-c)的多工處理API。關於併發處理API層面的比較,有很多相關的文章,如果感興趣可以自行閱讀。
相比於GCD,operation queues的優點是:提供了一些非常好用的便捷處理。其中最重要的一個就是可以取消在任務處理佇列中的任務(稍後舉例)。另外operation queues在處理任務之間的依賴關係方面也更加容易。而GCD的特長是:可以訪問和操作那些operation queues所不能使用的低層函式。詳情參考低層併發處理API相關文章。
延伸閱讀:
Core Data in the Background
在著手Core Data的多執行緒處理之前,我們建議先通讀一下蘋果的官方文件”Concurrency with Core Data guide”。這個文件中羅列了諸多規則,比如:不要在不同執行緒間直接傳遞managed objects。注意這意味著執行緒間不但不能對不屬於自己的managed object做修改操作,甚至連讀其中的屬性都不可以。正確做法是通過傳object ID和從其他執行緒的context資訊中獲取object的方式來達到傳遞object的效果。其實只要遵循文件中的各種指導規則,那麼處理 Core Data的並行程式設計問題就容易多了。
Xcode提供了一種建立Core Data的模版,工作原理是通過主執行緒作為persistent store coordinator(持久化協調者)來操作managed object context,進而實現物件的持久化。雖然這種方式很便捷並基本適用常規場景,但如果要操作的資料比較龐大,那就非常有必要將Core Data的操作分配到其他執行緒中去(注:大資料量的操作可能會阻塞主執行緒,長時間阻塞主執行緒使用者體驗很差並且有可能導致應用程式假死或崩潰)。
樣例:向Core Data中匯入大量的資料:
1.為引入資料建立一個單獨的operation
2.建立一個和main object context相同persistent store coordinator的object context
3.引入操作的context儲存完成後,通知main managed object context去合併資料。
在樣例app中,要匯入一大組柏林的運輸線路資料。在匯入的過程中會展示進度條並且使用者可以隨時取消當前匯入操作。等待條下面再用一個table view來展示目前已匯入的資料同時邊匯入邊重新整理介面。樣例採用的資料署名Creative Commons license,可以在此下載。使用公開標準的General Transit Feed格式。
接下來建立NSOperation的子類ImportOperation,通過複寫main方法來處理所有的匯入工作。再建立一個private queue concurrency型別的獨立的managed object context,這個context需要管理自己的queue,在其上的所有操作必須使用performBlock或者performBlockAndWait來觸發。這點相當重要,這是保證這些操作能在正確的執行緒上執行的關鍵。
1 2 3 4 5 6 7 |
NSManagedObjectContext* context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType]; context.persistentStoreCoordinator = self.persistentStoreCoordinator; context.undoManager = nil; [self.context performBlockAndWait:^ { [self import]; }]; |
注:在樣例中複用了persistent store coordinator。正常情況下,需要初始化managed object contexts並且指定其型別:如NSPrivateQueueConcurrencyType,NSMainQueueConcurrencyType或者NSConfinementConcurrencyType,其中NSConfinementConcurrencyType不建議使用,因為它是給一些遺留的舊程式碼使用的。
匯入前,按行迭代運輸線路資料檔案的內容,給每一個能解析的行資料建立一個managed object:
1 2 3 4 5 6 7 8 9 10 |
[lines enumerateObjectsUsingBlock: ^(NSString* line, NSUInteger idx, BOOL* shouldStop) { NSArray* components = [line csvComponents]; if(components.count < 5) { NSLog(@"couldn't parse: %@", components); return; } [Stop importCSVComponents:components intoContext:context]; }]; |
通過view controller中來觸發操作:
1 2 3 |
ImportOperation* operation = [[ImportOperation alloc] initWithStore:self.store fileName:fileName]; [self.operationQueue addOperation:operation]; |
至此為止,多執行緒匯入資料到Core Data部分已經完成。接下來,是取消匯入部分,非常簡單隻需要在集合的快速列舉block中加個判斷即可:
1 2 3 4 |
if(self.isCancelled) { *shouldStop = YES; return; } |
最後是增加進度條,在operation中建立一個progressCallback屬性block。注意更新進度條必須在主執行緒中完成,否則會導致UIKit崩潰。
1 2 3 4 5 6 7 |
operation.progressCallback = ^(float progress) { [[NSOperationQueue mainQueue] addOperationWithBlock:^ { self.progressIndicator.progress = progress; }]; }; |
在快速列舉中加上下面這行去呼叫進度條更新block:
1 |
self.progressCallback(idx / (float) count); |
然而,如果你執行樣例app就會發現一切都特別慢而且取消操作也有遲滯。這是因為main opertation queue中塞滿了要更新進度條的block。通過降低更新進度條的頻度可以解決這個問題,
例如以百分之一的節奏更新進度條:
1 2 3 4 5 |
NSInteger progressGranularity = lines.count / 100; if (idx % progressGranularity == 0) { self.progressCallback(idx / (float) count); } |
Updating the Main Context
我們樣例app中的table view後面掛接了一個專門在主執行緒上執行取資料任務的controller。如前面所述,在匯入資料的過程中table view會同期展示資料。要達成這個任務,在資料匯入的過程中,需要向main context發出廣播,要在Store類的init方法中註冊Core Data廣播監聽:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
[[NSNotificationCenter defaultCenter] addObserverForName:NSManagedObjectContextDidSaveNotification object:nil queue:nil usingBlock:^(NSNotification* note) { NSManagedObjectContext *moc = self.mainManagedObjectContext; if (note.object != moc) [moc performBlock:^(){ [moc mergeChangesFromContextDidSaveNotification:note]; }]; }]; }]; |
注:如果block在main queue中作為引數傳遞,該block就會在main queue中執行。執行樣例,此時table view是在匯入結束後才會展示匯入結果。大概那麼幾秒鐘,使用者的操作會被阻塞掉。因此,需要通過批量操作來解決這個問題。因為凡是匯入較大的資料,都應該採用逐漸匯入的方式,否則記憶體很快就會被耗光,效率會奇差。同時,漸進式的匯入也會分散main thread 更新table view的壓力。
至於說合理的儲存的次數基本上就得靠試。存得太頻繁,缺點是反覆操作I/O。存得次數太少,應用會變得經常無響應。經過多次試驗,我們認為本樣例中儲存250次比較合適。改進後,匯入過程變得很平滑,更新了table view,整個過程也沒有阻塞main context太久。
其他考量
在匯入檔案的時候,樣例程式碼將整個檔案直接讀入記憶體後轉成一個String物件接著再對其分行。這種方式非常適合操作那些小檔案,但對於大檔案應該採用逐行懶載入的方式。StackOverflow上Dave DeLong 提供了一段非常好的樣例程式碼來實現逐行讀取。本文的最後也會提供一個流方式讀入檔案的樣例。
注:在app第一次執行時,也可以通過sqlite來替代將大量資料匯入Core Data這個過程。sqlite可以放在bundle內,也可以從伺服器下載或者動態生成。某些情況下,真機上使用sqlite的儲存過程會非常快。
最後要提一下,最近關於child contexts的爭論很多,並不建議在多執行緒中使用它。如果在非主執行緒中建立了一個context作為main context的child context,在這些非主執行緒中執行儲存操作還是會阻塞主執行緒。反過來,要是將main context設定為其他非主執行緒context的child context,其效果與傳統的建立兩個有依賴關係的contexts類似,還是需要手動的將其他執行緒的context變化和main context做合併。
事實證明,除非有更好的選擇,否則設定一個persistent store coordinator和兩個獨立的contexts才是對Core Data多執行緒操作的合理方式。
延伸閱讀:
- Core Data Programming Guide: Efficiently importing data
- Core Data Programming Guide: Concurrency with Core Data
- StackOverflow: Rules for working with Core Data
- WWDC 2012 Video: Core Data Best Practices
- Book: Core Data by Marcus Zarra
UI Code in the Background
首先強調一點:UIKit只在主執行緒上執行。換句話說,為了不阻塞UI,那些和UIKit不相關的但是卻非常耗時的任務最好放到其他執行緒上執行。另外也不能盲目的將任務分到其他執行緒佇列中去,真正需要被優化的的是那些瓶頸任務。
獨立的、耗時的操作最適合放在operation queue中:
1 2 3 4 5 6 7 8 |
__weak id weakSelf = self; [self.operationQueue addOperationWithBlock:^{ NSNumber* result = findLargestMersennePrime(); [[NSOperationQueue mainQueue] addOperationWithBlock:^{ MyClass* strongSelf = weakSelf; strongSelf.textLabel.text = [result stringValue]; }]; }]; |
如上樣例所見,裡面的引用設定其實也並不簡單。先要對self宣告做weak弱引用,不然就會形成retain cycle迴圈引用(block對self做了retain,private operation queue又retain了block,接著self又retain了operation queue)。為了避免在執行block時出現訪問已被自動釋放的物件的情況,又需將對self的weak弱引用轉換成strong強引用。
Drawing in the Background
如果drawRect:真的是應用的效能瓶頸,可以考慮使用core animation layers或者pretender預渲染圖片的方式來取代原本的plain Core Graphics的繪製。詳情見Florian對真機上圖形處理效能分析的帖子,或者可以看看來自UIKit工程師Andy Matuschak對箇中好處的評論。如果實在找不到其他好法子了,才有必要把繪製相關的工作放到其他執行緒中去執行。多執行緒繪製的處理方式也比較簡單,直接把drawRect:中的程式碼丟到其他operation去執行即可。原本需要繪製的檢視用image view佔位等待,等到operation執行完畢,再去通知原來的檢視進行更新。實現層面上,用UIGraphicsGetCurrentContext來取代原來繪製程式碼中的使用的UIGraphicsBeginImageContextWithOpertions:
1 2 3 4 5 |
UIGraphicsBeginImageContextWithOptions(size, NO, 0); // drawing code here UIImage *i = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return i; |
上述程式碼中UIGraphicsBeginImageContextWithOpertion中的第三個參數列示對裝置main screen的scale幅度,如果傳0,那麼表示自動填充,這麼處理的話無論裝置是否為視網膜螢幕,看起來都會很不錯。
如果是在繪製table view或者collection view的cell,最好將他們都放進operation執行,再把這些operation新增到非main queue佇列中去,這樣一旦使用者滑動觸發了didEndDisplayingCell代理方法,就可以隨時取消佇列中的繪製operation。上述的內容,都在WWDC2012的Session211-Building Concurrenct User Interfaces on iOS中都有涵蓋。當然除了多執行緒繪製還可以考慮嘗試一下CALayer的drawsAsynchronously屬性。但是需要自己評估一下使用它的效果,因為有時候它的效能表現不快反慢。
非同步網路請求處理
切記,所有的網路請求都要採用非同步的方式處理!
但是有些人運用GCD來處理網路請求的時候,程式碼是這個樣子的:
1 2 3 4 5 6 7 |
// Warning: please don't use this code. dispatch_async(backgroundQueue, ^{ NSData* contents = [NSData dataWithContentsOfURL:url] dispatch_async(dispatch_get_main_queue(), ^{ // do something with the data. }); }); |
咋看起來挺好,其實裡面很有問題,這根本是一個沒辦法取消的同步網路請求!除非請求完成,否則會把執行緒卡住。如果請求一直沒響應結果,那就只能乾等到超時(比如dataWithContentsOfURL的超時時間是30秒)。
如果queue佇列是線性執行,佇列中網路請求執行緒其後的執行緒都會被阻塞。假如queue佇列是並行執行的,由於網路請求執行緒受阻,GCD需要重新發放新的執行緒來做事。這兩種結果都不好,最好是不要阻礙任何執行緒。
如何來解決上述問題呢?應該使用NSURLConnection的非同步請求方式,並且把所有和請求相關的事情打包放到一個operation中去處理。這樣可以隨時控制這些並行operations,比如處理operation間的依賴關係,隨時取消operation等,這便會發揮operation queue的便捷優勢。這裡還需要注意的是,URL connections通過run loop來傳送事件,因為事件資料傳遞一般不怎麼耗時,所以用main run loop來處理起來會很簡單。然後我們用其他執行緒來處理返回的資料。當然還有其他的方式,比如很流行的第三方library AFNetworking的處理是:建立一個獨立的執行緒,基於這個執行緒設定run loop,然後通過這個執行緒處理url connection。 但是不推薦讀者自己採用這種方式。
複寫樣例中operation中的start方法來觸發請求:
1 2 3 4 5 6 7 8 9 10 11 |
- (void)start { NSURLRequest* request = [NSURLRequest requestWithURL:self.url]; self.isExecuting = YES; self.isFinished = NO; [[NSOperationQueue mainQueue] addOperationWithBlock:^ { self.connection = [NSURLConnectionconnectionWithRequest:request delegate:self]; }]; } |
由於複寫了start方法,所以必須要自行處理operation的state屬性狀態:isExecuting和isFinished。如果想要取消operation,需要先取消connection然後再設定正確的flag,這樣queue佇列才知道這個operation已經結束了。
1 2 3 4 5 6 7 |
- (void)cancel { [super cancel]; [self.connection cancel]; self.isFinished = YES; self.isExecuting = NO; } |
請求結束後向請求代理髮起回撥:
1 2 3 4 5 6 7 |
- (void)connectionDidFinishLoading:(NSURLConnection *)connection { self.data = self.buffer; self.buffer = nil; self.isExecuting = NO; self.isFinished = YES; } |
以上處理完畢,請參見GitHub上的樣例程式碼工程。
總而言之,我們建議按照我們上面所羅列的方式方式處理網路請求,或者直接使用AFNetworking這種第三方library。AFNetworking還提供了很多好用的uitities方法,比如說它對UIImageView做了category擴充套件,功能是根據指定URL非同步載入網路圖片資源,而且它會自動處理table view非同步載入圖片operation的取消邏輯等。
延伸閱讀:
- Concurrency Programming Guide
- NSOperation Class Reference: Concurrent vs. Non-Concurrent Operations
- Blog: synchronous vs. asynchronous NSURLConnection
- GitHub: SDWebImageDownloaderOperation.m
- Blog: Progressive image download with ImageIO
- WWDC 2012 Session 211: Building Concurrent User Interfaces on iOS
File I/O in the Background
在之前我們的Core Data多執行緒處理樣例中,提到了將一整個大檔案一次性讀入記憶體的事情,我們說這種方式適合小檔案,鑑於iOS裝置的記憶體容量,大檔案不適宜採用這種讀入方式。我們建了一個只類來解決讀入大檔案的問題,這個類只做兩件事:逐行讀取檔案,將對整個檔案的處理放到其他執行緒中去。以此來保證應用能夠同時響應使用者的其他操作。我們使用NSInputStream來達到非同步處理檔案的目的。官方文件說:“如果總是需要從頭到尾來讀/寫檔案,streams提供了非同步讀寫介面”。
大體上,逐行讀取檔案的過程是:
1.用一箇中間buffer來快取讀入的資料
2.從stream讀進一塊檔案資料
3.讀進的資料不斷堆入buffer中,對buffer所快取資料進行處理,每發現一行資料(用換行符來判斷),就把這行輸出(樣例中是輸出到button title上)。
4.繼續處理buffer中其他剩餘資料
5.重新開始執行步驟2極其之後步驟,直到stream讀取檔案完畢
其中讀檔案的Reader介面類如下:
1 2 3 4 5 |
@interface Reader : NSObject - (void)enumerateLines:(void (^)(NSString*))block completion:(void (^)())completion; - (id)initWithFileAtPath:(NSString*)path; @end |
注意,這個類不是NSOperation的子類。與URL connections類似,streams通過run loop來分發事件。因此,我們還是採用main run loop來分發事件,但是將資料處理過程移至其他operation queue去處理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
- (void)enumerateLines:(void (^)(NSString*))block completion:(void (^)())completion { if (self.queue == nil) { self.queue = [[NSOperationQueue alloc] init]; self.queue.maxConcurrentOperationCount = 1; } self.callback = block; self.completion = completion; self.inputStream = [NSInputStream inputStreamWithURL:self.fileURL]; self.inputStream.delegate = self; [self.inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [self.inputStream open]; } |
input stream通過主執行緒向代理髮送訊息,代理接受後再把資料處理任務新增到operation queue中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
- (void)stream:(NSStream*)stream handleEvent:(NSStreamEvent)eventCode { switch (eventCode) { ... case NSStreamEventHasBytesAvailable: { NSMutableData *buffer = [NSMutableData dataWithLength:4 * 1024]; NSUInteger length = [self.inputStream read:[buffer mutableBytes] maxLength:[buffer length]]; if (0 < length) { [buffer setLength:length]; __weak id weakSelf = self; [self.queue addOperationWithBlock:^{ [weakSelf processDataChunk:buffer]; }]; } break; } ... } } |
資料處理過程中會不斷的從buffer中獲取已讀入的資料。然後把這些新讀入的資料按行分開並儲存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
- (void)processDataChunk:(NSMutableData *)buffer; { if (self.remainder != nil) { [self.remainder appendData:buffer]; } else { self.remainder = buffer; } [self.remainder obj_enumerateComponentsSeparatedBy:self.delimiter usingBlock:^(NSData* component, BOOL last) { if (!last) { [self emitLineWithData:component]; } else if (0 < [component length]) { self.remainder = [component mutableCopy]; } else { self.remainder = nil; } }]; } |
就這樣,樣例工程在執行時響應事件非常迅速,記憶體的開銷也很低(測試資料顯示,不管待讀入的檔案本身有多大,堆佔用始終低於800KB)。所以,處理大檔案,還是應該採用逐塊處理的方式。
延伸閱讀:
- File System Programming Guide: Techniques for Reading and Writing Files Without File Coordinators
- StackOverflow: How to read data from NSFileHandle line by line?
結論
上面舉了幾個例子來展示如何非同步執行一些常見任務。需要強調的還是:在所涉及的所有方案中,我們都儘量採用清晰明瞭的程式碼實現,因為對於多執行緒程式設計,稍不留神就會搞出一堆麻煩來。大多數情況下,為了規避麻煩,你可能會選擇讓主執行緒打理一切活計。但是一旦出現了效能問題,建議還是儘量採用相對簡單的多執行緒處理方法來解決問題。我們樣例中提到的各種處理方式都是比較安全且不錯的選擇。總之,在main queue中接收事件或資料,在其他執行緒或佇列中做詳細的處理並且將處理結果回傳給main queue。