iOS 多執行緒之NSOperation

QiShare發表於2019-01-10

級別: ★★☆☆☆
標籤:「iOS」「多執行緒」「NSOperation」
作者: dac_1033
審校: QiShare團隊

上一篇介紹了NSThread,本篇將介紹“iOS多執行緒之NSOperation”。

一、執行緒池

多執行緒處理任務的過程中,頻繁的建立/銷燬執行緒會很大程度上影響處理效率,新起的執行緒數過多會降低系統效能甚至引起app崩潰。在Java和C#開發過程中可以使用執行緒池來解決這些問題,執行緒池快取一些執行緒,在接到任務的時候,系統就線上程池中排程一個閒置的執行緒來處理這個任務,免去了頻繁建立/銷燬的過程。從NSOperation的使用過程就能體會到,它和執行緒池非常類似,下面我們就來介紹一下NSOperation的使用。

二、NSOperation簡介

NSOperation是一個抽象類,實際開發中需要使用其子類NSInvocationOperation、NSBlockOperation。首先建立一個NSOperationQueue,再建多個NSOperation例項(設定好要處理的任務、operation的屬性和依賴關係等),然後再將這些operation放到這個queue中,執行緒就會被依次啟動。蘋果官網對於NSOperation的介紹 NSOperation及其子類中的常用方法如下:

//// NSOperation
@property (readonly, getter=isCancelled) BOOL cancelled;
@property (readonly, getter=isExecuting) BOOL executing;
@property (readonly, getter=isFinished) BOOL finished;
@property (readonly, getter=isReady) BOOL ready;

@property NSOperationQueuePriority queuePriority;
@property (readonly, copy) NSArray<NSOperation *> *dependencies;

@property (nullable, copy) NSString *name;
@property (nullable, copy) void (^completionBlock)(void);

- (void)start;
- (void)main;
- (void)cancel;

- (void)addDependency:(NSOperation *)op;
- (void)removeDependency:(NSOperation *)op;

- (void)waitUntilFinished;
複製程式碼

下面我們依次介紹NSInvocationOperation、NSBlockOperation的使用過程,並自定義一個繼承於NSOperation的子類並實現內部相應的方法。

2.1 NSInvocationOperation

NSInvocationOperation繼承於NSOperation,NSInvocationOperation的定義如下:

@interface NSInvocationOperation : NSOperation {
@private
    id _inv;
    id _exception;
    void *_reserved2;
}

- (nullable instancetype)initWithTarget:(id)target selector:(SEL)sel object:(nullable id)arg;
- (instancetype)initWithInvocation:(NSInvocation *)inv NS_DESIGNATED_INITIALIZER;property (readonly, retain) NSInvocation *invocation;

@property (nullable, readonly, retain) id result;

@end
複製程式碼

下面使用NSInvocationOperation來載入一張圖片,示例方法如下:

- (void)loadImageWithMultiThread {
    /*建立一個呼叫操作
     object:呼叫方法引數
    */
    NSInvocationOperation *invocationOperation=[[NSInvocationOperation alloc]initWithTarget:self selector:@selector(loadImage) object:nil];
    //建立完NSInvocationOperation物件並不會呼叫,它由一個start方法啟動操作,但是注意如果直接呼叫start方法,則此操作會在主執行緒中呼叫,一般不會這麼操作,而是新增到NSOperationQueue中
//    [invocationOperation start];
    
    //建立操作佇列
    NSOperationQueue *operationQueue=[[NSOperationQueue alloc]init];
    //注意新增到操作隊後,佇列會開啟一個執行緒執行此操作
    [operationQueue addOperation:invocationOperation];
}
複製程式碼
2.2 NSBlockOperation

NSBlockOperation繼承於NSOperation,NSBlockOperation的定義如下:

@interface NSBlockOperation : NSOperation {
@private
    id _private2;
    void *_reserved2;
}

+ (instancetype)blockOperationWithBlock:(void (^)(void))block;

