下载器之定制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: // 从准备状态过渡到暂停或执行状态表示有效, 过渡到完成状态需要看是否被取消了, 如果被取消了表示有效否则表示无效
        {
            switch (to) {
                case SYOperationPaused:
                case SYOperationExecuting:
                    return YES;
                    
                case SYOperationFinished:
                    return isCanceled;
                    
                default:
                    return NO;
            }
        }
         
        case SYOperationExecuting: // 从执行状态过渡到暂停或完成状态表示有效, 否则表示无效
        {
            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阅读 206,311评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,339评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,671评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,252评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,253评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,031评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,340评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,973评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,466评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,937评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,039评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,701评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,254评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,259评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,497评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,786评论 2 345

推荐阅读更多精彩内容