1、架构
项目中需要管理下载,多个任务同时下载,下载的暂停,恢复,进度显示等等。比如现在的百度云,迅雷等软件的下载功能:
WechatIMG1.jpeg
当然我不要做的那么牛逼,但是在现有的基础上还是可以模仿一个差不多的下载管理器出来的。我主要用的下面三个类:
KTDownloadManager.h
KTDownloadManager.m
KTDownloadModel.h
KTDownloadModel.m
KTDownloadOperation.h
KTDownloadOperation.m
主要有下面的功能:
1、添加,删除下载项;
2、开始、暂停下载;
3、进度显示,错误提醒,保存路径等;
4、断点续传;
5、下载项的本地序列化,反序列化(对于可以断点续传的下载项,杀掉应用后还可以继续下载);
1.1、KTDownloadManager
使用单例方法生成的实例来做下载管理者,他负责管理KTDownloadModel和调度KTDownloadOperation,像SDWebImage也是使用的单例来管理下载的。除了session之外,KTDownloadManager还有两个重要的属性:
// 下载队列,默认并发下载数量为3
@property (nonatomic, strong, readonly) NSOperationQueue *downloadQueue;
// download models
@property (nonatomic, strong) NSMutableArray *downloadModels;
// nsurlsession
@property (nonatomic, strong) NSURLSession *session;
这两个都是私有属性,因为不需要外面知道。downloadModels是所有的KTDownloadModel对象的集合。downloadQueue是下载队列,实际下载操作在KTDownloadOperation里面进行,那么下载过程就是添加KTDownloadOperation实例到downloadQueue中。
1.2、KTDownloadModel
这里的model是用来显示以及保存相关信息的,比如界面上有一个下载项就有一个model,model里面有基本的url,totalReceivedBytes,totalBytes等属性,表征一个下载的基本信息。我们在做界面显示的时候,只需要把model的属性展示出来即可,下载进度等等变化也只需要监听对应的model属性变化,这里使用代理来通知界面UI变化。
@interface KTDownloadModel : NSObject
// 下载的url
@property (nonatomic, strong, readonly) NSURL *url;
// 下载的文件全路径,可以指定,必须处于Documents或者Library/Caches文件夹下面
// 如果为nil,那么下载完成之后使用KTDownloadManager的downloadFolderPath配合suggest name构成文件全路径
@property (nonatomic, copy) NSString *downloadFilePath;
// 已接收的总字节数
@property (nonatomic, assign) int64_t totalReceivedBytes;
// 当前接收的data,由于存在断点续传,只表示这一次下载的data,并不表示下载的总data
@property (nonatomic, strong) NSMutableData *receivedData;
// 总字节数
@property (nonatomic, assign) int64_t totalBytes;
// 下载状态
@property (nonatomic, assign) KTDownloadState state;
// 下载operation,正在下载中或者暂停一段时间之内的model会有一个operation,其他情况为nil
@property (nonatomic, weak) KTDownloadOperation *operation;
// delegate
@property (nonatomic, weak) id<KTDownloadModelDelegate> delegate;
// error
@property (nonatomic, strong) NSError *error;
1.3、KTDownloadOperation
实际下载动作都在KTDownloadOperation里面进行,这里是模仿SDWebImage的下载队列来的,使用NSOperationQueue的好处是你可以设置同时下载的最大个数,同时你暂停一个下载之后,或者一个下载完成之后下一个下载会自动开始,还有可以很方便的暂停,启动,取消一个下载操作,这些都非常符合下载队列的需求,而GCD是做不到的。
@interface KTDownloadOperation : NSOperation
// 每个operation必须有一个model
@property (nonatomic, weak, readonly) KTDownloadModel *downloadModel;
@property (nonatomic, strong, readonly) NSURLSessionDataTask *dataTask;
1.4、三者之间的关系
KTDownloadManager是管理KTDownloadModel和KTDownloadOperation的,model负责记录下载项的进度,url等属性,同时和UI打交道,operation负责下载。下载操作就是KTDownloadManager通过model生成一个operation操作,然后把它丢到队列queue中去下载,下载进度,结果,错误等operation会告诉model,然后model在记录下来的同时会去通知UI。model和operation会互相弱引用,由于一个下载项对应一个model,但是下载项不一定处于下载状态,有可能暂停,没开始,或者已经完成,此时是没有对应一个operation的。所以KTDownloadModel的operation属性可能为空,但是KTDownloadOperation的downloadModel属性一定不为空。
2、注意点
2.1、使用NSURLSessionDataTask
我没用NSURLSessionDownloadTask的原因居然是断点续传,实际上NSURLSessionDownloadTask下载的时候会将临时下载的文件保存在tmp文件夹中,下一次下载的时候可以根据这个文件恢复下载,即实现断点续传。但是杀掉应用之后,tmp文件夹很有可能被清掉,那么此时是不能恢复下载的。当然也可以像这两篇文章那样曲线救国实现退出应用后的断点续传:
http://www.cocoachina.com/ios/20160503/16053.html
http://www.tuicool.com/articles/uyQrIzi
我采用的办法是直接使用NSURLSessionDataTask来下载,自己实现临时文件的管理以及恢复下载的逻辑。实现这两个代理方法:
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveData:(NSData *)data
{
self.downloadModel.totalBytes = [dataTask.response expectedContentLength];
[self.receivedData appendData:data];
dispatch_async(dispatch_get_main_queue(), ^{
self.downloadModel.totalReceivedBytes += data.length;
});
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error
{
......
}
自己管理下载的临时数据,自己保存临时数据。
2.2、断点续传
http断点续传使用http请求头Range字段来实现的,网上有很多文章:
http://www.cnblogs.com/ziyunfei/archive/2012/11/18/2775499.html
KTDownloadOperation有一个私有属性receivedData用来保存当前下载的内容,如果你暂停了下载,或者其他原因断掉了,KTDownloadOperation会做下面的操作:
1、检查返回的response(也就是http响应)头部信息里面有没有Accept-Ranges字段,并且值不是none。比如http响应头里有这样的字段Accept-Ranges: bytes,那么说明服务器支持Range分段请求,否则不支持。
2、如果支持断点续传那么保存当前下载的receivedData到本地。
启动下载的时候,根据receivedData的大小来设置http请求头Range:bytes=1024-,假设receivedData的大小是1024字节。
2.3、文件size的返回
我在测试的时候发现有些链接,比如github上面的这个下载链接:
https://codeload.github.com/hanton/HTY360Player/zip/master
下载时不能正确知道Content-Length的大小,不知道这个就等于你没法显示下载进度,这里有解决办法:
http://stackoverflow.com/questions/12235617/mbprogresshud-with-nsurlconnection/12599242#12599242
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:anURL];
[request addValue:@"" forHTTPHeaderField:@"Accept-Encoding"];
此时服务器就会在http响应头里面写上正确的Content-Length字段值了。
2.4、持久化
我这里就是用的很简单的plist文件保存。KTDownloadManager监听applicationWillTerminate消息,然后将当前下载项保存,下次启动读取plist文件即可。
2.5、NSURLSession代理回调的分发
NSURLSession对象是KTDownloadManager的属性,NSURLSession的代理也是KTDownloadManager,那么上面提到的回调方法只能写在KTDownloadManager里面。但是有多个下载操作,NSURLSession的代理回调内容必须正确分发到KTDownloadOperation中。这里模仿的是AFNetworking的做法,使用分类给dataTask添加属性downloadOperation,从而让dataTask与operation一一对应,KTDownloadOperation复写代理方法即可。
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveData:(NSData *)data
{
[dataTask.downloadOperation URLSession:session dataTask:dataTask didReceiveData:data];
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error
{
[task.downloadOperation URLSession:session task:task didCompleteWithError:error];
}
2.6、UI更新
给KTDownloadModel设置一个state属性,表明下载状态,通过这两个代理方法告诉UI下载状态和进度的变化:
typedef NS_ENUM(NSUInteger, KTDownloadState)
{
KTDownloadStateNone = 0, // 创建新的实例时所处状态
KTDownloadStateWaiting, // 等待中(前面还有正在下载的操作)
KTDownloadStateDownloading, // 下载中
KTDownloadStatePaused, // 暂停
KTDownloadStateFinished, // 完成
KTDownloadStateFailed // 失败
};
@protocol KTDownloadModelDelegate <NSObject>
// 以下代理方法在operation存在并且在下载的时候才会调用
@optional
- (void)downloadModel:(KTDownloadModel *)model didChangedState:(KTDownloadState)state;
- (void)downloadModel:(KTDownloadModel *)model didReceivedTotalBytes:(int64_t)totalReceivedBytes totalBytes:(int64_t)totalBytes;
@end
如果只是在一个静态页面显示一个KTDownloadModel下载项这样并不会有什么问题,但是如果使用tableView显示多个下载项就会出问题了。比如文章开头的那个图里面,一个tableViewCell显示一个下载项,我们肯定会这样写:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
DownloadTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"DownloadTableViewCell" forIndexPath:indexPath];
KTDownloadModel *model = [self.downloadModels objectAtIndex:indexPath.row];
model.delegate = cell;
[cell configWithModel:model];
return cell;
}
这样基本是没什么问题的,但是我们都知道tableView会复用tableViewCell,那么在滑动页面的时候,会出现这样的情况:比如有20个KTDownloadModel需要展示,但是一个界面只能展示10个tableViewCell,那么tableView实际上会有11个tableViewCell实例在内存里。tableViewCell1展示KTDownloadModel1,tableViewCell2展示KTDownloadModel2。。。tableViewCell11展示KTDownloadModel11。滑到第12个的时候,此时是tableViewCell1来展示KTDownloadModel12的。说了这么多就是想说同一个tableViewCell可能会在不同的时候展示不同的KTDownloadModel,上面的代码会导致同一个tableViewCell是多个KTDownloadModel的代理,那么意味着一个tableViewCell可能在同一个时刻收到多个KTDownloadModel的进度或状态更新通知,那么就会:显示紊乱!
要保证tableViewCell在一个时刻只能是一个KTDownloadModel的代理,我在KTDownloadManager里面添加了这个方法:
- (void)setDelegate:(id<KTDownloadModelDelegate>)delegate forModel:(KTDownloadModel *)model
{
for (KTDownloadOperation *op in self.downloadQueue.operations) {
if (op.downloadModel.delegate == delegate) {
op.downloadModel.delegate = nil;
}
}
model.delegate = delegate;
}
将上面的model.delegate = cell替换成这一句:[[KTDownloadManager sharedManager] setDelegate:cell forModel:model];保证一个cell同一时刻只能是一个model的代理,就不会出现显示紊乱的问题了。
3、完善
项目地址:https://github.com/tujinqiu/KTDownloadManager
1、使用文件句柄写缓存,避免内存占用过大
2、后台下载
3、bug修复
欢迎提问题
网友评论