自定义 NSURLSessionDownloadTask Resume Data 内容

NSURLSession

Apple 在 iOS 7 之后将原有的 NSURLConnection 替换成了新的 NSURLSession ,新的 NSURLSession 包含诸多新的特性,其中有一项就是在应用退出到后台以及崩溃后,系统可以继续帮我们进行上传、下载任务。

同时,NSURLSession 对一些任务进行了很好的封装。例如我们只需要使用下面这种方法就可以开始一个下载任务,非常简单。

var downloadTask = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration()).downloadTaskWithURL(NSURL("SomePathString")!)

downloadTask.resume()  

同时, NSURLSession 对进行中的任务也可以做到很好的管理。通过系统的 delegate 方法,例如

optional func URLSession(\_ session: NSURLSession, downloadTask downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten totalBytesWritten: Int64, totalBytesExpectedToWrite totalBytesExpectedToWrite: Int64)  

可以在下载过程中获取到下载的进度,以及进行一些处理。

NSURLSessionDownloadTask 续传

NSURLSessionDownloadTask 可以通过 suspend() 方法进行暂停,并在程序没有被终止之前通过 resume() 方法进行恢复。

假如需要在应用重新开启后继续下载,可以通过 cancelByProducingResumeData(_ completionHandler: (NSData?) -> Void) 方法产生一个 NSData。将这个 NSData 存储到本地,待应用程序再次启动时从本地文件中读取回这个 NSData,就可以实现。就像下面这样:

let localPath = "SomedLocalPathToSaveResumeData"  
downloadTask.cancelByProducingResumeData(){  
    (resumeData) -> void in
        resumeData?.writeToFile(localPath, atomically: true)
}

待程序再次启动后:

let localPath = "SomeLocalPathToSaveResumeData"  
let resumeData = NSData(contentsOfFile: localPath)  
var resumeDownloadTask = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration()).downloadTaskWithResumeData(resumeData!)

resumeDownloadTask.resume()  

这样通过这个 ResumeData 就可以方便的在应用关闭、重新启动后进行下载任务的续传了。

实际中遇到的问题

最近在项目中使用 NSURLSession 来完成一些下载任务。需要续传时很自然的就使用到了 resumeData 来恢复下载任务。

现实和想象总是存在差距的。

cancelByProducingResumeData 方法的文档中有这么一段话:

A download can be resumed only if the following conditions are met:

  • The resource has not changed since you first requested it
  • The task is an HTTP or HTTPS GET request
  • The server provides either the ETag or Last-Modified header (or both) in its response
  • The server supports byte-range requests
  • The temporary file hasn’t been deleted by the system in response to disk space pressure

这就产生了问题。因为项目中的下载任务,资源的 URL 是有时效的,每一次请求资源的 URL 都会发生改变。当从 resumeData 中恢复出一个 NSURLSessionDownloadTask 后,由于在 resumeData 中包含了第一次请求的 request。所以开始任务后就会出错。收到的响应状态码是 404,因为资源的 URL 发生了改变。

ResumeData 的内容

ResumeData 存入一个文件后打开,格式是这样的:

<?xml version="1.0" encoding="UTF-8"?\>  
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"\>  
<plist version="1.0">  
<dict>  
    <key>NSURLSessionDownloadURL</key>
    <string>https://d1opms6zj7jotq.cloudfront.net/idea/ideaIC-15.0.2-custom-jdk-bundled.dmg</string>
    <key>NSURLSessionResumeBytesReceived</key>
    <integer>25858975</integer>
    <key>NSURLSessionResumeCurrentRequest</key>
    <data>
    YnBsaXN0MDDUAQIDBAUGcnNYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3AS
    AAGGoK8QFwcIKUVLTFJTVFU4VjpXWGRlZmdoaWpuVSRudWxs3xAeCQoLDA0ODxAREhMU
    FRYXGBkaGxwdHh8gISIjJCUmJygpKissLSgvMDErMysqKio4Jzo7Kj0qPyo7QjhDUyQx
    ... 后面省略
    </data>
    <key>NSURLSessionResumeEntityTag</key>
    <string>"054beecc2c7dbd16d58ecb9084ab4b79-19"</string>
    <key>NSURLSessionResumeInfoTempFileName</key>
    <string>CFNetworkDownload_desnj1.tmp</string>
    <key>NSURLSessionResumeInfoVersion</key>
    <integer>2</integer>
    <key>NSURLSessionResumeOriginalRequest</key>
    <data>
    YnBsaXN0MDDUAQIDBAUGTk9YJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3AS
    AAGGoKwHCCM5P0BGR0gvSUpVJG51bGzfEBgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAh
    IiMkJSInKCOg==
    ... 后面省略
    </data>
    <key>NSURLSessionResumeServerDownloadDate</key>
    <string>Mon, 07 Dec 2015 21:52:40 GMT</string>
