05
2017
10

iOS 多任务下载(支持离线

代码下载

代码下载地址

效果展示

屏幕快照 2017-03-30 上午9.41.16.png

屏幕快照 2017-03-30 上午9.41.33.png

屏幕快照 2017-03-30 上午9.41.43.png

分析

说到iOS中的下载,有很多方式可以实现,NSURLConnection(已经弃用)就不说了,AFNetworking也不说了。我使用的是NSURLSession,常用的有3个任务类,NSURLSessionDataTask、NSURLSessionDownloadTask、NSURLSessionUploadTask,它们都继承自NSURLSessionTask。很明显他们一个用于获取数据一个用于下载另一个用于上传的。首先我们肯定选择使用NSURLSessionDownloadTask来做下载,那么接下来就聊聊吧。

  1. 创建一个NSURLSession实例来管理网络任务
        //可以上传下载HTTP和HTTPS的后台任务(程序在后台运行)。 在后台时,将网络传输交给系统的单独的一个进程,即使app挂起、推出甚至崩溃照样在后台执行。
        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"QSPDownload"];
        _session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
 
  • 1
  • 2
  • 3

2.添加下载任务

/** 添加下载任务  @param netPath 下载地址 */
 - (void)addDownloadTast:(NSString *)netPath
{
    NSURLSessionDownloadTask *tast = [self.session downloadTaskWithURL:[NSURL URLWithString:netPath]];
    [(NSMutableArray *)self.downloadSources addObject:tast];
    //开始下载任务
    [task resume];
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

3.实现相关协议 
NSURLSessionDownloadTaskDelegate协议有如下3个方法:

这个方法在下载过程中反复调用,用于获知下载的状态 
- (void)URLSession:(NSURLSession )session downloadTask:(NSURLSessionDownloadTask )downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite 
这个方法在下载完成之后调用,用于获取下载后的文件 
- (void)URLSession:(NSURLSession )session downloadTask:(NSURLSessionDownloadTask )downloadTask didFinishDownloadingToURL:(NSURL *)location 
这个方法在暂停后重新开始下载时调用,一般不操作这个代理 
- (void)URLSession:(NSURLSession )session downloadTask:(NSURLSessionDownloadTask )downloadTask didResumeAtOffset:(int64_t)fileOffset expectedTotalBytes:(int64_t)expectedTotalBytes

我们可以使用- (void)cancelByProducingResumeData:(void (^)(NSData * _Nullable resumeData))completionHandler这个方法来暂停任务,使用- (NSURLSessionDownloadTask )downloadTaskWithResumeData:(NSData )resumeData这个方法来重启任务。可是没有办法获取下载过程中的数据来实现离线下载,程序退出后就要重新下载了。

使用NSURLSessionDataTask实现离线下载

一、包装一个QSPDownloadSource类来存储每个下载任务的数据资源,并且实现NSCoding协议用于归档存储数据,并通QSPDownloadSourceDelegate协议来监听每个下载任务的过程

#import <Foundation/Foundation.h>

typedef NS_ENUM(NSInteger, QSPDownloadSourceStyle) {
    QSPDownloadSourceStyleDown = 0,//下载
    QSPDownloadSourceStyleSuspend = 1,//暂停
    QSPDownloadSourceStyleStop = 2,//停止
    QSPDownloadSourceStyleFinished = 3,//完成
    QSPDownloadSourceStyleFail = 4//失败
};

@class QSPDownloadSource;
@protocol QSPDownloadSourceDelegate <NSObject>
@optional
- (void)downloadSource:(QSPDownloadSource *)source changedStyle:(QSPDownloadSourceStyle)style;
- (void)downloadSource:(QSPDownloadSource *)source didWriteData:(NSData *)data totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite;
@end

@interface QSPDownloadSource : NSObject <NSCoding>
//地址路径
@property (copy, nonatomic, readonly) NSString *netPath;
//本地路径
@property (copy, nonatomic, readonly) NSString *location;
//下载状态
@property (assign, nonatomic, readonly) QSPDownloadSourceStyle style;
//下载任务
@property (strong, nonatomic, readonly) NSURLSessionDataTask *task;
//文件名称
@property (strong, nonatomic, readonly) NSString *fileName;
//已下载的字节数
@property (assign, nonatomic, readonly) int64_t totalBytesWritten;
//文件字节数
@property (assign, nonatomic, readonly) int64_t totalBytesExpectedToWrite;
//是否离线下载
@property (assign, nonatomic, getter=isOffLine) BOOL offLine;
//代理
@property (weak, nonatomic) id<QSPDownloadSourceDelegate> delegate;

@end
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

二、使用单例QSPDownloadTool来管理所有下载任务 
1.单例化工具类,初始化、懒加载相关数据。

+ (instancetype)shareInstance
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _shareInstance = [[self alloc] init];
    });

    return _shareInstance;
}
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _shareInstance = [super allocWithZone:zone];
        if (![[NSFileManager defaultManager] fileExistsAtPath:QSPDownloadTool_DownloadDataDocument_Path]) {
            [[NSFileManager defaultManager] createDirectoryAtPath:QSPDownloadTool_DownloadDataDocument_Path withIntermediateDirectories:YES attributes:nil error:nil];
        }
    });

    return _shareInstance;
}

