IOS 多線程05-OperationQueue 、GCD詳解

 

  注:本人是翻譯過來,而且加上本人的一點看法。html

 

1. 開始 ios

    目前在 iOS中有兩套先進的同步 API 可供咱們使用:操做隊列OperationQueueGCD 。其中 GCD 是基於 C 的底層的 API ,而操做隊列OperationQueue則是 GCD 實現的 Objective-C APIgit

    OperationQueue供了在 GCD 中不那麼容易複製的有用特性。其中最重要的一個就是能夠取消在任務處理隊列中的任務,在稍後的例子中咱們會看到這個。並且OperationQueue在管理操做間的依賴關係方面也容易一些。另外一面,GCD 給予你更多的控制權力以及操做隊列中所不能使用的底層函數。github

 

擴展閱讀:shell

 

2. 後臺的 Core Data編程

    在着手 Core Data 的並行處理以前,最好先打一些基礎。咱們強烈建議通讀蘋果的官方文檔Concurrency with Core Data 。這個文檔中羅列了基本規則,好比絕對不要在線程間傳遞 managed objects等。這並不單是說你毫不應該在另外一個線程中去更改某個其餘線程的 managed object ,甚至是讀取其中的屬性都是不能作的。要想傳遞這樣的對象,正確作法是經過傳遞它的 object ID ,而後從其餘對應線程所綁定的 context 中去獲取這個對象。安全

    其實只要你遵循那些規則,並使用這篇文章裏所描述的方法的話,處理 Core Data 的並行編程仍是比較容易的。網絡

    Xcode 所提供的 Core Data 標準模版中,所設立的是運行在主線程中的一個存儲調度 (persistent store coordinator)和一個託管對象上下文 (managed object context) 的方式。在不少狀況下,這種模式能夠運行良好。建立新的對象和修改已存在的對象開銷都很是小,也都能在主線程中沒有困難地完成。而後,若是你想要作大量的處理,那麼把它放到一個後臺上下文來作會比較好。一個典型的應用場景是將大量數據導入到 Core Data 中。併發

    咱們的方式很是簡單,而且能夠被很好地描述:app

1)咱們爲導入工做單首創建一個操做

2)咱們建立一個 managed object context ,它和主 managed object context 使用一樣的 persistent store coordinator

3)一旦導入 context 保存了,咱們就通知 主 managed object context 而且合併這些改變

讀者能夠從這裏下載實例app https://github.com/cjt321/QueueTransitDataImport

    在示例app中,咱們要導入一大組交通方面的數據。在導入的過程當中,咱們展現一個進度條,若是耗時太長,咱們但願能夠取消當前的導入操做。同時,咱們顯示一個隨着數據加入能夠自動更新的 table view 來展現目前可用的數據。

    咱們建立一個 NSOperation 的子類,將其叫作 ImportOperation,咱們經過重寫 main 方法,用來處理全部的導入工做。

    在代碼中,咱們看到判斷若是用戶中斷操做,則return,不作回調和更新的操做。睡眠5秒鐘爲了演示此程序加載數據時間長。

/*
咱們經過重寫 main 方法,用來處理全部的導入工做.
 */
-(void)main {
    NSLog(@"--ImportOperation main--");
    NSLog(@"處理導入數據開始");
    sleep(5);
    [SingletonData shareInstance];
    NSLog(@"處理導入數據結束");
    
    if(self.isCancelled) {
        NSLog(@"取消運行Operation");
        return;
    }
    
    NSLog(@"call processCallBack");
    self.processCallBack(1);
}

 

    在 view controller 中經過如下代碼來初始化OperationQueue:

_operationQueue = [[NSOperationQueue alloc] init];
  

    而後導入與取消操做

#pragma mark 導入數據
- (IBAction)import:(id)sender {
    ImportOperation *importOperation = [[ImportOperation alloc] init];
    
    __weak UITableView *tv = self.tableview;
    importOperation.processCallBack = ^(float f) {
        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            _data = [SingletonData shareInstance].stopsData;
            __strong UITableView *tvs = tv;
            [tvs reloadData];
            NSLog(@"刷新數據");
        }];
    };
    [self.operationQueue addOperation: importOperation];
}

