iOS渣逼(2)泥淖虾渣逼看URLConnection

NSURLConnection下载

课程目标

  • NSURLConnection下载是一个网络多线程的综合性演练项目
  • 充分体会 NSURLConnection 开发中的细节
  • 虽然 NSURLConnection 在 iOS 9.0 中已经被废弃,但是作为资深的 iOS 程序员,必须要了解 NSURLConnection 的细节
  • 利用 HTTP 请求头的 Range 实现断点续传
  • 利用 NSOutputStream 实现文件流拼接
  • 自定义 NSOperation及操作缓存管理
  • Block 的综合演练
  • 利用 IB_DESIGNABLE 和 IBInspectable 实现在 Stroybaord 中自定义视图的实时渲染
  • NSURLSession 从 Xcode 6.0 到 Xcode 6.3.1 都存在内存问题,历时7个月,如下图所示


    iOS渣逼(2)泥淖虾渣逼看URLConnection_第1张图片
    session下载QQ内存.png

NSURLConnection 的历史

  • iOS 2.0 推出的,至今有10多年的历史
  • 苹果几乎没有对 NSURLConnection 做太大的改动
  • sendAsynchronousRequest 方法是 iOS 5.0 之后,苹果推出的
  • 在 iOS 5.0 之前,苹果的网络开发是处于黑暗时代
  • 需要使用代理方法,还需要使用运行循环,才能够处理复杂的网络请求!
  • 只提供了 启动 和 取消 两个方法,没有中间状态

使用异步方法下载

- (void)downloadWithURL:(NSURL *)url {

    // 请求
    NSURLRequest *request = [NSURLRequest requestWithURL:url cachePolicy:1 timeoutInterval:kTimeout];

    NSLog(@"start");
    // 下载
    [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) {

        // 将文件写入磁盘
        [data writeToFile:@"/Users/liufan/Desktop/123" atomically:YES];
        NSLog(@"下载完成");
    }];
}

问题:

  1. 没有进度跟进,用户体验不好
  2. 会出现内存峰值,如果文件太大,在真机上会闪退

解决办法

  • 使用代理方法来解决下载进度跟进的问题

HEAD方法

HEAD 方法通常是用来在下载文件之前,获取远程服务器上的文件信息

  • 与 GET 方法相比,同样能够拿到响应头,但是不返回数据实体
  • 用户可以根据响应头信息,确定下一步操作
NSURL *url = [NSURL URLWithString:@"http://localhost/demo.json"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:1 timeoutInterval:10.0];
request.HTTPMethod = @"HEAD";

NSURLResponse *response = nil;
[NSURLConnection sendSynchronousRequest:request returningResponse:&response error:NULL];
NSLog(@"要下载文件的长度 %tu", response.expectedContentLength);

同步方法

  • 同步方法是阻塞式的,通常只有 HEAD 方法才会使用同步方法
  • 如果在开发中,看到参数的类型是 **,就传入对象的地址

注意

  • NSURLConnectionDownloadDelegate 代理方法是为 Newsstand Kit’s(杂志包) 创建的下载服务的
  • Newsstand 主要在国外使用比较广泛,国内极少
  • 如果使用 NSURLConnectionDownloadDelegate 代理方法监听下载进度,能够监听到进度,但是:找不到下载的文件

示例代码如下:

- (void)downloadWithURL:(NSURL *)url {

    // 请求
    NSURLRequest *request = [NSURLRequest requestWithURL:url cachePolicy:1 timeoutInterval:kTimeout];

    [NSURLConnection connectionWithRequest:request delegate:self];
}

#pragma mark - NSURLConnectionDownloadDelegate
- (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes {

    NSLog(@"%f", (float)totalBytesWritten / expectedTotalBytes);
}

- (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL {

    NSLog(@"%@", destinationURL);
}

跟踪下载进度

#pragma mark - NSURLConnectionDataDelegate
// 1. 接收到服务器响应
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    NSLog(@"%@", response);
    self.expectedContentLength = response.expectedContentLength;
    self.fileSize = 0;
}

// 2. 接收到数据
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {

    self.fileSize += data.length;
    float progress = (float)self.fileSize / self.expectedContentLength;
    NSLog(@"%f", progress);
}

// 3. 接收完成
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    NSLog(@"下载完成");
}

