在前面的笔记中,我们整理了和多线程相关的一些基础知识,下面通过一个实例来做一个综合演练。我们都见过下面这种界面:

首先,它是一个tableView,联网以后获取cell上面的数据。在每一行cell中,它的左边是一个应用图标,右边有若干文字或者按钮。接下来,我们就搭建一个类似于这样的界面。
一、搭建基本界面
新建一个工程。因为我们的界面是基于tableView的,所以,你可以把ViewController相关的类给删掉。或者,干脆对它进行相应的修改,让它继承自UITableViewController。修改完继承关系以后,来到Main.storyboard文件中,删掉原来的ViewController控件,然后再往里面拖一个UITableViewController控件,如下图所示:

选中这个UITableViewController控件,然后点击右上方的"Show the Identity Inspector",将它的Class绑定为ViewController(也就是要用ViewController这个类来描述它),具体操作如下图所示:

接下来需要对UITableViewController控件进行初始化。选中它,然后点击右上角的"Show the Attributes Inspector",接下来勾选View Controller下面的"Is Initial View Controller",具体操作如下图所示:

勾选完成以后,Main.storyboard文件中的UITableViewController前面会多出一个小箭头,如下图所示:

接下来,可以运行程序检查一下,看看我们的设置有没有成功:

最后,还有很重要的一步工作要做,就是设置cell的样式,以及绑定cell的可重用标识。回到Main.storyboard文件中,展开"View Controller Scene",选中它下面的"Table View Cell",然后点击右上方的"Show the Attributes Inspector",将Style后面的样式修改为Subtitle,最后再给Identifier绑定一个可重用标识符。这个可重用标识符可以随便绑,主要是为了cell的循环利用。具体操作如下图所示:

至此,前期的准备工作基本算是完成了,剩下的就是写代码了。
二、设计模型
将准备好的plist文件拖到项目中来。选择这个plist文件,将其展开,我们先对它进行分析。首先,它是一个数组,并且数组里面装的是16个字典;接着,展开这个字典,它里面有3个元素name、icon和download,并且每个元素的类型都是string:

