iOS原生级别后台下载详解

初衷

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

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

当然,这个问题其实很简单,解决方案也有很多。但是,我第一时间想到的是,目前是否存一个具有任务组概念,非常权威,非常流行、稳定可靠,并且是用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 的结构

在iOS 12之前,直接把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赋值,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文件夹,会调用didCompleteWithErrorcaches文件夹里面的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被系统关闭,系统会有另外一条进程对下载任务进行管理,没有开启的任务会自动开启,已经开启的会保持原来的状态(继续运行或者暂停),当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.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

// 或者是在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? 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

前后台切换

在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和要处理的情况实在是太多。这篇文章只是我个人的一些总结,可能会存在没有发现问题或者细节,如果有新的发现,请给我留言。

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值