好久之前,我发现了一个将要面对的问题:ios
怎样才能并发地下载一堆文件,而且所有下载完成后再执行其余操做?git
固然,这个问题其实很简单,解决方案也有不少。但我第一时间想到的是,目前是否存一个具备任务组概念,很是权威,很是流行、稳定可靠,而且是用Swift写的,Github上star很是多的下载框架?若是存在这样的轮子,我就打算把它做为项目里专用的下载模块。很惋惜,下载框架不少,也有不少这方面的文章和Demo,可是像AFNetworking
、SDWebImage
这种著名权威,star很是多的,真的一个都没有,并且有一些仍是用NSURLConnection
实现的,用Swift写的就更少了,这让我有了打算本身实现一个的想法。github
轮子这种东西,既然要本身撸,就不能随便,并且下载框架这方面也没权威著名的,因此一开始我打算知足本身需求的同时,尽可能能作更多的事情,争取之后负责的项目均可以用得上。首先要知足的就是后台下载,众所周知iOS的App在后台是暂停的,那么要实现后台下载,就须要按照苹果的规定,使用URLSessionDownloadTask
。swift
网上一搜就有大量的相关文章和Demo,而后我就开始愉快地撸代码。结果撸到一半发现,真正实现起来而且没有网上的文章说得那么简单,测试发现开源的轮子和Demo也有不少地方有Bug,不完善,或者说没有完整地实现后台下载。因而只能靠本身继续深刻的研究,但当时确实没有这方面研究地比较透彻文章,而时间方面也不容许,必须得尽快撸个轮子出来使用。因此最后我妥协了,我用了一个比较容易处理的办法,改为用URLSessionDataTask
实现,虽然不是原生支持后台下载,但我以为总有一些邪门歪道能够实现的,最后我写出了Tiercel
,一个对现实妥协的下载框架,不过已经知足了个人需求。api
由于其实我并无遇到后台下载硬性需求,因此我一直没有寻找其余办法去实现,并且我以为若是要作,就必须使用URLSessionDownloadTask
,实现原生级别的后台下载。随着时间的推移,我内心一直都以为没有完成当初的想法是一个极大的遗憾,因而我最后下定决心,打算把iOS的后台下载研究透彻。缓存
终于,完美支持原生后台下载的Tiercel 2诞生了。下面我将详细讲解后台下载的实现和注意事项,但愿可以帮助有须要的人。服务器
关于后台下载,其实苹果有提供文档---Downloading Files in the Background,但实现起来要面对的问题比文档说的要多得多。session
首先,若是须要实现后台下载,就必须建立Background Sessions
并发
private lazy var urlSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "com.Daniels.Tiercel")
config.isDiscretionary = true
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
复制代码
经过这种方式建立的URLSession
,实际上是__NSURLBackgroundSession
:app
background(withIdentifier:)
方法建立URLSessionConfiguration
,其中这个identifier
必须是固定的,并且为了不跟其余App冲突,建议这个identifier
跟App的Bundle ID
相关URLSession
的时候,必须传入delegate
Background Sessions
,即它的生命周期跟App几乎一致,为方便使用,最好是做为AppDelegate
的属性,或者是全局变量,缘由在后面会有详细说明。只有URLSessionDownloadTask
才支持后台下载
let downloadTask = urlSession.downloadTask(with: url)
downloadTask.resume()
复制代码
经过Background Sessions
建立出来的downloadTask,实际上是__NSCFBackgroundDownloadTask
到目前为止,已经建立而且开启了支持后台下载的任务,但真正的难题,如今才开始
苹果的官方文档----Pausing and Resuming Downloads
URLSessionDownloadTask
的断点续传依靠的是resumeData
// 取消时保存resumeData
downloadTask.cancel { resumeDataOrNil in
guard let resumeData = resumeDataOrNil else { return }
self.resumeData = resumeData
}
// 或者是在session delegate 的 urlSession(_:task:didCompleteWithError:) 方法里面获取
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error,
let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
self.resumeData = resumeData
}
}
// 用resumeData恢复下载
guard let resumeData = resumeData else {
// inform the user the download can't be resumed
return
}
let downloadTask = urlSession.downloadTask(withResumeData: resumeData)
downloadTask.resume()
复制代码
正常状况下,这样就已经能够恢复下载任务,但实际上并无那么顺利,resumeData
就存在各类各样的问题。
在iOS中,这个resumeData
简直就是奇葩的存在,若是你有去研究过它,你会以为难以想象,由于这个东西一直在变,并且常常有Bug,彷佛苹果就是不想咱们对它进行操做。
在iOS12以前,直接把resumeData
保存为resumeData.plist
到本地,能够看出里面的结构。
// url
NSURLSessionDownloadURL
// 已经接受的数据大小
NSURLSessionResumeBytesReceived
// currentRequest
NSURLSessionResumeCurrentRequest
// Etag,下载文件的惟一标识
NSURLSessionResumeEntityTag
// 已经下载的缓存文件路径
NSURLSessionResumeInfoLocalPath
// resumeData版本
NSURLSessionResumeInfoVersion = 1
// originalRequest
NSURLSessionResumeOriginalRequest
NSURLSessionResumeServerDownloadDate
复制代码
NSURLSessionResumeInfoVersion = 2
,resumeData
版本升级NSURLSessionResumeInfoLocalPath
改为NSURLSessionResumeInfoTempFileName
,缓存文件路径变成了缓存文件名NSURLSessionResumeInfoVersion = 4
,resumeData
版本再次升级,应该是直接跳过3了取消 - 恢复
操做,生成的resumeData
会多出一个key为NSURLSessionResumeByteRange
的键值对resumeData
编码方式改变,须要用NSKeyedUnarchiver
来解码,结构没有改变了解resumeData
结构对解决它引发的Bug,实现离线断点续传,起到关键做用。
resumeData
不但结构一直变化,并且也一直存在各类各样的Bug
resumeData
没法直接恢复下载,缘由是currentRequest
和originalRequest
的NSKeyArchived
编码异常,iOS 10.2及以上会修复这个问题。resumeData
后,须要对它进行修正,使用修正后的resumeData
建立downloadTask,再对downloadTask的currentRequest
和originalRequest
赋值,Stack Overflow上面有具体说明。取消 - 恢复
操做,生成的resumeData
会多出一个key为NSURLSessionResumeByteRange
的键值对,因此会致使直接下载成功(实际上没有),下载的文件大小直接变成0,iOS 11.3及以上会修复这个问题。NSURLSessionResumeByteRange
的键值对删除。取消 - 恢复
操做,使用生成的resumeData
建立downloadTask,它的originalRequest
为nil,到目前最新的系统版本(iOS 12.1)仍然同样,虽然不会影响文件的下载,但会影响到下载任务的管理。currentRequest
匹配任务,这里涉及到一个重定向问题,后面会有详细说明。以上是目前总结出的resumeData
在不一样的系统版本出现的改动和Bug,解决的具体代码能够参考Tiercel
。
支持后台下载的downloadTask已经建立,resumeData
的问题也已经解决,如今已经能够愉快地开启和恢复下载了。接下来要面对的是,这个downloadTask的具体表现,这也是实现一个下载框架最重要的环节。
为了测试downloadTask在不一样状况下的表现,花费了大量的时间和精力,具体以下:
操做 | 建立 | 运行中 | 暂停(suspend) | 取消(cancelByProducingResumeData) | 取消(cancel) |
---|---|---|---|---|---|
当即产生的效果 | 在App沙盒的caches文件夹里面建立tmp文件 | 把下载的数据写入caches文件夹里面的tmp文件 | caches文件夹里面的tmp文件不会被移动 | caches文件夹里面的tmp文件会被移动到Tmp文件夹,会调用didCompleteWithError | caches文件夹里面的tmp文件会被删除,会调用didCompleteWithError |
进入后台 | 自动开启下载 | 继续下载 | 没有发生任何事情 | 没有发生任何事情 | 没有发生任何事情 |
手动kill App | 关闭的时候caches文件夹里面的tmp文件会被删除,从新打开app后建立相同identifier的session,会调用didCompleteWithError(等于调用了cancel) | 关闭的时候下载中止了,caches文件夹里面的tmp文件不会被移动,从新打开app后建立相同identifier的session,tmp文件会被移动到Tmp文件夹,会调用didCompleteWithError(等于调用了cancelByProducingResumeData) | 关闭的时候caches文件夹里面的tmp文件不会被移动,从新打开app后建立相同identifier的session,tmp文件会被移动到Tmp文件夹,会调用didCompleteWithError(等于调用了cancelByProducingResumeData) | 没有发生任何事情 | 没有发生任何事情 |
crash或者被系统关闭 | 自动开启下载,caches文件夹里面的tmp文件不会被移动,从新打开app后,无论是否有建立相同identifier的session,都会继续下载(保持下载状态) | 继续下载,caches文件夹里面的tmp文件不会被移动,从新打开app后,无论是否有建立相同identifier的session,都会继续下载(保持下载状态) | caches文件夹里面的tmp文件不会被移动,从新打开app后建立相同identifier的session,不会调用didCompleteWithError,session里面还保存着task,此时task仍是暂停状态,能够恢复下载 | 没有发生任何事情 | 没有发生任何事情 |
支持后台下载的URLSessionDownloadTask
,真实类型是__NSCFBackgroundDownloadTask
,具体表现跟普通的有很大的差异,根据上面的表格和苹果官方文档:
Background Sessions
,系统会把它的identifier
记录起来,只要App从新启动后,建立对应的Background Sessions
,它的代理方法也会继续被调用session
管理,则下载中的tmp格式缓存文件会在沙盒的caches文件夹里;若是不被session
管理,且能够恢复,则缓存文件会被移动到Tmp文件夹里;若是不被session
管理,且不能够恢复,则缓存文件会被删除。即:
suspend
方法,缓存文件会在沙盒的caches文件夹里cancelByProducingResumeData
方法,则缓存文件会在Tmp文件夹里cancel
方法,缓存文件会被删除cancelByProducingResumeData
或者cancel
方法
cancelByProducingResumeData
或者cancel
方法,而后会调用urlSession(_:task:didCompleteWithError:)
代理方法Background Sessions
后,才会调用cancelByProducingResumeData
或者cancel
方法,而后会调用urlSession(_:task:didCompleteWithError:)
代理方法Background Sessions
,可使用session.getTasksWithCompletionHandler(_:)
方法来获取任务,session的代理方法也会继续被调用(若是须要)既然已经总结出规律,那么处理起来就简单了:
Background Sessions
cancelByProducingResumeData
方法暂停任务,保证能够恢复任务
suspend
方法,但在iOS 10.0 - iOS 10.1 中暂停后若是不立刻恢复任务,会没法恢复任务,这又是一个Bug,因此不建议cancelByProducingResumeData
或者cancel
,最后会调用urlSession(_:task:didCompleteWithError:)
代理方法,能够在这里作集中处理,管理downloadTask,把resumeData
保存起来Background Sessions
后,使用session.getTasksWithCompletionHandler(_:)
来获取任务因为支持后台下载,下载任务完成时,App有可能处于不一样状态,因此还要了解对应的表现:
Background Sessions
里面全部的任务(注意是全部任务,不仅仅是下载任务)都完成后,会调用AppDelegate
的application(_:handleEventsForBackgroundURLSession:completionHandler:)
方法,激活App,而后跟在前台时同样,调用相关的session代理方法,最后再调用urlSessionDidFinishEvents(forBackgroundURLSession:)
方法Background Sessions
里面全部的任务(注意是全部任务,不仅仅是下载任务)都完成后,会自动启动App,调用AppDelegate
的application(_:didFinishLaunchingWithOptions:)
方法,而后调用application(_:handleEventsForBackgroundURLSession:completionHandler:)
方法,当建立了对应的Background Sessions
后,才会跟在前台时同样,调用相关的session代理方法,最后再调用urlSessionDidFinishEvents(forBackgroundURLSession:)
方法Background Sessions
:没有建立session时,只会调用AppDelegate
的application(_:handleEventsForBackgroundURLSession:completionHandler:)
方法,当建立了对应的Background Sessions
后,才会跟在前台时同样,调用相关的session代理方法,最后再调用urlSessionDidFinishEvents(forBackgroundURLSession:)
方法Background Sessions
后全部任务才完成:跟在前台的时候同样总结:
AppDelegate
的application(_:handleEventsForBackgroundURLSession:completionHandler:)
方法Background Sessions
,才会调用对应的session代理方法,若是不在前台,还会调用urlSessionDidFinishEvents(forBackgroundURLSession:)
具体处理方式:
首先就是Background Sessions
的建立时机,前面说过:
必须在App启动的时候建立
URLSession
,即它的生命周期跟App几乎一致,为方便使用,最好是做为AppDelegate
的属性,或者是全局变量。
缘由:下载任务有可能在App处于不一样状态时完成,因此须要保证App启动的时候,Background Sessions
也已经建立,这样才能使它的代理方法正确的调用,而且方便接下来的操做。
根据下载任务完成时的表现,结合苹果官方文档:
// 必须在AppDelegate中,实现这个方法
//
// - identifier: 对应Background Sessions的identifier
// - completionHandler: 须要保存起来
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
if identifier == urlSession.configuration.identifier ?? "" {
// 这里用做为AppDelegate的属性,保存completionHandler
backgroundCompletionHandler = completionHandler
}
}
复制代码
而后要在session的代理方法里调用completionHandler
,它的做用请看:application(_:handleEventsForBackgroundURLSession:completionHandler:)
// 必须实现这个方法,而且在主线程调用completionHandler
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else { return }
DispatchQueue.main.async {
// 上面保存的completionHandler
backgroundCompletionHandler()
}
}
复制代码
至此,下载完成的状况也处理完毕
支持后台下载的downloadTask失败的时候,在urlSession(_:task:didCompleteWithError:)
方法里面的(error as NSError).userInfo
可能会出现一个key为NSURLErrorBackgroundTaskCancelledReasonKey
的键值对,由此能够得到只有后台下载任务失败时才有相关的信息,具体请看:Background Task Cancellation
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
let backgroundTaskCancelledReason = (error as NSError).userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Int
}
}
复制代码
支持后台下载的downloadTask,因为App有可能处于后台,或者crash,或者被系统关闭,只有当Background Sessions
全部任务完成时,才会激活或者启动,因此没法处理处理重定向的状况。
苹果官方文档指出:
Redirects are always followed. As a result, even if you have implemented
urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)
, it is not called.
意思是始终听从重定向,而且不会调用urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)
方法。
前面有提到downloadTask的originalRequest
有可能为nil,只能用currentRequest
来匹配任务进行管理,但currentRequest
也有可能由于重定向而发生改变,而重定向的代理方法又不会调用,因此只能用KVO来观察currentRequest
,这样就能够获取到最新的currentRequest
URLSessionConfiguration
里有个httpMaximumConnectionsPerHost
的属性,它的做用是控制同一个host同时链接的数量,苹果的文档显示,默认在macOS里是6,在iOS里是4。单从字面上来看它的效果应该是:若是设置为N,则同一个host最多有N个任务并发下载,其余任务在等待,而不一样host的任务不受这个值影响。可是实际上又有不少须要注意的地方。
URLSessionConfiguration.default
来建立一个URLSession
时,不管在真机仍是模拟器上
httpMaximumConnectionsPerHost
设置为10000,不管是否同一个host,均可以有多个任务(测试过180多个)并发下载httpMaximumConnectionsPerHost
设置为1,对于同一个host只能同时有一个任务在下载,不一样host能够有多个任务并发下载URLSessionConfiguration.background(withIdentifier:)
来建立一个支持后台下载的URLSession
httpMaximumConnectionsPerHost
设置为10000,不管是否同一个host,均可以有多个任务(测试过180多个)并发下载httpMaximumConnectionsPerHost
设置为1,对于同一个host只能同时有一个任务在下载,不一样host能够有多个任务并发下载httpMaximumConnectionsPerHost
设置为10000,不管是否同一个host,并发下载的任务数都有限制(目前最大是6)httpMaximumConnectionsPerHost
设置为1,对于同一个host只能同时有一个任务在下载,不一样host并发下载的任务数有限制(目前最大是6)URLSession
开启下载,能够并发下载的任务数量也不会增长从以上几点能够得出结论,因为支持后台下载的URLSession
的特性,系统会限制并发任务的数量,以减小资源的开销。同时对于不一样的host,就算httpMaximumConnectionsPerHost
设置为1,也会有多个任务并发下载,因此不能使用httpMaximumConnectionsPerHost
来控制下载任务的并发数。Tiercel 2是经过判断正在下载的任务数从而进行并发的控制。
在downloadTask运行中,App进行先后台切换,会致使urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)
方法不调用
以上是我测试了一些机型后发现的问题,没有覆盖所有机型,更多的状况可自行测试
解决办法:使用通知监听UIApplication.didBecomeActiveNotification
,延迟0.1秒调用suspend
方法,再调用resume
方法
resumeData
,其实还须要对应的缓存文件,在resumeData
里能够获得缓存文件的文件名(在iOS 8得到的是缓存文件路径),由于以前推荐使用cancelByProducingResumeData
方法暂停任务,那么缓存文件会被移动到沙盒的Tmp文件夹,这个文件夹的数据在某些时候会被系统自动清理掉,因此为了以防万一,最好是额外保存一份。若是你们有耐心把前面的内容认真看完,那么恭喜大家,大家已经了解了iOS后台下载的全部特性和注意事项,同时大家也已经明白为何目前没有一款完整实现后台下载的开源框架,由于Bug和要处理的状况实在是太多。这篇文章只是我我的的一些总结,可能会存在没有发现问题或者细节,若是有新的发现,请给我留言。
目前Tiercel 2已经发布,完美地支持后台下载,还加入了文件校验等功能,须要了解更多的细节,能够参考代码,欢迎各位使用,测试,提交Bug和建议。