单独使用多线程不难,单独解决UITableViewCell复用带来的问题也不难,但是当两者强强联合的时候就比较让人崩溃了.本文会由浅入深地教你如何用NSOperation自己实现tableView上的多图片下载.
先简单介绍下工程,很简单,就是将一系列数据以列表形式展示.每组数据包括图片的url、标题title.为排除其他干扰数据我们直接从plist获取.展示效果如下:(数据来自知乎日报接口)
准备工作:
- 以UITableViewController的子类
ViewController
为窗口根控制器 - UITableViewCell使用Storyboard中tableView的动态单元格,并进行自定义.
- News数据模型, 包含以下属性:
@interface ATApp : NSObject
/** 标题 */
@property (nonatomic, strong) NSString *title;
/** 图片地址 */
@property (nonatomic, strong) NSString *url;
@end
本课题的关键在于如何配置cell.
我们先尝试最直接的方法
版本V0.1
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *ID = @"news";
NewsCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];
News *news = self.newsArray[indexPath.row];
cell.titleLabel.text = news.title;
// 下载图片
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:news.url]];
cell.imgView.image = [UIImage imageWithData:data];
return cell;
}
其中下载图片的部分,图片直接由URL下载数据并初始化.
此做法的缺点为:
- 该方法调用频繁,随着不断有cell滚入屏幕,即使已经下载过的图片仍会被不停地重复下载.
-
dataWithContentsOfURL:
方法是阻塞式的,即图片的下载阶段不能进行其它操作,这会导致tableView的滑动异常卡顿.
为了解决重复下载的问题,我们为图片进行缓存.每次显示cell需要获得图片的时候,先从缓存中取,如果没有再进行下载.
图片缓存是一个可变字典:
版本V0.2
@property (nonatomic, strong) NSMutableDictionary *imagesCache;
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *ID = @"news";
NewsCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];
News *news = self.newsArray[indexPath.row];
cell.titleLabel.text = news.title;
// 先从缓存中取图片
UIImage *image = self.imagesCache[news.url];
if (image) {
cell.imgView.image = image;
}
else {
// 如果缓存中没有该图片
cell.imgView.image = [UIImage imageNamed:@"placeholder"];
// 根据url进行下载
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:news.url]];
image = [UIImage imageWithData:data];
// 并将下载好的图片进行缓存
self.imagesCache[news.url] = image;
cell.imgView.image = image;
}
return cell;
}
0.2版本比0.1版本的进步在于不会无限制地反复下载图片了,但是存在的问题仍然是很多.比如,不论应用被打开几次,相同的资源应该只需要下载一次就够了.所以图片应该不只在内存中做缓存,还要做持久化的存储:
版本V0.3
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *ID = @"news";
NewsCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];
News *news = self.newsArray[indexPath.row];
cell.titleLabel.text = news.title;
// 先从缓存中取图片
UIImage *image = self.imagesCache[news.url];
if (image) {
cell.imgView.image = image;
}
else {
// 如果缓存中没有该图片
cell.imgView.image = [UIImage imageNamed:@"placeholder"];
// 去沙盒里面找
NSString *cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
// 获取文件全路径
NSString *filePath = [cachePath stringByAppendingPathComponent:news.url.lastPathComponent];
image = [UIImage imageWithContentsOfFile:filePath];
// 如果沙盒也没有
if (!image) {
// 根据url进行下载
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:news.url]];
image = [UIImage imageWithData:data];
// 数据写入沙盒
[data writeToFile:filePath atomically:YES];
}
// 将沙盒里面的图片或下载好的图片进行缓存
self.imagesCache[news.url] = image;
cell.imgView.image = image;
}
return cell;
}
代码升级了两次,但始终没有解决一个根本性的问题就是:每张图片在下载的时候仍然会对主线程造成阻塞.这时候NSOperation就派上用场了.
版本V0.4
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *ID = @"news";
NewsCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];
News *news = self.newsArray[indexPath.row];
cell.titleLabel.text = news.title;
// 先从内存缓存中取图片
__block UIImage *image = self.imagesCache[news.url];
// 如果内存缓存中有则直接显示在cell上
if (image) {
cell.imgView.image = image;
}
else {
// 先显示占位图片
cell.imgView.image = [UIImage imageNamed:@"placeholder"];
// 如果内存缓存中没有,再去沙盒里面看看
NSString *cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
// 文件全路径
NSString *filePath = [cachePath stringByAppendingPathComponent:news.url.lastPathComponent];
NSData *diskData = [NSData dataWithContentsOfFile:filePath];
// 如果沙盒有数据
if (diskData) {
UIImage *diskImage =[UIImage imageWithData:diskData];
cell.imgView.image = diskImage;
// 缓存到内存
self.imagesCache[news.url] = diskImage;
}
else {
// 沙盒也没有,就使用NSOperation进行下载
// self.queue是NSOperationQueue类型的属性
[self.queue addOperationWithBlock:^{
// 根据url进行下载
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:news.url]];
image = [UIImage imageWithData:data];
// 写入缓存
self.imagesCache[news.url] = image;
// 写入沙盒
[data writeToFile:filePath atomically:YES];
// 回主线程展示
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
cell.imgView.image = image;
}];
}];
}
}
return cell;
}
0.4版本的思想就是在内存沙盒的缓存中都没有图片的情况下,用NSOperation进行异步下载,这样不会阻塞主线程.但是还是有问题.
如果你自己在尝试敲这段代码,会发现在程序打开后迅速滑动tableView,cell上的图片会有一瞬间先显示不对应这条数据的图片,然后变回正确的图片.现象不是很容易观察,如果在显示图片之前让线程暂停半秒钟会看到明显的效果.
这种现象的原因是:由于下载图片是异步的,可能在图片下载的过程中,这行cell已滑出屏幕,并被复用到其它行.这时候图片下载完成,便显示到了被复用的cell上(错乱了).被复用的cell一开始也有自己对应的图片在下载,紧接着也下载完了,新的图片便覆盖到刚刚错乱的图片上.
这个问题其实还算比较容易解决,注意看之前的代码,我们在主线程要显示图片的时候,都是直接拿cell去给imageView赋值.问题就在这,cell是会被复用的.而不管怎么复用,indexPath却始终唯一.所以要拿indexPath开刀:
版本V0.5
为避免给读者造成干扰,这个版本就只贴和0.4版本不一样的地方了(在图片下载完成回主线程显示的地方)
// ...
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 下载完成后刷新对应的行
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
}];
// ...
我们的工程进入了0.5版本,肉眼基本已经看不出什么问题了.但潜在的问题还是有的,我们一个个解决.
接下来的问题是由两个基本要素造成的:
- cellForRow...方法的调用时机:每次cell进入视野的时候调用.
- 当cell请求图片的时候,如果内存和沙盒里面都没有,会向队列添加下载操作.而这个下载是需要时间的.
将两者结合起来考虑,当在图片还在下载过程中,如果快速上下滑动tableView,同一个cell反复从屏幕出现和消失,就会反复调用cellForRow...方法,这个时候图片还没下载好,没有在内存也没有在沙盒,就又会添加同一个image的下载操作.任务重复添加造成不必要的浪费,所以还得改改.思路就是将操作也进行缓存.添加操作的时候先从缓存取,没有再创建.
版本0.6
// 添加操作缓存属性
@property (nonatomic, strong) NSMutableDictionary *operationCache;
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *ID = @"news";
NewsCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];
News *news = self.newsArray[indexPath.row];
cell.titleLabel.text = news.title;
// 先从内存缓存中取图片
__block UIImage *image = self.imagesCache[news.url];
// 如果内存缓存中有则直接显示在cell上
if (image) {
cell.imgView.image = image;
}
else {
cell.imgView.image = [UIImage imageNamed:@"placeholder"];
// 如果内存缓存中没有,再去沙盒里面看看
NSString *cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
// 文件全路径
NSString *filePath = [cachePath stringByAppendingPathComponent:news.url.lastPathComponent];
NSData *diskData = [NSData dataWithContentsOfFile:filePath];
diskData = nil;
// 如果沙盒有数据
if (diskData) {
UIImage *diskImage =[UIImage imageWithData:diskData];
cell.imgView.image = diskImage;
// 缓存到内存
self.imagesCache[news.url] = diskImage;
}
else {
NSBlockOperation *op = self.operationCache[news.url];
if (!op) {
op = [NSBlockOperation blockOperationWithBlock:^{
// 根据url进行下载
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:news.url]];
image = [UIImage imageWithData:data];
// 写入缓存
self.imagesCache[news.url] = image;
// 写入沙盒
[data writeToFile:filePath atomically:YES];
// 回主线程展示
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
}];
}];
// 进行缓存
self.operationCache[news.url] = op;
// 添加操作
[self.queue addOperation:op];
}
}
}
return cell;
}
现在程序瞬间看起来高大上了很多,但是还有一些细节需要注意:
- 网络不好的时候下载可能失败,如果下载失败了,需要将下载任务从缓存移除,这样有机会可以添加新的任务重新下载.
- 下载完成的时候需要将任务移除
版本1.0
这里只贴下载图片部分的代码
// ...
NSBlockOperation *op = self.operationCache[news.url];
if (!op) {
op = [NSBlockOperation blockOperationWithBlock:^{
// 根据url进行下载
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:news.url]];
// 如果网络中断下载失败等导致data为空
if (!data) {
// 从操作缓存中移除,使得该图片有机会重新下载
[self.operationCache removeObjectForKey:news.url];
return;
}
image = [UIImage imageWithData:data];
// 写入缓存
self.imagesCache[news.url] = image;
// 写入沙盒
[data writeToFile:filePath atomically:YES];
// 回主线程展示
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
}];
// 下载完成,移除操作
[self.operationCache removeObjectForKey:news.url];
}];
// 进行缓存
self.operationCache[news.url] = op;
// 添加操作
[self.queue addOperation:op];
}
// ...
自己实现的多图片下载就这样搞定了.
小结:
虽然我们有SDWebImage可以帮我们一行代码搞定上面的所有过程,但是我觉得了解并自己实现以下这个过程对我们思考问题的周全性,设计框架的健壮性以及读懂别人代码的能力都有一定程度的提高,是一个不错的案例.