多线程异步加载图片async_pictures 异步加载图片 搭建界面&数据准备 异步下载图像 占位图像 自定义 Cell MVC 操作缓冲池 图像缓冲池 代码重构 内存警告 黑名单 沙盒缓存实现 iOS6 的适配问题 小结 SDWebImage初体验

  • 目标:在表格中异步加载网络图片
  • 目的:

    • 模拟 SDWebImage 基本功能实现
    • 理解 SDWebImage 的底层实现机制
    • SDWebImage 是非常著名的网络图片处理框架,目前国内超过 90% 公司都在使用!
  • 要求:

    • 不要求能够打出来
    • 需要掌握思路
    • 需要知道开发过程中,每一个细节是怎么递进的
    • 需要知道每一个隐晦的问题是如何发现的

搭建界面&数据准备

代码

数据准备

@interface AppInfo : NSObject
///  App 名称
@property (nonatomic, copy) NSString *name;
///  图标 URL
@property (nonatomic, copy) NSString *icon;
///  下载数量
@property (nonatomic, copy) NSString *download;

+ (instancetype)appInfoWithDict:(NSDictionary *)dict;
///  从 Plist 加载 AppInfo
+ (NSArray *)appList;

@end
+ (instancetype)appInfoWithDict:(NSDictionary *)dict {
    id obj = [[self alloc] init];

    [obj setValuesForKeysWithDictionary:dict];

    return obj;
}

///  从 Plist 加载 AppInfo
+ (NSArray *)appList {

    NSURL *url = [[NSBundle mainBundle] URLForResource:@"apps.plist" withExtension:nil];
    NSArray *array = [NSArray arrayWithContentsOfURL:url];

    NSMutableArray *arrayM = [NSMutableArray arrayWithCapacity:array.count];

    [array enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
        [arrayM addObject:[self appInfoWithDict:obj]];
    }];

    return arrayM.copy;
}

视图控制器数据

///  应用程序列表
@property (nonatomic, strong) NSArray *appList;
  • 懒加载
- (NSArray *)appList {
    if (_appList == nil) {
        _appList = [AppInfo appList];
    }
    return _appList;
}

表格数据源方法

#pragma mark - 数据源方法
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.appList.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"AppCell"];

    // 设置 Cell...
    AppInfo *app = self.appList[indexPath.row];

    cell.textLabel.text = app.name;
    cell.detailTextLabel.text = app.download;

    return cell;
}

知识点

  1. 数据模型应该负责所有数据准备工作,在需要时被调用
  2. 数据模型不需要关心被谁调用
  3. 数组使用
    • [NSMutableArray arrayWithCapacity:array.count]; 的效率更高
    • 使用块代码遍历的效率比 for 要快
  4. @"AppCell" 格式定义的字符串是保存在常量区的
  5. 在 OC 中,懒加载是无处不在的
    • 设置 cell 内容时如果没有指定图像,择不会创建 imageView
      # 同步加载图像
// 同步加载图像
// 1. 模拟延时
NSLog(@"正在下载 %@", app.name);
[NSThread sleepForTimeInterval:0.5];
// 2. 同步加载网络图片
NSURL *url = [NSURL URLWithString:app.icon];
NSData *data = [NSData dataWithContentsOfURL:url];
UIImage *image = [UIImage imageWithData:data];

cell.imageView.image = image;

注意:之前没有设置 imageView 时,imageView 并不会被创建

存在的问题

  1. 如果网速慢,会卡爆了!影响用户体验
  2. 滚动表格,会重复下载图像,造成用户经济上的损失!

解决办法

  • 异步下载图像

异步下载图像

全局操作队列

///  全局队列,统一管理所有下载操作
@property (nonatomic, strong) NSOperationQueue *downloadQueue;
  • 懒加载
- (NSOperationQueue *)downloadQueue {
    if (_downloadQueue == nil) {
        _downloadQueue = [[NSOperationQueue alloc] init];
    }
    return _downloadQueue;
}

异步下载

// 异步加载图像
// 1. 定义下载操作
// 异步加载图像
NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
    // 1. 模拟延时
    NSLog(@"正在下载 %@", app.name);
    [NSThread sleepForTimeInterval:0.5];
    // 2. 异步加载网络图片
    NSURL *url = [NSURL URLWithString:app.icon];
    NSData *data = [NSData dataWithContentsOfURL:url];
    UIImage *image = [UIImage imageWithData:data];

    // 3. 主线程更新 UI
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        cell.imageView.image = image;
    }];
}];

// 2. 将下载操作添加到队列
[self.downloadQueue addOperation:downloadOp];

运行测试

存在的问题

  • 下载完成后不现实图片

原因分析:
* 使用的是系统提供的 cell
* 异步方法中只设置了图像,但是没有设置 frame
* 图像加载后,一旦与 cell 交互,会调用 cell 的 layoutSubviews 方法,重新调整 cell 的布局