// 4. 网络错误
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    NSLog(@"%@", error);
}

拼接数据

- (NSMutableData *)fileData {
    if (_fileData == nil) {
        _fileData = [NSMutableData data];
    }
    return _fileData;
}

// 2. 接收到数据
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {

    self.fileSize += data.length;
    float progress = (float)self.fileSize / self.expectedContentLength;
    NSLog(@"%f", progress);

    // 拼接数据
    [self.fileData appendData:data];
}

// 3. 接收完成
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    NSLog(@"下载完成");

    [self.fileData writeToFile:@"/Users/liufan/Desktop/321" atomically:YES];
    self.fileData = nil;
}

存在的问题

  • 内存峰值依旧

意外发现:运行结果和 NSURLConnection 的异步方法的效果几乎一样!

NSFileHandle 拼接文件

  • NSFileManager : 主要是做文件的删除,移动,复制,检查文件是否存在等操作,类似于 Finder
  • NSFileHandle : 文件句柄(指针),操纵,提示:凡是看到 Handle 这个单词,就表示对前面一个单词(File)的独立操作
- (void)writeData:(NSData *)data {

    NSFileHandle *fp = [NSFileHandle fileHandleForWritingAtPath:self.targetPath];

    if (fp == nil) {
        [data writeToFile:self.targetPath atomically:YES];
    } else {
        [fp seekToEndOfFile];
        [fp writeData:data];
        [fp closeFile];
    }
}

问题:文件会被重复追加

// 下载前删除文件
[[NSFileManager defaultManager] removeItemAtPath:self.targetPath error:NULL];

NSOutputStream 拼接文件

定义属性

// 1. 接收到服务器响应
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    NSLog(@"%@", response);
    self.expectedContentLength = response.expectedContentLength;
    self.fileSize = 0;

    self.targetPath = [NSTemporaryDirectory() stringByAppendingPathComponent:response.suggestedFilename];
    NSLog(@"%@", self.targetPath);

    // 删除文件
    [[NSFileManager defaultManager] removeItemAtPath:self.targetPath error:NULL];

    // 打开文件流
    self.fileStream = [[NSOutputStream alloc] initToFileAtPath:self.targetPath append:YES];
    [self.fileStream open];
}

// 2. 接收到数据
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {

    self.fileSize += data.length;
    float progress = (float)self.fileSize / self.expectedContentLength;
    NSLog(@"%f", progress);

    // 拼接数据
    [self.fileStream write:data.bytes maxLength:data.length];
}

// 3. 接收完成
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    NSLog(@"下载完成");

    // 关闭流
    [self.fileStream close];
}

// 4. 网络错误
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    NSLog(@"%@", error);

    [self.fileStream close];
}

文件流操作方法

打开流 - 要对文件读写之前,首先需要打开流
- (void)open;

关闭流 - 对文件读写操作完成之后,需要关闭流
- (void)close;

将数据写入到流
- (NSInteger)write:(const uint8_t *)buffer maxLength:(NSUInteger)len;

断点续传

确认思路

  1. 检查服务器文件信息
  2. 检查本地文件
    • 如果比服务器文件小,续传
    • 如果比服务器文件大,重新下载
    • 如果和服务器文件一样,下载完成
  3. 断点续传

代码实现

检查服务器文件信息

///  检查服务器文件信息
- (void)remoteInfoWithURL:(NSURL *)url {

    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
    request.HTTPMethod = @"HEAD";

    NSURLResponse *response = nil;
    [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:NULL];

    self.expectedContentLength = response.expectedContentLength;
    self.targetPath = [NSTemporaryDirectory() stringByAppendingPathComponent:response.suggestedFilename];
}

检查本地文件

///  检查本地文件大小
- (long long)localFileSize {

    NSFileManager *manager = [NSFileManager defaultManager];

    long long fileSize = 0;
    // 1. 文件是否存在
    if ([manager fileExistsAtPath:self.targetPath]) {
        fileSize = [[manager attributesOfItemAtPath:self.targetPath error:NULL] fileSize];
    }

    // 2. 判断是否大于服务器大小
    if (fileSize > self.expectedContentLength) {
        [manager removeItemAtPath:self.targetPath error:NULL];
        fileSize = 0;
    }

    return fileSize;
}