#pragma mark 取消導入
- (IBAction)cancel:(id)sender {
    [self.operationQueue cancelAllOperations];
}

 

3. 後臺 UI 代碼

    首先要強調:UIKit 只能在主線程上運行。而那部分不與 UIKit 直接相關,卻會消耗大量時間的 UI 代碼能夠被移動到後臺去處理,以免其將主線程阻塞過久。可是在你將你的 UI 代碼移到後臺隊列以前,你應該好好地測量哪一部分纔是你代碼中的瓶頸。這很是重要,不然你所作的優化根本是南轅北轍。

    若是你找到了你可以隔離出的昂貴操做的話,能夠將其放到操做隊列中去:

- (IBAction)import:(id)sender {
    ImportOperation *importOperation = [[ImportOperation alloc] init];  
    __weak id weakSelf = self;
    importOperation.processCallBack = ^(float f) {
        _data = [SingletonData shareInstance].stopsData;
        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            ViewController *strongSelf = weakSelf;
            [strongSelf.tableview reloadData];
            NSLog(@"刷新數據");
        }];
    };
    [self.operationQueue addOperation: importOperation];
}

    如你所見,這些代碼其實一點也不直接明瞭。咱們首先聲明瞭一個 weak 引用來參照 self,不然會造成循環引用( block 持有了 self,私有的 operationQueue retain 了 block,而 self 又 retain 了 operationQueue )。爲了不在運行 block 時訪問到已被釋放的對象,在 block 中咱們又須要將其轉回 strong 引用。

這在 ARC 和 block 主導的編程範式中是解決 retain cycle 的一種常見也是最標準的方法。

 

4. 後臺繪製

    若是你肯定 drawRect: 是你的應用的性能瓶頸,那麼你能夠將這些繪製代碼放到後臺去作。可是在你這樣作以前,檢查下看看是否是有其餘方法來解決,好比、考慮使用 core animation layers 或者預先渲染圖片而不去作 Core Graphics 繪製。能夠看看 Florian 對在真機上圖像性能測量的帖子,或者能夠看看來自 UIKit 工程師 Andy Matuschak 對個各類方式的權衡的評論

    若是你確實認爲在後臺執行繪製代碼會是你的最好選擇時再這麼作。其實解決起來也很簡單,把 drawRect: 中的代碼放到一個後臺操做中去作就能夠了。而後將本來打算繪製的視圖用一個 image view 來替換,等到操做執行完後再去更新。在繪製的方法中,使用UIGraphicsBeginImageContextWithOptions 來取代 UIGraphicsGetCurrentContext

UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// drawing code here
UIImage *i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return i;

    經過在第三個參數中傳入 0 ,設備的主屏幕的 scale 將被自動傳入,這將使圖片在普通設備和 retina 屏幕上都有良好的表現。

    若是你在 table view 或者是 collection view 的 cell 上作了自定義繪製的話,最好將它們放入 operation 的子類中去。你能夠將它們添加到後臺操做隊列,也能夠在用戶將 cell 滾動出邊界時的 didEndDisplayingCell 委託方法中進行取消。這些技巧都在 2012 年的WWDC Session 211 -- Building Concurrent User Interfaces on iOS中有詳細闡述。

    除了在後臺本身調度繪製代碼,以也能夠試試看使用 CALayerdrawsAsynchronously 屬性。然而你須要精心衡量這樣作的效果,由於有時候它能使繪製加速,有時候卻拔苗助長。

 

5. 異步網絡請求處理

    你的全部網絡請求都應該採起異步的方式完成。

    然而,在 GCD 下,有時候你可能會看到這樣的代碼

