1. 需求背景
公司的app需要需要支持订阅更新的自动下载功能。当订阅更新的静默推送将app启动到后台时,在后台开始下载更新的内容。
本文主要记录以下我开发过程中对于方案的选择,开发需要注意的细节和踩过的坑。如果需要先了解系统生命周期和NSURLSession后台下载的相关机制,那么可以移步本文最后相关资料的部分,找到相关资料的链接自行了解。
2. 整体方案
2.1 NSURLSession对后台下载的支持
使用如下backgroundSessionConfiguration
创建的NSURLSession
能够提供以下几个特别重要的能力,对于iOS上的后台下载任务background session是不二选择。(后面就叫backgroundSession
了)
当你通过backgroundSession
创建的NSURLSessionDownloadTask
开始之后:
- app切换到后台进入suspended状态,这个
downloadTask
仍然可以在一个独立的进程中继续进行,并在下载完成后系统会将你的app从suspended状态切换到UIApplicationStateBackground
, 让你的app继续处理下载任务。 - app在后台被系统杀死之后,
downloadTask
同样会继续进行(注意如果用户force quit你的app,那么下载任务会被取消,并且在下一次启动恢复backgroundSession
后会有下载失败的回调)。任务完成时,系统同样会重新启动你的app到UIApplicationStateBackground
,app可以通过相同的identifier重新创建backgroundSession
并获取到完成的downloadTask
,然后通过代理事件处理下载任务。 -
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吗?文档是这么说的:
结合以上两点我是这么判断的:
- 下载源是可控并满足生成resumeData,那么毫无疑问通过
cancelByProducingResumeData
来暂停下载是最佳选择。 - 如果下载源不可控,那么可以选择
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
中有两个地方可以限制移动网络访问:
-
NSURLSession
的allowsCellularAccess
属性,这是一个readonly
的属性,在session初始化的时候会根据传入的configuration对象的对应属性决定。 -
NSURLRequest
的allowCellularAccess
属性,在创建request对象的时候可以设置。
需要注意的是以上两个地方都只能在实例初始化的时候设置一次,不能随时改动。并且在NSURLSession
和NSURLRequest
同时设置该属性的时候,更严格的设置会生效,也就是说只要有一个地方限制了移动网络那么最终生成的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中恢复下载任务的过程可以分为以下两种情况:
-
手动启动app过程中发现进行中的下载任务。
-
下载任务完成通过
background session
的回调启动app
可以看到这种情况任务的恢复陷入的僵局。实际上任务已经下载完成,但是不得不重新下载任务,而且可能会陷入不断重新下载任务的死循环。(虽然iOS会限制background session
在后台启动的次数但是仍然很不好 )
一开始调试的时候app启动频繁,基本上遇到的是情况1. 虽然会遇到一些下载状态异常,但是因为考虑到background session
的机制比较tricky,而且又是开发中,出现也没有那么频繁没有介意(其实是因为任务丢失之后,我会自动重新开始下载。。)。但是在版本发布之后因为一些统计数据的异常发现了情况2的问题,我的解决方案如下:
目前来看应该可以满足业务需求。
至于为什么不直接在NSURLDownloadTask回调时再从持久化数据中寻找和绑定到对应的任务上。
- 考虑到机制改动的风险和开发成本。。
- 懒。。
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。
problem solved!
3.4.2 关于AFURLSessionManager
的completionQueue
completionQueue
只针对AFURLSessionManagerTaskDelegate
,setXXXBlock:
系列方法设置的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,那么你大可以通过NSURLSession
的HTTPMaximumConnectionsPerHost
属性来进行限制。
如果你的下载源来自很多不同的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进行调试,包括但不限于:
- 直接用Xcode再run一遍。
- 手动启动app。
- 使用静默推送的通知启动app到后台。
- 设置scheme中的Background Fetch调试选项,可以通过xcode把app启动到后台。(相当于从suspended状态唤起到后台)
另外多提一句。只要在scheme中把launch选项设置成 wait for executable to be launched。那么通过方式2/3/4启动app也可以通过debugger调试。
使用[session invalidateAndCancel]
来让session恢复初始状态
从任务管理页面force quit你的app并不会让backgroundSession
恢复初始状态。如果需要让backgroundSession
回到初始状态来进行调试,那么[backgroundSession invalidateAndCancel]
或者删除app重新安装才能让你的session回到初始状态。
ps:[backgroundSession invalidateAndCancel]
之后这个session实例对象就不可用了,需要重新创建实例或者直接重新启动app。
更多调试相关的注意事项请看下面的帖子:
- Testing Background Session Code |Apple Developer Forums
- Got a serious NSURLSession background session bug |Apple Developer Forums
总结
暂时没想到什么可总结的,有空的时候准备补一个demo,就酱。
PS:整体还是一个比较蛋疼的方案,如果让我重新开始写一个下载框架,我应该会抛弃AFNetworking,在NSURLSession/ NSURLRequest的基础上去写。
5. 参考资料
官方文档
- URL Loading System | Apple Developer Documentation
- NSURLSession: New Features and Best Practices - WWDC 2016 - Videos - Apple Developer
- Downloading Files in the Background | Apple Developer Documentation
NSURLSession相关教程
- URLSession Tutorial: Getting Started | Ray Wenderlich
- Background Modes Tutorial: Getting Started | Ray Wenderlich
第三方库:
- GitHub - mzeeshanid/MZDownloadManager: This download manager uses NSURLSession api to download files. It can download multiple files at a time. It can download large files if app is in background. It can resume downloads if app was quit.
- GitHub - HustHank/BackgroundDownloadDemo: 一个简单的使用NSURLSession的下载Demo,包括后台下载和断点下载