断点续传

///  从偏移位置下载文件
- (void)downloadWithURL:(NSURL *)url offset:(long long)offset {

    self.fileSize = offset;

    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:1 timeoutInterval:kTimeout];

    NSString *rangeStr = [NSString stringWithFormat:@"bytes=%lld-", offset];
    [request setValue:rangeStr forHTTPHeaderField:@"Range"];

    NSURLConnection *conn = [NSURLConnection connectionWithRequest:request delegate:self];
    [conn start];
}

修改代理方法

// 1. 接收到服务器响应
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    // 打开文件流
    self.fileStream = [[NSOutputStream alloc] initToFileAtPath:self.targetPath append:YES];
    [self.fileStream open];
}

下载主方法

- (void)downloadWithURL:(NSURL *)url {

    // 1. 检查服务器文件信息
    [self remoteInfoWithURL:url];

    // 2. 检查本地文件大小
    long long fileSize = [self localFileSize];

    if (fileSize == self.expectedContentLength) {
        NSLog(@"下载完成");
        return;
    }

    // 3. 从偏移位置下载文件
    [self downloadWithURL:url offset:fileSize];
}

多线程

  • 异步下载
- (void)downloadWithURL:(NSURL *)url {

    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        // 1. 检查服务器文件信息
        [self remoteInfoWithURL:url];

        // 2. 检查本地文件大小
        long long fileSize = [self localFileSize];

        if (fileSize == self.expectedContentLength) {
            NSLog(@"下载完成");
            return;
        }

        // 3. 从偏移位置下载文件
        [self downloadWithURL:url offset:fileSize];
    });
}
  • 启动运行循环
NSURLConnection *conn = [NSURLConnection connectionWithRequest:request delegate:self];
[conn start];

// NSURLConnection 会在网络请求结束后,自动停止运行循环
[[NSRunLoop currentRunLoop] run];

NSLog(@"come here %@", [NSThread currentThread]);

完成回调

回调细节

  1. 进度回调,通常在异步执行

    1. 通常进度回调的频率非常高!如果界面上有很多文件,同时下载,又要更新 UI,可能会造成界面的卡顿
    2. 让进度回调,在异步执行,可以有选择的处理进度的显示,例如:只显示一个指示器!
    3. 有些时候,如果文件很小,调用方通常不关心下载进度!(SDWebImage)
    4. 异步回调,可以降低对主线程的压力
  2. 完成回调,通常在主线程执行

    1. 调用方不用考虑线程间通讯,直接更新UI即可
    2. 完成只有一次

增加类方法

///  实例化下载操作
///
///  @param url      下载文件的URL
///  @param progress 进度回调
///  @param finised  完成回调
///
///  @return 下载操作
+ (instancetype)downloadWithURL:(NSURL *)url progress:(void (^)(float progress))progress finised:(void (^)(NSString *filePath, NSError *error))finised;

///  开始下载
- (void)download;

利用属性记录block

  • 如果本方法可以直接调用,就不需要使用属性记录
  • 如果本方法不能直接调用,就需要使用属性记录,然后在需要的时候执行

定义 block 属性

///  下载文件 URL
@property (nonatomic, strong) NSURL *url;
///  进度回调
@property (nonatomic, copy) void (^progressBlock)(float);
///  完成回调
@property (nonatomic, copy) void (^finishedBlock)(NSString *, NSError *);

类方法实现

+ (instancetype)downloadWithURL:(NSURL *)url progress:(void (^)(float))progress finised:(void (^)(NSString *, NSError *))finised {

    HMDownloadOperation *d = [[HMDownloadOperation alloc] init];

    // 记录属性
    d.url = url;
    d.progressBlock = progress;
    d.finishedBlock = finised;

    return d;
}

