下載器之定制NSOperation

1 系統KVO通知的設置

首先需要覆蓋isConcurrent屬性并返回值YES

// 必須的,這個方法的返回值用來標識一個 operation 是否是并發的 operation ,我們需要重寫這個方法并返回 YES
- (BOOL)isConcurrent
{
    return YES;
}

其次要覆蓋isReady isExecuting isFinished這三個屬性,返回值看下載情況而定

- (BOOL)isReady {
    return self.state == SYOperationReady && [super isReady];
}

- (BOOL)isExecuting {
    return self.state == SYOperationExecuting;
}

- (BOOL)isFinished {
    return self.state == SYOperationFinished;
}

作用:并發執行的 operation 需要負責配置它們的執行環境,并且向外界客戶報告執行環境的狀態。因此,一個并發執行的 operation 必須要維護一些狀態信息,用來記錄它的任務是否正在執行,是否已經完成執行等。此外,當這兩個方法所代表的值發生變化時,我們需要生成相應的 KVO 通知(此時還沒有進行KVO通知的設置下面會進行),以便外界能夠觀察到這些狀態的變化


參考AFN中的AFURLConnectionOperation類定制NSOperation的思路進行了以下整理:
首先,設置一個表示下載狀態的枚舉,其中有準備狀態、執行中狀態、完成狀態、暫停狀態(定制模式該狀態實際并無意義)

/// 操作的狀態
typedef NS_ENUM(NSUInteger, SYOperationState) {
    /// 暫停狀態
    SYOperationPaused,
    /// 準備下載狀態
    SYOperationReady,
    /// 執行中狀態
    SYOperationExecuting,
    /// 完成狀態
    SYOperationFinished,
};

設置好枚舉之后對應的設置一個表示下載狀態的屬性

/// 操作狀態
@property (readwrite, nonatomic, assign) SYOperationState state;

其次,設計改變狀態需要發送KVO通知的值的名稱

- (NSString *)systemVariableNameByOperationState:(SYOperationState)state
{
    switch (state) {
        case SYOperationReady:
            return @"isReady";
            break;
            
        case SYOperationExecuting:
            return @"isExecuting";
            break;
            
        case SYOperationFinished:
            return @"isFinished";
            break;
            
        case SYOperationPaused:
            return @"isPaused";
            break;
            
        default:
            break;
    }
}

該方法的效果是:通過傳入一個下載狀態枚舉值獲得需要發送KVO通知的系統屬性名進而方便后面發送改變值之后發送的KVO通知

再次,需要判斷更改的值是否有效,假如從已完成狀態更改為準備狀態就肯定是無效的。

- (BOOL)stateTransitionIsValidFrom:(SYOperationState)from To:(SYOperationState)to isCanceled:(BOOL)isCanceled
{
    switch (from) {
        case SYOperationReady: // 從準備狀態過渡到暫?;驁绦袪顟B表示有效, 過渡到完成狀態需要看是否被取消了, 如果被取消了表示有效否則表示無效
        {
            switch (to) {
                case SYOperationPaused:
                case SYOperationExecuting:
                    return YES;
                    
                case SYOperationFinished:
                    return isCanceled;
                    
                default:
                    return NO;
            }
        }
         
        case SYOperationExecuting: // 從執行狀態過渡到暫?;蛲瓿蔂顟B表示有效, 否則表示無效
        {
            switch (to) {
                case SYOperationPaused:
                case SYOperationFinished:
                    return YES;

                default:
                    return NO;
            }
        }
            
        case SYOperationFinished: // 從完成狀態過渡到其他狀態都表示無效的
        {
            return NO;
        }
            
        case SYOperationPaused: // 從暫停狀態過渡到準備狀態表示有效的, 否則表示無效的
        {
            return to == SYOperationReady;
        }
            
        default:
            break;
    }
}

最后,在合適的地方更改下載狀態必須保證系統可以接收到相對應的KVO通知,因此需要重寫state屬性的set方法

- (void)setState:(SYOperationState)state {

    // 如果狀態改變是無效的就直接返回
    if (![self stateTransitionIsValidFrom:self.state To:state isCanceled:[self isCancelled]]) {
        return;
    }

    
    @synchronized (self) {

        NSString *oldStateKey = [self systemVariableNameByOperationState:self.state];
        NSString *newStateKey = [self systemVariableNameByOperationState:state];
        
        [self willChangeValueForKey:newStateKey];
        [self willChangeValueForKey:oldStateKey];
        _state = state;
        [self didChangeValueForKey:oldStateKey];
        [self didChangeValueForKey:newStateKey];
    }
}

2 NSOperation的操作方法

首先有個main方法可以重寫, 通常這個方法就是專門用來實現與該 operation相關聯的任務的。盡管我們可以直接在 start 方法中執行我們的任務,但是用 main 方法來實現我們的任務可以使設置代碼和任務代碼得到分離,從而使 operation 的結構更清晰(然而并沒有什么亂用,本地不使用該方法前面所說的只作為參考了解一下main方法的用處)

//- (void)main
//{
//}

