AG亚游集团 >iOS开发

iOS原生级别后台下载详解

2019-01-30 10:20 编辑: Gboy 分类:iOS开发 来源: Danie1s

初衷

很久以前,我发现了一个可能要面对的问题:

怎样才能并发地下载一堆文件,并且全部下载完成后再执行其他操作?

当然,这个问题其实很简单,解决方案也有很多。但是,我第一时间想到的是,目前是否存一个具有任务组概念,非常权威,非常流行、稳定可靠,并且是用Swift写的,Github上star非常多的下载框架?我考虑的是如果存在这样的轮子,我就打算把它作为项目里专用的下载模块。很可惜,下载框架很多,也有很多这方面的文章和demo,但是像AFNetworkingSDWebImage这种著名,star非常多的,真的一个都没有,并且有一些还是用NSURLConnection实现的,用Swift写的就更少了,这让我有了打算自己撸一个的想法。

理想与现实

轮子这种东西,既然要自己撸,就不能随便,而且下载框架这方面也没权威著名的,所以一开始我打算满足自己需求的同时,尽量能做更多的事情,争取以后负责的项目都可以用得上。首先要满足的就是后台下载,众所周知iOS的App在后台是暂停的,那么要实现后台下载,就需要按照苹果的规定,使用URLSessionDownloadTask

网上一搜就有大量的相关文章和demo,然后我就开始愉快地撸代码。结果撸到一半发现,真正实现起来并且没有网上的文章说得那么简单,测试发现开源的轮子和demo也有很多地方有Bug,不完善,或者说没有完整地实现后台下载。于是只能靠自己继续深入的研究,但当时确实没有这方面研究地比较透彻文章,而时间方面也不允许,必须得尽快撸个轮子出来使用。所以最后我妥协了,我用了一个比较容易处理的办法,改成用URLSessionDataTask实现,虽然不是原生支持后台下载,但我觉得总有一些邪门歪道可以实现的,最后我写出了Tiercel,一个对现实妥协的下载框架,但也满足了我的需求,除了不支持后台下载。

勿忘初心

因为其实我并没有遇到后台下载硬性需求,所以我一直没有去寻找其他办法实现,而且我觉得如果要做,就必须使用URLSessionDownloadTask,实现原生级别的后台下载。但我心里一直都觉得没有实现当初的想法是一个极大的遗憾,于是我最后下定决心,打算把iOS的后台下载研究透彻。

终于,完美支持原生后台下载的Tiercel 2诞生了。下面我将详细讲解后台下载的实现和注意事项,希望能够帮助有需要的人。

后台下载

关于后台下载,其实苹果有提供文档---Downloading Files in the Background,但还是那句话,实现起来要面对的问题比文档说的要多得多。

URLSession

首先,如果需要实现后台下载,就必须创建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

  • 必须使用background(withIdentifier:)方法创建URLSessionConfiguration,其中这个identifier必须是固定的,而且为了避免跟其他App冲突,建议这个identifier跟App的Bundle ID相关

  • 创建URLSession的时候,必须传入delegate

  • 必须在App启动的时候创建Background Sessions,即它的生命周期跟App几乎一致,为方便使用,最好是作为AppDelegate的属性,或者是全局变量,原因在后面会有详细说明。

URLSessionDownloadTask

只有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就是需要解决的第一个大坑。

ResumeData

在iOS中,这个resumeData简直就是奇葩的存在,如果你有去研究过它,你会觉得不可思议,因为这个东西一直在变,而且经常有Bug,似乎苹果就是不想让我们去操作它。

ResumeData 的结构

在iOS12之前,直接把resumeData保存为resumeData.plist到本地,可以看出里面的结构。

  • 在iOS 8,resumeData的key:

/ url
NSURLSessionDownloadURL
/ 已经接受的数据大小
NSURLSessionResumeBytesReceived
/ currentRequest
NSURLSessionResumeCurrentRequest
/ tag
NSURLSessionResumeEntityTag
/ 已经下载的缓存文件路径
NSURLSessionResumeInfoLocalPath
/ resumeData版本
NSURLSessionResumeInfoVersion = 1
/ originalRequest
NSURLSessionResumeOriginalRequest

NSURLSessionResumeServerDownloadDate
  • 在iOS 9 - iOS 10,改动如下:

    • NSURLSessionResumeInfoVersion = 2resumeData版本升级

    • NSURLSessionResumeInfoLocalPath改成NSURLSessionResumeInfoTempFileName,缓存文件路径变成了缓存文件名

  • 在iOS 11,改动如下:

    • NSURLSessionResumeInfoVersion = 4resumeData版本再次升级,应该是直接跳过3了

    • 如果是多次对downloadTask进行 取消 - 恢复 操作,生成的resumeData会多出一个key为NSURLSessionResumeByteRange的键值对

  • 在iOS 12,resumeData编码方式改变,需要用NSKeyedUnarchiver来解码,结构没有改变