在视图控制器中准备块代码

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    NSURL *url = [NSURL URLWithString:@"http://dldir1.qq.com/qqfile/QQforMac/QQ_V4.0.2.dmg"];

    HMDownloadOperation *down = [HMDownloadOperation downloadWithURL:url progress:^(float progress) {
        NSLog(@"%f %@", progress, [NSThread currentThread]);
    } finised:^(NSString *filePath, NSError *error) {
        NSLog(@"%@ %@ %@", filePath, error, [NSThread currentThread]);
    }];

    [down download];
}

进度回调

if (self.progressBlock) {
    self.progressBlock(progress);
}

完成回调

dispatch_async(dispatch_get_main_queue(), ^{
    self.finishedBlock(self.filePath, nil);
});

失败回调

dispatch_async(dispatch_get_main_queue(), ^{
    self.finishedBlock(nil, error);
});

暂停下载

暂停下载

- (void)pause {
    [self.conn cancel];
}
  • Cancels an asynchronous load of a request.
    After this method is called, the connection makes no further delegate method calls. If you want to reattempt the connection, you should create a new connection object.

  • 取消一个异步请求,调用此方法后,connection不会再调用代理方法。如果要再次尝试连接,需要建立一个新的连接对象

下载进度视图

属性

IB_DESIGNABLE
@interface ProgressButton : UIButton

@property (nonatomic, assign) IBInspectable float progress;
@property (nonatomic, strong) IBInspectable UIColor *lineColor;
@property (nonatomic, assign) IBInspectable CGFloat lineWidth;

@end

代码实现

@implementation ProgressButton

- (void)setProgress:(float)progress {
    _progress = progress;

    [self setTitle:[NSString stringWithFormat:@"%.02f%%", progress * 100] forState:UIControlStateNormal];

    [self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect {

    CGPoint center = CGPointMake(rect.size.width * 0.5, rect.size.height * 0.5);
    CGFloat r = (MIN(rect.size.width, rect.size.height) - self.lineWidth) * 0.5;
    CGFloat startAngle = - M_PI_2;
    CGFloat endAngle = self.progress * 2 * M_PI + startAngle;

    UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:center radius:r startAngle:startAngle endAngle:endAngle clockwise:YES];

    path.lineWidth = self.lineWidth;
    path.lineCapStyle = kCGLineCapRound;

    [self.lineColor setStroke];

    [path stroke];
}

@end

Storyboard 技巧

在 SB 中直接设置自定义视图属性

下载管理器

单例

+ (instancetype)sharedDownloadManager {
    static id instance;

    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });

    return instance;
}

移植下载方法

- (void)downloadWithURL:(NSURL *)url progress:(void (^)(float))progress finised:(void (^)(NSString *, NSError *))finised {

    HMDownloadOperation *downloader = [HMDownloadOperation downloadWithURL:url progress:progress finised:finised];

    [downloader download];
}

下载缓冲池

缓冲池属性

///  下载缓冲池
@property (nonatomic, strong) NSMutableDictionary *downloaderCache;

// MARK: - 懒加载
- (NSMutableDictionary *)downloaderCache {
    if (_downloaderCache == nil) {
        _downloaderCache = [[NSMutableDictionary alloc] init];
    }
    return _downloaderCache;
}

修改下载方法

- (void)downloadWithURL:(NSURL *)url progress:(void (^)(float))progress finised:(void (^)(NSString *, NSError *))finised {

    // 1. 判断下载操作缓冲池中是否存在下载操作
    if (self.downloaderCache[url]) {
        NSLog(@"正在玩命下载中...");
        return;
    }

    // 2. 实例化下载操作
    HMDownloadOperation *downloader = [HMDownloadOperation downloadWithURL:url progress:progress finised:finised];

    // 3. 添加到下载操作缓冲池
    [self.downloaderCache setObject:downloader forKey:url];

    // 4. 开始下载
    [downloader download];
}

下载完成后,将操作从缓冲池中删除

// 2. 实例化下载操作
HMDownloadOperation *downloader = [HMDownloadOperation downloadWithURL:url progress:progress finised:^(NSString *filePath, NSError *error) {

    // 将操作从缓冲池中删除
    [self.downloaderCache removeObjectForKey:url];

    // 执行调用方准备的 finished
    finised(filePath, error);
}];