模型里面的属性名称和类型都是已经确定好了的,不能随意更改。按住command + N键,新建一个继承自NSObject的ESApps类,然后在它的头文件中声明3个属性和一个字典转模型的类方法:
// ESApps.h
@interface ESApps : NSObject
/** app的名称 */
@property (copy, nonatomic) NSString *name;
/** app icon的下载地址 */
@property (copy, nonatomic) NSString *icon;
/** app的下载数量 */
@property (copy, nonatomic) NSString *download;
/** 字典转模型 */
+ (instancetype)appsWithDict:(NSDictionary *)dict;
@end
再来到ESApps.m文件中实现这个字典转模型的类方法:
// ESApps.m
+ (instancetype)appsWithDict:(NSDictionary *)dict {
// 创建模型
ESApps *apps = [[self alloc] init];
// 利用KVC快速设置
[apps setValuesForKeysWithDictionary:dict];
return apps;
}
关于模型的设计,我在之前的笔记中已经详细整理过,具体情形参见《UITableView的基本使用》第三部分使用plist文件来管理数据。
三、项目的基本实现
接下来,我们要加载plist文件中的数据,简单的实现一下程序的基本功能。来到ViewController文件中,包含模型的头文件,然后在它的类扩展中声明一个NSArray类型的apps属性,用来加载来自apps.plist文件中的数据:
@interface ViewController ()
/** 用于加载来自apps.plist文件中的数据 */
@property (strong, nonatomic) NSArray *apps;
@end
重写apps属性的getter方法,对apps.plist文件中的数据进行懒加载,将它里面的字典数据转换为模型数据:
// MARK:- 对apps.plist文件中的数据进行懒加载
- (NSArray *)apps {
// 如果_apps为空
if (!_apps) {
// 获取apps.plist文件所在的路径
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"apps.plist" ofType:nil];
// 将apps.plist文件中的字典数据加载到字典数组dictArr中
NSArray *dictArr = [NSArray arrayWithContentsOfFile:filePath];
// 创建一个可变的临时数组,用于存放后面转换完成的模型数据
NSMutableArray *tmpArr = [NSMutableArray array];
// 遍历字典数组dictArr,将里面的字典数据取出来
for (NSDictionary *dict in dictArr) {
// 将取出来的字典数据转换为模型数据
ESApps *apps = [ESApps appsWithDict:dict];
// 将转换完成的模型数据存储到临时数组tmpArr中
[tmpArr addObject:apps];
}
// 将存储有模型数据的临时数组tmpArr赋值给_apps
_apps = tmpArr;
}
// 返回_apps
return _apps;
}
接下来要做的,就是实现数据源方法,将apps.plist文件中的数据展示到tableView的cell上,或者说根据apps.plist文件中的URL地址下载对应的应用图标:
#pragma tableView data Source
// MARK:- 返回tableView中的组数
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return 1;
}
// MARK:- 返回tableView中每一组数据里的行数
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.apps.count;
}
// MARK:- 返回tableView中每一行cell的数据
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
// 定义cell的可重用标识符
static NSString *reuseIdentifier = @"apps"; // 这里的可重用标识符一定要和Main.storyboard文件中绑定的标识符一致
// 根据可重用标识符去缓存池中取出可重用的cell
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier];
// 从数组apps中取出模型数据
ESApps *apps = self.apps[indexPath.row];
// 根据模型中的数据给cell设置相应的数据
cell.textLabel.text = apps.name; // 设置应用的名称
cell.detailTextLabel.text = apps.download; // 设置应用的下载量
// 设置应用的图标
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
UIImage *image = [UIImage imageWithData:imageData]; // 将应用图标的二进制数据转换为对应的图标
cell.imageView.image = image; // 将应用图标设置到对应的cell上
// 返回cell
return cell;
}
如果此时运行程序,它只会展示apps.plist文件中的文字数据,并不会根据URL地址下载网络图片:

没有下载网络图片的原因是,自从Xcode 7.0之后,苹果默认不再支持HTTP协议的网络请求。为此,我们需要修改info.plist文件中的设置。具体的修改步骤,参见《NSThread线程间的通信》第一部分内容。修改完成之后,再次运行程序:

程序运行以后,最直观的感受是,它非常的卡,操作也不流畅。其主要原因是,图片的下载是在主线程中完成的。除此之外,它还有另外一个隐藏的问题,就是图片重复下载。可以在代码中加入打印信息进行查看:

从打印出来的序号来看,在tableView滚动的过程中,有些图片被下载了多次。接下来,我们就着手解决程序卡顿和图片重复下载的问题。
四、优化程序性能
1、内存缓存
我们先来解决图片重复下载的问题。可以声明一个属性,用来保存从网络下载的图片。也就是说,一开始先不要直接去网络下载图片,而是先检查用来保存网络图片的属性,如果该图片已经存在,就不要去下载,直接将图片来过来用。当图片不存在,也就是没有下载过时,才去网络下载。
用来保存网络图片的属性用什么类型呢?因为每一个应用图片和它的cell是一一对应的关系,因此,除了要保存下载下来的图片之外,还要保存一个东西,用来标识它和图片是一种一一对应的关系。为此,我们可以选用字典。至于用什么东西来标识不同的图片呢?可以先看一下apps.plist文件:

