本文主要探討一些常用后臺任務的最佳實踐。我們將會看看如何并發(fā)地使用 Core Data ,如何并行繪制 UI ,如何做異步網(wǎng)絡請求等。最后我們將研究如何異步處理大型文件,以保持較低的內(nèi)存占用。因為在異步編程中非常容易犯錯誤,所以,本文中的例子都將使用很簡單的方式。因為使用簡單的結構可以幫助我們看透代碼,抓住問題本質(zhì)。如果你最后把代碼寫成了復雜的嵌套回調(diào)的話,那么你很可能應該重新考慮自己當初的設計選擇了。
目前在 iOS 和 OS X 中有兩套先進的同步 API 可供我們使用:操作隊列和 GCD 。其中 GCD 是基于 C 的底層的 API ,而操作隊列則是 GCD 實現(xiàn)的 Objective-C API。關于我們可以使用的并行 API 的更加全面的總覽,可以參見 并發(fā)編程:API 及挑戰(zhàn)。
操作隊列提供了在 GCD 中不那么容易復制的有用特性。其中最重要的一個就是可以取消在任務處理隊列中的任務,在稍后的例子中我們會看到這個。而且操作隊列在管理操作間的依賴關系方面也容易一些。另一面,GCD 給予你更多的控制權力以及操作隊列中所不能使用的底層函數(shù)。詳細介紹可以參考底層并發(fā) API 這篇文章。
擴展閱讀:
March 2015 更新: 這篇文章是基于已經(jīng)過時了的 Concurrency with Core Data 來編寫的。
在著手 Core Data 的并行處理之前,最好先打一些基礎。我們強烈建議通讀蘋果的官方文檔 Concurrency with Core Data 。這個文檔中羅列了基本規(guī)則,比如絕對不要在線程間傳遞 managed objects等。這并不單是說你絕不應該在另一個線程中去更改某個其他線程的 managed object ,甚至是讀取其中的屬性都是不能做的。要想傳遞這樣的對象,正確做法是通過傳遞它的 object ID ,然后從其他對應線程所綁定的 context 中去獲取這個對象。
其實只要你遵循那些規(guī)則,并使用這篇文章里所描述的方法的話,處理 Core Data 的并行編程還是比較容易的。
Xcode 所提供的 Core Data 標準模版中,所設立的是運行在主線程中的一個存儲調(diào)度 (persistent store coordinator)和一個托管對象上下文 (managed object context) 的方式。在很多情況下,這種模式可以運行良好。創(chuàng)建新的對象和修改已存在的對象開銷都非常小,也都能在主線程中沒有困難地完成。然后,如果你想要做大量的處理,那么把它放到一個后臺上下文來做會比較好。一個典型的應用場景是將大量數(shù)據(jù)導入到 Core Data 中。
我們的方式非常簡單,并且可以被很好地描述:
在示例app中,我們要導入一大組柏林的交通數(shù)據(jù)。在導入的過程中,我們展示一個進度條,如果耗時太長,我們希望可以取消當前的導入操作。同時,我們顯示一個隨著數(shù)據(jù)加入可以自動更新的 table view 來展示目前可用的數(shù)據(jù)。示例用到的數(shù)據(jù)是采用的 Creative Commons license 公開的,你可以在此下載它們。這些數(shù)據(jù)遵守一個叫做 General Transit Feed 格式的交通數(shù)據(jù)公開標準。
我們創(chuàng)建一個 NSOperation
的子類,將其叫做 ImportOperation
,我們通過重寫 main
方法,用來處理所有的導入工作。這里我們使用 NSPrivateQueueConcurrencyType
來創(chuàng)建一個獨立并擁有自己的私有 dispatch queue 的 managed object context,這個 context 需要管理自己的隊列。在隊列中的所有操作必須使用 performBlock
或者 performBlockAndWait
來進行觸發(fā)。這點對于保證這些操作能在正確的線程上執(zhí)行是相當重要的。
NSManagedObjectContext* context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType];
context.persistentStoreCoordinator = self.persistentStoreCoordinator;
context.undoManager = nil;
[self.context performBlockAndWait:^
{
[self import];
}];
在這里我們重用了已經(jīng)存在的 persistent store coordinator 。一般來說,初始化 managed object contexts 要么使用 NSPrivateQueueConcurrencyType
,要么使用 NSMainQueueConcurrencyType
。第三種并發(fā)類型 NSConfinementConcurrencyType
是為老舊代碼準備的,我們不建議再使用它了。
在導入前,我們枚舉文件中的各行,并對可以解析的每一行創(chuàng)建 managed object :
[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 中通過以下代碼來開始操作:
ImportOperation* operation = [[ImportOperation alloc]
initWithStore:self.store fileName:fileName];
[self.operationQueue addOperation:operation];
至此為止,后臺導入部分已經(jīng)完成。接下來,我們要加入取消功能,這其實非常簡單,只需要枚舉的 block 中加一個判斷就行了:
if(self.isCancelled) {
*shouldStop = YES;
return;
}
最后為了支持進度條,我們在 operation 中創(chuàng)建一個叫做 progressCallback
的屬性。需要注意的是,更新進度條必須在主線程中完成,否則會導致 UIKit 崩潰。
operation.progressCallback = ^(float progress)
{
[[NSOperationQueue mainQueue] addOperationWithBlock:^
{
self.progressIndicator.progress = progress;
}];
};
我們在枚舉中來調(diào)用這個進度條更新的 block 的操作:
self.progressCallback(idx / (float) count);
然而,如果你執(zhí)行示例代碼的話,你會發(fā)現(xiàn)它運行逐漸變得很慢,取消操作也有遲滯。這是因為主操作隊列中塞滿了要更新進度條的 block 操作。一個簡單的解決方法是降低更新的頻度,比如只在每導入一百行時更新一次:
NSInteger progressGranularity = 100;
if (idx % progressGranularity == 0) {
self.progressCallback(idx / (float) count);
}
在 app 中的 table view 是由一個在主線程上獲取了結果的 controller 所驅(qū)動的。在導入數(shù)據(jù)的過程中和導入數(shù)據(jù)完成后,我們要在 table view 中展示我們的結果。
在讓一切運轉起來之前之前,還有一件事情要做?,F(xiàn)在在后臺 context 中導入的數(shù)據(jù)還不能傳送到主 context 中,除非我們顯式地讓它這么去做。我們在 Store
類的設置 Core Data stack 的 init
方法中加入下面的代碼:
[[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 在主隊列中被作為參數(shù)傳遞的話,那么這個 block 也會在主隊列中被執(zhí)行。如果現(xiàn)在你運行程序的話,你會注意到 table view 會在完成導入數(shù)據(jù)后刷新數(shù)據(jù),但是這個行為會阻塞用戶大概幾秒鐘。
要修正這個問題,我們需要做一些無論如何都應該做的事情:批量保存。在導入較大的數(shù)據(jù)時,我們需要定期保存,逐漸導入,否則內(nèi)存很可能就會被耗光,性能一般也會更壞。而且,定期保存也可以分散主線程在更新 table view 時的工作壓力。
合理的保存的次數(shù)可以通過試錯得到。保存太頻繁的話,可能會在 I/O 操作上花太多時間;保存次數(shù)太少的話,應用會變得無響應。在經(jīng)過一些嘗試后,我們設定每 250 次導入就保存一次。改進后,導入過程變得很平滑,它可以適時更新 table view,也沒有阻塞主 context 太久。
在導入操作時,我們將整個文件都讀入到一個字符串中,然后將其分割成行。這種處理方式對于相對小的文件來說沒有問題,但是對于大文件,最好采用惰性讀取 (lazily read) 的方式逐行讀入。本文最后的示例將使用輸入流的方式來實現(xiàn)這個特性,在 StackOverflow 上 Dave DeLong 也提供了一段非常好的示例代碼來說明這個問題。
在 app 第一次運行時,除開將大量數(shù)據(jù)導入 Core Data 這一選擇以外,你也可以在你的 app bundle 中直接放一個 sqlite 文件,或者從一個可以動態(tài)生成數(shù)據(jù)的服務器下載。如果使用這些方式的話,可以節(jié)省不少在設備上的處理時間。
最后,最近對于 child contexts 有很多爭議。我們的建議是不要在后臺操作中使用它。如果你以主 context 的 child 的方式創(chuàng)建了一個后臺 context 的話,保存這個后臺 context 將阻塞主線程。而要是將主 context 作為后臺 context 的 child 的話,實際上和與創(chuàng)建兩個傳統(tǒng)的獨立 contexts 來說是沒有區(qū)別的。因為你仍然需要手動將后臺的改變合并回主 context 中去。
設置一個 persistent store coordinator 和兩個獨立的 contexts 被證明了是在后臺處理 Core Data 的好方法。除非你有足夠好的理由,否則在處理時你應該堅持使用這種方式。
擴展閱讀:
首先要強調(diào):UIKit 只能在主線程上運行。而那部分不與 UIKit 直接相關,卻會消耗大量時間的 UI 代碼可以被移動到后臺去處理,以避免其將主線程阻塞太久。但是在你將你的 UI 代碼移到后臺隊列之前,你應該好好地測量哪一部分才是你代碼中的瓶頸。這非常重要,否則你所做的優(yōu)化根本是南轅北轍。
如果你找到了你能夠隔離出的昂貴操作的話,可以將其放到操作隊列中去:
__weak id weakSelf = self;
[self.operationQueue addOperationWithBlock:^{
NSNumber* result = findLargestMersennePrime();
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
MyClass* strongSelf = weakSelf;
strongSelf.textLabel.text = [result stringValue];
}];
}];
如你所見,這些代碼其實一點也不直接明了。我們首先聲明了一個 weak 引用來參照 self,否則會形成循環(huán)引用( block 持有了 self,私有的 operationQueue
retain 了 block,而 self 又 retain 了 operationQueue
)。為了避免在運行 block 時訪問到已被釋放的對象,在 block 中我們又需要將其轉回 strong 引用。
編者注 這在 ARC 和 block 主導的編程范式中是解決 retain cycle 的一種常見也是最標準的方法。
如果你確定 drawRect:
是你的應用的性能瓶頸,那么你可以將這些繪制代碼放到后臺去做。但是在你這樣做之前,檢查下看看是不是有其他方法來解決,比如、考慮使用 core animation layers 或者預先渲染圖片而不去做 Core Graphics 繪制??梢钥纯?Florian 對在真機上圖像性能測量的帖子,或者可以看看來自 UIKit 工程師 Andy Matuschak 對個各種方式的權衡的評論。
如果你確實認為在后臺執(zhí)行繪制代碼會是你的最好選擇時再這么做。其實解決起來也很簡單,把 drawRect:
中的代碼放到一個后臺操作中去做就可以了。然后將原本打算繪制的視圖用一個 image view 來替換,等到操作執(zhí)行完后再去更新。在繪制的方法中,使用 UIGraphicsBeginImageContextWithOptions
來取代 UIGraphicsGetCurrentContext
:
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// drawing code here
UIImage *i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return i;
通過在第三個參數(shù)中傳入 0 ,設備的主屏幕的 scale 將被自動傳入,這將使圖片在普通設備和 retina 屏幕上都有良好的表現(xiàn)。
如果你在 table view 或者是 collection view 的 cell 上做了自定義繪制的話,最好將它們放入 operation 的子類中去。你可以將它們添加到后臺操作隊列,也可以在用戶將 cell 滾動出邊界時的 didEndDisplayingCell
委托方法中進行取消。這些技巧都在 2012 年的WWDC Session 211 -- Building Concurrent User Interfaces on iOS中有詳細闡述。
除了在后臺自己調(diào)度繪制代碼,以也可以試試看使用 CALayer
的 drawsAsynchronously
屬性。然而你需要精心衡量這樣做的效果,因為有時候它能使繪制加速,有時候卻適得其反。
你的所有網(wǎng)絡請求都應該采取異步的方式完成。
然而,在 GCD 下,有時候你可能會看到這樣的代碼
// 警告:不要使用這些代碼。
dispatch_async(backgroundQueue, ^{
NSData* contents = [NSData dataWithContentsOfURL:url]
dispatch_async(dispatch_get_main_queue(), ^{
// 處理取到的日期
});
});
乍看起來沒什么問題,但是這段代碼卻有致命缺陷。你沒有辦法去取消這個同步的網(wǎng)絡請求。它將阻塞住線程直到它完成。如果請求一直沒結果,那就只能干等到超時(比如 dataWithContentsOfURL:
的超時時間是 30 秒)。
如果隊列是串行執(zhí)行的話,它將一直被阻塞住。假如隊列是并行執(zhí)行的話,GCD 需要重開一個線程來補湊你阻塞住的線程。兩種結果都不太妙,所以最好還是不要阻塞線程。
要解決上面的困境,我們可以使用 NSURLConnection
的異步方法,并且把所有操作轉化為 operation 來執(zhí)行。通過這種方法,我們可以從操作隊列的強大功能和便利中獲益良多:我們能輕易地控制并發(fā)操作的數(shù)量,添加依賴,以及取消操作。
然而,在這里還有一些事情值得注意: NSURLConnection
是通過 run loop 來發(fā)送事件的。因為時間發(fā)送不會花多少時間,因此最簡單的是就只使用 main run loop 來做這個。然后,我們就可以用后臺線程來處理輸入的數(shù)據(jù)了。
另一種可能的方式是使用像 AFNetworking 這樣的框架:建立一個獨立的線程,為建立的線程設置自己的 run loop,然后在其中調(diào)度 URL 連接。但是并不推薦你自己去實現(xiàn)這些事情。
要處理URL 連接,我們重寫自定義的 operation 子類中的 start
方法:
- (void)start
{
NSURLRequest* request = [NSURLRequest requestWithURL:self.url];
self.isExecuting = YES;
self.isFinished = NO;
[[NSOperationQueue mainQueue] addOperationWithBlock:^
{
self.connection = [NSURLConnectionconnectionWithRequest:request
delegate:self];
}];
}
由于重寫的是 start
方法,所以我們需要自己要管理操作的 isExecuting
和 isFinished
狀態(tài)。要取消一個操作,我們需要取消 connection ,并且設定合適的標記,這樣操作隊列才知道操作已經(jīng)完成。
- (void)cancel
{
[super cancel];
[self.connection cancel];
self.isFinished = YES;
self.isExecuting = NO;
}
當連接完成加載后,它向代理發(fā)送回調(diào):
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
self.data = self.buffer;
self.buffer = nil;
self.isExecuting = NO;
self.isFinished = YES;
}
就這么多了。完整的代碼可以參見GitHub上的示例工程。
總結來說,我們建議要么你花時間來把事情做對做好,要么就直接使用像 AFNetworking 這樣的框架。其實 AFNetworking 還提供了不少好用的小工具,比如有個 UIImageView
的 category,來負責異步地從一個 URL 加載圖片。在你的 table view 里使用的話,還能自動幫你處理取消加載操作,非常方便。
擴展閱讀:
SDWebImageDownloaderOperation.m
在之前我們的后臺 Core Data 示例中,我們將一整個文件加載到了內(nèi)存中。這種方式對于較小的文件沒有問題,但是受限于 iOS 設備的內(nèi)存容量,對于大文件來說的話就不那么友好了。要解決這個問題,我們將構建一個類,它負責一行一行讀取文件而不是一次將整個文件讀入內(nèi)存,另外要在后臺隊列處理文件,以保持應用相應用戶的操作。
為了達到這個目的,我們使用能讓我們異步處理文件的 NSInputStream
。根據(jù)官方文檔的描述:
如果你總是需要從頭到尾來讀/寫文件的話,streams 提供了一個簡單的接口來異步完成這個操作
不管你是否使用 streams,大體上逐行讀取一個文件的模式是這樣的:
為了將其運用到實踐中,我們又建立了一個示例應用,里面有一個 Reader
類完成了這件事情,它的接口十分簡單
@interface Reader : NSObject
- (void)enumerateLines:(void (^)(NSString*))block
completion:(void (^)())completion;
- (id)initWithFileAtPath:(NSString*)path;
@end
注意,這個類不是 NSOperation 的子類。與 URL connections 類似,輸入的 streams 通過 run loop 來傳遞它的事件。這里,我們?nèi)匀徊捎?main run loop 來分發(fā)事件,然后將數(shù)據(jù)處理過程派發(fā)至后臺操作線程里去處理。
- (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];
}
現(xiàn)在,input stream 將(在主線程)向我們發(fā)送代理消息,然后我們可以在操作隊列中加入一個 block 操作來執(zhí)行處理了:
- (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;
}
...
}
}
處理數(shù)據(jù)塊的過程是先查看當前已緩沖的數(shù)據(jù),并將新加入的數(shù)據(jù)附加上去。接下來它將按照換行符分解成小的部分,并處理每一行。
數(shù)據(jù)處理過程中會不斷的從buffer中獲取已讀入的數(shù)據(jù)。然后把這些新讀入的數(shù)據(jù)按行分開并存儲。剩余的數(shù)據(jù)被再次存儲到緩沖區(qū)中:
- (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;
}
}];
}
現(xiàn)在你運行示例應用的話,會發(fā)現(xiàn)它在響應事件時非常迅速,內(nèi)存的開銷也保持很低(在我們測試時,不論讀入的文件有多大,堆所占用的內(nèi)存量始終低于 800KB)。絕大部分時候,使用逐塊讀入的方式來處理大文件,是非常有用的技術。
延伸閱讀:
通過我們所列舉的幾個示例,我們展示了如何異步地在后臺執(zhí)行一些常見任務。在所有的解決方案中,我們盡力保持了代碼的簡單,這是因為在并發(fā)編程中,稍不留神就會捅出簍子來。
很多時候為了避免麻煩,你可能更愿意在主線程中完成你的工作,在你能這么做事,這確實讓你的工作輕松不少,但是當你發(fā)現(xiàn)性能瓶頸時,你可以嘗試盡可能用最簡單的策略將那些繁重任務放到后臺去做。
我們在上面例子中所展示的方法對于其他任務來說也是安全的選擇。在主隊列中接收事件或者數(shù)據(jù),然后用后臺操作隊列來執(zhí)行實際操作,然后回到主隊列去傳遞結果,遵循這樣的原則來編寫盡量簡單的并行代碼,將是保證高效正確的不二法則。