记一次NSURLSession下载开发任务

1. 需求背景

公司的app需要需要支持订阅更新的自动下载功能。当订阅更新的静默推送将app启动到后台时,在后台开始下载更新的内容。

本文主要记录以下我开发过程中对于方案的选择,开发需要注意的细节和踩过的坑。如果需要先了解系统生命周期和NSURLSession后台下载的相关机制,那么可以移步本文最后相关资料的部分,找到相关资料的链接自行了解。

2. 整体方案

2.1 NSURLSession对后台下载的支持

使用如下backgroundSessionConfiguration创建的NSURLSession能够提供以下几个特别重要的能力,对于iOS上的后台下载任务background session是不二选择。(后面就叫backgroundSession了)
当你通过backgroundSession创建的NSURLSessionDownloadTask开始之后:

  1. app切换到后台进入suspended状态,这个downloadTask仍然可以在一个独立的进程中继续进行,并在下载完成后系统会将你的app从suspended状态切换到UIApplicationStateBackground, 让你的app继续处理下载任务。
  2. app在后台被系统杀死之后,downloadTask同样会继续进行(注意如果用户force quit你的app,那么下载任务会被取消,并且在下一次启动恢复backgroundSession后会有下载失败的回调)。任务完成时,系统同样会重新启动你的app到UIApplicationStateBackground,app可以通过相同的identifier重新创建backgroundSession并获取到完成的downloadTask,然后通过代理事件处理下载任务。
  3. backgroundSession只支持HTTP and HTTPS协议,如果需要通过其他协议来进行下载任务(比如ftp),那么这部分的下载任务就需要考虑另行处理了。

2.2 AFNetworking or URLSession

AFNetworking对于大部分iOS开发者AFN已经是相当于网络基础库一样的存在了。AFNetworking封装的API,通过传递block的方式能够非常方便的应对复杂多样的数据接口请求。但是下载任务一般就是一个GET请求,下载完成之后将文件存储到对应的沙盒路径。通过NSURLSession本身的代理回调到XXXDownloadManager之类的管理类中集中处理也不会增加很多工作量。

但是,这一次对开发我还是选择了在AFN的基础上进行封装:因为项目中原本的下载库已经使用了AFN来创建下载请求,本着尽量减少改动的想法,就沿用了之前的方案。

ps:后续的开发中发现,AFN的封装方式对于后台下载的支持并不太友好,相比直接使用URLSession进行开发也并没有节省太多工作量。

3. 一些技术细节实现

3.1 暂停下载 - suspend vs cancelByProducingResumeData:

方案:很纠结的选择了用suspend来暂停下载任务,原因如下:

实际测试这两个方法都可以达到暂停下载的目的(至少用户看起来是暂停),但是苹果开发论坛上找到如下官方回复
Background Transfer Service: suspend DownloadTa… |Apple Developer Forums

Tasks suspension is rarely used and, when it is, it’s mostly used to temporarily disable callbacks as part of some sort of concurrency control system. That’s because a suspended task can still be active on the wire; all that the suspend does is prevent it making progress internally, issuing callbacks, and so on.
OTOH, if you’re implementing a long-term pause (for example, the user wants to pause a download), you’d be better off calling -cancelByProducingResumeData

来自官方的说法是很明确的,cancelByProducingResumeData应该是最合理的选择。但是所有下载任务在cancel时都能生成resumeData吗?文档是这么说的:

1CB402F8-BE44-4217-B456-2DF03B0A5639.png

结合以上两点我是这么判断的:

  1. 下载源是可控并满足生成resumeData,那么毫无疑问通过cancelByProducingResumeData来暂停下载是最佳选择。
  2. 如果下载源不可控,那么可以选择suspend来暂停下载。但是需要做好下载任务最终超时失败的处理,不然用户就会奇怪的发现,我已经暂停了所有下载问题怎么一直提醒我下载失败?。

综上,由于app面对的下载源不可控最终还是选择了suspend来暂停任务。

ps:iOS10.2之前的�生成的resumeData有bug,可能会导致resumeData不可用,可以用下面链接中的方式对判断系统版本resumeData进行处理。
ios - Resume NSUrlSession on iOS10 - Stack Overflow