- (void)addExecutionBlock:(void (^)(void))block;
@property (readonly, copy) NSArray<void (^)(void)> *executionBlocks;

@end
複製程式碼

下面我們來使用NSOperation,實現多個執行緒載入圖片,示例程式碼如下:

//// 首先 定義一個OperationImage的Model

@interface OperationImage : NSObject

@property (nonatomic, assign) NSInteger index;
@property (nonatomic, strong) NSData *imgData;

@end

@implementation OperationImage

@end



//// 使用NSOperation實現多執行緒載入圖片

#define ColumnCount    4
#define RowCount       5
#define Margin         10

@interface MultiThread_NSOperation1 ()

@property (nonatomic, strong) NSMutableArray *imageViews;

@end

@implementation MultiThread_NSOperation1

- (void)viewDidLoad {
    
    [super viewDidLoad];
    [self setTitle:@"NSOperation1"];
    [self.view setBackgroundColor:[UIColor whiteColor]];
    self.edgesForExtendedLayout = UIRectEdgeNone;
    
    [self layoutViews];
}

- (void)layoutViews {
    
    CGSize size = self.view.frame.size;
    CGFloat imgWidth = (size.width - Margin * (ColumnCount + 1)) / ColumnCount;
    
    _imageViews=[NSMutableArray array];
    for (int row=0; row<RowCount; row++) {
        for (int colomn=0; colomn<ColumnCount; colomn++) {
            UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(Margin + colomn * (imgWidth + Margin), Margin + row * (imgWidth + Margin), imgWidth, imgWidth)];
            imageView.backgroundColor = [UIColor cyanColor];
            [self.view addSubview:imageView];
            [_imageViews addObject:imageView];
        }
    }
    
    UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
    button.frame = CGRectMake(15, (imgWidth + Margin) * RowCount + Margin, size.width - 15 * 2, 45);
    [button addTarget:self action:@selector(loadImageWithMultiOperation) forControlEvents:UIControlEventTouchUpInside];
    [button setTitle:@"載入圖片" forState:UIControlStateNormal];
    [self.view addSubview:button];
}


#pragma mark - 多執行緒下載圖片

- (void)loadImageWithMultiOperation {
    
    int count = RowCount * ColumnCount;
    
    NSOperationQueue *operationQueue = [[NSOperationQueue alloc]init];
    operationQueue.maxConcurrentOperationCount = 5;
    
    NSBlockOperation *tempOperation = nil;
    for (int i=0; i<count; ++i) {
        OperationImage *operationImg = [[OperationImage alloc] init];
        operationImg.index = i;
        
        ////1.直接使用操佇列新增操作
        //[operationQueue addOperationWithBlock:^{
        //    [self loadImg:operationImg];
        //}];
        
        ////2.建立操作塊新增到佇列
        NSBlockOperation *blockOperation = [NSBlockOperation blockOperationWithBlock:^{
            [self loadImg:operationImg];
        }];
        if (i > 0) {// 設定依賴
            [blockOperation addDependency:tempOperation];
        }
        [operationQueue addOperation:blockOperation];
        tempOperation = blockOperation;
    }
}

#pragma mark - 將圖片顯示到介面

-(void)updateImage:(OperationImage *)operationImg {
    
    UIImage *image = [UIImage imageWithData:operationImg.imgData];
    UIImageView *imageView = _imageViews[operationImg.index];
    imageView.image = image;
}


#pragma mark - 請求圖片資料

- (NSData *)requestData {
    
    NSURL *url = [NSURL URLWithString:@"https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/image/AppleInc/aos/published/images/a/pp/apple/products/apple-products-section1-one-holiday-201811?wid=2560&hei=1046&fmt=jpeg&qlt=95&op_usm=0.5,0.5&.v=1540576114151"];
    NSData *data = [NSData dataWithContentsOfURL:url];
    return data;
}


#pragma mark - 載入圖片

