iOS開發中一些常見的並行處理(轉)

本文主要探討一些經常使用多任務的最佳實踐。包括Core Data的多線程訪問,UI的並行繪製,異步網絡請求以及一些在運行態內存吃緊的狀況下處理大文件的方案等。

其實編寫異步處理的程序有不少坑!因此,本文所涉及的樣例都儘可能採用簡潔直觀的處理方式。由於越是簡單的邏輯結構,越能彰顯代碼的脈絡清晰,越易於理解。打個比方,若是在程序中使用多層次的嵌套回調,基本上這個它會有很大的重構空間。html

 

Operation Queues vs. Grand Central Dispatch

目前,在iOS和OS X 中,SDK主要提供了兩類多任務處理的API:operation queuesGrand Central Dispatch(GCD)。其中GCD是基於C的更加底層的API,而operation queues被廣泛認爲是基於GCD而封裝的面向對象(objective-c)的多任務處理API。關於併發處理API層面的比較,有不少相關的文章,若是感興趣能夠自行閱讀。ios

相比於GCD,operation queues的優勢是:提供了一些很是好用的便捷處理。其中最重要的一個就是能夠取消在任務處理隊列中的任務(稍後舉例)。另外operation queues在處理任務之間的依賴關係方面也更加容易。而GCD的特長是:能夠訪問和操做那些operation queues所不能使用的低層函數。詳情參考低層併發處理API相關文章git

延伸閱讀:github

 

Core Data in the Background

在着手Core Data的多線程處理以前,咱們建議先通讀一下蘋果的官方文檔」Concurrency with Core Data guide」。這個文檔中羅列了諸多規則,好比:不要在不一樣線程間直接傳遞managed objects。注意這意味着線程間不但不能對不屬於本身的managed object作修改操做,甚至連讀其中的屬性都不能夠。正確作法是經過傳object ID和從其餘線程的context信息中獲取object的方式來達到傳遞object的效果。其實只要遵循文檔中的各類指導規則,那麼處理 Core Data的並行編程問題就容易多了。objective-c

Xcode提供了一種建立Core Data的模版,工做原理是經過主線程做爲persistent store coordinator(持久化協調者)來操做managed object context,進而實現對象的持久化。雖然這種方式很便捷並基本適用常規場景,但若是要操做的數據比較龐大,那就很是有必要將Core Data的操做分配到其餘線程中去(注:大數據量的操做可能會阻塞主線程,長時間阻塞主線程用戶體驗不好而且有可能致使應用程序假死或崩潰)。sql

樣例:向Core Data中導入大量的數據:shell

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格式。api

接下來建立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多線程操做的合理方式。

延伸閱讀:

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的取消邏輯等。

延伸閱讀:

 

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)。因此,處理大文件,仍是應該採用逐塊處理的方式。

延伸閱讀:

 

結論

上面舉了幾個例子來展現如何異步執行一些常見任務。須要強調的仍是:在所涉及的全部方案中,咱們都儘可能採用清晰明瞭的代碼實現,由於對於多線程編程,稍不留神就會搞出一堆麻煩來。大多數狀況下,爲了規避麻煩,你可能會選擇讓主線程打理一切活計。可是一旦出現了性能問題,建議仍是儘可能採用相對簡單的多線程處理方法來解決問題。咱們樣例中提到的各類處理方式都是比較安全且不錯的選擇。總之,在main queue中接收事件或數據,在其餘線程或隊列中作詳細的處理而且將處理結果回傳給main queue。

 

 



原文連接: Chris Eidhof   翻譯: 伯樂在線 sunset
譯文連接: http://blog.jobbole.com/52557/
轉載必須在正文中標註並保留原文連接、譯文連接和譯者等信息。]

相關文章
相關標籤/搜索