iOS编程

NSURLSession笔记(一) 文件下载、断点下载

2016-05-19  本文已影响1055人  WeiHing

qNSURLSession系列笔记:
NSURLSession笔记 上传文件

使用NSURLSession下载文件

NSURLSession 和 NSURLConnection 的比较 配合之前写的NSURLConnection下载文件笔记,有所体会。如果服务器返回的是一些比较大的数据,NSUrlSession 的下载做的是最好的,不需要去考虑什么边下载边写入沙盒的问题,这些都封装好了。

DownloadTask支持BackgroundSession,而dataTask不支持
DownloadTask支持断点续传(下载到一半的时候暂停,重启后继续下载,前提下载的服务器支持断点续传)

使用block回调

- (void)test{
    NSURL *url = [NSURL URLWithString:@"xxxxx"];
    
    NSURLSession *session = [NSURLSession sharedSession];
//NSURLResponse响应头,真实类型是NSHTTPURLResponse
    NSURLSessionDownloadTask *task = [session downloadTaskWithURL:url completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        NSLog(@"%@",location);
    }];
    [task resume];
}
NSURLSessionDownloadTask *task = [session downloadTaskWithURL:url completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    NSLog(@"%@",location);//tmp路径
    NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
    // response.suggestedFilename : 建议的文件名
    NSString *file = [caches stringByAppendingPathComponent:response.suggestedFilename];
    // 将临时文件move或者copy 到Caches文件夹
    // AtPath : 剪切前的文件路径  ToPath : 剪切后的文件路径
    [[NSFileManager defaultManager] moveItemAtPath:location.path toPath:file error:nil];
}];
沙盒目录:

Documents:应用中用户数据可以放在这里,iTunes备份和恢复的时候会包括此目录。[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject]
tmp:存放临时文件,iTunes不会备份和恢复此目录,此目录下文件可能会在应用退出后删除
Library/Caches:存放缓存文件,iTunes不会备份此目录,此目录下文件不会在应用退出删除。NSString *cacheStr = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];

下载进度跟进:使用代理NSURLSessionDownloadDelegate

除了NSURLSessionDelegate用来处理Session层次的事件,NSURLSessionTaskDelegate处理Task的共性事件之外,还有NSURLSessionDownloadTaskDelegate 用来特别处理Download事件

//全局网络会话,管理所有网络任务
@property (nonatomic , strong) NSURLSession *session;
//下载任务
@property (nonatomic , strong) NSURLSessionDownloadTask *downloadTask;

- (NSURLSession *)session{
    if (_session == nil) {
        NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
        _session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
    }
    return _session;
}
//开始
- (void)start{
    NSURL *url = [NSURL URLWithString:@"xxxxx"];
    //要使用代理就不能使用回调代码块
    //如果在块代码回调的方式,回调的执行线程是异步的。
    self.downloadTask = [self.session downloadTaskWithURL:url];
    [self.downloadTask resume];
}
//在iOS7中三个代理方法都是必须的,到了8.0只有第一个是必须的
//要支持iOS7&8,三个方法都要实现
//下载完成方法,一定要在这个函数返回之前,对数据进行使用,或者保存
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location{
    NSLog(@"finish %@",location);
    //生成沙盒的路径,对数据进行保存
    NSArray *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *path = [docs[0] stringByAppendingPathComponent:downloadTask.response.suggestedFilename];
    NSURL *toURL = [NSURL fileURLWithPath:path];
    [[NSFileManager defaultManager] copyItemAtURL:location toURL:toURL error:nil];
}

//下载进度
/*
 bytesWritten               本次下载的字节数
 totalBytesWritten          已经下载的字节数
 totalBytesExpectedToWrite  期望下载的字节数:文件总大小
 */
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite{
    
    float progress = (float)totalBytesWritten/totalBytesExpectedToWrite;
    NSLog(@"%f",progress);
}

//下载续传数据
//resume之后会调用这个方法
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didResumeAtOffset:(int64_t)fileOffset expectedTotalBytes:(int64_t)expectedTotalBytes{
}

//不管什么类型的task结束,URLSession:task:didCompleteWithError:都会被调用,根据error是否为空判断成功失败
//用户取消下载,调用cancelByProducingResumeData:也会调用这个代理方法
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error{
    self.resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData];
}