3.2 关于移动网络访问的限制

方案:使用两个NSURLSession搭配不同的allowCellularAccess属性进行移动网络访问限制。

3.2.1 为什么不监听Reachability变化的通知来做网络限制?

之前项目中下载的网络环境限制是通过AFReachabilityManager的通知来做的。这种方式无论app在前台或者后台,只要代码正在运行的时是没有问题的。在使用backgroundSession之后,app没有运行的时候下载任务仍然在继续,这种方式就有点力不从心了。 在这种情况下想要限制网络的访问,就必须通过NSURLSession的本身机制来实现网络限制。

NSURLSession中有两个地方可以限制移动网络访问:

  1. NSURLSessionallowsCellularAccess属性,这是一个readonly的属性,在session初始化的时候会根据传入的configuration对象的对应属性决定。
  2. NSURLRequestallowCellularAccess属性,在创建request对象的时候可以设置。

需要注意的是以上两个地方都只能在实例初始化的时候设置一次,不能随时改动。并且在NSURLSessionNSURLRequest同时设置该属性的时候,更严格的设置会生效,也就是说只要有一个地方限制了移动网络那么最终生成的downloadTask

ps:虽然下载任务的流量限制已经完全交给session管理。但是AFReachabilityManager的网络监听仍然保留,因为网络变化时session对请求的暂停或开始事件并没有回调,仍然需要这个监听在网络环境变化的更新UI用。

3.2.2 下载任务网络限制状态的切换

方案:使用两个NSURLSession,在session的层面做控制。

从产品的角度需要可以单独控制每个下载请求的移动网络访问限制。(比如用户在移动网络环境下手动继续了某个下载,那么就需要解除这个下载的移动网络访问限制)。
考虑到以单个请求作为控制的粒度,放在NSURLRequest上设置看起来是合理的,为了重新设置allowsCellularAccess属性,需要先取消当前的downloadTask然后重新发起请求。但在cancelByProducingResumeData:downloadTaskWithResumeData:之间却发现并没有API可以切换下载任务的allowsCellularAccess属性。
这下尴尬了,难道要舍弃已经下载了一大半的数据重新创建一个NSURLRequest吗? 这显然不划算。

所以我采用的方案是:
使用两个NSURLSession在session的层面做控制。
一个允许移动网络访问的commonSession
一个禁止移动网络访问的nonCellularAccessSession

需要切换网络权限时,先通过cancelByProducingResumeData:拿到resumeData,然后在对应的session上downloadTaskWithResumeData:继续下载任务。

完美解决后台下载网络限制的需求, 这部分的代码长这样


- (void)p_changeCellularAccessForDownload:(XXXDownloadModel *)download newStrategy:(BOOL)allowed {
    //  1. nonCellularSessionManager 中的任务
    BOOL foo = download.dataTask && [self.nonCellularSessionManager.downloadTasks containsObject:(NSURLSessionDownloadTask *)download.dataTask];
    if (foo) {
        if (allowed == NO) {
            download.allowCellularAccess = allowed;
            return;
        } else {
            NSURLSessionDownloadTask *downloadTask = (NSURLSessionDownloadTask *)download.dataTask;
            BOOL suspended = (downloadTask.state != NSURLSessionTaskStateRunning);
            [downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
                xxx_dispatch_queue_async_safe(self.syncQueue, ^{
                    download.resumeData = resumeData;
                    download.allowCellularAccess = allowed;
                    [self p_addDownloadItem:download suspended:suspended];
                });
            }];
            return;
        }
    }
    
    //   2. commonSessionManager 中的任务
    BOOL bar = download.dataTask && [self.commonSessionManager.downloadTasks containsObject:(NSURLSessionDownloadTask *)download.dataTask];
    if (bar) {
        if (YES == allowed) {
            download.allowCellularAccess = allowed;
            return;
        } else {
            NSURLSessionDownloadTask *downloadTask = (NSURLSessionDownloadTask *)download.dataTask;
            BOOL suspended = (downloadTask.state == NSURLSessionTaskStateSuspended);
            download.preferToIgnoreErrorToast = YES;
            [downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
                xxx_dispatch_queue_async_safe(self.syncQueue, ^{
                    download.resumeData = resumeData;
                    download.allowCellularAccess = allowed;
                    [self p_addDownloadItem:download suspended:suspended];
                });
            }];
            return;
        }
    }
    //  3. 设置未开始的任务
    if ([self.waitingModels containsObject:download]) {
        download.allowCellularAccess = allowed;
        return;
    }
    //  4. addSuspendDownloadWithURL添加的任务 (通过[task suspend]的任务应该也会在2.3.步骤中处理)
    download.allowCellularAccess = allowed;
}