- (void)loadImg:(OperationImage *)operationImg {
    
    // 請求資料
    operationImg.imgData = [self requestData];
    
    // 更新UI介面(mainQueue是UI主執行緒)
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        [self updateImage:operationImg];
    }];
    
    // 列印當前執行緒
    NSLog(@"current thread: %@", [NSThread currentThread]);
}

@end
複製程式碼

在載入網路圖片的程式碼上打一個斷點,檢視斷點資訊,從的執行過程可以看出NSOperation底層涉及到對GCD的封裝:

NSOperation底層對GCD的封裝

三、關於自定義封裝NSOperation

我們用到的很多三方庫都自定義封裝NSOperation,如MKNetworkOperation、SDWebImage等。自定義封裝抽象類NSOperation只需要重寫其中的main或start方法,在多執行緒執行任務的過程中需要注意執行緒安全問題,我們還可以通過KVO監聽isCancelled、isExecuting、isFinished等屬性,確切的回撥當前任務的狀態。下面就是對NSOperation的自定義封裝程式碼:

@interface MyOperation ()

//要下載圖片的地址
@property (nonatomic, copy) NSString *urlString;
//執行完成後,回撥的block
@property (nonatomic, copy) void (^finishedBlock)(NSData *data);

// 自定義變數,用於重寫父類isFinished的set、get方法
@property (nonatomic, assign) BOOL taskFinished;

@end

@implementation MyOperation

+ (instancetype)downloadDataWithUrlString:(NSString *)urlString finishedBlock:(void (^)(NSData *data))finishedBlock {
    
    MyOperation *operation = [[MyOperation alloc] init];
    operation.urlString = urlString;
    operation.finishedBlock = finishedBlock;
    return operation;
}

// 監聽/重寫readonly屬性的set、get方法
- (void)setTaskFinished:(BOOL)taskFinished {
    [self willChangeValueForKey:@"isFinished"];
    _taskFinished = taskFinished;
    [self didChangeValueForKey:@"isFinished"];
}

- (BOOL)isFinished {

    return self.taskFinished;
}

//- (void)main {
//
//    // 列印當前執行緒
//    NSLog(@"%@", [NSThread currentThread]);
//
//    //判斷是否被取消,取消正在執行的操作
//    if (self.cancelled) {
//        return;
//    }
//
//    NSURLSessionTask *task = [NSURLSession.sharedSession dataTaskWithRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:self.urlString]] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
//        //回到主執行緒更新UI
//
//        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
//            self.finishedBlock(data);
//        }];
//    }];
//    [task resume];
//}

- (void)start {
    
    // 列印當前執行緒
    NSLog(@"%@", [NSThread currentThread]);
    
    //判斷是否被取消,取消正在執行的操作
    if (self.cancelled) {
        return;
    }
    
    NSURLSessionTask *task = [NSURLSession.sharedSession dataTaskWithRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:self.urlString]] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        //回到主執行緒更新UI
        
        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            self.finishedBlock(data);
        }];
        
    self.taskFinished = YES;
    }];
    [task resume];
}

@end
複製程式碼

呼叫MyOperation中的方法:

- (void)testMyOperation {
    
    _queue = [[NSOperationQueue alloc] init];
    _queue.maxConcurrentOperationCount = 3;
    
    MyOperation *temp = nil;
    for (NSInteger i=0; i<500; i++) {
        MyOperation *operation = [MyOperation downloadDataWithUrlString:@"https://www.so.com" finishedBlock:^(NSData * _Nonnull data) {
            NSLog(@"--- %d finished---", (int)i);
        }];
        if (temp) {
            [operation addDependency:temp];
        }
        temp = operation;
        [_queue addOperation:operation];
    }
}
複製程式碼