解决办法

  • 使用占位图像
  • 自定义 Cell

注意演示不在主线程更新图像的效果

占位图像

// 0. 占位图像
UIImage *placeholder = [UIImage imageNamed:@"user_default"];
cell.imageView.image = placeholder;

问题

  • 因为使用的是系统提供的 cell
  • 每次和 cell 交互,layoutSubviews 方法会根据图像的大小自动调整 imageView 的尺寸

解决办法

  • 自定义 Cell

自定义 Cell

cell.nameLabel.text = app.name;
cell.downloadLabel.text = app.download;

// 异步加载图像
// 0. 占位图像
UIImage *placeholder = [UIImage imageNamed:@"user_default"];
cell.iconView.image = placeholder;

// 1. 定义下载操作
NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
    // 1. 模拟延时
    NSLog(@"正在下载 %@", app.name);
    [NSThread sleepForTimeInterval:0.5];
    // 2. 异步加载网络图片
    NSURL *url = [NSURL URLWithString:app.icon];
    NSData *data = [NSData dataWithContentsOfURL:url];
    UIImage *image = [UIImage imageWithData:data];

    // 3. 主线程更新 UI
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        cell.iconView.image = image;
    }];
}];

// 2. 将下载操作添加到队列
[self.downloadQueue addOperation:downloadOp];

问题

  • 如果网络图片下载速度不一致,同时用户滚动图片,可能会出现图片显示”错行”的问题

  • 修改延时代码,查看错误

// 1. 模拟延时
if (indexPath.row > 9) {
    [NSThread sleepForTimeInterval:3.0];
}

上下滚动一下表格即可看到 cell 复用的错误

解决办法

  • MVC

MVC

在模型中添加 image 属性

#import <UIKit/UIKit.h>

///  下载的图像
@property (nonatomic, strong) UIImage *image;

使用 MVC 更新表格图像

  • 判断模型中是否已经存在图像
if (app.image != nil) {
    NSLog(@"加载模型图像...");
    cell.iconView.image = app.image;
    return cell;
}
  • 下载完成后设置模型图像
// 3. 主线程更新 UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    // 设置模型中的图像
    app.image = image;
    // 刷新表格
    [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
}];

问题

  • 如果图像下载很慢,用户滚动表格很快,会造成重复创建下载操作

  • 修改延时代码

// 1. 模拟延时
if (indexPath.row == 0) {
    [NSThread sleepForTimeInterval:10.0];
}

快速滚动表格,将第一行不断“滚出/滚入”界面可以查看操作被重复创建的问题

解决办法

  • 操作缓冲池

操作缓冲池

缓冲池的选择

所谓缓冲池,其实就是一个容器,能够存放多个对象

  • 数组:按照下标,可以通过 indexPath 可以判断操作是否已经在进行中
    • 无法解决上拉&下拉刷新
  • NSSet -> 无序的
    • 无法定位到缓存的操作
  • 字典:按照key,可以通过下载图像的 URL(唯一定位网络资源的字符串)

小结:选择字典作为操作缓冲池

缓冲池属性

///  操作缓冲池
@property (nonatomic, strong) NSMutableDictionary *operationCache;
  • 懒加载
- (NSMutableDictionary *)operationCache {
    if (_operationCache == nil) {
        _operationCache = [NSMutableDictionary dictionary];
    }
    return _operationCache;
}

修改代码

  • 判断下载操作是否被缓存——正在下载
// 异步加载图像
// 0. 占位图像
UIImage *placeholder = [UIImage imageNamed:@"user_default"];
cell.iconView.image = placeholder;

// 判断操作是否存在
if (self.operationCache[app.icon] != nil) {
    NSLog(@"正在玩命下载中...");
    return cell;
}
  • 将操作添加到操作缓冲池
// 2. 将操作添加到操作缓冲池
[self.operationCache setObject:downloadOp forKey:app.icon];

// 3. 将下载操作添加到队列
[self.downloadQueue addOperation:downloadOp];

修改占位图像的代码位置,观察会出现的问题

  • 下载完成后,将操作从缓冲池中删除
[self.operationCache removeObjectForKey:app.icon];

循环引用分析!

  • 弱引用 self 的编写方法:
__weak typeof(self) weakSelf = self;
  • 利用 dealloc 辅助分析
- (void)dealloc {
    NSLog(@"我去了");
}
  • 注意
    • 如果使用 self,视图控制器会在下载完成后被销毁
    • 而使用 weakSelf,视图控制器在第一时间被销毁

图像缓冲池

使用模型缓存图像的问题

优点

  • 不用重复下载,利用MVC刷新表格,不会造成数据混乱