3.3 启动时从Session中恢复下载任务

方案:每次app启动后都要使用相同的identifier创建以上两个session,然后通过session getTasksWithCompletionHandler:获得所有downloadTask,然后使用downloadTask.originalRequest.URL作为唯一标识,从持久化的数据中恢复下载任务。

为什么使用URL而不是downloadTask.description或者downloadTask.identifier作为恢复任务时的唯一标识?

首先需要注意的是,请求可能被重定向,所以要使用originalRequest.URL而不是downloadTask.currentReqeust.URL

task.description这个属性在app被系统杀死后会丢失,显然不适合。
downloadTask.identifier实测可以在app两次启动之间保持一致。但是因为下载任务分布在两个session,使用该属性判断需要先判断session,增加了额外的复杂度。而一般情况下,每个下载任务的URL都是不同的。使用URL作为唯一标识已经能够满足需要。

重建session的线程同步问题

从session中恢复下载任务的过程可以分为以下两种情况:

  1. 手动启动app过程中发现进行中的下载任务。


    下载总结.002.jpeg
  2. 下载任务完成通过background session的回调启动app

    下载总结.001.jpeg

可以看到这种情况任务的恢复陷入的僵局。实际上任务已经下载完成,但是不得不重新下载任务,而且可能会陷入不断重新下载任务的死循环。(虽然iOS会限制background session 在后台启动的次数但是仍然很不好 )

一开始调试的时候app启动频繁,基本上遇到的是情况1. 虽然会遇到一些下载状态异常,但是因为考虑到background session的机制比较tricky,而且又是开发中,出现也没有那么频繁没有介意(其实是因为任务丢失之后,我会自动重新开始下载。。)。但是在版本发布之后因为一些统计数据的异常发现了情况2的问题,我的解决方案如下:

下载总结.003.jpeg

目前来看应该可以满足业务需求。
至于为什么不直接在NSURLDownloadTask回调时再从持久化数据中寻找和绑定到对应的任务上。

  1. 考虑到机制改动的风险和开发成本。。
  2. 懒。。

3.4 基于AFN完成下载时需要注意的地方

3.4.1 通过AFURLSessionManager的回调

必须通过AFURLSessionManager中的setXXXBlock:等方法设置回调的block,在这些block中处理下载请求的回调

先看AFURLSession的初始化中的部分代码

- (instancetype)initWithSessionConfiguration:(NSURLSessionConfiguration *)configuration {
   //   根据传入的configuration进行初始化...

    //  在初始化方法的最后从session中获取tasks并且设置delegate
    [self.session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
    //  获取downloadTask并且为downloadTask设置AFURLSessionManagerTaskDelegate, 然而设置的progressBlock & destinationBlock & completionBlock都是nil
        for (NSURLSessionDownloadTask *downloadTask in downloadTasks) {
            [self addDelegateForDownloadTask:downloadTask progress:nil destination:nil completionHandler:nil];
        }
    }];
//  设置dataTasks & uploadTasks的代理
//  ...
    return self;
}

可以看到调用addDelegateForDownloadTask: progress:destination:completionHandler:方法的时候,几个block参数都传的nil。按照默认的处理逻辑,这些在AFURLSessionManager初始化时恢复的task最终会默默的无感知的以失败结束。如果你想要自己设置AFURLSessionManagerTaskDelegate的参数,AFN并没有对外暴露这个类。幸运的是AFN还提供了setBlock…系列API。AFNURLSessionManager在收到session的代理事件回调时,在把事件传递给会AFURLSessionManagerTaskDelegate的同时会执行这些设置好的block。

AFNetworking setblock api.png

problem solved!

3.4.2 关于AFURLSessionManagercompletionQueue

