iOS原生级别后台下载详解

初衷

好久之前,我发现了一个将要面对的问题:ios

怎样才能并发地下载一堆文件,而且所有下载完成后再执行其余操做?git

固然,这个问题其实很简单,解决方案也有不少。但我第一时间想到的是,目前是否存一个具备任务组概念,很是权威,很是流行、稳定可靠,而且是用Swift写的,Github上star很是多的下载框架?若是存在这样的轮子,我就打算把它做为项目里专用的下载模块。很惋惜,下载框架不少,也有不少这方面的文章和Demo,可是像AFNetworkingSDWebImage这种著名权威,star很是多的,真的一个都没有,并且有一些仍是用NSURLConnection实现的,用Swift写的就更少了,这让我有了打算本身实现一个的想法。github

理想与现实

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

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

勿忘初心

由于其实我并无遇到后台下载硬性需求,因此我一直没有寻找其余办法去实现,并且我以为若是要作,就必须使用URLSessionDownloadTask,实现原生级别的后台下载。随着时间的推移,我内心一直都以为没有完成当初的想法是一个极大的遗憾,因而我最后下定决心,打算把iOS的后台下载研究透彻。缓存

终于,完美支持原生后台下载的Tiercel 2诞生了。下面我将详细讲解后台下载的实现和注意事项,但愿可以帮助有须要的人。服务器

后台下载

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

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,实际上是__NSURLBackgroundSessionapp

  • 必须使用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
// Etag,下载文件的惟一标识
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赋值,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在不一样状况下的表现,花费了大量的时间和精力,具体以下:

操做 建立 运行中 暂停(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管理,且不能够恢复,则缓存文件会被删除。即:
    • 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从新启动后,建立对应的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从新启动后,建立对应的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启动的时候建立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的任务不受这个值影响。可是实际上又有不少须要注意的地方。

  • 没有资料显示它的最大值是多少,经测试,设置为1000000都没有问题,可是若是设置为Int.Max,则会出问题,对于大多数URL都是没法下载(应该跟目标url的服务器有关);若是设置为小于1,对于大多数URL都没法下载
  • 当使用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开启下载,能够并发下载的任务数量也不会增长
      • 如下是部分系统并发数的限制
        • iOS 9 iPhone SE上是3
        • iOS 10.3.3 iPhone 5上是3
        • iOS 11.2.5 iPhone 7Plus上是6
        • iOS 12.1.2 iPhone 6s上是6
        • iOS 12.2 iPhone XS Max上是6

从以上几点能够得出结论,因为支持后台下载的URLSession的特性,系统会限制并发任务的数量,以减小资源的开销。同时对于不一样的host,就算httpMaximumConnectionsPerHost设置为1,也会有多个任务并发下载,因此不能使用httpMaximumConnectionsPerHost来控制下载任务的并发数。Tiercel 2是经过判断正在下载的任务数从而进行并发的控制。

先后台切换

在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相关的文件操做失败,在某些状况系统记录的是上次的项目沙盒路径,最终致使出现没法开启任务下载、找不到文件夹等错误。我刚开始就是遇到这种状况,我并不知道是这个缘由,因此以为没法预测,也没法解决。各位在开发测试的时候,必定要注意。
  • 真机与模拟器:因为iOS后台下载的特性和注意事项实在太多,并且不一样的iOS版本之间还存在必定的差异,因此使用模拟器进行开发和测试是一种很方便的选择。可是有些特性在真机和模拟器上表现又会不同,例如在模拟器上下载任务的并发数是很大的,而在真机上则很小(在iOS 12上是6),因此必定要在真机上进行测试或者校验,以真机的结果为准。
  • 缓存文件:前面说了恢复下载依靠的是resumeData,其实还须要对应的缓存文件,在resumeData里能够获得缓存文件的文件名(在iOS 8得到的是缓存文件路径),由于以前推荐使用cancelByProducingResumeData方法暂停任务,那么缓存文件会被移动到沙盒的Tmp文件夹,这个文件夹的数据在某些时候会被系统自动清理掉,因此为了以防万一,最好是额外保存一份。

最后

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

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

相关文章
相关标签/搜索