轉自:http://cleexiang.github.io/blog/2013/09/19/concurrent-programming-2-yi/
在本文中,我們將描述在後臺做常見的任務的最佳實踐。我們將看看怎麼併發的使用CoreData,怎麼併發繪製,並且怎麼異步地操作網絡。最後,我們將看下怎麼在保持低耗內存下異步地處理大文件。
異步編程非常容易出錯。因此本文中的所有例子將使用很簡單的方法。使用簡單的結構有助於我們我們思考代碼並保持概述。如果你以複雜的嵌套調用結束,你應該修訂一些你的設計決策。
Operation Queues vs. Grand Central Dispatch
目前,有兩種現代的併發API用於iOS和OS X:操作隊列(Operation Queues)和GCD。GCD是底層的C接口,然而操作隊列是在GCD基礎上實現的並以Objective-c的接口提供。想要在這個問題上對可用的併發API有一個綜合的概述,可以看看這篇文章:concurrency APIs and challenges。
操作隊列提供一些有用的方便特性,這是GCD不可複製的。在實際情況中,一個重要的情況就是可能會在一個隊列中取消操作,我們下面會證明。操作隊列也讓操作之間依賴性的管理變得更簡單一點,另一方面,GCD讓你有更多的控持和底層功能,這也是操作隊列所沒有的。更多詳情請參考low level concurrency APIs.
更多文章:
Core Data在後臺
在你用Core Data做一些併發的事情之前,得到一些基本的權力是恨重要的。強烈推薦閱讀蘋果的Concurrency with Core Data指南。這個文檔奠定了基本的規則,如絕不在多個線程間傳遞對象。這並僅不意味着你絕不應該在其他線程上修改一個被管理對象,而且你也決不能從它上面讀取任何屬性。要傳遞一個對象,使用對象ID,並且從關聯到其他線程的上下文中獲取對象。
用Core Data進行併發編程是很簡單的只要你堅持那些規則並且使用本文中描述的方法、
在Xcode模板裏面,Core Data的標準配置是一個運行在主線程的,擁有一個上下文管理對象的,持久化存儲管理者。在很多用例中,都是這樣的。在主線程中創建一些新的對象並且修改已經存在的對象是很方便的,也沒什麼問題。然而,如果你想操作大塊兒的數據,在後臺回話中去做是講得通的。一個典型的例子,就是將大數據集導入進Core Data。
我們的方法很簡單,並在已有的文獻中又提到:
1. 我們爲導入的任務創建一個單獨的操作。
2. 我們用同樣的一個持久化存儲協調者創建一個被管理對象上下文作爲主要的被管理對象上下文。
3. 一旦導入的回話保存了,我們就通知主要的被管理對象上下文並且合併改變。
在示例中,我們將導入柏林這個城市的交通大數據集合。在導入期間,我們顯示了一個進度條,並且我們可以取消它,如果它花的時間太長了。同時,我們顯示了一擁有迄今可用的數據的表視圖,當新數據進來的時候自動更新。示例數據集是在知識共享許可下的公開可用的,並且你可以在這裏下載。它符合General Transit Feed格式,一個開放的傳輸數據標準。
我們創建一個NSOperation的子類ImportOperation,處理導入的操作。我們複寫了要做所有事情的方法,main方法。我們在這裏使用私有隊列併發類型創建了一個單獨的被管理對象上下文。這就意味着上下文將管理自己的隊列,並且所有的在它之上的操作需要使用performBlock或者performBlockAndWait來執行。確定他們在正確的線程上執行是恨重要的。
NSManagedObjectContext* context = [[NSManagedObjectContext alloc]
initWithConcurrencyType:NSPrivateQueueConcurrencyType];
context.persistentStoreCoordinator = self.persistentStoreCoordinator;
context.undoManager = nil;
[self.context performBlockAndWait:^
{
[self import];
}];
注意我們重利用了已存在的持久存儲協調器。在現在的代碼裏,你應該用NSPrivateQueueConcurrencyType或者NSMainQueueConcurrencyType初始化被管理對象上下文。第三個併發類型NSConfinementConcurrencyType不變,用於遺留代碼,我們的建議是不再使用它。
爲了導入數據,我們迭代文件中的每一行,並且爲可以解析的每一行創建一個被管理對象。
[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];
}];
我們在試圖控制器裏面執行以下代碼開始這個操作:
ImportOperation* operation = [[ImportOperation alloc]
initWithStore:self.store fileName:fileName];
[self.operationQueue addOperation:operation];
爲了後臺導入,你不得不做這些。現在,我們將添加對取消的支持,並且幸運的是,在枚舉塊中添加檢查就那麼簡單:
if(self.isCancelled) {
*shouldStop = YES;
return;
}
最後,爲了支持進度提示,我們在操作裏面創建了一個progressCallback的屬性。至關重要的是我們要在主線程中更新進度條,否則UIKit會崩潰。
operation.progressCallback = ^(float progress)
{
[[NSOperationQueue mainQueue] addOperationWithBlock:^
{
self.progressIndicator.progress = progress;
}];
};
我們在枚舉塊中添加以下行來調用進程塊:
self.progressCallback(idx / (float) count);
然而,如果你運行這個代碼,你會發現一切都極大的減緩了。看起來好像操作不能立即取消。這樣的原因是主操作隊列填滿了想要更新進度條的塊。一個簡單的解決辦法是減少更新的粒度,即我們只調用導入的1%的進度回調:
NSInteger progressGranularity = lines.count / 100;
if (idx % progressGranularity == 0) {
self.progressCallback(idx / (float) count);
}
更新主會話
我們app裏面的表視圖在主線程上被一個獲取結果的控制器所支持。在導入數據的期間和之後,我們在表視圖裏面顯示導入的數據。
做這個事情缺失的一塊是;導入到後臺回話的數據不會傳送到主會話除非我們明確地告訴它這樣做。我們添加以下幾行到Store的init方法中,我們在這裏設置Core Data堆棧。
[[NSNotificationCenter defaultCenter]
addObserverForName:NSManagedObjectContextDidSaveNotification
object:nil
queue:nil
usingBlock:^(NSNotification* note)
{
NSManagedObjectContext *moc = self.mainManagedObjectContext;
if (note.object != moc)
[moc performBlock:^(){
[moc mergeChangesFromContextDidSaveNotification:note];
}];
}];
}];
注意在主線程中塊作爲一個參數傳遞,將在主線程中被調用。如果你啓動app,你將注意到在導入完成之後表視圖重新加載了它的數據。然而,這樣阻塞了用戶界面幾秒鐘。
要解決這個問題,我們需要這樣去做:分批保存。當做大數據導入時,要確定你是定期地保存,否則你可能耗盡內存,並且性能變得很差。此外,定期保存分散了在主線程上更新表視圖的超時。
你多久保存一次是一個重要的試錯。保存的太頻繁,你將花很多時間在I/O上。保存間隔太長,app會變得遲鈍。在嘗試了一些不同的數目之後,我們設置批量大小爲250。現在導入數據很平滑,並且更新表視圖也不阻塞主會話太長時間。
其他注意事項
在導入操作中,我們將整個文件讀取到一個字符串,然後分割成多行。相對較小的文件工作正常,但是對於較大的文件,逐行讀取文件看起來很緩慢的。正確的做法是本文中最後使用輸入流的例子。Dave DeLong在write-up on StackOverflow中也很好的展示了怎麼去做。
取代在app啓動時傳入大數據集到Core Data,你也可以在你的app bundle裏面放置一個sqlite文件,或者從服務器下載它,這樣你可以動態生成它。如果你特殊的用例用到這個解決方案,在設備上運行的更快,並且節約處理時間,
最後,這期間有很多關於子會話的問題。我們的建議是別用他們做後臺操作。如果你創建一個後臺會話作爲主會話的子會話,在後臺會話保存仍將阻塞主線程。如果你創建一個主會話作爲一個後臺會話的子會話,你事實上沒有增加什麼比起傳統的設置兩個獨立的會話,因爲你仍不得不手動地合併從後臺到主會話的改變。
在後臺操作核心數據,通過用一個持久存儲協調者和兩個獨立的會話設置是行之有效的方法。堅持這麼做,除非你有更好的理由不這麼做。
更多文章:
- 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代碼
首先:UIKit只工作於主線程。據說,有一些UI相關的代碼不直接關聯於UIKit,而且UIKit會消耗大量的時間,這些任務可以交給後臺而不會阻塞主線程太久。但在你開始移動UI代碼到後臺隊列之前,衡量那部分代碼真正需要這麼做是非常重要的,否則你可能優化了錯誤的代碼。
如果你已經鑑定一個耗時的操作是可以隔離的,就把它放到一個操作隊列裏:
__weak id weakSelf = self;
[self.operationQueue addOperationWithBlock:^{
NSNumber* result = findLargestMersennePrime();
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
MyClass* strongSelf = weakSelf;
strongSelf.textLabel.text = [result stringValue];
}];
}];
就像你看到的,這不是一件容易完成的事情;我們需要創建一個自己弱引用,否則我們創建一個retain循環(塊retain自己,私有操作隊列又retain了塊,並且自己retain了操作隊列)。在塊裏面我們又把它轉換爲了強引用,以確保在運行塊的時候不被銷燬。
在後臺繪製
如果你的測量值顯示drawRect:是性能瓶頸,你可以將繪製代碼移動到後臺。在你這樣做之前,檢查是否有其他的方式來實現相同的效果,如通過使用core animation層或者預渲染圖像而不是簡單的Core Graphics繪製。看看Florian關於在當前設備商圖形性能測量值的鏈接,或者一個叫Andy Matuschak的UIKit工程師的評論,爲了獲得更好的體驗在所有涉及到的微妙之處。
如果你決定你的最好的選擇是在後臺執行繪製代碼,這個解決辦法非常的簡單。把代碼放到你的drawRect:方法裏面並把它放到一個操作裏。用一張圖片替換原始視圖,一旦操作完成立即更新。在你的繪製代碼裏,使用UIGraphicsBeginImageContextWithOptions代替UIGraphicsGetCurrentContext。
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// drawing code here
UIImage *i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return i;
通過傳0作爲第三個參數,設備主屏幕的比例將被自動填充,並且圖像在retina或者非retina設備上看起來都很不錯。
如果你在表視圖或者集合視圖單元格里面自定義繪製,把它們都放到操作的子類裏去是講得通的。你可以將他們添加到後臺操作隊列裏面,並且當用戶將單元格從邊緣滑出的時候,在didEndDisplayingCell代理方法裏面取消他們。所有對這些細節的描述在https://developer.apple.com/videos/wwdc/2012/。
代替在後臺調度繪製代碼,你也應該試試CALayer的drawsAsynchronously屬性。然後確報測量這樣的的影響。有時它可以提升速度,而有時是事與願違的。
異步網絡
所有你的異步操作都應該以異步的方式完成。然而,使用GCD,你的代碼有時候看起來像這樣:
// 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秒超時)。
如果隊列是串行的隊列,它將一直阻塞。如果是併發的隊列,爲了正阻塞的線程,GCD不得不加速一個新線程。這兩種情況下都不好。所以最好完全避免阻塞。
爲了改進這種情況,我們將使用NSURLConnection的異步方法並且把所有事情封裝到一個operation對象裏。這樣我們就得到了操作隊列強大的力量和方便性;我們可以方便地控制併發操作的數量,依賴性和取消操作。
然而,需要小心這樣做:URL連接傳遞事件到run loop裏面。最簡單的是在main run loop做這件事,這樣數據傳遞不會花費太多時間。那時我們能分配輸入數據的處理到後臺線程上。
另一個可能的方法是像AFNetworking庫那樣:創建一個拆分的線程,設置一個run loop在這個線程上,並且調度url連接。但是你可能自己去做這件事。
啓動URL連接,我們在自定義的操作子類裏複寫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。要取消一個操作,我們需要取消網絡連接並設置正確的標誌,讓操作隊列知道操作完成了。
- (void)cancel
{
[super cancel];
[self.connection cancel];
self.isFinished = YES;
self.isExecuting = NO;
}
當網絡連接完成加載,它會調用一個回調代理方法:
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
self.data = self.buffer;
self.buffer = nil;
self.isExecuting = NO;
self.isFinished = YES;
}
這就是所有的了,從Github獲取所有源代碼。最戶,我們建議你要麼花時間自己去做這些事,要麼使用像AFNetworking的庫。他們提供了一些便利的工具像UIImageView的分類可以異步的從URL加載一個圖片。在你的表視圖代碼裏使用將自動注意取消加載圖片的操作。
更多文章:
- 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
高級:後臺文件輸入/輸出
在我們的後臺core data例子裏,我們讀取整個文件導入到內存。這對較小的文件可以,但是對較大的文件是不可行的,因爲在iOS設備裏內存是有限制的。爲了解決這個問題,我們將創建一個類來做兩件事:逐行讀取文件到內存而非整個文件,並在後臺隊列處理文件以至於app是保持響應的。
爲了這個目標我們使用了NSInputStream,可以讓我們對文件進行異步地處理。就像文檔裏說的:“如果你總是讀或寫文件從開始到完成,流提供了一個簡單的接口去異步做這件事。”。
無論你是否用流,一般逐行讀取的模式是下面這樣的:
- 在還沒有找到下一行時,用一箇中間緩衝流存起來。
- 從文件流裏讀取一整塊。
- 在塊中找到的每新的一行,使用中間緩衝流,從文件流裏往新的一行上追加數據,並且輸出。
- 追加中間緩衝流裏剩下的字節。
- 返回第2步直到文件流關閉。
將這一想法付諸實踐,我們創建了一個簡單的示例應用,用一個Reader類做這件事情。接口非常的簡單:
@interface Reader : NSObject
- (void)enumerateLines:(void (^)(NSString*))block
completion:(void (^)())completion;
- (id)initWithFileAtPath:(NSString*)path;
@end
注意這不是NSOperation的子類。像URL鏈接一樣,輸入流用run loop傳遞它們的事件。因此,我們也將使用main run loop 傳遞事件,然後把數據的處理分配到一個後臺操作隊列。
- (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];
}
現在輸入流將向我們發送代理信息(在主線程上),並且我們通過添加一個block operation在操作隊列上做處理。
- (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;
}
...
}
}
處理數據塊看着當前緩衝的數據並且追加新的數據流塊。那時以逐行分成很多部分,發出每一行。又把剩下的存起來:
- (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;
}
}];
}
如果你運行示例應用,你將看到app保持高響應度,並且內存消耗很低(在我們測試運行中,不管文件多大,堆大小保持在800KB以下)。一塊一塊的處理大文件,這種技術可能是你想要的。
更多閱讀:
- 文件系統編程指南:Techniques for Reading and Writing Files Without File Coordinators
- StackOverflow: How to read data from NSFileHandle line by line?
總結
在以上的例子中,我們闡明瞭怎樣在後臺異步的執行公共的任務。在所有這些解決方案中,我們力圖讓我們的代碼保持簡單,因爲併發編程很容易不注意就犯錯。
有時你也許能在主線程上僥倖完成你的工作,它會是很簡單的事情。但是如果你發現性能瓶頸了,儘可能用最簡單的方法把這些任務放到後臺去。
我們在上面的例子中展示的模式對其他任務來說也是一個安全的選擇。在主線程接收事件或者數據,在向主線程傳遞結果之前使用後臺操作隊列執行實際的任務。