代理的方式能监听下载进度,不会将数据写入缓存,所以适合大文件,而block的方式将数据写入缓存,不适合大文件下载

断点续传

NSURLConnection需要手动设置请求头的Range的方式实现,NSURLSession的resumeData已经包含了,也就是NSURLSession已经实现了。
1.开始

//开始
- (IBAction)start{
    NSURL *url = [NSURL URLWithString:@"xxxxx"];

    self.downloadTask = [self.session downloadTaskWithURL:url];
    [self.downloadTask resume];
}

2.暂停:NSURLSessionDownloadTask的- cancelByProducingResumeData:(void (^)(NSData * __nullable resumeData))completionHandler
回调block里参数resumeData包含了继续下载文件的位置信息,下次继续下载的时候是从这个位置开始。

//暂停
- (IBAction)pause{    
    //取消下载任务
    [self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
        //参数resumeData:续传的数据(已经下载下来的数据)
        NSLog(@"数据长度 %tu", resumeData.length);   
        //释放下载任务
        //self.downloadTask = nil;
    }];
}
@property (nonatomic , strong) NSData *resumeData;

- (IBAction)pause{
    [self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
        //resumeData:续传的数据
        NSLog(@"数据长度 %tu", resumeData.length);
        
        self.resumeData = resumeData;
        //释放下载任务//如果任务已经被暂停,不应该能够再次被暂停(连点两次暂停按钮)
        self.downloadTask = nil;
    }];
}

3.继续

//继续
- (IBAction)resume{    
    //使用“续传数据”启动下载任务,使用的是之前保存的续传数据
    //creat a new task
    self.downloadTask = [self.session downloadTaskWithResumeData:self.resumeData];
    [self.downloadTask resume];
}
- (IBAction)resume{    
    if (self.resumeData == nil) {
        NSLog(@"没有暂停的任务");
        return;
    }
    //使用“续传数据”启动下载任务,使用的是之前保存的续传数据
    //续传数据的作用就是建立新的下载任务,一旦下载任务建立以后,续传数据就没有用了
    self.downloadTask = [self.session downloadTaskWithResumeData:self.resumeData];
    //清空续传数据
    self.resumeData = nil;
    [self.downloadTask resume];
}
源自网络:下载交互过程顺序图

tips:
这种断点下载只支持应用内断点,如果程序在下载过程中途关闭,则不能恢复下载

- (void)URLSession:(NSURLSession *)sessiona
           task:(NSURLSessionTask *)task
didCompleteWithError:(NSError *)error {
 if (error) {
     if ([error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData]) {
         //self.resumeData = [error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData];//做到这步
         //重新请求
         NSURLSessionTask *task = [[self backgroundURLSession] downloadTaskWithResumeData: [error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData]];
         [task resume];
     }
 }
}

一旦任务失败就马上重新请求,不过调用cancelByProducingResumeData:方法会触发URLSession: task:didCompleteWithError:(error有值),所以就做到把resumeData保存下来。

NSURLSession发起任务并且对任务强引用

所有的任务都是由session发起的,任务一旦发起,session就会对任务进行强引用。一旦任务被取消,session就不再对任务进行强引用,arc中如果没有对象对某一个对象强引用,就会被立即释放。因此任务(task)就会被立即释放
如果是@property (nonatomic , strong) NSURLSessionDownloadTask *downloadTask;,在“暂停”中self.downloadTask = nil;这句也可以不写。

NSURLSession 的代理工作队列

_session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
NSURLSession 在异步处理上要比NSURLConnection要好。

NSURLSession:session对象会对代理强引用

The session object keeps a strong reference to the delegate until your app exits or explicitly invalidates the session. If you do not invalidate the session by calling the invalidateAndCancel or finishTasksAndInvalidate method, your app leaks memory until it exits.
session会对代理进行强引用,如果任务执行结束后不取消session,会造成内存泄漏。
使用代理,一般委托方对代理方弱引用。一旦委托方对代理方强引用,则会产生循环引用:

_session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
这里session对象是委托方,viewController是session的代理,session对vc强引用。而通常navgationVC导航控制器也会对VC进行强引用。在这种情况下nav对vc pop,vc并不会被释放掉(点击开始下载文件,无论文件有没有下载完,点击导航栏返回按钮vc都无法跳进dealloc)(session也不会被取消,下载中的任务仍然继续)

- (void)dealloc{
    NSLog(@"销毁了");
}

