iOSiOS面试iOS高级开发

iOS原生级别后台下载详解

2019-01-29  本文已影响873人  Dan1els

初衷

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

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

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

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到本地,可以看出里面的结构。

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

NSURLSessionResumeServerDownloadDate

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

ResumeData的Bug

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

以上是目前总结出的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,具体表现跟普通的有很大的差别,根据上面的表格和苹果官方文档:

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

下载完成

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

总结:

具体处理方式:

首先就是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的任务不受这个值影响。但是实际上又有很多需要注意的地方。

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

前后台切换

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

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

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

注意事项

最后

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

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

上一篇 下一篇

猜你喜欢

热点阅读