缺点

  • 所有下载后的图像,都会记录在模型中
  • 如果模型数据本身很多(2000),单纯图像就会占用很大的内存空间
  • 如果图像和模型绑定的很紧,不容易清理内存

解决办法

  • 使用图像缓存池

图像缓存

  • 缓存属性
///  图像缓冲池
@property (nonatomic, strong) NSMutableDictionary *imageCache;
  • 懒加载
- (NSMutableDictionary *)imageCache {
    if (_imageCache == nil) {
        _imageCache = [[NSMutableDictionary alloc] init];
    }
    return _imageCache;
}
  • 删除模型中的 image 属性
  • 哪里出错改哪里!

断网测试

问题

  • image == nil 时会崩溃=>不能向字典中插入 nil
  • image == nil 时会重复刷新表格,陷入死循环

解决办法

  • 修改主线程回调代码
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    if (image != nil) {
        // 设置模型中的图像
        [weakSelf.imageCache setObject:image forKey:app.icon];
        // 刷新表格
        [weakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
    }
}];

代码重构

代码重构介绍

重构目的

  • 相同的代码最好只出现一次
  • 主次方法
    • 主方法
      • 只包含实现完整逻辑的子方法
      • 思维清楚,便于阅读
    • 次方法
      • 实现具体逻辑功能
      • 测试通过后,后续几乎不用维护

重构的步骤

  • 新建一个方法
    • 新建方法
    • 把要抽取的代码,直接复制到新方法中
    • 根据需求调整参数
  • 调整旧代码
    • 注释原代码,给自己一个后悔的机会
    • 调用新方法
  • 测试
  • 优化代码
    • 在原有位置,因为要照顾更多的逻辑,代码有可能是合理的
    • 而抽取之后,因为代码少了,可以检查是否能够优化
    • 分支嵌套多,不仅执行性能会差,而且不易于阅读
  • 测试
  • 修改注释
    • 在开发中,注释不是越多越好
    • 如果忽视了注释,有可能过一段时间,自己都看不懂那个注释
    • .m 关键的实现逻辑,或者复杂代码,需要添加注释,否则,时间长了自己都看不懂!
    • .h 中的所有属性和方法,都需要有完整的注释,因为 .h 文件是给整个团队看的

重构一定要小步走,要边改变测试

重构后的代码

- (void)downloadImage:(NSIndexPath *)indexPath {

    // 1. 根据 indexPath 获取数据模型
    AppInfo *app = self.appList[indexPath.row];

    // 2. 判断操作是否存在
    if (self.operationCache[app.icon] != nil) {
        NSLog(@"正在玩命下载中...");
        return;
    }

    // 3. 定义下载操作
    __weak typeof(self) weakSelf = self;
    NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
        // 1. 模拟延时
        NSLog(@"正在下载 %@", app.name);
        if (indexPath.row == 0) {
            [NSThread sleepForTimeInterval:3.0];
        }
        // 2. 异步加载网络图片
        NSURL *url = [NSURL URLWithString:app.icon];
        NSData *data = [NSData dataWithContentsOfURL:url];
        UIImage *image = [UIImage imageWithData:data];

        // 3. 主线程更新 UI
        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            // 将下载操作从缓冲池中删除
            [weakSelf.operationCache removeObjectForKey:app.icon];

            if (image != nil) {
                // 设置模型中的图像
                [weakSelf.imageCache setObject:image forKey:app.icon];
                // 刷新表格
                [weakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
            }
        }];
    }];

    // 4. 将操作添加到操作缓冲池
    [self.operationCache setObject:downloadOp forKey:app.icon];

    // 5. 将下载操作添加到队列
    [self.downloadQueue addOperation:downloadOp];
}

内存警告

如果接收到内存警告,程序一定要做处理,日常上课时,不会特意处理。但是工作中的程序一定要处理,否则后果很严重!!!

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];

    // 1. 取消下载操作
    [self.downloadQueue cancelAllOperations];

    // 2. 清空缓冲池
    [self.operationCache removeAllObjects];
    [self.imageCache removeAllObjects];
}

黑名单

如果网络正常,但是图像下载失败后,为了避免再次都从网络上下载该图像,可以使用“黑名单”

  • 黑名单属性
@property (nonatomic, strong) NSMutableArray *blackList;
  • 懒加载
- (NSMutableArray *)blackList {
    if (_blackList == nil) {
        _blackList = [NSMutableArray array];
    }
    return _blackList;
}
  • 下载失败记录在黑名单中