NSOperation

使用 NSOperation 改造 HMDownloader

修改父类

@interface HMDownloadOperation : NSOperation

重写 main 方法

  • 自定义操作,重写了main方法,在当操作被添加到队列的时候,会自动被执行
  • 不要忘记自动释放池
- (void)main {
    // 自定义操作千万不要忘记自动释放池
    @autoreleasepool {
        // 执行下载
        [self download];
    }
}

修改管理器代码

操作队列

@property (nonatomic, strong) NSOperationQueue *downloaderQueue;

- (NSOperationQueue *)downloaderQueue {
    if (_downloaderQueue == nil) {
        _downloaderQueue = [[NSOperationQueue alloc] init];
    }
    return _downloaderQueue;
}

修改开始下载代码

// 4. 开始下载
[self.downloaderQueue addOperation:downloader];

取消下载操作

- (void)pauserWithURL:(NSURL *)url {
    // 1. 在缓冲池中查找下载操作
    HMDownloadOperation *downloader = self.downloaderCache[url];

    // 2. 判断是否存在下载操作
    if (downloader == nil) {
        NSLog(@"%@", self.downloaderQueue.operations);
        return;
    }

    // 3. 暂停操作,操作队列会认为操作已经完成,会自动将操作从操作队列中删除
    [downloader pause];

    // 4. 将下载操作从缓冲池中删除
    [self.downloaderCache removeObjectForKey:url];
}

重构步骤笔记

重构的目的

  • 相同的代码不要出现两次
  • 相同功能的代码可以及时抽取,以备日后复用,不要重复创建轮子

重构的原则

  • 明确每一步的目标
  • 小步走
  • 测试(每一个改动都有可能出现错误)

抽取代码的步骤

  • 新建方法
  • 复制代码
  • 根据代码调整参数和返回值
  • 调整调用位置代码
  • 测试

抽取类的步骤

  • 示意图
iOS渣逼(2)泥淖虾渣逼看URLConnection_第2张图片
下载目标1.png

抽取主方法

  • 新建类
  • 抽取主方法
    • .h 中定义方法接口,明确该方法是否适合被外部调用
    • .m 中增加方法实现
  • 将主方法复制到新方法中
  • 复制相关的方法
  • 复制相关属性
  • 检查代码的有效性
    • 调整内部变量,让 NSURL 由调用方传递,保证代码的灵活性
  • 复制代理方法,
    • 注释更新 UI 部分的代码
    • 使用 #warning TODO 提醒自己此处有未完成的工作
    • 这样做可以不影响重构的节奏
  • 调整视图控制器 测试重构方法执行
  • 调整视图控制器代码,删除被移走代码
  • 再次测试,确保调整没有失误!

确认接口

  • 确认重构的接口
    • 需要进度回调
    • 需要完成&错误回调
  • 定义类方法,传递回调参数
  • 实现类方法,记录住回调 block
  • 调整调用方法
  • 增加 block 实现
  • 测试
  • 增加已经下载完成的回调
    • 进度回调(100%)
    • 完成回调(路径)
  • 断言
  • 暂停操作
  • 测试,测试,测试!

新问题:如果连续点击,会重复下载,造成错乱!

解决办法:建立一个下载管理器的单例,负责所有的文件下载,以及下载操作的缓存!

  • 示意图
iOS渣逼(2)泥淖虾渣逼看URLConnection_第3张图片
下载目标2.png

抽取下载管理器

  • 建立单例
  • 接管下载操作
    • 定义接口方法
    • 实现方法
    • 替换方法
    • 测试
  • 操作缓存
  • 暂停实现
  • 最大并发数,NSOperationQueue+NSOperation

block 小结

  • block 是 C 语言的数据结构
  • 是预先准备好的代码,在需要时执行,类似于匿名函数指针
  • 可以被当作参数传递
  • 在需要时,可以对 block 进行扩展
  • 如果当前方法不执行 block,需要使用 属性 记录
  • block 属性需要使用 copy 描述符
  • 对于必须传递的 block 回调,可以使用 断言

你可能感兴趣的:(iOS渣逼(2)泥淖虾渣逼看URLConnection)