說明:

  1. 在執行上面的程式碼時,我們發現同時重寫start和main方法時,start方法優先執行,main方法不會被執行;如果只重寫main方法,則main方法會被執行。
  2. 因為isFinished是readonly屬性,因此我們通過自定義變數taskFinished來重寫isFinished的set、get方法,實現方式詳見程式碼。
  3. 如果只重寫start方法,並且其中沒有self.taskFinished = YES時,且在testMyOperation設定如下:
    執行結果
    可以看到log只能能打出來執行了5次(正好是maxConcurrentOperationCount的值),之後便卡死不動。如果不設定maxConcurrentOperationCount或將maxConcurrentOperationCount設定的足夠大,則可正常執行至結束。如果開啟start方法中的self.taskFinished = YES,則也可正常執行至結束。可見start方法中的任務執行結束後,系統並沒有將執行緒的isFinished置為YES,導致之後的任務無法對其重用。
  4. 如果只重寫main方法,並且其中沒有self.taskFinished = YES時,testMyOperation方法都是可以正常執行的,也就是說main執行結束時系統將執行緒的isFinished置為YES了,其餘任務可對其重用。
  5. 比較start與main方法,兩個方法的執行過程都是並行的;start方法更容易通過KVO監聽到任務的執行狀態,但是需要手動設定一些狀態;main自動化程度更高。
  6. 使用NSOperationQueue時,我們列印程式碼執行,過程中的執行緒,發現執行緒池中執行緒的最大個數在66個左右。
    以上驗證過程,得到了昆哥的指教,非常感謝!?

四、NSOperation中的依賴

用NSThread來實現多執行緒時,執行緒間的執行順序很難控制,但是使用NSOperation時可以通過設定操作的依賴關係來控制執行順序。假設操作A依賴於操作B,執行緒操作佇列在啟動執行緒時就會首先執行B操作,然後執行A。例如在第三節testMyOperation方法中,我們從第二個任務一次設定了關係:

MyOperation *temp = nil;
    for (NSInteger i=0; i<500; i++) {
        MyOperation *operation = [MyOperation downloadDataWithUrlString:@"https://www.so.com" finishedBlock:^(NSData * _Nonnull data) {
            NSLog(@"--- %d finished---", (int)i);
        }];
        if (temp) {
            [operation addDependency:temp];
        }
        temp = operation;
        [_queue addOperation:operation];
    }
複製程式碼

PS:

  1. NSOperationQueue的maxConcurrentOperationCount一般設定在5個以內,數量過多可能會有效能問題。maxConcurrentOperationCount為1時,佇列中的任務序列執行,maxConcurrentOperationCount大於1時,佇列中的任務併發執行;
  2. 不同的NSOperation例項之間可以設定依賴關係,不同queue的NSOperation之間也可以建立依賴關係 ,但是要注意不要“迴圈依賴”;
  3. NSOperation例項之間設定依賴關係應該在加入佇列之前;
  4. 在沒有使用 NSOperationQueue時,在主執行緒中單獨使用 NSBlockOperation 執行(start)一個操作的情況下,操作是在當前執行緒執行的,並沒有開啟新執行緒,在其他執行緒中也一樣;
  5. NSOperationQueue可以直接獲取mainQueue,更新介面UI應該在mainQueue中進行;
  6. 區別自定義封裝NSOperation時,重寫main或start方法的不同;
  7. 自定義封裝NSOperation時需要我們完全過載start,在start方法裡面,我們還要檢視isCanceled屬性,確保start一個operation前,task是沒有被取消的。如果我們自定義了dependency,我們還需要傳送isReady的KVO通知。

工程原始碼GitHub地址


小編微信:可加並拉入《QiShare技術交流群》。

iOS 多執行緒之NSOperation

關注我們的途徑有:
QiShare(簡書)
QiShare(掘金)
QiShare(知乎)
QiShare(GitHub)
QiShare(CocoaChina)
QiShare(StackOverflow)
QiShare(微信公眾號)

推薦文章:
iOS Winding Rules 纏繞規則
iOS 簽名機制
iOS 掃描二維碼/條形碼
iOS 瞭解Xcode Bitcode
iOS 重繪之drawRect
奇舞週刊

相關文章