if (image != nil) {
    // 设置模型中的图像
    [weakSelf.imageCache setObject:image forKey:app.icon];
    // 刷新表格
    [weakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
} else {
    // 下载失败记录在黑名单中
    [weakSelf.blackList addObject:app.icon];
}
  • 判断黑名单
// 2.1 判断黑名单
if ([self.blackList containsObject:app.icon]) {
    NSLog(@"已经将 %@ 加入黑名单...", app.icon);
    return;
}

沙盒缓存实现

沙盒目录介绍

  • Documents
    • 保存由应用程序产生的文件或者数据,例如:涂鸦程序生成的图片,游戏关卡记录
    • iCloud 会自动备份 Document 中的所有文件
    • 如果保存了从网络下载的文件,在上架审批的时候,会被拒!
  • tmp

    • 临时文件夹,保存临时文件
    • 保存在 tmp 文件夹中的文件,系统会自动回收,譬如磁盘空间紧张或者重新启动手机
    • 程序员不需要管 tmp 文件夹中的释放
  • Caches

    • 缓存,保存从网络下载的文件,后续仍然需要继续使用,例如:网络下载的离线数据,图片,视频…
    • 缓存目录中的文件系统不会自动删除,可以做离线访问!
    • 要求程序必需提供一个完善的清除缓存目录的”解决方案”!
  • Preferences

    • 系统偏好,用户偏好
    • 操作是通过 [NSUserDefaults standardDefaults] 来直接操作

iOS 不同版本间沙盒目录的变化

  • iOS 7.0及以前版本 bundle 目录和沙盒目录是在一起的
  • iOS 8.0之后,bundle 目录和沙盒目录是分开的

NSString+Path

#import "NSString+Path.h"

@implementation NSString (Path)

- (NSString *)appendDocumentPath {
    NSString *dir = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).lastObject;
    return [dir stringByAppendingPathComponent:self.lastPathComponent];
}

- (NSString *)appendCachePath {
    NSString *dir = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).lastObject;
    return [dir stringByAppendingPathComponent:self.lastPathComponent];
}

- (NSString *)appendTempPath {
    return [NSTemporaryDirectory() stringByAppendingPathComponent:self.lastPathComponent];
}

@end

沙盒缓存

  • 将图像保存至沙盒
if (data != nil) {
    [data writeToFile:app.icon.appendCachePath atomically:true];
}
  • 检查沙盒缓存
// 判断沙盒文件是否存在
UIImage *image = [UIImage imageWithContentsOfFile:app.icon.appendCachePath];
if (image != nil) {
    NSLog(@"从沙盒加载图像 ... %@", app.name);
    // 将图像添加至图像缓存
    [self.imageCache setObject:image forKey:app.icon];
    cell.iconView.image = image;

    return cell;
}

iOS6 的适配问题

面试题:iOS 6.0 的程序直接运行在 iOS 7.0 的系统中,通常会出现什么问题

  • 状态栏高度 20 个点是不包含在 view.frame 中的,self.view 的左上角原点的坐标位置是从状态栏下方开始计算

    • iOS 6.0 程序直接在 iOS 7.0 的系统中运行最常见的问题,就是少了20个点
  • 如果包含有 UINavigationControllerself.view的左上角坐标原点从状态栏下方开始计算

    • 因此,iOS 6.0的系统无法实现表格从导航条下方穿透的效果
  • 如果包含有 UITabBarControllerself.view的底部不包含 TabBar

    • 因此,iOS 6.0的系统无法实现表格从 TabBar 下方穿透效果

小结

代码实现回顾

  • tableView 数据源方法入手
  • 根据 indexPath 异步加载网络图片
  • 使用操作缓冲池避免下载操作重复被创建
  • 使用图像缓冲池实现内存缓存,同时能够对内存警告做出响应
  • 使用沙盒缓存实现再次运行程序时,直接从沙盒加载图像,提高程序响应速度,节约用户网络流量

遗留问题

  • 代码耦合度太高,由于下载功能是与数据源的 indexPath 绑定的,如果想将下载图像抽取到 cell 中,难度很大!

SDWebImage初体验

简介

  • iOS中著名的牛逼的网络图片处理框架
  • 包含的功能:图片下载、图片缓存、下载进度监听、gif处理等等
  • 用法极其简单,功能十分强大,大大提高了网络图片的处理效率
  • 国内超过90%的iOS项目都有它的影子
  • 框架地址:https://github.com/rs/SDWebImage

演示 SDWebImage

  • 导入框架
  • 添加头文件
#import "UIImageView+WebCache.h"
  • 设置图像
[cell.iconView sd_setImageWithURL:[NSURL URLWithString:app.icon]];

思考:SDWebImage 是如何实现的?

  • 将网络图片的异步加载功能封装在 UIImageView 的分类中
  • UITableView 完全解耦

要实现这一目标,需要解决以下问题:

  • UIImageView 下载图像的功能
  • 要解决表格滚动时,因为图像下载速度慢造成的图片错行问题,可以在给 UIImageView 设置新的 URL 时,取消之前未完成的下载操作

目标锁定:取消正在执行中的操作!