断点下载是很常见的一个需求,AFN3.0 也为我们提供了下载的方法,但要实现断点下载,还需要我们自己另行处理。不过也可以用ASI下载,很方便。Demo
一、 AFN3.0 下载过程
-
创建NSURLSessionDownloadTask:两种方式(简写,具体查看api)
1. -[AFURLSessionManager downloadTaskWithRequest...] 普通下载 2. -[AFURLSessionManager downloadTaskWithResumeData:resumeData...]: 断点下载,resumeData是关键,没有就不能
-
开始下载
1. [downloadTask resume] 2. 下载时,会在tmp文件中生成下载的临时文件, 文件名是CFNetworkDownload_XXXXXX.tmp,后缀由系统随机生成 3. 下载完将临时文件移动到目的路径
-
暂停下载
1. [downloadTask suspend] 2. 暂停后task依然有效,通过resume又可以恢复下载
-
取消下载任务:取消后,task无效,要想继续下载,需要重新创建下载任务
1. [downloadTask cancle]:普通取消,无断点信息 2. [downloadTask cancelByProducingResumeData...] 1. 断点下载用,取消并返回断点信息,下次开启下载任务时传入 2. 取消任务时,只有满足以下的各条件,才会产生resumeData 1. 自从资源开始请求后,资源未更改过 2. 任务必须是 HTTP 或 HTTPS 的 GET 请求 3. 服务器在response信息汇总提供了 ETag 或 Last-Modified头部信息 4. 服务器支持 byte-range 请求 5. 下载的临时文件未被删除
二、断点下载实现代码
-
新建下载任务
+ (NSURLSessionDownloadTask *)downloadTaskWithUrl:(NSString *)url destinationUrl:(NSString *)desUrl progress:(void (^)(NSProgress *))progressHandler complete:(MISDownloadManagerCompletion)completionHandler { // 检错 if (!url || url.length < 1 || !desUrl || desUrl.length < 1) { NSError *error = [NSError errorWithDomain:@"参数不全" code:-1000 userInfo:nil]; completionHandler(nil,nil,error); return nil; } // 参数 NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:url]]; NSURL *(^destination)(NSURL *, NSURLResponse *) = ^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) { return [NSURL fileURLWithPath:desUrl]; }; // 1. 生成任务 NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; AFURLSessionManager *manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:configuration]; NSData *resumeData = [self getResumeDataWithUrl:url]; if (resumeData) { // 1.1 有断点信息,走断点下载 NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithResumeData:resumeData progress:progressHandler destination:destination completionHandler:completionHandler]; // 删除历史恢复信息,重新下载后该信息内容已不正确,不再使用, [self removeResumeDataWithUrl:url]; return downloadTask; } else { // 1.2 普通下载 NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithRequest:request progress:progressHandler destination:destination completionHandler:completionHandler]; return downloadTask; } }
-
开始
+ (void)startDownloadTask:(NSURLSessionDownloadTask *) downloadTask { [downloadTask resume]; }
-
暂停
+ (void)suspendDownloadTask:(NSURLSessionDownloadTask *) downloadTask { [downloadTask suspend]; }
-
取消
+ (void)cancleDownloadTask:(NSURLSessionDownloadTask *) downloadTask { __weak typeof(task) weakTask = task; [downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) { // 存储resumeData,以便一次进行断点下载 [YYDownloadManager saveResumeData:resumeData withUrl:weakTask.currentRequest.URL.absoluteString]; }]; }
-
断点信息存储:代码太多,只列个思路供参考,需要的可以查看Demo
1. 随机为resumeData分配一个文件名并储存到本地 2. 用一个map文件记录特定url对应的resumeData位置,以便查找 + (void)saveResumeData:(NSData *)resumeData withUrl:(NSString *)url{ // 存储resumeData } + (NSData *)getResumeDataWithUrl:(NSString *)url { // 获取resumeData } + (void)removeResumeDataWithUrl:(NSString *)url { // resumeData无效之后应该删除 }
三、问题及解决方案:获取resumeData
场景:以上的下载过程,只适合用户手动暂停的场景,当出现意外情况的时候,比如好奇点了小飞机🤡,手一抖kill掉了app💩,将无法获取到resumeData,也就无法断点下载,若刚好碰到下载一个超大的文件,那也就无奈了😹😹😹....用户当然也无法容忍这种情况发生。
解决方案:创建下载任务时,只提供了传入resumeData进行断点下载的方法,这大大简化了断点下载的过程,但同时又造成了很大的不便,当没有resumeData时,便无法断点下载,所以出现问题的解决办法就是获取resumeData。
1. 网络中断
1. downloadTask会中断,并返回错误信息,任务不能resume,若要继续需重建任务
2. 这种情况,查看错误信息会发现,里面有携带resumeDat
3. 那这就好办了,拿到resumeData并保存起来
4. 在(第一步)新建downloadTask时,有传入completionHandler,我们对其做一层处理
// 1.3 下载完成处理
MISDownloadManagerCompletion completeBlock =
^(NSURLResponse *response, NSURL *filePath, NSError *error) {
// 任务完成或暂停下载
if (!error || error.code == -999) {
// 调用cancle的时候,任务也会结束,并返回-999错误,
// 此时由于系统已返回resumeData,不另行处理了
if (!error) {
// 任务完成,移除resumeData信息
[self removeResumeDataWithUrl:response.URL.absoluteString];
}
if (completionHandler) {
completionHandler(response,filePath,error);
}
} else {
// 部分网络出错,会返回resumeData
NSData *resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData];
[self saveResumeData:resumeData withUrl:response.URL.absoluteString];
if (completionHandler) {
completionHandler(response,filePath,error);
}
}
};
2. 意外kill掉了app
这种情况不好获取resumeData,也曾做过尝试,监听UIApplicationWillTerminateNotification的通知,在app要结束的时候获取resumeData并保存,但现实还是比较残酷,由于时间太短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>http://downloadUrl</string> <key>NSURLSessionResumeBytesReceived</key> <integer>1474327</integer> <key>NSURLSessionResumeCurrentRequest</key> <data> ...... </data> <key>NSURLSessionResumeEntityTag</key> <string>"XXXXXXXXXX"</string> <key>NSURLSessionResumeInfoTempFileName</key> <string>CFNetworkDownload_XXXXX.tmp</string> <key>NSURLSessionResumeInfoVersion</key> <integer>2</integer> <key>NSURLSessionResumeOriginalRequest</key> <data> ..... </data> <key>NSURLSessionResumeServerDownloadDate</key> <string>week, dd MM yyyy hh:mm:ss </string> </dict></plist>
- 上面就是解析resumeData之后的数据,其实就是一个plist文件,里面信息包括了下载URL、已接收字节数、临时的下载文件名(文件默认存在tmp文件夹中)、当前请求、原始请求、下载事件、resumeInfo版本、EntityTag这些数据
- iOS8生成的resumeData稍有不同,没有NSURLSessionResumeInfoTempFileName字段,有NSURLSessionResumeInfoLocalPath,记录了完整的tmp文件地址
-
回顾一下断点下载实际所需要的几要素
1. 下载url 2. 临时文件:即未完成的文件,断点下载开始后,需要继续将剩余文件流导入到临时文件尾部 3. 文件开始位置:即临时文件大小,用于告诉服务器从哪块开始继续下载
🤓🤓🤓从2、3可以发现,resumeData其实就是一个包含了断点下载所需数据的一个plist文件...那就有思路了,何不尝试自己建一个resumeData呢?
尝试:按照上面resumeData的格式手动建一个plist文件,但只保留NSURLSessionDownloadURL、NSURLSessionResumeBytesReceived、NSURLSessionResumeInfoTempFileName三个字段,下载时加载该文件当成resumeData传入,开始下载任务........哈哈哈,竟然能成功进行断点下载
-
解决方案:分析后,发现可以自己伪造一个resumeData进行断点下载,只要拿到关键的几个数据
- 下载url:很方便能拿到
- 临时文件的path:由于其是系统自动下载,要拿到也需费一番功夫,地址隐藏在创建好的NSURLSessionDownloadTask对象中
- 已接收字节数:需拿到临时文件的字节数
代码实现
1.创建好普通下载任务后(非断点下载任务),
从NSURLSessionDownloadTask中获取临时文件名,
并存入到tempFile的map文件中
{
//****创建普通task时多加一步骤:获取tmp文件名并保存****
// 1.2 创建普通下载任务
downloadTask = [manager downloadTaskWithRequest:request
progress:progressHandler
destination:destination
completionHandler:completeBlock];
// 1.3 获取临时文件名,并保存
NSString *tempFileName = [self getTempFileNameWithDownloadTask:downloadTask];
[self saveTempFileName:tempFileName withUrl:url];
}
2. 获取临时文件名的代码
+ (NSString *)getTempFileNameWithDownloadTask:(NSURLSessionDownloadTask *)downloadTask {
//NSURLSessionDownloadTask --> 属性downloadFile:__NSCFLocalDownloadFile --> 属性path
NSString *tempFileName = nil;
// downloadTask的属性(NSURLSessionDownloadTask) dt
unsigned int dtpCount;
objc_property_t *dtps = class_copyPropertyList([downloadTask class], &dtpCount);
for (int i = 0; i<dtpCount; i++) {
objc_property_t dtp = dtps[i];
const char *dtpc = property_getName(dtp);
NSString *dtpName = [NSString stringWithUTF8String:dtpc];
// downloadFile的属性(__NSCFLocalDownloadFile) df
if ([dtpName isEqualToString:@"downloadFile"]) {
id downloadFile = [downloadTask valueForKey:dtpName];
unsigned int dfpCount;
objc_property_t *dfps = class_copyPropertyList([downloadFile class], &dfpCount);
for (int i = 0; i<dfpCount; i++) {
objc_property_t dfp = dfps[i];
const char *dfpc = property_getName(dfp);
NSString *dfpName = [NSString stringWithUTF8String:dfpc];
// 下载文件的临时地址
if ([dfpName isEqualToString:@"path"]) {
id pathValue = [downloadFile valueForKey:dfpName];
NSString *tempPath = [NSString stringWithFormat:@"%@",pathValue];
tempFileName = tempPath.lastPathComponent;
break;
}
}
free(dfps);
break;
}
}
free(dtps);
return tempFileName;
}
3. 获取resumeData过程稍微调整
1. 创建断点下载任务时,根据resumeDataMap找到resumeData,
2. 若没发现resumeData,则根据tempFileMap的信息找到临时文件,
获取其大小,然后尝试手动建一个resumeData,并加载到内存中
3. 若没发现临时文件,则不创建resumeData,建立普通下载任务
/// 手动创建resume信息
+ (NSData *)createResumeDataWithUrl:(NSString *)url {
if (url.length < 1) {
return nil;
}
// 1. 从map文件中获取resumeData的name
NSMutableDictionary *resumeMap = [NSMutableDictionary dictionaryWithContentsOfFile:[self resumeDataMapPath]];
NSString *resumeDataName = resumeMap[url];
if (resumeDataName.length < 1) {
resumeDataName = [self getRandomResumeDataName];
resumeMap[url] = resumeDataName;
[resumeMap writeToFile:[self resumeDataMapPath] atomically:YES];
}
// 2. 获取断点下载的参数信息
NSString *resumeDataPath = [self resumeDataPathWithName:resumeDataName];
NSDictionary *tempFileMap = [NSDictionary dictionaryWithContentsOfFile:[self tempFileMapPath]];
NSString *tempFileName = tempFileMap[url];
if (tempFileName.length > 0) {
NSString *tempFilePath = [self tempFilePathWithName:tempFileName];
NSFileManager *fileMgr = [NSFileManager defaultManager];
if ([fileMgr fileExistsAtPath:tempFilePath]) {
// 获取文件大小
NSDictionary *tempFileAttr = [[NSFileManager defaultManager] attributesOfItemAtPath:tempFilePath error:nil ];
unsigned long long fileSize = [tempFileAttr[NSFileSize] unsignedLongLongValue];
// 3. 手动建一个resumeData
NSMutableDictionary *fakeResumeData = [NSMutableDictionary dictionary];
fakeResumeData[@"NSURLSessionDownloadURL"] = url;
// ios8、与>ios9方式稍有不同
if (NSFoundationVersionNumber >= NSFoundationVersionNumber_iOS_9_0) {
fakeResumeData[@"NSURLSessionResumeInfoTempFileName"] = tempFileName;
} else {
fakeResumeData[@"NSURLSessionResumeInfoLocalPath"] = tempFilePath;
}
fakeResumeData[@"NSURLSessionResumeBytesReceived"] = @(fileSize);
[fakeResumeData writeToFile:resumeDataPath atomically:YES];
// 重新加载信息
return [NSData dataWithContentsOfFile:resumeDataPath];
}
}
return nil;
}
四、其他
- Demo中的测试地址
是GitHub Desktop的下载地址,支持断点下载、下载完后打开文件可用于检验文件是否完整;文件比较大,可以模拟各个过程 - 既然可以自己造一个resumeData,为什么还用系统返回的数据?
自己造的毕竟不规范,能用系统提供的尽量用系统提供的,也为了减少未知的错误
五、更新
iOS后台下载、断点下载:里面详细介绍了如何在app被kill掉了之后如何恢复下载