本地真正用的方法是start 方法和cancel方法
start 方法是一個 operation 的起點,所有并發執行的 operation 都必須要重寫這個方法,替換掉 NSOperation 類中的默認實現。我們可以在這里配置任務執行的線程或者一些其它的執行環境。另外,需要特別注意的是,在我們重寫的 start 方法中一定不要調用父類的實現

- (void)start
{
    // 0. 設置互斥鎖防止多個線程同時改變某個屬性
    @synchronized (self) {
        // 1. 第一步需要檢測是否被取消了, 如果被取消了要實現相應的KVO,在真正開始執行任務前,我們通過檢查 isCancelled 方法的返回值來判斷 operation 是否已經被 cancel ,如果是就直接返回了
        if (self.isCancelled) {
            /** 2.
             有一個非常重要的點需要引起我們的注意,那就是即使一個 operation 是被 cancel 掉了,我們仍然需要手動觸發 isFinished 的 KVO 通知。因為當一個 operation 依賴其他 operation 時,它會觀察所有其他 operation 的 isFinished 的值的變化,只有當它依賴的所有 operation 的 isFinished 的值為 YES 時,這個 operation 才能夠開始執行。因此,如果一個我們自定義的 operation 被取消了但卻沒有手動觸發 isFinished 的 KVO 通知的話,那么所有依賴它的 operation 都不會執行。
             */
            self.state = SYOperationFinished;
            return;
        }
        
        // 3. 根據請求創建會話任務
        self.dataTask = [_session dataTaskWithRequest:_request];

        // 更改操作狀態為執行中狀態
        self.state = SYOperationExecuting;
    }
    
    // 4. 手動開啟會話任務
    [self.dataTask resume];
    
    // 5. 判斷會話任務是否存在 -- 如果存在改為下載中  否則  改為下載失敗
    [self.downloadRecord setState:self.dataTask ? SYSourceDownloading : SYSourceDownloadFailed];
    [self archiveDownloadRecordFile];
    [self downloadStateChanged];
}

cancel方法很多地方都會調用,1. 手動取消會調用 2. 下載失敗會調用 3. 下載完成會調用,表明只要想結束某個操作就必須調用cancel方法

- (void)cancel
{
    @synchronized (self) {
        // 1. 判斷此時是否已經取消了
        if (self.isCancelled) return;
        // 2. 假如此時沒有取消,調用父類的取消方法
        [super cancel];
        // 3. 判斷dataTask是否存在, 如果存在調用其取消功能
        if (self.dataTask) {
            [self.dataTask cancel];
        }
        
        // 5. 讓dataTask置空
        self.dataTask = nil;
        
        // 銷毀定時器
        [self.speedTimer invalidate];
        self.speedTimer = nil;
        
        // 回調下載速度block
        dispatch_async(dispatch_get_main_queue(), ^{//用GCD的方式,保證在主線程上更新UI
            if (_sizeBlock) {
                _sizeBlock(0, @"0KB/s");
            }
        });
    }
}

值得注意的是: 當操作被添加到隊列中后會自動調用start方法,因此不需要手動調用start方法,手動再次調用可能會出現混亂

3 下載功能實現

首先設計一個初始化方法,通過該方法傳入下載所需的會話對象NSURLSession下載請求對象NSURLRequest以及下載的資源保存到本地的文件夾路徑folderPath

- (instancetype)initWithRequest:(NSURLRequest *)request inSession:(NSURLSession *)session saveTo:(NSString *)folderPath
{
    self = [super init];
    if (self) {
        self.folderPath = folderPath;
        
        // 0. 保存資源下載地址
        _sourceURL = request.URL.absoluteString;
        
        // 1. 保存請求對象 -- 以便于獲得請求url字符串
        _request = request;
        
        // 2. 保存操作所在會話對象 -- 以便于以后根據會話對象創建dataTask
        _session = session;
        
        // 設置操作狀態初始化時為準備狀態
        _state = SYOperationReady;
    }
    
    return self;
}

由于操作在添加到隊列中的時候自動調用start方法,在start方法中實現了會話對象根據請求對象所創建的會話任務并啟動會話任務,因此會調用NSURLSessionDataDelegate代理方法中部分方法

