iOS文件下载(支持断点续传)
公司项目需要做一个视频下载功能,很简单,一次只需要下载一个视频,不需要同时下载多个视频,唯一的需求是支持断点续传。网上搜索了一下,好多文章介绍的断点续传,都是千篇一律的复制粘贴,完成的功能也只是简单的支持暂停/继续,对于App被杀掉后的情况都无法做到继续下载,最后google查看了很多开发者分享的文章,综合之后完成了需求,现在分享出来。
最初用系统原生NSURLSession接口实现了一下方案,但是因为本身项目中已经有AFNetworking库,所以又将功能用AFNetworking接口实现了一下,逻辑更聚合,代码更简单
一、普通下载
普通下载利用AFNetworking非常简单,代码如下:
- 下载任务创建
NSURL *downloadURL = [NSURL URLWithString:@"http://122.228.13.13/cdn/pcclient/20161104/18/31/iQIYIMedia_000.dmg"];
NSURLRequest *request = [NSURLRequest requestWithURL:downloadURL];
NSURLSessionDownloadTask *downloadTask = [[AFHTTPSessionManager manager] downloadTaskWithRequest:request progress:^(NSProgress * _Nonnull downloadProgress) {
NSLog(@"download progress : %.2f%%", 1.0f * downloadProgress.completedUnitCount / downloadProgress.totalUnitCount * 100);
} destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
NSString *fileName = response.suggestedFilename;
//返回文件的最终存储路径
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths objectAtIndex:0];
NSString *filePath = [documentsDirectory stringByAppendingPathComponent:fileName];
return [NSURL fileURLWithPath:filePath];
} completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
if (error) {
NSLog(@"download file failed : %@", [error description]);
}else {
NSLog(@"download file success");
}
}];
[downloadTask resume];
- 暂停
[downloadTask suspend];
- 恢复下载
[downloadTask resume];
二、断点续下原理
- AFNetworking中创建NSURLSessionDownloadTask的方式有两种:
- (NSURLSessionDownloadTask *)downloadTaskWithRequest:(NSURLRequest *)request
progress:(void (^)(NSProgress *downloadProgress)) downloadProgressBlock
destination:(NSURL * (^)(NSURL *targetPath, NSURLResponse *response))destination
completionHandler:(void (^)(NSURLResponse *response, NSURL *filePath, NSError *error))completionHandler
- (NSURLSessionDownloadTask *)downloadTaskWithResumeData:(NSData *)resumeData
progress:(void (^)(NSProgress *downloadProgress)) downloadProgressBlock
destination:(NSURL * (^)(NSURL *targetPath, NSURLResponse *response))destination
completionHandler:(void (^)(NSURLResponse *response, NSURL *filePath, NSError *error))completionHandler
第一个方法是创建全新的下载,第二个方法就是使用已存在的
resumeData 进行续下,所以断点续传的关键就是获取前次下载的 resumeData。
-
NSURLSessionDownloadTask
的下载在完成前,会先将下载文件存储在App的tmp目录下,文件命名类似于CFNetworkDownload_PNopRV.tmp
,只有当文件下载完成后系统才会将完整文件移动到 destination 回调中返回的路径下(所以
destination 回调是文件下载完成才会触发的)。 -
resumeData 其实是 plist 文件,包含了以下键值:
//下载的文件的URL string
NSURLSessionDownloadURL
//已下载完成的文件大小
NSURLSessionResumeBytesReceived
//当前请求的NSURLRequest对象,续传需要使用,指定了续传时的下载区间
NSURLSessionResumeCurrentRequest
//E-Tag
NSURLSessionResumeEntityTag
//下载过程中的临时文件名"CFNetworkDownload_PNopRV.tmp"
NSURLSessionResumeInfoTempFileName
//下载过程中的临时文件存储路径
NSURLSessionResumeInfoLocalPath
//暂不清楚用途,用来区分下载该文件的系统版本?
NSURLSessionResumeInfoVersion
//初始请求时的NSURLRequest对象,不过续传时可以为空
NSURLSessionResumeOriginalRequest
//文件下载日期
NSURLSessionResumeServerDownloadDate
所以获取 resumeData 有两个步骤:
- 获取之前未下载完成、缓存下来的文件
利用运行时态,获取缓存的文件名,这个需要在初次创建下载请求时就记录下缓存的文件名(保存在本地)
NSString * const DownloadFileProperty = @"downloadFile";
NSString * const DownloadPathProperty = @"path";
- (NSString *)tempCacheFileNameForTask:(NSURLSessionDownloadTask *)downloadTask
{
NSString *resultFileName = nil;
//拉取属性
unsigned int outCount, i;
objc_property_t *properties = class_copyPropertyList([downloadTask class], &outCount);
for (i = 0; i<outCount; i++) {
objc_property_t property = properties[i];
const char* char_f = property_getName(property);
NSString *propertyName = [NSString stringWithUTF8String:char_f];
NSLog(@"proertyName : %@", propertyName);
if ([DownloadFileProperty isEqualToString:propertyName]) {
id propertyValue = [downloadTask valueForKey:(NSString *)propertyName];
unsigned int downloadFileoutCount, downloadFileIndex;
objc_property_t *downloadFileproperties = class_copyPropertyList([propertyValue class], &downloadFileoutCount);
for (downloadFileIndex = 0; downloadFileIndex < downloadFileoutCount; downloadFileIndex++) {
objc_property_t downloadFileproperty = downloadFileproperties[downloadFileIndex];
const char* downloadFilechar_f = property_getName(downloadFileproperty);
NSString *downloadFilepropertyName = [NSString stringWithUTF8String:downloadFilechar_f];
NSLog(@"downloadFilepropertyName : %@", downloadFilepropertyName);
if([DownloadPathProperty isEqualToString:downloadFilepropertyName]){
id downloadFilepropertyValue = [propertyValue valueForKey:(NSString *)downloadFilepropertyName];
if(downloadFilepropertyValue){
resultFileName = [downloadFilepropertyValue lastPathComponent];
//应在此处存储缓存文件名
//......
NSLog(@"broken down temp cache path : %@", resultFileName);
}
break;
}
}
free(downloadFileproperties);
}else {
continue;
}
}
free(properties);
return resultFileName;
}
扩展:其实也可以通过解析 resumeData 获取缓存文件名,不过需要主动调用一次暂停后才可以获取 resumeData,所以上面的方案更佳
2.知道了 resumeData 结构,就可以根据之前存储的缓存文件路径获取缓存文件大小、路径等信息组装新的 resumeData
NSString * const DownloadResumeDataLength = @"bytes=%ld-";
NSString * const DownloadHttpFieldRange = @"Range";
NSString * const DownloadKeyDownloadURL = @"NSURLSessionDownloadURL";
NSString * const DownloadTempFilePath = @"NSURLSessionResumeInfoLocalPath";
NSString * const DownloadKeyBytesReceived = @"NSURLSessionResumeBytesReceived";
NSString * const DownloadKeyCurrentRequest = @"NSURLSessionResumeCurrentRequest";
NSString * const DownloadKeyTempFileName = @"NSURLSessionResumeInfoTempFileName";
NSData *resultData = nil;
NSString *tempCacheFileName = _cacheDic[SystemDownloadCahceFileNameKey]; //缓存文件名
if (tempCacheFileName.length > 0) {
NSString *tempCacheFilePath = [[FitnessVideoCacheManager videoDownloadTempCacheDir] stringByAppendingPathComponent:tempCacheFileName]; //缓存文件路径,其实就是tmp目录+缓存文件名
NSData *tempCacheData = [NSData dataWithContentsOfFile:tempCacheFilePath];
if (tempCacheData && tempCacheData.length > 0) {
NSMutableDictionary *resumeDataDict = [NSMutableDictionary dictionaryWithCapacity:0];
NSMutableURLRequest *newResumeRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:downloadUrl]];
[newResumeRequest addValue:[NSString stringWithFormat:DownloadResumeDataLength,(long)(tempCacheData.length)] forHTTPHeaderField:DownloadHttpFieldRange];
NSData *newResumeRequestData = [NSKeyedArchiver archivedDataWithRootObject:newResumeRequest];
[resumeDataDict setObject:@(tempCacheData.length) forKey:DownloadKeyBytesReceived];
[resumeDataDict setObject:newResumeRequestData forKey:DownloadKeyCurrentRequest];
[resumeDataDict setObject:tempCacheFileName forKey:DownloadKeyTempFileName];
[resumeDataDict setObject:downloadUrl forKey:DownloadKeyDownloadURL];
[resumeDataDict setObject:tempCacheFilePath forKey:DownloadTempFilePath];
resultData = [NSPropertyListSerialization dataWithPropertyList:resumeDataDict format:NSPropertyListBinaryFormat_v1_0 options:NSPropertyListImmutable error:nil];
}
}
if (![self isValidResumeData:resultData]) {
resultData = nil;
}
return resultData;
PS:需要注意的是,resumeData 中的信息要尽可能完整,我在实践中就发现有些键值如果没有,在 iOS9 上可以正常续传,但是到了 iOS10 或者 iOS8 上就会报错,无法继续下载。
扩展:下载大文件,比如视频这种,最好在下载之前检查下存储空间,如果空间不够就不必下载了,这样用户体验会好点。
网上搜索了一下,iPhone获取存储空间大小有两类接口,一种是
//手机剩余空间
+ (NSString *)freeDiskSpaceInBytes{
struct statfs buf;
long long freespace = -1;
if(statfs("/var", &buf) >= 0){
freespace = (long long)(buf.f_bsize * buf.f_bavail);
/*网上有一部分博客文章用的是f_bfree,而不是f_bavail,是不正确的*/
}
return [self humanReadableStringFromBytes:freespace];
}
//手机总空间
+ (NSString *)totalDiskSpaceInBytes
{
struct statfs buf;
long long freespace = 0;
if (statfs("/", &buf) >= 0) {
freespace = (long long)buf.f_bsize * buf.f_blocks;
}
if (statfs("/private/var", &buf) >= 0) {
freespace += (long long)buf.f_bsize * buf.f_blocks;
}
printf("%lld\n",freespace);
return [self humanReadableStringFromBytes:freespace];
}
f_bfree和f_bavail两个值是有区别的,前者是硬盘所有剩余空间,后者为非root用户剩余空间。一般ext3文件系统会给root留5%的独享空间。所以如果计算出来的剩余空间总比df显示的要大,那一定是你用了f_bfree。5%的空间大小这个值是仅仅给root用的,普通用户用不了,目的是防止文件系统的碎片。
参考链接:f_bfree和f_bavail的区别
还有一种是
+ (long long)freeDiskSpace
{
/// 剩余大小
long long freesize = 0;
NSError *error = nil;
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSDictionary *dictionary = [[NSFileManager defaultManager] attributesOfFileSystemForPath:[paths lastObject] error: &error];
if (dictionary) {
NSNumber *_free = [dictionary objectForKey:NSFileSystemFreeSize];
freesize = [_free unsignedLongLongValue];
}else {
NSLog(@"Error Obtaining System Memory Info: Domain = %@, Code = %ld", [error domain], (long)[error code]);
}
return freesize;
}
+ (long long)totalDiskSpace
{
/// 总大小
long long totalsize = 0;
NSError *error = nil;
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSDictionary *dictionary = [[NSFileManager defaultManager] attributesOfFileSystemForPath:[paths lastObject] error: &error];
if (dictionary) {
NSNumber *_total = [dictionary objectForKey:NSFileSystemSize];
totalsize = [_total unsignedLongLongValue];
}else {
NSLog(@"Error Obtaining System Memory Info: Domain = %@, Code = %ld", [error domain], (long)[error code]);
}
return totalsize;
}
两种方案计算出的可用空间大小是一样的(与微信也是相同的),不知道有什么区别,不过有一点需要注意的是:计算出的可用空间大小和手机系统 设置 中 可用容量 大小是不一样的。