- (NSURLSession *)session
{
    if (_session == nil) {
        //可以上传下载HTTP和HTTPS的后台任务(程序在后台运行)。 在后台时,将网络传输交给系统的单独的一个进程,即使app挂起、推出甚至崩溃照样在后台执行。
        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"QSPDownload"];
        _session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
    }

    return _session;
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

2.设计QSPDownloadToolDelegate协议来监控下载工具类任务的完成

@class QSPDownloadTool;
@protocol QSPDownloadToolDelegate <NSObject>

- (void)downloadToolDidFinish:(QSPDownloadTool *)tool downloadSource:(QSPDownloadSource *)source;

@end
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

3.添加一系列控制下载的方法

/** 添加下载任务  @param netPath 下载地址  @param offLine 是否离线下载该任务  @return 下载任务数据模型 */
- (QSPDownloadSource *)addDownloadTast:(NSString *)netPath andOffLine:(BOOL)offLine;

/** 添加代理  @param delegate 代理对象 */
- (void)addDownloadToolDelegate:(id<QSPDownloadToolDelegate>)delegate;
/** 移除代理  @param delegate 代理对象 */
- (void)removeDownloadToolDelegate:(id<QSPDownloadToolDelegate>)delegate;

/** 暂停下载任务  @param source 下载任务数据模型 */
- (void)suspendDownload:(QSPDownloadSource *)source;
/** 暂停所有下载任务 */
- (void)suspendAllTask;

/** 继续下载任务  @param source 下载任务数据模型 */
- (void)continueDownload:(QSPDownloadSource *)source;
/** 开启所有下载任务 */
- (void)startAllTask;
/** 停止下载任务  @param source 下载任务数据模型 */
- (void)stopDownload:(QSPDownloadSource *)source;
/** 停止所有下载任务 */
- (void)stopAllTask;
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53

说明: 
- 所有的下载任务数据保存在数组downloadSources中,如果添加的是离线任务,我们需要保存到本地。所以增加一个保存下载任务数据的方法:

- (void)saveDownloadSource
{
    NSMutableArray *mArr = [[NSMutableArray alloc] initWithCapacity:1];
    for (QSPDownloadSource *souce in self.downloadSources) {
        if (souce.isOffLine) {
            NSData *data = [NSKeyedArchiver archivedDataWithRootObject:souce];
            [mArr addObject:data];
        }
    }

    [mArr writeToFile:QSPDownloadTool_DownloadSources_Path atomically:YES];
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 关于QSPDownloadTool工具类,我设计的是能够添加多个代理对象,代理对象存储于数组中,数组对内部的数据都是强引用,所以会造成循环引用,为了解决这个问题,我设计一个代理中间类QSPDownloadToolDelegateObject,数组强引用代理中间类对象,代理中间类对象弱引用代理对象。
@interface QSPDownloadToolDelegateObject : NSObject

@property (weak, nonatomic) id<QSPDownloadToolDelegate> delegate;

@end
 
  • 1
  • 2
  • 3
  • 4
  • 5

4.重中之重,实现NSURLSessionDataDelegate协议记录相关数据

#pragma mark - NSURLSessionDataDelegate代理方法
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    NSLog(@"%s", __FUNCTION__);
    dispatch_async(dispatch_get_main_queue(), ^{
        for (QSPDownloadSource *source in self.downloadSources) {
            if (source.task == dataTask) {
                source.totalBytesExpectedToWrite = source.totalBytesWritten + response.expectedContentLength;
            }
        }
    });

    // 允许处理服务器的响应,才会继续接收服务器返回的数据
    completionHandler(NSURLSessionResponseAllow);
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    dispatch_async(dispatch_get_main_queue(), ^{
        for (QSPDownloadSource *source in self.downloadSources) {
            if (source.task == dataTask) {
                [source.fileHandle seekToEndOfFile];
                [source.fileHandle writeData:data];
                source.totalBytesWritten += data.length;
                if ([source.delegate respondsToSelector:@selector(downloadSource:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)]) {
                    [source.delegate downloadSource:source didWriteData:data totalBytesWritten:source.totalBytesWritten totalBytesExpectedToWrite:source.totalBytesExpectedToWrite];
                }
            }
        }
    });
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
    if (error) {
        NSLog(@"%@", error);
        NSLog(@"%@", error.userInfo);
    }

    dispatch_async(dispatch_get_main_queue(), ^{
        QSPDownloadSource *currentSource = nil;
        for (QSPDownloadSource *source in self.downloadSources) {
            if (source.fileHandle) {
                [source.fileHandle closeFile];
                source.fileHandle = nil;
            }

            if (error) {
                if (source.task == task && source.style == QSPDownloadSourceStyleDown) {
                    source.style = QSPDownloadSourceStyleFail;
                    if (error.code == -997) {
                        [self continueDownload:source];
                    }
                }
            }
            else
            {
                if (source.task == task) {
                    currentSource = source;
                    break;
                }
            }
        }

        if (currentSource) {
            currentSource.style = QSPDownloadSourceStyleFinished;
            [(NSMutableArray *)self.downloadSources removeObject:currentSource];
            [self saveDownloadSource];
            for (QSPDownloadToolDelegateObject *delegateObj in self.delegateArr) {
                if ([delegateObj.delegate respondsToSelector:@selector(downloadToolDidFinish:downloadSource:)]) {
                    [delegateObj.delegate downloadToolDidFinish:self downloadSource:currentSource];
                }
            }
        }
    });
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74

说明: 
- NSURLSessionDataDelegate代理方法为异步调用,为了避免多线程对资源的争夺和能够刷新UI,把代理方法中的操作都切入主线程。 
- 在(void)URLSession:(NSURLSession )session dataTask:(NSURLSessionDataTask )dataTask didReceiveData:(NSData *)data这个代理方法中,把数据写入磁盘,防止内存过高,并记录相关数据存储于QSPDownloadSource对象中,还有就是在这里需要回调QSPDownloadSourceDelegate协议的代理方法。 
- 在(void)URLSession:(NSURLSession )session task:(NSURLSessionTask )task didCompleteWithError:(NSError *)error这个代理方法中,我们需要对错误进行处理,移除掉完成的任务,并回调QSPDownloadToolDelegate的代理方法。

三、问题与补充 
- 计算下载文件的大小,代码中的QSPDownloadTool_Limit值为1024

/**
 按字节计算文件大小

 @param tytes 字节数
 @return 文件大小字符串
 */
+ (NSString *)calculationDataWithBytes:(int64_t)tytes
{
    NSString *result;
    double length;
    if (tytes > QSPDownloadTool_Limit) {
        length = tytes/QSPDownloadTool_Limit;
        if (length > QSPDownloadTool_Limit) {
            length /= QSPDownloadTool_Limit;
            if (length > QSPDownloadTool_Limit) {
                length /= QSPDownloadTool_Limit;
                if (length > QSPDownloadTool_Limit) {
                    length /= QSPDownloadTool_Limit;
                    result = [NSString stringWithFormat:@"%.2fTB", length];
                }
                else
                {
                    result = [NSString stringWithFormat:@"%.2fGB", length];
                }
            }
            else
            {
                result = [NSString stringWithFormat:@"%.2fMB", length];
            }
        }
        else
        {
            result = [NSString stringWithFormat:@"%.2fKB", length];
        }
    }
    else
    {
        result = [NSString stringWithFormat:@"%lliB", tytes];
    }
 return result;
}
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 计算下载速率:思路是这样的,在获得下载数据的代理方法中记录多次回调的时间差和多次回调获得的数据大小总和,就这样数据大小除以时间就为下载速率了,具体实现请看工程中的代码。
上一篇:Android内存泄漏与优化(下) 下一篇:类加载器ClassLoader