completionQueue只针对AFURLSessionManagerTaskDelegatesetXXXBlock:系列方法设置的block并不会在completionQueue上执行,而是在session初始化的时候制定的operationQueue上执行,所以你提供的block需要自己处理线程同步

类似下面这样:

- (void)setTaskDidCompleteBlockForSessionManager:(AFHTTPSessionManager *)manager {
    __weak typeof(self) weakSelf = self;

    [manager setTaskDidCompleteBlock:^(NSURLSession * _Nonnull session, NSURLSessionTask * _Nonnull task, NSError * _Nullable error) {
        __strong typeof(weakSelf) self = weakSelf;
        __block NSError *blockError = error;
        p_dispatch_queue_async_safe(self.syncQueue, ^{
                //  处理下载任务完成
        };
    }];
}

3.5 关于下载并发数量的限制

如果你所有下载源都来自同一个host,那么你大可以通过NSURLSessionHTTPMaximumConnectionsPerHost属性来进行限制。
如果你的下载源来自很多不同的host(比如我面对的情况),那么并发数量只能自己进行限制了,具体限制的并发数量是多少就见仁见智了。

还有一点需要考虑的是,backgroundSession唤起你app的间隔是会随着唤起次数指数递增的。
如果你有30个下载任务,策略1:每三个任务完成之后再开始三个任务。策略2: 同时开启30个下载任务,30个下载任务都完成之后session唤起你的app。苹果开发人员的建议是使用策略2的,具体可以看下面这个链接NSURLSession’s Resume Rate Limiter |Apple Developer Forums

4. 测试调试

一开始,调试backgroundSession相关的代码会诡异到让我怀疑人生。建议所有要调试backgroundSession相关的业务,尤其是和suspention/relaunch机制相关业务的同学仔细看一下这个官方论坛上的帖子

这里只简要的说明一下几个注意点:

尽量使用真机进行调试

一开始用XCode配合模拟器调试的时候经常会遇到NSPosixErrorDomain code 2 file not fuond..这个错误。这是因为XCode每次运行时会改变app的路径,导致downloadTaskByResumeData:时传入的resumeData指向的路径失效造成的)。帖子中提到了这个问题,并且建议使用真机进行调试可以规避这个问题。然而我在使用真机进行调试时也会遇到这个问题。

使用exit()方法调试app重新启动相关的逻辑

使用Xcode debugger进行调试的时候,debugger会防止app进入suspended状态。并且app进入suspended状态或者进入suspended状态然后被系统kill是不会有任何通知或回调的。所以不要傻傻的锁上屏幕等着app进入suspended状态或者被系统skill了。正确的做法是使用exit()方法来退出app。这是app会等同于被系统杀死的状态。然后就可以根据你的需要,用各种方式启动app进行调试,包括但不限于:

  1. 直接用Xcode再run一遍。
  2. 手动启动app。
  3. 使用静默推送的通知启动app到后台。
  4. 设置scheme中的Background Fetch调试选项,可以通过xcode把app启动到后台。(相当于从suspended状态唤起到后台)

另外多提一句。只要在scheme中把launch选项设置成 wait for executable to be launched。那么通过方式2/3/4启动app也可以通过debugger调试。

6F6B4C44-DA1D-4270-B7DA-F594B2907966.png

使用[session invalidateAndCancel]来让session恢复初始状态

从任务管理页面force quit你的app并不会让backgroundSession恢复初始状态。如果需要让backgroundSession回到初始状态来进行调试,那么[backgroundSession invalidateAndCancel]或者删除app重新安装才能让你的session回到初始状态。
ps:[backgroundSession invalidateAndCancel]之后这个session实例对象就不可用了,需要重新创建实例或者直接重新启动app。

更多调试相关的注意事项请看下面的帖子:

总结

暂时没想到什么可总结的,有空的时候准备补一个demo,就酱。
PS:整体还是一个比较蛋疼的方案,如果让我重新开始写一个下载框架,我应该会抛弃AFNetworking,在NSURLSession/ NSURLRequest的基础上去写。

5. 参考资料

官方文档

NSURLSession相关教程

第三方库:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272

推荐阅读更多精彩内容