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 有两个步骤:

  1. 获取之前未下载完成、缓存下来的文件
    利用运行时态,获取缓存的文件名,这个需要在初次创建下载请求时就记录下缓存的文件名(保存在本地)
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;
}

两种方案计算出的可用空间大小是一样的(与微信也是相同的),不知道有什么区别,不过有一点需要注意的是:计算出的可用空间大小和手机系统 设置可用容量 大小是不一样的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,470评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,393评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,577评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,176评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,189评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,155评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,041评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,903评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,319评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,539评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,703评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,417评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,013评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,664评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,818评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,711评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,601评论 2 353

推荐阅读更多精彩内容