了解resumeData结构对解决它引起的Bug,实现离线断点续传,起到关键作用。

ResumeData 的Bug

resumeData不但结构一直变化,而且也一直存在各种各样的Bug

  • 在iOS 10.0 - iOS 10.1:

    • Bug:使用系统生成的resumeData无法直接恢复下载,原因是currentRequestoriginalRequestNSKeyArchived编码异常,iOS 10.2及以上会修复这个问题。

    • 解决方法:获取到resumeData后,需要对它进行修正,使用修正后的resumeData创建downloadTask,再对downloadTask的currentRequestoriginalRequest赋值,AG亚游集团Stack Overflow上面有具体说明。

  • 在iOS 11.0 - iOS 11.2:

    • Bug:由于多次对downloadTask进行 取消 - 恢复 操作,生成的resumeData会多出一个key为NSURLSessionResumeByteRange的键值对,所以会导致直接下载成功(实际上没有),下载的文件大小直接变成0,iOS 11.3及以上会修复这个问题。

    • 解决方法:把key为NSURLSessionResumeByteRange的键值对删除。

  • 在iOS 10.3 - iOS 12.1:

    • Bug:从iOS 10.3开始,只要对downloadTask进行 取消 - 恢复 操作,使用生成的resumeData创建downloadTask,它的originalRequest为nil,到目前最新的系统版本(iOS 12.1)仍然一样,虽然不会影响文件的下载,但会影响到下载任务的管理。

    • 解决方法:使用currentRequest匹配任务,这里涉及到一个重定向问题,后面会有详细说明。

以上是目前总结出的resumeData在不同的系统版本出现的改动和Bug,具体代码可以参考Tiercel

具体表现

支持后台下载的downloadTask已经创建,resumeData的问题也已经解决,现在已经可以愉快地开启和恢复下载了,但接下来要面对的是,这个downloadTask的具体表现,这也是实现一个下载框架最重要的环节。

下载过程中

为了测试downloadTask在不同情况下的表现,花费了大量的时间和精力,具体如下:


QQ截图20190130102542.png


支持后台下载的URLSessionDownloadTask,真实类型是__NSCFBackgroundDownloadTask,具体表现跟普通的有很大的差别,根据上面的表格和苹果官方文档:

  • 当创建了Background Sessions,系统会把它的identifier记录起来,只要App重新启动后,创建对应的Background Sessions,它的代理方法也会继续被调用

  • 如果是任务被session管理,则下载中的tmp格式缓存文件会在沙盒的caches文件夹里;如果不被session管理,且可以恢复,则缓存文件会被移动到Tmp文件夹里;如果不被session管理,且不可以恢复,则缓存文件会被删除。即:

    • downloadTask运行中和调用suspend方法,缓存文件会在沙盒的caches文件夹里

    • 调用cancelByProducingResumeData方法,则缓存文件会在Tmp文件夹里

    • 调用cancel方法,缓存文件会被删除

  • 手动Kill App会调用了cancelByProducingResumeData或者cancel方法

    • 在iOS 8 上,手动kill会马上调用cancelByProducingResumeData或者cancel方法,然后会调用urlSession(_:task:didCompleteWithError:)代理方法

    • 在iOS 9 - iOS 12 上,手动kill会马上停止下载,当App重新启动后,创建对应的Background Sessions后,才会调用cancelByProducingResumeData或者cancel方法,然后会调用urlSession(_:task:didCompleteWithError:)代理方法

  • 进入后台、crash或者App被系统关闭,系统会有另外一条进程对下载任务进行管理,没有开启的任务会自动开启,已经开启的会保持原来的状态(继续运行或者暂停),当App重新启动后,创建对应的Background Sessions,可以使用session.getTasksWithCompletionHandler(_:)方法来获取任务,session的代理方法也会继续被调用(如果需要)

  • 最令人意外的是,只要没有手动手动Kill App,就算重启手机,重启完成后原来在运行的下载任务还是会继续下载,实在牛逼

