NSURLSession笔记(一) 文件下载、断点下载
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];
}
- 在块代码回调的方式,回调的执行线程是异步的(block在子线程中调用,如果拿到数据后要做一些UI更新操作,就要回到主线程刷新)。
- 回调中location输出的是一个文件路径,通过打断点,可以看到要下载的文件在被下载时存在于该路径下,但代码块执行完毕后就被删除了。原因:
这里block的location参数在api中的描述如下:The location of a temporary file where the server’s response is stored. You must move this file or open it for reading before your completion handler returns. Otherwise, the file is deleted, and the data is lost.
下载文件会保存在沙盒的tmp文件下,如果在回调方法中,不做任何处理,下载的文件会被删除。这样设计的目的:通常从网络上下载文件,zip格式文件最多,这样可以替用户节约流量;如果是zip包,下载之后,需要解压缩,解压之后原始的zip就不需要了,系统会自动帮我们删除初始zip文件。
因此需要作出如下修改:
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事件
- 要使用代理就不能使用回调代码块
- 如果要跟进下载进度(也就是使用代理),不能使用全局session:sharesession
- NSURLSessionConfiguration 提供了一个全局的网络环境配置,包括:身份验证,浏览器类型,cookie,缓存,超时时长。一旦设置可以全局共享,替代NSURLRequset中的附加信息!
- 下面的代码中,下载的文件在下载时存在于沙盒tmp文件夹下
- 没有内存峰值问题
//全局网络会话,管理所有网络任务
@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;
}];
}
- 如果任务已经被暂停(点击了一次暂停键),不应该能够再次被暂停(再点一次暂停按钮)。上面这样写,当第二次点暂停键的时候会打印出:“数据长度 0”
因此在暂停之后要释放下载任务:self.downloadTask = nil;
- 由于在第3步“继续下载”(见下)中需要使用到续传数据,因此需要把续传数据保存起来。
@property (nonatomic , strong) NSData *resumeData;
- (IBAction)pause{
[self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
//resumeData:续传的数据
NSLog(@"数据长度 %tu", resumeData.length);
self.resumeData = resumeData;
//释放下载任务//如果任务已经被暂停,不应该能够再次被暂停(连点两次暂停按钮)
self.downloadTask = nil;
}];
}
- 这里是否涉及block循环引用的问题?是的,有循环引用问题。self对task进行了强引用,task又对block进行了引用,block又对self进行引用,这就形成了循环使用。
解决方法:对self进行弱引用__weak typeof(self) ws = self; -
cancelByProducingResumeData:
取消任务是不能恢复的,只能重新创建任务。
3.继续
//继续
- (IBAction)resume{
//使用“续传数据”启动下载任务,使用的是之前保存的续传数据
//creat a new task
self.downloadTask = [self.session downloadTaskWithResumeData:self.resumeData];
[self.downloadTask resume];
}
- 使用之前保存的续传数据来启动下载任务,这里的downloadTask是新创建的,和之前那个不是同一个。另外因为所有的任务task默认都是挂起不执行的,所以最后要resume一下。
- 运行程序,点击 开始->暂停->继续(执行到这里可以正常续传)->继续(这是任务会回到从“续传数据”的地方开始下载,放个进度条控件就可以看见了。。。)
续传数据的作用就是建立新的下载任务,一旦下载任务建立以后,续传数据就没有用了。因此重新建立下载任务之后要清空续传数据self.resumeData = nil
- (IBAction)resume{
if (self.resumeData == nil) {
NSLog(@"没有暂停的任务");
return;
}
//使用“续传数据”启动下载任务,使用的是之前保存的续传数据
//续传数据的作用就是建立新的下载任务,一旦下载任务建立以后,续传数据就没有用了
self.downloadTask = [self.session downloadTaskWithResumeData:self.resumeData];
//清空续传数据
self.resumeData = nil;
[self.downloadTask resume];
}
源自网络:下载交互过程顺序图
tips:
这种断点下载只支持应用内断点,如果程序在下载过程中途关闭,则不能恢复下载
- 下载失败后如何恢复下载?
不管什么类型的task结束,URLSession:task:didCompleteWithError:
都会被调用,根据error是否为空判断成功失败。在任务失败的情况下,大多数app应当尝试重新请求直到用户取消任务或者服务端返回error code于是这个任务不会成功。
NSError对象的userInfo字典包含一个Key值为NSURLSessionDownloadTaskResumeData
的value,应将这个值传给downloadTaskWithResumeData:
或者downloadTaskWithResumeData: completionHandler:
并创建一个新的下载任务继续执行之前的下载。
- (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统一调度的。(在界面上放置一个uitextview,当下载任务开始的时候拖拽textview,下载任务并不会受到影响)(NSURLConnection在初始化时确定发送的是同步还是异步请求,而NSURLSession智能异步发送网络请求)
- 代理方法的工作队列指的是:当网络事件需要监听的时候,去执行代理方法所调度的队列
-
_session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
参数delegateQueue:指定调度代理方法执行的队列,并不会影响到session本身的异步执行
nil :代理在异步多个线程执行
[[NSOperationQueue alloc]init]和nil的执行效果一样,如果希望代理在异步执行,直接使用nil即可
[NSOperationQueue mainQueue]:主队列 - 如果使用block回调的方式,回调的执行线程是异步的。
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
- 留坑待填:
1.防止重复下载。“当下载的长度等于服务器响应的长度时说明下载过了。”