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

扩展:其实也可以通过解析 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;
}

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

你可能感兴趣的:(iOS文件下载(支持断点续传))