既然已经总结出规律,那么处理起来就简单了:

  • 在App启动的时候创建Background Sessions

  • 使用cancelByProducingResumeData方法暂停任务,保证可以恢复任务

    • 其实也可以使用suspend方法,但在iOS 10.0 - iOS 10.1 中暂停后如果不马上恢复任务,会无法恢复任务,这又是一个Bug,所以不建议

  • 手动Kill App会调用了cancelByProducingResumeData或者cancel,最后会调用urlSession(_:task:didCompleteWithError:)代理方法,可以在这里做集中处理,管理downloadTask,把resumeData保存起来

  • 进入后台、crash或者App被系统关闭,不影响原来任务的状态,当App重新启动后,创建对应的Background Sessions后,使用session.getTasksWithCompletionHandler(_:)来获取任务

下载完成

由于支持后台下载,下载任务完成时,App有可能处于不同状态,所以还要了解对应的表现:

  • 在前台:跟普通的downloadTask一样,调用相关的session代理方法

  • 在后台:当Background Sessions里面所有的任务(注意是所有任务,不单单是下载任务)都完成后,会调用AppDelegateapplication(_:handleEventsForBackgroundURLSession:completionHandler:)方法,激活App,然后跟在前台时一样,调用相关的session代理方法,最后再调用urlSessionDidFinishEvents(forBackgroundURLSession:)方法

  • 代码引起的crash或者App被系统关闭:当Background Sessions里面所有的任务(注意是所有任务,不单单是下载任务)都完成后,会自动启动App,调用AppDelegateapplication(_:didFinishLaunchingWithOptions:)方法,然后调用application(_:handleEventsForBackgroundURLSession:completionHandler:)方法,当创建了对应的Background Sessions后,才会跟在前台时一样,调用相关的session代理方法,最后再调用urlSessionDidFinishEvents(forBackgroundURLSession:)方法

  • crash或者App被系统关闭,打开App保持前台,当所有的任务都完成后才创建对应的Background Sessions:没有创建session时,只会调用AppDelegateapplication(_:handleEventsForBackgroundURLSession:completionHandler:)方法,当创建了对应的Background Sessions后,才会跟在前台时一样,调用相关的session代理方法,最后再调用urlSessionDidFinishEvents(forBackgroundURLSession:)方法

  • 代码引起的crash或者App被系统关闭,打开App,创建对应的Background Sessions后所有任务才完成:跟在前台的时候一样

总结:

  • 只要不在前台,当所有任务完成后会调用AppDelegateapplication(_:handleEventsForBackgroundURLSession:completionHandler:)方法

  • 只有创建了对应Background Sessions,才会调用对应的session代理方法,如果不在前台,还会调用urlSessionDidFinishEvents(forBackgroundURLSession:)

具体处理方式:

首先就是Background Sessions的创建时机,前面说过:

必须在App启动的时候创建Background Sessions,即它的生命周期跟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.session.configuration.identifier ?? "" {
            / 这里用作为AppDelegate的属性,保存completionHandler
            backgroundCompletionHandler = completionHandler
        }
}

然后要在session的代理方法里调用completionHandler,它的作用请看:application(_:handleEventsForBackgroundURLSession:completionHandler:)

/ 必须实现这个方法,并且在主线程调用completionHandler
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession{
    DispatchQueue.main.async {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
            let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else {
                return
        }
        / 上面保存的completionHandler
        backgroundCompletionHandler()
    }
}

至此,下载完成的情况也处理完

下载错误

支持后台下载的downloadTask失败的时候,在urlSession(_:task:didCompleteWithError:)方法里面的(error as NSError).userInfo可能会出现一个key为NSURLErrorBackgroundTaskCancelledReasonKey的键值对,由此可以获得只有后台下载任务失败时才有相关的信息,具体请看:Background Task Cancellation

/ 或者是在session delegate 的 urlSession(_:task:didCompleteWithError:) 方法里面获取
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?{
    if let error = error {
        let backgroundTaskCancelledReason = (error as NSError).userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Data
    }
}

支持后台下载的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

前后台切换

在downloadTask运行中,App进行前后台切换,会导致urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)方法不调用

  • 在iOS 12 - iOS 12.1,iPhone 8 以下的真机中,App进入后台再回到前台,进度的代理方法不调用,当再次进入后台的时候,有短暂的时间会调用进度的代理方法

  • 在iOS 12.1,iPhone XS的模拟器中,多次进行前台后台切换,偶尔会出现进度的代理方法不调用,真机目测不会

  • 在iOS 11.2.2,iPhone 6真机中,进行前台后台切换,会出现进度的代理方法不调用,多次切换则有机会恢复

以上是我测试了一些机型后发现的问题,没有覆盖全部机型,更多的情况可自行测试

解决办法:使用通知监听UIApplication.didBecomeActiveNotification,延迟0.1秒调用suspend方法,再调用resume方法