取消会话对象的位置:
1.下载完成时

- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location{
    NSLog(@"finish %@",location);    
    //完成任务,如果会话已经被设置成完成,就无法再次使用session(就是说点开始下载,完成之后再点一次开始,此时无法下载)
    [self.session finishTasksAndInvalidate];//点击导航栏返回按钮可以跳进dealloc,打印出“销毁了”
    //解决办法,清空session(这样就可以懒加载了)
    self.session = nil;
}

2.视图控制器销毁前

- (void)viewWillDisappear:(BOOL)animated{
    [super viewWillDisappear:animated];    
    //取消会话
    [self.session invalidateAndCancel];
    self.session = nil;
}

当不再需要连接时,可以调用Session的invalidateAndCancel直接关闭,或者调用finishTasksAndInvalidate等待当前Task结束后关闭。这时Delegate会收到URLSession:didBecomeInvalidWithError:这个事件。Delegate收到这个事件之后会被解引用。
两种方式对比:
方法1:可以保证文件”可能“完整被下载完,但这种方法会重复创建和销毁session,会造成额外的开销
方法2:只是在离开界面前销毁sission,相对开销会小。缺点:如果一个文件没有下载完成会直接被取消掉

真正的解决方法:
在网络访问中,应该将所有的网络访问操作,封装到一个方法中。由一个统一的单例来负责处理所有的网络事件。session对代理进行强引用,而单例本身就是一个静态实例,本身就不需要被释放。

更新:

正在下载时关掉强制退出程序再重启的断点续传

得益于这些文章,从它们那学到了很多:
http://www.cocoachina.com/ios/20160503/16053.html
http://www.jianshu.com/p/1211cf99dfc3
http://www.cocoachina.com/industry/20131106/7304.html

第一种方法

基本上和之前讲的差不多,但是用到NSURLSession后台模式。

利用在NSURLSessionConfiguration设置的identifier。
在应用被杀掉前,iOS系统保存应用下载session的信息。重新启动应用,当identifier相同的时候(苹果通过identifier找到对应的session数据),一旦生成Session对象设置Delegate(否则会因为没有对 session 的 delegate 进行设定,相应的delegate方法不会被调用),iOS系统会对之前下载中的任务进行依次回调URLSession:task:didCompleteWithError:方法。

但是当ID不相同,这些情况就收不到了。因此为了不让自己的消息被别的应用程序收到,或者收到别的应用程序的消息,起见ID还是和程序的Bundle名称绑定上比较好,至少保证唯一性。

demo 还没有下载完的时候关掉强制退出程序再重启,点击继续按钮进行续传。
ps:如果用户先暂停下载、退出程序,再重启,那么利用在NSURLSessionConfiguration设置ID这种方法进行续传就不可行了。下面第二种方法就没有这个问题了。

第二种方法

在后台下载模式下,
当使用NSURLSessionDownloadTask进行下载的时候,系统会在cache文件夹下创建一个下载的路径(/Downloads),路径下会有一个以"CFNetworking"打头的.tmp文件,这个就是我们正在下载中的文件。而当我们调用了cancelByProducingResumeData:方法后,会得到resumeData。而原本存在于Downloads文件下的.tmp文件,则被移动到了tmp文件夹目录下。当我们再次进行resume操作的时候,下载文件则又被移回到了Downloads文件夹下。


先来看看resumeData到底是什么东西:通过断点,resumeData转换为string,发现是一个XML文件,里面包含了关于.tmp文件的一些关键点的描述,包括"Range","key","download url",tmp文件的名字等等信息。

暂停时得到的resumeData与.tmp文件是一一对应的。DownloadTask进行断点续传的时候,会根据resumeData中的temp文件名去寻找.tmp文件,然后校验后再根据"Range"属性去进行断点续传。

因此,程序被杀死的断点下载具体实现思路:
0.选择document文件夹作为安全目录。
1.暂停下载时先清掉document文件夹中的.tmp文件;然后把tmp文件夹中的.tmp文件复制到document文件夹。并且把resumeData另外以文件的形式保存下来。
2.resumeDownload。先清掉tmp文件夹下的.tmp文件;然后把document中的.tmp文件复制到tmp文件夹。然后利用保存下来的resumeData对downloadTask resume。
3.(关键)设置一个Bool变量用来判断是否正在下载中,同时用一个周期事件每隔一段时间暂停一次。保存进度步骤同1。

