iOS的下载的模块是App中的一个重要的模块,例如视频类,音乐类的app等等。本文主要是分析下网络下载模块的几种的选型的方案以及各种方案过程中遇到的坑。笔者能力有限,错误之处,还望指点,及时改正。
常用的缓存模块包括以下的几个核心的功能:
1.断点续传
2.后台下载
3.持久化到本地
4.网络切换处理
5.删除缓存任务
一个完整的缓存模块都必须至少具备上述的几个这几个功能,一些额外的功能只需从业务层上面进行实现即可,例如批量添加,批量删除等等。本文主要讨论下断点续传,后台下载以及缓存model持久化到本地这三个方面。
iOS中,关于网络请求的接口,从下而上的话主要分为以下几层:
CFSocket
CFNetwork ---------------------->ASIHTTPRequest
NSURLConnectioon ----------->AFN2.x
NSURLSession ----------------->AFN3.x
在iOS7之前,网络是基于NSURLConnection来创建网络的链接,iOS7之后,apple逐步废弃NSURLConnection,取而代之的是NSURLSession,这更进一步方便了广大的开发者来实现网络的功能。NSURLSession和NSURLConnection的优缺点不是本文的描述的范围之类,具体的可以自行google。虽然ASI几年前已不再维护,但是作为技术的实现,仍在本文的讨论范围之类。所以,主要由以下几中实现方法。
方案一
基于ASIHTTPRequest的下载实现。由于ASIHTTPRequest好几年前就不在维护了,所以,基本上使用的人也很少。但是作为实现的方案,大致描述下实现的思路。
ASIHTTPRequest是基于CFNetwork的网络封装,其实现下载的本质是自定义了NSOperation,开启了一个永久的子线程来进行网络的下载。
@interface ASIHTTPRequest : NSOperation <NSCopying>
在接收到下载的数据之后,将其写入到文件流里面去。
BOOL append = NO;
if (![self fileDownloadOutputStream]) {
if (![self temporaryFileDownloadPath]) {
[self setTemporaryFileDownloadPath:[NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]];
} else if ([self allowResumeForFileDownloads] && [[self requestHeaders] objectForKey:@"Range"]) {
if ([[self responseHeaders] objectForKey:@"Content-Range"]) {
append = YES;
} else {
[self incrementDownloadSizeBy:-(long long)[self partialDownloadSize]];
[self setPartialDownloadSize:0];
}
}
[self setFileDownloadOutputStream:[[[NSOutputStream alloc] initToFileAtPath:[self temporaryFileDownloadPath] append:append] autorelease]];
[[self fileDownloadOutputStream] open];
}
[[self fileDownloadOutputStream] write:buffer maxLength:(NSUInteger)bytesRead];
主要的写数据流的过程如代码所示。
方案二
AFN2.x + AFDownlaodRequestOperation:
现在主流的网络的底层框架都是基于AFN的,有的公司可能网络库还是停留在AFN的2.x就是NSURLConnection这个版本。但是AFN2.x以及AFN3.x对文件的下载没有做太多的封装,功能都不是很完善,可能需要我们去做一些别的工作。
AFDownlaodRequestOperation继承自AFHTTPRequestOperation,封装了写文件流NSFileStream的操作,将接收到的数据写入到本地。AFDownlaodRequestOperation的主要的流程如下:
- (id)initWithRequest:(NSURLRequest *)urlRequest fileIdentifier:(NSString *)fileIdentifier targetPath:(NSString *)targetPath shouldResume:(BOOL)shouldResume {
if ((self = [super initWithRequest:urlRequest])) {
....
NSString *tempPath = [self tempPath];
....
BOOL isResuming = [self updateByteStartRangeForRequest];
....
self.outputStream = [NSOutputStream outputStreamToFileAtPath:tempPath append:isResuming];
....
}
return self;
}
上述截取的代码片段,主要做了如下的操作,获得下载的临时文件的路径并且计算文件的大小,判断是否是追加到文件的末尾,打开数据流。
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
...
if(httpResponse.statusCode == 206) {//206是断点续传的状态码
NSString *contentRange = [httpResponse.allHeaderFields valueForKey:@"Content-Range"];
if ([contentRange hasPrefix:@"bytes"]) {
NSArray *bytes = [contentRange componentsSeparatedByCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@" -/"]];
if ([bytes count] == 4) {
fileOffset = [bytes[1] longLongValue];
totalContentLength = [bytes[3] longLongValue];
}
}
}
从response中获取contentOffset和totalLength。
....
//如果本地的contentOffset和服务端返回的不一致,则将本地进行截取。
if ([self fileSizeForPath:tempPath] != _offsetContentLength) {
[self.outputStream close];
BOOL isResuming = _offsetContentLength > 0;
if (isResuming) {
NSFileHandle *file = [NSFileHandle fileHandleForWritingAtPath:tempPath];
[file truncateFileAtOffset:_offsetContentLength];
[file closeFile];
}
//重新创建输出流。
self.outputStream = [NSOutputStream outputStreamToFileAtPath:tempPath append:isResuming];
[self.outputStream open];
}
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
...
NSUInteger length = [data length];
while (YES) {
NSInteger totalNumberOfBytesWritten = 0;
if ([self.outputStream hasSpaceAvailable]) {
const uint8_t *dataBuffer = (uint8_t *)[data bytes];
NSInteger numberOfBytesWritten = 0;
while (totalNumberOfBytesWritten < (NSInteger)length) {
numberOfBytesWritten = [self.outputStream write:&dataBuffer[(NSUInteger)totalNumberOfBytesWritten] maxLength:(length - (NSUInteger)totalNumberOfBytesWritten)];
if (numberOfBytesWritten == -1) {
break;
}
totalNumberOfBytesWritten += numberOfBytesWritten;
}
break;
}
...
}
将NSData写入到NSOutputStream中,从而实现文件的下载。
方案三
iOS7之后,iOS SDK逐渐用NSURLSession的网络的架构来取代了NSURLConnection(关于两者的对比)。NSURLSession中,实现下载的任务会更简单,包括前台的下载以及后台常驻进程的下载。这里,我不建议使用通过封装AFN3.x 的形式来进行大文件的下载,原因后面在详细的描述。在自己执行各种delegate回调的时候,这其中有些坑或者问题是一般不容易察觉的。下面分别讲述一下利用NSURLSsssion来进行下载的过程中的方案以及遇到的点。
1.基于NSURLSessionDataTask。
使用NSURLSessionDataTask来进行任务下载的话,其本质是和ASI以及NSURLConnection一样,都是接收到NSData的数据,再通过NSOutputStram写入到本地沙盒实现永久存储。
主要的实现过程如下:
//创建一个数据的model,相关的数据放在model里面,包括文件的下载的地址以及文件的存贮的地址等等。
self.downloadModel = [[LLDownloadBaseModel alloc] init];
self.downloadModel.url = @"xxxx";
self.downloadModel.target = [NSURL URLWithString:[[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:self.downloadModel.url.lastPathComponent]];
//使用默认的配置来初始化session。
NSURLSessionConfiguration *confi = [NSURLSessionConfiguration defaultSessionConfiguration];
//创建一个operationQueue,session的所有的回调都在这个delegate里面进行。
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
//创建session。注意,这里的retainCycle的问题。VC强引用session,session强引用delegate。
self.session = [NSURLSession sessionWithConfiguration:confi delegate:self delegateQueue:queue];
开始一个任务的时候,判断文件存在不存在。如果不存在,就从头开始下载,如果断点续传则需要设置HTTP的请求头属性。
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:self.downloadModel.url]];
//如果文件存在,读取已经下载的字节数,进行断点续传。
if ([[NSFileManager defaultManager] fileExistsAtPath:self.downloadModel.target.absoluteString]) {
long long downloadedBytes = [self fileSizeForPath:self.downloadModel.target.absoluteString];
//设置请求头的Range属性。
NSString *requestRange = [NSString stringWithFormat:@"bytes=%llu-", downloadedBytes];
[request setValue:requestRange forHTTPHeaderField:@"Range"];
}
NSURLSessionDataTask *dataTask = [self.session dataTaskWithRequest:request];
[dataTask resume];
self.dataTask = dataTask;
调用NSURLSessionTask的resume之后,开始请求网络,回调delegate的receiveResponse,receiveData等相关的方法。
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler
当delegate的receiveResponse方法被回调的时候,在这里创建文件(如果文件不存在的话)并且打开文件的数据流NSOutputStream。
NSURLSessionResponseDisposition 表示这个task接收到这个response之后如何进行下一步的处理。
typedef enum NSURLSessionResponseDisposition : NSInteger {
NSURLSessionResponseCancel = 0,//取消任务,相当于[task cancel]
NSURLSessionResponseAllow = 1,//允许加载继续进行
NSURLSessionResponseBecomeDownload = 2,//变成一个download下载任务
NSURLSessionResponseBecomeStream = 3//变成一个流任务(iOS9.0开始)
} NSURLSessionResponseDisposition;
所以在completionHandler中传递NSURLSessionResponseAllow参数,否则后续的delegate方法比如receiveData回调得不到调用。
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveData:(NSData *)data
在receiveData的调用中进行数据的接受,以及写入到文件的数据流中,从而存到本地的沙盒。
至此,一个简单的基于NSURLSesisonDataTask的支持断点续传的文件下载模块就可以大致的实现了。但是这种方法有个问题:不支持后台的任务运行。当app进入后台,下载任务就会停止。这对于已经申请了后台工作模式的app来说,任务的下载进入到后台之后任务可以继续保持运行,但是大部分的app是没有后台运行权限的。后台的运行权限只允许这几类app来申请:VOIP,Picture in Picture,Music等等。具体的参考喵神的这篇文章。所以,一般的app无法具备后台的下载的权限,也就是说使用NSURLSesisonDataTask实现后台下载行不通。好在,苹果提供了NSURLSessionDownloadTask这个类。
NSURLSessionDownloadTask:
创建一个支持后台下载的downloadTask,需要在创建session的时候使用background模式,defaultSessionConfiguration和ephemeralSessionConfiguration这两种配置在程序进入后台之后仍然无法继续下载。创建的流程一般如下:
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.download.backgroundTask"];
这个com.download.backgroundTask表示的是你这个session的id,这个id会在后续的杀死app以及后台传输完成时用到。创建一个donwloadTask进行下载的过程如下:
如上图所示,resume一个task之后,直到下载完成之前,可能会经过如下的回调方法。
但是,这个是理想的情况。在实际的情况下,downloadTask需要考虑的问题很多,比如新开启的newTask的回调时序图所示。下面依次分析在这个时序中可能会出现的问题(上图只是简单的描述了相关delegate回调的情况,有些回调的情况没有考虑,这里只是为了说明情况)。
在上图的PS1,手动的取消了task之后,这个operation会被从operationQueue中移除。相对于AFN2.x时候的AFURLOperation不同的是,前者是用NSOperation封装了NSURLConnection的对象并且显示的加入到了operationQueue中,而调用[task resume]的时候,系统会自动的在内部创建operationQueue并将这个task加入。downloadTask调用cancelByProducingResumeData来取消网络请求,其方法声明如下:
- (void)cancelByProducingResumeData:(void (^)(NSData * _Nullable resumeData))completionHandler
系统提供了一个参数为NSData的回调,在这个回调里面,我们可以获得这个下载的resumeData数据。与此同时未下载完成的文件会被move到沙盒中的tmp文件夹下面。
task被取消之后,NSURLSessionTask的completeWithError回调到执行
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error
这时候error中也包含了resumeData的相关的信息。至此,我们就可以在这两个不同的地方获取到resumeData了。
resumeData其本质是在下载文件的时候系统为我们产生的一个记录下载信息的XML文件,而非已经下载的数据的本身。其内容的格式如下:
NSURLSessionDownloadURL代表的是请求的url的地址。NSURLSessionResumeBytesReceived表示当前已经缓存下来的文件的字节数。
NSURLSessionResumeEntityTag表示的网络实体的tag,用来表示这个网络资源有没有发生变动。
NSURLSessionResumeInfoTempFileName表示的是在下载完成之前,这个临时问价的文件名。
NSURLSessionResumeCurrentRequest:用来进行断点续传的request。
NSURLSessionResumeOriginalRequest:原始的请求的request。
通过这个resumeData可以解析到XML的字符串的内容,从而通过字符截取的方式来获得临时文件所对应的tmpFileName,这样就可以在tmp文件夹下面找到对应的文件。
拿到这个resumeData之后,怎么将这个resumeData保存起来供下次使用呢?我们在下载的时候,都会去创建一个相对应的model来保存相关的信息,比如下载的url,文件的保存地址,文件的大小以及文件已经下载的大小等等。所以,在这个缓存的model中,创建一个属性来保存这个resumeData。NSData实现了NSCoping协议,支持序列化。所以,我们可以讲包含下载model的数组直接写入到等地沙盒永久存储。
可以调用NSArray的如下方法来写入:
- (BOOL)writeToFile:(NSString *)path atomically:(BOOL)useAuxiliaryFile;
通过这样的方式,我们就可以实现缓存model的本地持久化,包括resumeData的持久化。
下面有一个重要的不容易被觉察到的tmp文件的存储的问题。
用NSURLSessionDownloadTask来进行文件的下载的时候,在文件没有下载完成之前是以.tmp为扩展名的临时文件来保存数据的,当下载完成之后才命名为指定的格式。临时文件的命名格式为:CFNetworkDownload_k50bxn.tmp.CFNetworkDownload_是固定的,后面的几位是根据文件的不同而不同。
首先,不同的configuration下,文件的存储位置是不同。对于defaultSessionConfiguration配置的session创建的downloadTask,他的tmp文件一直存在于沙盒中的tmp文件夹下面。backgroundSessionConfiguration配置的session创建的task,在不同的状态下,比如正在下载的状态,tmp文件的存储路径是:
其中,library/cache是系统一直存在的文件夹。com.apple.nsurlsessiond是在backgroundSessionConfiguration模式下面系统创建的文件夹。Downloads表示下载的文件夹,XHJ.Download是根据bundler id生成的文件夹。当文件正在下载的时候,tmp文件存储在这个文件夹下面。当取消任务下载的时候,tmp文件被转移到了沙盒的tmp文件夹下面。
沙盒中的tmp文件夹主要是app用来存放系统产生的临时文件,会在磁盘空间不足时被清空。所以,当用户cancel当前的downloadTask后,系统将未下载完成的tmp文件move到了tmp文件夹下面。然后在某一个时间tmp文件夹被清空,当用户再重新点击下载的时候,由于当前已下载的临时文件被删除不存在了,就会重新从0开始重新下载,无法做到断点续传。这样的话,在取消任务下载的时候,再开始task,没有办法做到断点续传了吗?如果解决了每一个下载的临时文件不被删除,下次下载直接利用这个tmp文件就可以了。所以,想了个办法,在每一次取消任务的时候,将tmp文件夹中move到别的不会被清空的文件夹下面,当再次下载的时候,使用存储在本地的对应的resumeData和tmp文件来实现断点续传。
经过上述的步骤,差不多可以实现了在取消一个downloadTask之后,保存resumeData到本地,并且将这个临时tmp文件保存到其他目录。这样这个未完成的临时文件不会被删除,下次断点续传的时候可以直接使用,从而保证了断点续传的正确性。
在下载的过程中,必须要考虑app被杀死的情况。
1.下载的过程中通过多任务的切换来杀死app。
在手动的杀死APP的时候,会调用appdelegate的下面的方法:
- (void)applicationWillTerminate:(UIApplication *)application
这个方法调用之后,系统会发一个UIApplicationWillTerminateNotification 通知。
在这个方法里面,有大约5s的时间来执行你的操作任务,超时的话,系统会将进程直接杀死。所以,在这个方法里面,我们可以来进行下载的数据的model的状态更新的操作。杀死App之后,下载的临时文件任然在library所对应的文件夹下面,并没有像调用cancelByProducingResumeData这个方法一样将tmp文件move到tmp文件夹下面,所以tmp文件并不会丢失。当重新运行app的时候,重新初始化session完成之后,系统会将com.apple.nsurlsessiond中的对应的tmp文件move到tmp文件夹下面,然后,NSURLSessionTaskDelegate的回调会调用
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error
error.userInfo包含NSURLSessionDownloadTaskResumeData,这个就是杀死app之前正在下载的task的resumeData。从error中取到resumeData之后,就可以进行task的断点续传了。注意,这一步是先move对应的tmp文件到指定的tmp文件夹下面,再通过resumeData来创建downloadTask。这样就实现了杀死app情况下的断点续传。
2.app在运行的过程中自身内部crash。
由于app自己代码原因的crash,有个比较特殊的地方:backgroundConfiguration配置的session创建的downloadTask会一直下载。这个下载的任务一直在com.apple.nsurlsessiond这个进程中一直在执行。但是,通过多任务切换杀死的app却停止下载。这是这两种杀死app之后下载行为的却别。
杀死app之后,是怎么测出来当前的app在不在继续下载呢?这里可以通过charles来监听。app 自己crash之后,下载的任务还是在系统的co.apple.nsurlsessiond这个进程中下载。与正常下载不同的是,当app crash后,当下载完成之后,appDelagate不会再收到handleEventsForBackgroundURLSession的回调。而正常的下载中,当加入到队列中的所有的task都执行完之后,会收到拉起的回调。在这个回调里面,我们可以继续处理我们的逻辑。下图是检测app下载情况的示意图。
3.断点续传文件的一个坑。
backgroundSessionConfiguration配置下的downloadTask在进行断点续传的时候,在iOS10 到 iOS10.2版本之间,resumeData的持久化有一个问题。
-[NSKeyedUnarchiver initForReadingWithData:]: data is NULL
-[NSKeyedUnarchiver initForReadingWithData:]: data is NULL
Invalid resume data for background download. Background downloads must use http or https and must download to an accessible file.
这是因为在iOS10上面,XML的中的NSURLSessionResumeOriginalRequest and NSURLSessionResumeCurrentRequest对应的data数据为非法的。所以,我们需要手动的去修正整个resumeData来提供给系统来使用。stackOverFlow上解决的办法如下:
http://stackoverflow.com/questions/39346231/resume-nsurlsession-on-ios10。
正确的创建了resumeData之后,就可以正确的创建downloadTask来进行下载了。
总结
在文中所描述的四种下载的方案中,前三中方案都有以下相似的特点:
第一:不支持后台下载。当app进入后台一段时间之后就会进入挂起的状态,停止下载。虽然可以申请app的后台运行权限,但是可能审核会有问题。
第二:技术的实现上都需要自己来流文件的写入的问题以及在请求的时候请求头传入Range参数来实现续传。
而NSURLSessionDownloadTask是苹果专门用来处理下载的类,系统自动支持了断点下载以及为我们封装好了文件的写入的问题,无需我们申请后台的权限就可以实现后台的下载。但是在使用的过程中,我们需要处理好resumeData以及临时文件tmp的保存,否则就会出现无法断点续传的情况。
参考文献
1.http://ios.jobbole.com/93142/
2.https://onevcat.com/2013/08/ios7-background-multitask/
3.http://benscheirman.com/2016/09/resume-data-broken-in-ios-10/
4.http://stackoverflow.com/questions/39346231/resume-nsurlsession-on-ios10