1 當接收到了服務器的反饋會調用URLSession: dataTask: didReceiveResponse: completionHandler:方法,這個Response包括了HTTP的header(數據長度,類型等信息),這里可以決定DataTask以何種方式繼續(繼續,取消,轉變為Download)

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    // ’304 沒有修改‘ 是一個異常 -- ('304 Not Modified' is an exceptional one)
    // 如果response沒有實現statusCode屬性或方法  或者  (NSHTTPURLResponse *)response的statusCode狀態碼小于400并且不等于304 -- 表示成功
    if (![response respondsToSelector:@selector(statusCode)] || (((NSHTTPURLResponse *)response).statusCode < 400 && ((NSHTTPURLResponse *)response).statusCode != 304)) {
        
        
        // 1. 獲得求取文件的總長度
        int64_t expected = response.expectedContentLength;
        
        // 3. 設置下載文件的字節總長度
        if (!self.downloadRecord.totalBytes || self.downloadRecord.totalBytes == 0) {
            
            // 如果模型中下載文件字節總數不存在計算并保存
            self.downloadRecord.totalBytes = self.downloadRecord.totalBytesWritten + expected;
            
            // 歸檔一次
            [self archiveDownloadRecordFile];
        }
        
        if (expected != -1) {
            
            // 2. 拼接保存到該目錄下的下載文件的全路徑
            NSString *fileFullPath = [self.folderPath stringByAppendingPathComponent:self.downloadRecord.fileName];
            
            // 3. 創建輸出流 -- 意味著下載下來的文件拼接到該路徑的文件后
            self.outputStream = [[NSOutputStream alloc] initToFileAtPath:fileFullPath append:YES];
            
            // 4. 打開輸出流
            [self.outputStream open];
            dispatch_async(dispatch_get_main_queue(), ^{//用GCD的方式,保證在主線程上更新UI
                // 5. 打開計時器
                self.speedTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(speedTimerAction) userInfo:nil repeats:YES];
                
                [[NSRunLoop currentRunLoop] addTimer:self.speedTimer forMode:UITrackingRunLoopMode];
            });

            _lastSecondSize = self.downloadRecord.totalBytesWritten;
            
        }else {
            completionHandler(NSURLSessionResponseCancel);//如果Response里不包括數據長度的信息,就取消數據傳輸
            SYLog(@"錯誤信息: Response里不包括數據長度的信息");
            [self.downloadRecord setState:SYSourceDownloadFailed];
            
            // 歸檔
            [self archiveDownloadRecordFile];
            
            // 回調狀態
            [self downloadStateChanged];
        }

    }
    else if (![response respondsToSelector:@selector(statusCode)] || (((NSHTTPURLResponse *)response).statusCode == 416))
    {
        // response沒有實現statusCode屬性或方法  或者  (NSHTTPURLResponse *)response的statusCode狀態碼是416 表示 該資源已經被下載完了

        // 2. 改變下載狀態為完成 并 歸檔下載記錄文件  并  調用下載狀態改變block
        [self.downloadRecord setState:SYSourceDownloadCompleted];
        
        // 歸檔
        [self archiveDownloadRecordFile];
        
        // 回調
        [self downloadStateChanged];
    }
    else
    {
        // 1. 發送下載停止(取消)通知
        // 2. 調用下載完成回調block
        [self.downloadRecord setState:SYSourceDownloadCancel];

        [self archiveDownloadRecordFile];
        
        [self downloadStateChanged];
    }
    
    // 5. 是否接收服務器的響應
    /*
     NSURLSession在接收到響應的時候要先對響應做允許處理:completionHandler(NSURLSessionResponseAllow);,才會繼續接收服務器返回的數據,進入后面的代理方法.值得一提的是,如果在接收響應的時候需要對返回的參數進行處理(如獲取響應頭信息等),那么這些處理應該放在前面允許操作的前面.
     */
    if (completionHandler) {
        completionHandler(NSURLSessionResponseAllow);
    }
}

2 接收到數據之后會調用URLSession: dataTask: didReceiveData:方法,且每次接收到數據都會調用一次

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    
    // 1. 輸出流寫入
    [self.outputStream write:data.bytes maxLength:data.length];

    // 2. 把下載的字節計數累加到下載記錄模型中的已下載字節屬性中
    self.downloadRecord.totalBytesWritten += data.length;
    [self archiveDownloadRecordFile];

    // 3. 調用下載進度block
    dispatch_async(dispatch_get_main_queue(), ^{//用GCD的方式,保證在主線程上更新UI
        if (self.progressBlock) {
            self.progressBlock(self.downloadRecord.totalBytesWritten, self.downloadRecord.totalBytes);
        }
    });
}

3 是否把response存儲到cache中會調用以下方法

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask willCacheResponse:(NSCachedURLResponse *)proposedResponse completionHandler:(void (^)(NSCachedURLResponse * _Nullable))completionHandler{
    SYLog(@"是否把Response存儲到Cache中");
    
    // 如果調用此方法,這意味著響應不是從緩存讀取
    NSCachedURLResponse *cachedResponse = proposedResponse;
    
    if (completionHandler) {
        completionHandler(cachedResponse);
    }
}

4 當資源下載完或下載出錯會調用以下方法

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
#warning 如果沒網的情況下調用回調函數可能在處理的地方出現崩潰
    [self didCompleteWithError:error];

    
    // 0. 配置互斥鎖防止多個線程同時改變某一屬性
    @synchronized (self) {
        // 1. 讓dataTask置空
        self.dataTask = nil;

        self.state = SYOperationFinished;
        // 2. 返回到主線程發送下載停止或者下載完成通知
        [self cancel];
    }
    
    // 1. 關閉輸出流
    [self.outputStream close];

    // 2. 輸出流指針置空
    self.outputStream = nil;
}

此處有一個問題是當沒有網絡的時候執行到此處會直接崩潰程序

最后有必要在類銷毀方法中打印一下方便查看前面設置的KVO是否成功了

- (void)dealloc
{
    SYLog(@"SYDownloadOperation類銷毀了");
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,380評論 2 379

推薦閱讀更多精彩內容