- (void)download{
    //如果设置保存间隔过长,中间杀掉进程可能会损失较多进度
    _timer = [NSTimer scheduledTimerWithTimeInterval:3 target:self selector:@selector(pauseToStoreData) userInfo:nil repeats:YES];
    
    NSString *downloadURLString = @"http://sw.bos.baidu.com/sw-search-sp/software/797b4439e2551/QQ_mac_5.0.2.dmg";
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:downloadURLString]];
    
    self.resumeData = [NSData dataWithContentsOfFile:self.resumeDataPath];
    if (self.resumeData) {
        NSArray *paths = [self.fileManager subpathsAtPath:self.docPath];//查找给定路径下的所有子路径.深度查找,不限于当前层
        for (NSString *filePath in paths){
            if ([filePath rangeOfString:@"CFNetworkDownload"].length>0)
            {
                //1.先清掉tmp文件夹下的tmp文件 -removeItemAtPath:目标目录是文件
                [self.fileManager removeItemAtPath:[self.tmpPath stringByAppendingPathComponent:filePath] error:nil];
                //2.把doucment中的tmp文件复制到tmp文件夹
                self.docTmpFilePath = [_docPath stringByAppendingPathComponent:filePath];//document文件夹中tmp文件的路径
                //-copyItemAtPath:toPath:error:拷贝到目标目录的时候,如果文件已经存在则会直接失败;目标目录必须是文件(一定要以文件名结尾,而不要以文件夹结尾)
                [self.fileManager copyItemAtPath:_docTmpFilePath toPath:[self.tmpPath stringByAppendingPathComponent:filePath] error:nil];

            }
        }
        self.task = [self.backgroundSession downloadTaskWithResumeData:self.resumeData];
        self.resumeData = nil;
    }else{
        self.task = [self.backgroundSession downloadTaskWithRequest:request];
    }
    [self.task resume];
}

- (void)pauseDownload{
    __weak typeof(self) ws = self;
    [self.task cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
        ws.task = nil;
        ws.resumeData = resumeData;
        [resumeData writeToFile:self.resumeDataPath atomically:YES];
        
        NSArray *paths = [self.fileManager subpathsAtPath:self.tmpPath];
        for (NSString *filePath in paths)
        {
            if ([filePath rangeOfString:@"CFNetworkDownload"].length>0)
            {
                //1.先清掉document文件夹中的tmp文件
                [self.fileManager removeItemAtPath:[self.docPath stringByAppendingPathComponent:filePath] error:nil];
                //2.把tmp文件夹中的tmp文件复制到document文件夹
                self.docTmpFilePath = [self.docPath stringByAppendingPathComponent:filePath];
                NSString *path = [self.tmpPath stringByAppendingPathComponent:filePath];
                [self.fileManager copyItemAtPath:path toPath:_docTmpFilePath error:nil];
            }
        }
    }];
}

- (void)pauseToStoreData
{
    if (!_downloading)
    {
        return;
    }
    [_task cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
        self.resumeData = resumeData;
        self.task = nil;
        [resumeData writeToFile:self.resumeDataPath atomically:YES];
        NSArray *paths = [self.fileManager subpathsAtPath:self.tmpPath];
        for (NSString *filePath in paths)
        {
            if ([filePath rangeOfString:@"CFNetworkDownload"].length>0)
            {
                //1.先清掉document文件夹中的tmp文件
                [self.fileManager removeItemAtPath:[self.docPath stringByAppendingPathComponent:filePath] error:nil];
                //2.把tmp文件夹中的tmp文件复制到document文件夹
                self.docTmpFilePath = [self.docPath stringByAppendingPathComponent:filePath];
                NSString *path = [self.tmpPath stringByAppendingPathComponent:filePath];
                [self.fileManager copyItemAtPath:path toPath:_docTmpFilePath error:nil];
            }
        }
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            if (self.resumeData)
            {
                self.task = [self.backgroundSession downloadTaskWithResumeData:self.resumeData];
                [self.task resume];
            }
        });
    }];
}

因为每隔一段时间暂停一次保存进度,在实际运行中发现此处会有明显的停顿感。设置的周期间隔过长过短都不好,过短会影响效率,过长则有可能在突然杀掉进程时来不及保存进度导致进度丢失过多。完整demo

上一篇下一篇

猜你喜欢

热点阅读