// 警告:不要使用這些代碼。
dispatch_async(backgroundQueue, ^{
   NSData* contents = [NSData dataWithContentsOfURL:url]
   dispatch_async(dispatch_get_main_queue(), ^{
      // 處理取到的日期
   });
});

    乍看起來沒什麼問題,可是這段代碼卻有致命缺陷。你沒有辦法去取消這個同步的網絡請求。它將阻塞住線程直到它完成。若是請求一直沒結果,那就只能乾等到超時(好比 dataWithContentsOfURL: 的超時時間是 30 秒)。

    若是隊列是串行執行的話,它將一直被阻塞住。假如隊列是並行執行的話,GCD 須要重開一個線程來補湊你阻塞住的線程。兩種結果都不太妙,因此最好仍是不要阻塞線程。

    要解決上面的困境,咱們可使用 NSURLConnection 的異步方法,而且把全部操做轉化爲 operation 來執行。經過這種方法,咱們能夠從操做隊列的強大功能和便利中獲益良多:咱們能輕易地控制併發操做的數量,添加依賴,以及取消操做。

    然而,在這裏還有一些事情值得注意: NSURLConnection 是經過 run loop 來發送事件的。由於時間發送不會花多少時間,所以最簡單的是就只使用 main run loop 來作這個。而後,咱們就能夠用後臺線程來處理輸入的數據了。

    另外一種可能的方式是使用像 AFNetworking 這樣的框架:創建一個獨立的線程,爲創建的線程設置本身的 run loop,而後在其中調度 URL 鏈接。可是並不推薦你本身去實現這些事情。

    要處理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 方法,因此咱們須要本身要管理操做的 isExecutingisFinished 狀態。要取消一個操做,咱們須要取消 connection ,而且設定合適的標記,這樣操做隊列才知道操做已經完成。

- (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 這樣的框架。其實 AFNetworking 還提供了很多好用的小工具,好比有個 UIImageView 的 category,來負責異步地從一個 URL 加載圖片。在你的 table view 裏使用的話,還能自動幫你處理取消加載操做,很是方便。

擴展閱讀:

 

6. 進階:後臺文件 I/O

    在以前咱們的後臺 Core Data 示例中,咱們將一整個文件加載到了內存中。這種方式對於較小的文件沒有問題,可是受限於 iOS 設備的內存容量,對於大文件來講的話就不那麼友好了。要解決這個問題,咱們將構建一個類,它負責一行一行讀取文件而不是一次將整個文件讀入內存,另外要在後臺隊列處理文件,以保持應用相應用戶的操做。

    爲了達到這個目的,咱們使用能讓咱們異步處理文件的 NSInputStream 。根據官方文檔的描述:

若是你老是須要從頭至尾來讀/寫文件的話,streams 提供了一個簡單的接口來異步完成這個操做

    無論你是否使用 streams,大致上逐行讀取一個文件的模式是這樣的:

  1. 創建一箇中間緩衝層以提供,當沒有找到換行符號的時候能夠向其中添加數據
  2. 從 stream 中讀取一塊數據
  3. 對於這塊數據中發現的每個換行符,取中間緩衝層,向其中添加數據,直到(幷包括)這個換行符,並將其輸出
  4. 將剩餘的字節添加到中間緩衝層去
  5. 回到 2,直到 stream 關閉

    爲了將其運用到實踐中,咱們又創建了一個示例應用,裏面有一個 Reader 類完成了這件事情,它的接口十分簡單

@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 來分發事件,而後將數據處理過程派發至後臺操做線程裏去處理。

- (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 將(在主線程)向咱們發送代理消息,而後咱們能夠在操做隊列中加入一個 block 操做來執行處理了:

- (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中獲取已讀入的數據。而後把這些新讀入的數據按行分開並存儲。剩餘的數據被再次存儲到緩衝區中:

- (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)。絕大部分時候,使用逐塊讀入的方式來處理大文件,是很是有用的技術。

    延伸閱讀:

 

7. 總結

    經過咱們所列舉的幾個示例,咱們展現瞭如何異步地在後臺執行一些常見任務。在全部的解決方案中,咱們盡力保持了代碼的簡單,這是由於在併發編程中,稍不留神就會捅出簍子來。

    不少時候爲了不麻煩,你可能更願意在主線程中完成你的工做,在你能這麼作事,這確實讓你的工做輕鬆很多,可是當你發現性能瓶頸時,你能夠嘗試儘量用最簡單的策略將那些繁重任務放到後臺去作。

    咱們在上面例子中所展現的方法對於其餘任務來講也是安全的選擇。在主隊列中接收事件或者數據,而後用後臺操做隊列來執行實際操做,而後回到主隊列去傳遞結果,遵循這樣的原則來編寫儘可能簡單的並行代碼,將是保證高效正確的不二法則。

相關文章
相關標籤/搜索