1,首先要确定下载好的文件放在哪里
放在缓存目录( NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)[0])是不行的,因为系统不知道什么时候就会把缓存给清了,这可能会导致用户下载的文件丢失。
所以应该放在用户的document目录,也就是 NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true)[0];
可以在这个目录下新建一个xxx.xxx.download文件夹,作为下载目录。
这里还需要注意: iOS在备份的时候会默认备份放在document目录下面的文件,而一般来说,可下载的内容一般是不需要备份的,
可以手动设置这个下载目录为不需要备份
do {
try kOJSFileManager.createDirectory(atPath: kWHGSaveDirectoryPath, withIntermediateDirectories: false, attributes: nil)
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
var url = URL(fileURLWithPath: kWHGSaveDirectoryPath)
try url.setResourceValues(resourceValues)
} catch {
NSLog(error)
}
2,确定下载所用的网络连接类
其实没啥好选的, iOS7以后已经全面开始推广URLSession了,而且URLSession功能确实很强大,这里需要确定是,是用AFNetworking又封装了一层的AFHTTPSessionManager呢,还是用系统自带的URLSession,区别是AFNetworking提供了block和delegate的回调,而系统的只有delegate的回调方式。这个看个人喜好了,我自己是更倾向于用AFNetworking的,毕竟简单好用才是硬道理。
然后看 URLSessionConfiguration,
open class var `default`: URLSessionConfiguration { get }
open class var ephemeral: URLSessionConfiguration { get }
@available(iOS 8.0, *)
open class func background(withIdentifier identifier: String) -> URLSessionConfiguration
因为要支持后台下载,所以只能用background,这里需要一个identifier,用来标识session,当重新启动应用时,如果创建和之前有相同identifier的session,系统会找到对应的session数据,并响应-URLSession: task: didCompleteWithError:方法
3,NSURLSessionTask
一个下载任务对应一个task,一个URLSession可以对应多个task,而NSURLSessionTask是一个基类,有四个子类:
1)NSURLSessionDataTask,这是一般的网络请求用的类,不支持后台传输,切换后台会终止下载。AFNetworking的get,post方法都是用的这个类
有几点需要注意,调用cancel方法会立即进入-URLSession: task: didCompleteWithError这个回调;调用suspend方法,即使任务已经暂停,但达到超时时长,也会进入这个回调,可以通过error进行判断;当一个任务调用了resume方法,但还未开始接受数据,这时调用suspend方法是无效的。也可以通过cancel方法实现暂停,只是每次需要重新创建NSURLSessionDataTask。
2)NSURLSessionUploadTask,继承自NSURLSessionDataTask,内容以NSData对象返回,协议方法中可以查看请求时上传内容的过程,支持后台传输。
3)NSURLSessionStreamTask,建立了一个TCP/IP连接,替代NSInputStream/NSOutputStream,新的API可异步读写,自动通过HTTP代理连接远程服务器。
4)NSURLSessionDownloadTask,支持断点续传,资源会下载到一个临时文件,下载完成需将文件移动至想要的路径,系统会删除临时路劲文件,暂停时,系统会返回NSData对象,恢复下载时用这个data创建task,支持后台传输。AFNetworking的下载方法也是用这个类。
downloadTask有两种创建方式,用resumeData就能实现断点续传,直接用request就是从头开始下载。
用 open func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void)方法即可产生resumeData。
4,具体下载方法就比较简单了,我这里直接用AFNetworking的download方法。
5,下载的model
把要下载的数据定义为一个model,这个model包含下载的url,保存地址,唯一标志符,下载状态,目标文件大小,已下载的大小等。
其中下载状态包括{none, downloading, waiting, paused, finished, failed}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
综上所述,大体的结构应该是这样:
有一个downloadManager来管理下载相关事务,
它有一个downloadSession来创建下载任务,下载指定类型的model;
有一个dataArray来保存各个状态的model,可以用只读的方法分成finishedArray,downloadingArray等;
这个dataArray应该是可以保存到本地的,不管是用序列化还是数据库,重启的时候应该可以重新拿到这些内容;
需要实现downloadSession的各种回调。
5,下载进度更新
首先将下载进度保存到下载的model当中,然后可以用通知或者kvo的方式来实时改变进度条。
这里稍微注意一下,让ViewController来做observer是不太合适的,下载进度更新很快,每次都在ViewController让tableView reloadData显然是不行的,
所以还是应该让cell自己来监听,而cell是复用的,配置cell用的数据可以一开始是model1,滑动几下就变成model2,model3了,
用通知的话,要判断发送通知的下载任务的model和当然配置cell的model是同一个model才可以更新;
用kvo的话,在要配置cell时,移除对原来model的监听,添加对新model的监听。
6,允许蜂窝网络下载
NSURLSessionConfiguration本身就有一个属性allowsCellularAccess,默认为YES,允许蜂窝网络下载。但是对于正在下载的任务,修改这个属性是无效的,即我们已经通过session创建了task对象,开启了任务,再试图用session.configuration.allowsCellularAccess = NO;去修改这个选项是无效的。如果一定要用这个属性修改这个选项,那么只能重新创建session。
所以如果想达到设置属性或者切换网络就暂停下载的效果,还是需要自定义一个allowsCellularAccess属性,在设置属性的时候,手动暂停正在下载的任务
7,支持后台下载
支持后台下载,首先要用background的URLConfigSession,
然后在AppDelegate中实现方法:
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void)
在其中保存completionHandler这个block;
然后在URLSession的delegate方法
- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session
中,调用这个block;
借用别人的流程图:(https://www.jianshu.com/p/1211cf99dfc3)
当APP进入后台时,系统会接管这个background的URLSession,当下载任务完成时,系统会调用AppDelegate的handleEventsForBackgroundURLSession方法,
关于completionHandler的作用,可以参考https://www.jianshu.com/p/2ccb34c460fd
8,断点续传
断点续传的功能URLSession已经封装实现了,就是靠resumeData,这个resumeData并不是真正的已经下载的文件,它只是一个描述已下载文件的描述文件,相当于用迅雷下载的时候产生的cfg文件,真正的下载中的文件应该是系统下载到临时文件夹了,下载完成后会将文件从临时文件夹移动到指定的下载目录。
需要注意的是iOS10,iOS10.1系统中(也有说法是iOS11和iOS11.1),downloadTaskWithResumeData方法有问题,需要特殊处理,参考https://benscheirman.com/2016/09/resume-data-broken-in-ios-10/,在iOS11.2之后的版本应该是已经修复了。
注意:如果想要APP在后台被kill掉时依然保存resumeData,那就不能使用AFNetworking的download方法中的那个几个block,因为AFNetworking文档有提到,这些block可能会丢失(因为是保存在内存里的嘛,没有本地化,当APP被kill掉时就没了),只能用delegate的方式,或者用session的 setXXXBlock方法,这些方法在session重新生成的时候会重新调用,所以不存在丢失的问题。
注意:正在下载的过程中,手动kill掉APP,这时候是拿不到resumeData的,resumeData是系统保存起来的,当APP再次启动,重新创建了跟原来backgroundSession的identifier一致的session时,系统会调用这个session的delegate方法
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
这个error的userInfo中,会有resumeData和task的url等信息。
在APP willTerminate的通知里调用cancelDownloadByProducingResumeData也是是不行的,因为这个方法是异步的,需要执行时间的,可能还没执行完APP就已经被kill了
另外,貌似直接调用task的resume是可行,会直接开始断点续传,但这种操作应该不是用户希望看到的。所以还是保存下来resumeData,将任务设置为paused,等用户手动开启比较好。
还有,如果APP被kill了,那delegate方法中拿到的task,跟原来开始的task不是同一个对象,但是它们所有属性都相同(我也没有一个一个挨个看,task的指针地址是不一样的,但是taskIdentifier,taskDescription,currentRequest等都是相同的)。这里尤其需要注意的是,如果用了AFNetworking,AFURLSession在初始化的时候,会调用getTasksWithCompletionHandler
方法
再其block回调中会调用addDelegateForDownloadDataTask
方法,这个方法会修改task的taskDescription,所以就算之前自己手动修改了task的taskDescription,重启后获取的还是会不一样。。。这个稍微有点坑!!!
还有一个特别容易坑的地方,也是使用AFNetworking才会有的。就是didCompleteWithError方法调用时机的问题,AFURLSession内部是用delegate方式实现回调的,提供给外部block的回调方式。而didCompleteWithError方法会在session初始化的时候( + (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration delegate:(nullable id <NSURLSessionDelegate>)delegate delegateQueue:(nullable NSOperationQueue *)queue;
)就调用,(目测应该是在设置delegate后的下一个runloop或几个runloop之内)因为session初始化后对session设置block回调是可以的,打断点观察delegate方法的调用也在getTasksWithCompletionHandler
方法的回调之后,可能是新建delegateQueue需要一点时间。总之,如果在session初始化后没有立刻给session设置block回调,而是执行了某些可能会耗时的操作(比如io读写之类),那可能在设置block回调之前delegate方法就被调用了,从而导致回调block没有没触发!!!AFURLSession内部是用delegate的方式,所以不存在这个问题~
9,下载链接失效的问题
如果下载链接失效了,继续用这个链接下载的话,就会触发didCompleteWithError方法但是error可能为nil,这时候貌似只能用task.response的statusCode来判断,注意!!!
参考:
https://blog.csdn.net/hero_wqb/article/details/80407478
https://benscheirman.com/2016/09/resume-data-broken-in-ios-10/