从apps.plist文件中可以看,每一张图片对应的URL地址其实是最好的选择。我们可以在保存图片的同时,将与之对应的URL地址也保存进去。下次再取出图片时,可以根据这URL地址去取就可以了。为此,在ViewController的类扩展中声明一个可变的字典,专门用来缓存从网络下载下来的应用图标:
@interface ViewController ()
/** 用于加载来自app.plist文件中的数据 */
@property (strong, nonatomic) NSArray *apps;
/** 用于缓存从网络下载下来的应用图标 */
@property (strong, nonatomic) NSMutableDictionary *images;
@end
重写images的getter方法,用懒加载的方式对其进行初始化:
// MARK:- 通过懒加载的方式对images进行初始化
- (NSMutableDictionary *)images {
// 如果images为空
if (!_images) {
// 初始化images
_images = [NSMutableDictionary dictionary];
}
// 返回字典
return _images;
}
接下来就是要修改返回cell的数据源方法。在设置cell图片时,一开始先不要去网络上下载,先检查images中有没有这张图片,如果它里面有这张图片,就直接将它拿过来用;如果没有,再去网络上下载,并且要将它保存到images中,以便下次直接使用。为了方便对比,我在代码中加入了调试信息:
// MARK:- 返回tableView中每一行cell的数据
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
// 定义cell的可重用标识符
static NSString *reuseIdentifier = @"apps";
// 根据可重用标识符去缓存池中取出可重用的cell
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier];
// 从数组apps中取出模型数据
ESApps *apps = self.apps[indexPath.row];
// 根据模型中的数据给cell设置相应的数据
cell.textLabel.text = apps.name; // 设置应用的名称
cell.detailTextLabel.text = apps.download; // 设置应用的下载量
// 先通过标识(也就是字典中保存的key)去images中取出与之对应的图片
UIImage *image = [self.images objectForKey:apps.icon];
// 如果image图片已经存在
if (image) {
// 直接将该图片拿过来设置上去
cell.imageView.image = image;
// 调试
NSLog(@"从缓存中去取---%ld---", indexPath.row);
} else {
// 如果该图片不存在,再去网络上下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
UIImage *image = [UIImage imageWithData:imageData]; // 将应用图标的二进制数据转换为对应的图标
// 将下载下来的图片保存到images中,以便下次直接使用
[self.images setObject:image forKey:apps.icon];
// 将下载下来的图片设置到cell上去
cell.imageView.image = image;
// 调试
NSLog(@"下载网络图片---%ld---", indexPath.row);
}
// 返回cell
return cell;
}
运行程序,注意看一下控制台打印出来的调试信息,看一下我们有没有解决图片重复下载的问题:

从打印信息来看,我们基本解决了图片重复下载的问题。但是,它还不够完善。原因是,我们的图片是缓存到内存中的。也就是说,只要我们退出程序,再次运行时,还要去网络下载。好一点的做法是,除了内存缓存之外,还应该弄一个沙盒缓存。有了沙盒缓存,即便是退出程序,下次再运行时,我们的应用可以去沙盒中取出之前已经下载过的图片。
2、沙盒缓存
沙盒缓存的相关知识,我们在之前的笔记《简单的数据存储知识》中有过一点介绍,这里再简单的回顾一下沙盒中不同目录的作用:
Document:用来保存应用运行时生成的需要持久化的数据,iTunes同步设备时会备份该目录。苹果官方不允许我们将数据保存在这个文件夹目录下;
tmp:用来保存应用运行时所需的临时数据,可能随时会被删掉;
Library:它下面有两个文件夹,Caches和Preference。其中,Caches用来保存体积大、不需要备份的非重要数据;Preference一般用来保存应用的偏好设置和账号,iTunes同步时会备份这个目录。
通过对沙盒中不同目录作用的简单了解,我们应该知道,项目中下载下来的图片应该缓存到Library文件夹下面的Caches中。为此,我们要再次修改返回cell的数据源方法。具体的思路是,程序运行以后,先去内存缓存中取出图片;如果内存缓存中没有图片,再去沙盒缓存中取;如果沙盒缓存中也没有,最后再去网络下载图片:
// MARK:- 返回tableView中每一行cell的数据
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
// 定义cell的可重用标识符
static NSString *reuseIdentifier = @"apps";
// 根据可重用标识符去缓存池中取出可重用的cell
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier];
// 从数组apps中取出模型数据
ESApps *apps = self.apps[indexPath.row];
// 根据模型中的数据给cell设置相应的数据
cell.textLabel.text = apps.name; // 设置应用的名称
cell.detailTextLabel.text = apps.download; // 设置应用的下载量
// 先通过标识(也就是字典中保存的key)去images中取出与之对应的图片
UIImage *image = [self.images objectForKey:apps.icon];
// 如果image图片已经存在
if (image) {
/************************* 内存缓存 *************************/
// 直接将该图片拿过来设置上去
cell.imageView.image = image;
// 调试
NSLog(@"从内存缓存中去取---%ld---", indexPath.row);
} else {
/************************* 沙盒缓存 *************************/
// 获取沙盒中Caches文件夹的路径
NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
// 获取与图片对应的URL地址最后一个节点,把它作为存储图片的名字
NSString *fileName = [apps.icon lastPathComponent];
// 拼接存储图片时的全路径
NSString *fullPath = [caches stringByAppendingPathComponent:fileName];
// 先去沙盒缓存中取出图片的二进制数据
NSData *imageData = [NSData dataWithContentsOfFile:fullPath];
// 如果存在该图片的二进制数据
if (imageData) {
// 直接将该图片的二进制数据拿过来转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中,以便下次直接使用
[self.images setObject:image forKey:apps.icon];
// 将图片设置到cell的图片控件上去
cell.imageView.image = image;
// 调试
NSLog(@"从沙盒缓存中去取---%ld---", indexPath.row);
} else {
/************************* 网络下载 *************************/
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 最后再将图片设置到cell的图片控件上去
cell.imageView.image = image;
// 调试
NSLog(@"从网络上下载图片---%ld---", indexPath.row);
}
}
// 返回cell
return cell;
}
在把下载下来的图片保存到沙盒中时,有两点需要注意:1、保存图片到沙盒时,不要直接保存图片,应该保存图片的二进制数据;2、存储图片的名字时,不要有斜杠("/"),因为斜杠在这里有特殊含义,它表示文件目录的层级关系。运行程序看一下:

从运行的情况来看,首次运行程序时,是从网络下载图片的,而且在滚动过程中也没有重复下载;重新运行程序之后,不再是去网络下载图片,而是直接利用沙盒中缓存的图片,并且在tableView的滚动过程中,也有效的利用了内存缓存。最后,你打开沙盒中Caches所在的目录,也能看到缓存下来的图片:

利用缓存机制的好处是,除了可以减少网络下载的时间,提高程序运行速度之外,还可以有效的节约本地空间和网络流量。解决完图片重复下载的问题,剩下的就是解决应用卡顿和操作不流畅。
3、开子线程下载图片
从上面的代码中可以看出,我们图片下载的任务都是在主线程中完成的。这个对于我们这个应用来说,影响似乎不是很大。但是,如果是在实际开发过程中,碰到图片非常大,下载量非常多,再加上网络不是很好时,那就比较麻烦了。因此,为了提升用户体验,需要将下载图片的操作放在子线程中去执行:
// MARK:- 返回tableView中每一行cell的数据
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
// 定义cell的可重用标识符
static NSString *reuseIdentifier = @"apps";
// 根据可重用标识符去缓存池中取出可重用的cell
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier];
// 从数组apps中取出模型数据
ESApps *apps = self.apps[indexPath.row];
// 根据模型中的数据给cell设置相应的数据
cell.textLabel.text = apps.name; // 设置应用的名称
cell.detailTextLabel.text = apps.download; // 设置应用的下载量
// 先通过标识(也就是字典中保存的key)去images中取出与之对应的图片
UIImage *image = [self.images objectForKey:apps.icon];
// 如果image中存在该图片
if (image) {
/************************* 内存缓存 *************************/
// 直接将该图片拿过来设置上去
cell.imageView.image = image;
// 调试
NSLog(@"从内存缓存中去取---%ld---", indexPath.row);
} else {
/************************* 沙盒缓存 *************************/
// 获取沙盒中Caches文件夹的路径
NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
// 获取与图片对应的URL地址最后一个节点,把它作为存储图片的名字
NSString *fileName = [apps.icon lastPathComponent];
// 拼接存储图片时的全路径
NSString *fullPath = [caches stringByAppendingPathComponent:fileName];
// 先去沙盒缓存中取出图片的二进制数据
NSData *imageData = [NSData dataWithContentsOfFile:fullPath];
// 如果存在该图片的二进制数据
if (imageData) {
// 直接将该图片的二进制数据拿过来转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中,以便下次直接使用
[self.images setObject:image forKey:apps.icon];
// 将图片设置到cell的图片控件上去
cell.imageView.image = image;
// 调试
NSLog(@"从沙盒缓存中去取---%ld---", indexPath.row);
} else {
/************************* 在子线程中下载 *************************/
// 创建一个非主队列
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
// 封装下载任务
NSBlockOperation *downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 调试
NSLog(@"从网络上下载图片---%ld---", indexPath.row);
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 最后再将图片设置到cell的图片控件上去
cell.imageView.image = image;
}];
}];
// 将操作任务添加到队列中
[queue addOperation:downloadImage];
}
}
// 返回cell
return cell;
}
仔细看一下上面的代码,尤其是开子线程下载的部分,似乎是没什么问题。但是,我们都知道,- tableView: cellForRowAtIndexPath:这个方法的调用是十分频繁的,而我们开子线程创建非主队列时,只需开一次就够了,如果将创建主队列的代码放在这里面,那么开的主队列将会非常多!为此,我们需要在ViewController的类扩展中声明一个NSOperationQueue类型的queue属性,然后对它进行懒加载:
// MARK:- 队列只需要创建一次,因此对它进行懒加载
- (NSOperationQueue *)queue {
// 如果队列不存在
if (!_queue) {
// 创建一个非主队列
_queue = [[NSOperationQueue alloc] init];
// 设置队列的最大并发数
_queue.maxConcurrentOperationCount = 4;
}
// 返回队列
return _queue;
}
回到- tableView: cellForRowAtIndexPath:这个方法中,修改开子线程下载网络数据部分的代码,不要直接在这里面创建队列:
// 封装下载任务
NSBlockOperation *downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 调试
NSLog(@"下载图片---%ld---%@", indexPath.row, [NSThread currentThread]);
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 最后再将图片设置到cell的图片控件上去
cell.imageView.image = image;
NSLog(@"刷新UI---%@", [NSThread currentThread]);
}];
}];
// 将操作任务添加到队列中
[self.queue addOperation:downloadImage];
为了演示图片下载的过程,先去沙盒中把之前缓存的图片给删掉,然后再运行程序,注意看下控制台图片下载时的打印:

从打印出来的线程number来看,我们已经实现了在子线程中下载图片,在主线程中刷新UI。不过,好像又引发了新的问题,从直观感受来看,程序似乎是比之前更慢了。其实,这个是假象。我们现在下载图片的过程是在子线程中并发执行的,因此应该比以前更快了。只不过显示cell的图片时,需要手动刷新:
// MARK:- 返回tableView中每一行cell的数据
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
// 定义cell的可重用标识符
static NSString *reuseIdentifier = @"apps";
// 根据可重用标识符去缓存池中取出可重用的cell
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier];
// 从数组apps中取出模型数据
ESApps *apps = self.apps[indexPath.row];
// 根据模型中的数据给cell设置相应的数据
cell.textLabel.text = apps.name; // 设置应用的名称
cell.detailTextLabel.text = apps.download; // 设置应用的下载量
// 先通过标识(也就是字典中保存的key)去images中取出与之对应的图片
UIImage *image = [self.images objectForKey:apps.icon];
// 如果image中存在该图片
if (image) {
/************************* 内存缓存 *************************/
// 直接将该图片拿过来设置上去
cell.imageView.image = image; // 后面手动刷新以后,程序会调用这句代码,将cell的图片设置上去
// 调试
NSLog(@"从内存缓存中去取---%ld---", indexPath.row);
} else {
/************************* 沙盒缓存 *************************/
// 获取沙盒中Caches文件夹的路径
NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
// 获取与图片对应的URL地址最后一个节点,把它作为存储图片的名字
NSString *fileName = [apps.icon lastPathComponent];
// 拼接存储图片时的全路径
NSString *fullPath = [caches stringByAppendingPathComponent:fileName];
// 先去沙盒缓存中取出图片的二进制数据
NSData *imageData = [NSData dataWithContentsOfFile:fullPath];
// 如果存在该图片的二进制数据
if (imageData) {
// 直接将该图片的二进制数据拿过来转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中,以便下次直接使用
[self.images setObject:image forKey:apps.icon];
// 将图片设置到cell的图片控件上去
cell.imageView.image = image;
// 调试
NSLog(@"从沙盒缓存中去取---%ld---", indexPath.row);
} else {
/************************* 网络下载 *************************/
// 封装下载任务
NSBlockOperation *downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 调试
NSLog(@"下载图片---%ld---%@", indexPath.row, [NSThread currentThread]);
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 刷新界面
// cell.imageView.image = image;
NSLog(@"刷新UI---%@", [NSThread currentThread]);
// 手动刷新
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
/**
* 为什么这里手动刷新之后要把上面的 cell.imageView.image = image;给注释掉?
* 主要原因是,采用手动刷新之后,程序会重新调用- tableView: cellForRowAtIndexPath:方法,
* 而我们在下载网络图片的过程中已经将图片保存到沙盒和内存缓存中去了,所以再次调用数据源方法时,
* 这个图片已经有了,它会直接从内存缓存中去取,然后再通过if (image) { }这个判断语句中设置
* 图片的代码将图片给设置上去。
*/
}];
}];
// 将操作任务添加到队列中
[self.queue addOperation:downloadImage];
}
}
// 返回cell
return cell;
}
去沙盒中删掉之前缓存的图片,然后再次运行程序,看一下执行手动刷新以后,图片的显示是不是更快了:

为什么需要手动刷新呢?主要原因是,我们tableView中cell的样式是subtitle。而这种样式中cell图片的尺寸一开始是(0, 0),图片下载完成以后一开始因为没有尺寸,所以不显示。等到刷新完成以后,它才会有尺寸,因此图片才会显示。
加入手动刷新以后,程序看起来似乎是完美的,其实,它还有很多隐藏的问题,尤其是在网速比较慢时,这种问题就会表现得非常严重。下面,为了模拟网速比较慢的情形,我们在下载图片时,人为的阻塞线程:
// 封装下载任务
NSBlockOperation *downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 调试
NSLog(@"下载图片---%ld---%@", indexPath.row, [NSThread currentThread]);
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 故意阻塞线程,模拟网速比较慢的状况
[NSThread sleepForTimeInterval:2.0];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 手动刷新
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
}];
}];
先去沙盒中删除之前缓存的图片,同时,为了模拟cell比较多的情况,去Main.storyboard文件中将cell的高度拉大一点。再次运行程序,注意看一下控制台打印信息,同时也看一下模拟器中的UI界面:

从控制台打印出来的消息来看,这里又出现了两个比较严重的问题:1、图片又重复下载了,第10行和第11行cell的图片下载了两次;2、cell的图片数据发生错乱,本来应该是"植物大战僵尸"的图标,中途却出现在"杀手2"的位置上。导致出错的根本原因是cell的循环利用。下面,我们将逐步分析错误产生的具体原因,以及解决的办法。
4、再次出现图片重复下载
上一次出现图片重复下载,是因为没有实现缓存机制,导致多次下载图片。这一次出现图片重复下载,主要原因是cell的循环利用。以第10行cell为例,因为网速比较慢,下载图片是要耗费时间的。在图片还没有完全下载时,我们快速滚动tableView,导致第10行cell滚出屏幕,但是,它的下载请求并不会取消。当第10行cell再次滚出界面并循环利用到后面时,它又一次发出了下载网络图片的请求,因而出现了重复下载。也就是说,当网速比较慢,而滚动很快时,就会出现重复下载的问题。
为了解决上面这个问题,我们需要先对下载网络图片这个操作进行过滤,当我们发现之前已经发起过下载图片的请求时,就直接等待它下载完成;当我们发现之前没有发起过下载请求时,再去网络上下载。为此,在ViewController的类扩展中声明一个NSMutableDictionary类型的operations属性,专门用来过滤重复的下载请求。先对它进行懒加载:
// MARK:- 解决因为网速慢而滚动较快时出现重复下载的问题
- (NSMutableDictionary *)operations {
// 如果没有下载过图片
if (!_operations) {
// 对_operations进行初始化
_operations = [NSMutableDictionary dictionary];
}
// 返回初始化以后的_operations
return _operations;
}
回到- tableView: cellForRowAtIndexPath:这个方法中,对下载网络图片的操作进行过滤:
/************************* 网络下载 *************************/
// 先去operations中检查一下,看看之前有没有发送下载图片的网络请求
NSBlockOperation *downloadImage = [self.operations objectForKey:apps.icon];
// 如果之前已经发起过下载图片的请求
if (downloadImage) {
// 什么也不干,等待图片下载完成
} else {
// 如果之前没有发起过下载图片的请求,再去网络上进行下载
downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 故意阻塞线程,模拟网速比较慢的状况
[NSThread sleepForTimeInterval:2.0];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 手动刷新
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
}];
}];
// 将这次下载请求存储到operations中
[self.operations setObject:downloadImage forKey:apps.icon];
// 将操作任务添加到队列中
[self.queue addOperation:downloadImage];
}
运行程序之前,先去沙盒中删除之前缓存过的图片,注意控制台打印,看一下还有没有图片重复下载的问题:

从运行的结果来看,图片重复下载的情况已经没有了。但是数据错乱的问题还在,比如说"俄罗斯方块"的图标错配成了"找你妹",还有"刀塔传奇"的图片也不对,变成了"植物大战僵尸"。这个问题产生的原因也是因为cell的循环利用。
5、数据错乱
在网络比较慢的情况下,无论是图片的下载还是刷新都比较慢,因此,当你快速滚动tableView时,很容将上一个cell的图片循环利用到下一个cell上,这样很容易导致数据错乱的问题。解决问题的方式比较简单,就是在去网络上下载之前,先将上一个cell的图片给清空,或者,比较好的做法是,搞一个占位图片。我们这里就来演示一下占位图片的使用。
将准备好的占位图片拖到项目中的Assets.xcassets文件夹下,然后再来到- tableView: cellForRowAtIndexPath:这个方法中,在下载图片之前,先将占位图片设置上去:
// 为了防止因为循环利用而导致数据错乱,在下载之前,先将图片数据给清空(或者使用占位图片)
cell.imageView.image = [UIImage imageNamed:@"placeholder"];
// 如果之前没有发起过下载图片的请求,再去网络上进行下载
downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 故意阻塞线程,模拟网速比较慢的状况
[NSThread sleepForTimeInterval:2.0];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 手动刷新
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
}];
}];
再次运行程序,注意看一下UI界面的变化,看看还有没有数据错乱的问题:

现在,因为网速慢,再加上循环利用而出现的重复下载和数据错乱的问题基本得到解决。需要说明的是,在用线程阻塞模拟网速较慢的过程中,可能偶尔会出现程序崩溃的问题,这个只需要把线程阻塞的代码移除就可以了。
五、完善程序细节
1、从operations字典中删除已经完成下载操作的任务
下载网络图片的操作完成以后就没什么用了,所以可以考虑在封装下载操作任务的最后,将其从operations中删除:
// 为了防止因为循环利用而导致数据错乱,在下载之前,先将图片数据给清空(或者使用占位图片)
cell.imageView.image = [UIImage imageNamed:@"placeholder"];
// 如果之前没有发起过下载图片的请求,再去网络上进行下载
downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 手动刷新
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
}];
// 下载操作完成以后就没什么用了,可以将它从operations中删除
[self.operations removeObjectForKey:apps.icon];
}];
2、容错处理
我们都知道,字典中存储的值是不能为空的。但是,假如我们的下载地址有误,那么很有可能下载的图片为空,此时程序就会崩溃。先去apps.plist文件中,将"捕鱼达人2"图片的URL地址改成错误的,然后再运行程序看一下:

从运行结果看,当某个图片的URL地址错误时,程序运行就会崩溃。为了保证下载地址有误时,我们的程序不会崩溃,这里需要做一个容错处理。在下载网络图片的过程中,主要是将图片存储到内存缓存(images)之前,要先对图片可能为空值的情况进行判断,一旦图片为空,就将下载操作从operations中删除,同时直接返回:
// 为了防止因为循环利用而导致数据错乱,在下载之前,先将图片数据给清空(或者使用占位图片)
cell.imageView.image = [UIImage imageNamed:@"placeholder"];
// 如果之前没有发起过下载图片的请求,再去网络上进行下载
downloadImage = [NSBlockOperation blockOperationWithBlock:^{
// 如果沙盒缓存中没有该图片,再去网络下载
NSURL *url = [NSURL URLWithString:apps.icon]; // 获取应用图标的下载地址
NSData *imageData = [NSData dataWithContentsOfURL:url]; // 根据应用图标的URL地址下载图片的二进制数据
// 将下载下来的图片二进制数据保存到沙盒中
[imageData writeToFile:fullPath atomically:YES];
// 将图片的二进制数据转换为图片
UIImage *image = [UIImage imageWithData:imageData];
/************************* 容错处理 *************************/
/**
* 如果下载图片的URL地址有误,那么我们就不能下载图片数据,
* 也就是说,我们下载的图片很有可能是空值。但是,字典中是
* 不能存储空值的。因此,在存储图片之前,要对这种情况做容
* 错处理:当图片值为空时,将下载操作从operations中移除,
* 同时,程序直接返回。
*/
// 容错处理(比如说图片为空值)
if (!image) {
// 将下载操作移除,以便再次下载
[self.operations removeObjectForKey:apps.icon];
// 图片为空值,直接返回
return ;
}
// 将转换完成的图片保存一份到内存缓存中
[self.images setObject:image forKey:apps.icon];
// 回到主线程中去刷新UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 手动刷新
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
}];
// 下载操作完成以后就没什么用了,可以将它从operations中删除
[self.operations removeObjectForKey:apps.icon];
}];
"捕鱼达人2"图片的URL地址还是错误的,我们再次运行程序,看一下做完容错处理之后,程序运行是否崩溃:

虽然有图片的URL地址是错误的,但是程序不再崩溃,图片为空的cell现在用占位图片代替了。
3、内存警告处理
在实际开发过程中,多图下载时还要考虑内存警告的问题。在我们这个项目中,图片非常少,而且每张图片也不是很大,基本上不会产生内存警告问题。但是,在实际应用过程中,很有可能会碰到海量高清图的开发场景。在这种情况下,内存缓存有可能很快就爆满,如果不及时处理,我们的应用很快就会挂掉。
要解决这种问题,就必须实现- didReceiveMemoryWarning方法。当内存面临爆满时,及时清除内存缓存中的数据,与此同时,还要立即停止正在执行的网络下载:
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
// 发生内存警告时,要及时清除内存缓存
[self.images removeAllObjects];
// 与此同时,还要立即停止正在执行的下载任务
[self.queue cancelAllOperations];
}
至此,我们这个综合应用的笔记算是整理完了。控制器中的代码有点长,可以考虑自定义cell,这里暂时不演示。详细代码参见multithreadedExercise。