注意事项

  • 沙盒路径:用Xcode运行和停止项目,可以达到App crash的效果,但是无论是用真机还是模拟器,每用Xcode运行一次,都会改变沙盒路径,这会导致系统对downloadTask相关的文件操作失败,在某些情况系统记录的是上次的项目沙盒路径,最终导致出现奇怪的错误。我刚开始就是遇到这种情况,我并不知道是这个原因,所以觉得无法预测,也无法解决。各位在开发测试的时候,一定要注意。

  • 缓存文件,前面说了恢复下载依靠的是resumeData,其实还需要对应的缓存文件,在resumeData里可以得到缓存文件的文件名(在iOS 8获得的是缓存文件路径),因为之前推荐使用cancelByProducingResumeData方法暂停任务,那么缓存文件会被移动到沙盒的Tmp文件夹,这个文件夹的数据在某些时候会被系统自动清理掉,所以为了以防万一,最好是自己保存一份。

最后

如果大家有耐心把前面的内容认真看完,那么恭喜你们,你们已经了解了iOS后台下载的所有特性和注意事项,同时你们也已经明白为什么目前没有一款完整实现后台下载的开源框架,因为Bug和要处理的情况实在是太多。这篇文章只是我个人的一些总结,可能会存在没有发现问题或者细节,如果有新的发现,请给我留言。

目前AG亚游集团Tiercel 2已经发布,完美地支持后台下载,还加入了文件校验等功能,需要了解更多的细节,可以参考代码,欢迎各位使用,测试,提交Bug和建议。


作者:Danie1s
链接:/juejin0686im/post/5c4ed0b0e51d4511dc730799


搜索CocoaChina微信公众号:CocoaChina
微信扫一扫
订阅每日移动开发及APP推广热点资讯
公众号:
CocoaChina
我要投稿   收藏文章
上一篇:ios CAAnimation动画实践和SceneKit飞机小游戏
我来说两句
发表评论
您还没有登录!请登录注册
所有评论(0

综合评论

相关帖子

sina weixin mail 回到顶部
曝贵州欲为斯蒂夫注册遭足协拒绝 回延边没可能 全球债务处历史高位,IMF呼吁各国应未雨绸缪 部分亚马逊商家正在欺骗消费者支付数千美元的运费 柯洁棋迷答谢会8月2日举行 互动问答指导棋 俄称中国赠菲武器标志互信加强 菲决意摆脱对美依赖 大马大师赛国羽喜忧参半 张楠刘成止步8强 亚巡新西兰赛澳球手领先 梁容银压线晋级崔京周淘汰 网络中立法案该不该保留?美国两院、白宫掐起来了 我国防部:文职人员改革对我军建设有三点重要意义 文筱婷:对保级目标比较有信心 曼萨诺满意引援 台湾高雄路面突然塌陷 司机惊慌弃车脱困(图) 支付宝回应央行18万罚单:各项改进措施已落实完毕
欧文:皇马利物浦实力五五开 没想过齐祖会当教练 黎巴嫩将废旧坦克沉入海底拯救生态 炮口对准以色列 毕加索画作拍出4980万英镑高价 系其恋人肖像 笑喷!巴萨杀神为洗黄牌面子都不要了 就差咬人 北京有房东700万不卖 当地楼市最强调控1年后惨了 美国依据301调查结果公布拟加征关税中国商品清单 游客\"沾光\" 东京都政府决定2020年奥运会间免住… 又见血月:本世纪时间最长的月全食明天后半夜发生 惊呆了!时间领主又秀逆天神操作 锁定年度最佳 巴黎宣布内马尔周末手术 缺席6-8周铁定缺战皇马 政协委员呼吁推动养老业发展 吸引中青年培训从业 巴西联邦法官发出逮捕令 要求卢拉自首入狱服刑
消防吐槽《绝地求生》主播操作:身上着火别瞎跑 垃圾iMessage没停过 苹果称对垃圾短信无法完全屏… 法国前司法部长因涉嫌挪用公款被立案调查 火箭军某基地党委“领跑”练兵 多名主官摘金夺银 平昌冬残奥会落幕 北京文艺表演震撼全场 人民日报:除了道歉和删除,快手们还应做点什么? 毒贩被便衣警察追捕 情急之下掏手机报警称遭抢劫 Lyft推出包月制固定价格打车服务 同一线路同一价格 邦达亚洲:特朗普开启贸易战 美指高位跳水收跌 女人开什么店比较好 夏天什么产品最好卖 养20头牛一年赚多少钱 白手起家创业做什么 AG亚游集团