</dict>  
</plist>  

是一个 plist 文件。 其中 NSURLSessionDownloadURL 是资源的 URL。NSURLSessionResumeBytesReceived 记录下来了已经下载完成的字节数。NSURLSessionResumeCurrentRequest 以及 NSURLSessionResumeOriginalRequest 是当前以及初始请求时的 NSURLRequest 对象。是通过 NSKeyArchiver encode 出的数据。NSURLSessionResumeEntityTagE-Tag 部分。 NSURLSessionResumeInfoTempFileName 是下载过程中临时文件所存储的位置,存储在应用程序 tmp 文件夹下。好了我们需要的就是这几个部分(主要是后面几个没去深究)。

重新生成 ResumeData

先上代码:

//1
let savePath = "ResumeDataPlistFilePath"  
var resumeData = NSData(contentsOfFile: savePath)

var resumeDataDict: NSMutableDictionary  
do {  
    resumeDataDict = try NSPropertyListSerialization.propertyListWithData(resumeData!, operions: NSPropertyListReadOptions.Immutable, format: nil) as! NSMutableDictionary
}catch {
    print("Catch Error: \(error)")
}

//2
var newResumeRequest = NSMutableURLRequest(URL: NSURL(string: "NewRemoteURL")!)  
newResumeRequest.addValue("bytes=\(resumeDataDict["NSURLSessionResumeBytesReceived"])", forHTTPHeaderField: "Range")

//3
var newResumeRequestData = NSKeyedArchiver.archivedDataWithRootObject(newResumeRequest)

//4
resumeDataDict.setObject(newResumeRequestData, forKey: "NSURLSessionResumeCurrentRequest")  
resumeDataDict.setObject("NewRemoteURL", forKey: "NSURLSessionResumeCurrentRequest")

//5
var newResumeData: NSData?  
do {  
    newResumeData = try NSPropertyListSerialization.dataWithPropertyList(resumeDataDict, format: NSPropertyListFormat.XMLFormat_v1_0, option: 0)
}catch {
    print("Catch Error: \(error)")
}

将 plist 文件重新读回应用中后是一个 NSData 文件,首先第一步将 NSData 通过 NSData(contentsOfFile: savePath) 从之前存储的 plist 文件中读取。并转化为 NSMutableDictionary

第二步重新生成一个 NSURLMutableRequest,之所以用 Mutable,是因为方便后面自定义信息。 第二行代码通过为 NSURLMutableRequest 为 HTTP 请求的头部增加了一个 Range 字段,这样我们就可以从 Range 标明的位置执行下载了。

第三步重新将 NSURLMutableRequest encode 为 NSData

第四步讲新的 URL 和 新生成的 NSData 存储到字典中。

第五步将字典重新序列化为 NSData

这样就完成了,只要每一次停止时生成一个数据,开始时读取、修改数据。就可以在 URL 不断改变的情况下进行续传了。

后记

这是我第一次用 Swift 写 Blog。也是最近一些日子学习 Swift 的一些成果吧。暂时还不是很熟悉这门语言。但是这大概就代表了 Apple 未来的趋势。虽然相对于 Objective-C 来说有些许的复杂,但是新的特性的出现依旧令人激动。 最近 Swift 也如约开源,相信它将来会有更大的发展空间。

又将 Blog 搬回了 Github Page,这一次换成了 Hexo。