利用NSOperation实现UITableView的多图片下载

作者: CoderAO | 来源:发表于2015-07-20 13:05 被阅读1008次

单独使用多线程不难,单独解决UITableViewCell复用带来的问题也不难,但是当两者强强联合的时候就比较让人崩溃了.本文会由浅入深地教你如何用NSOperation自己实现tableView上的多图片下载.

先简单介绍下工程,很简单,就是将一系列数据以列表形式展示.每组数据包括图片的url、标题title.为排除其他干扰数据我们直接从plist获取.展示效果如下:(数据来自知乎日报接口)

最终效果
准备工作:
  1. 以UITableViewController的子类ViewController为窗口根控制器
  2. UITableViewCell使用Storyboard中tableView的动态单元格,并进行自定义.
  3. 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可以帮我们一行代码搞定上面的所有过程,但是我觉得了解并自己实现以下这个过程对我们思考问题的周全性,设计框架的健壮性以及读懂别人代码的能力都有一定程度的提高,是一个不错的案例.

完整代码获取: https://github.com/CoderAO/Multi-ImagesDownload

相关文章

网友评论

  • 鲸鱼与巨浪:您好。 提 两点建议: 1)队列下载 能否 只在cell 滑动显示 在屏幕范围内 去下载;如果是快速滑动 则 不去下载。 2)建议不适用内存缓存 。如果下载的图片过大,内存一下就回爆满,那也会造成卡顿。 建议 用一个图片下载池 管理 多任务下载,下载后 压缩 存储到沙盒,cell 显示的时候 直接从沙盒读取图片。 期待作者2.0 实现。
  • 淡水的救赎_945f:真经典的例子,非常不错。
  • 金鲤:mark
  • 爱吃米饼:之前收藏了,现在正用swift实现,在版本2里,imagesCache[news.url]总是拆包失败,因为imagesCache是空的,怎么用swift来解决这种问题呢?
    // 先从缓存中取图片
    UIImage *image = self.imagesCache[news.url];
    if (image) {
    cell.imgView.image = image;
    }
    爱吃米饼:@CoderAO 解决了,谢谢,swift可选链使用理解的还是不深刻, :smile:
    CoderAO:@爱吃米饼 我没有用swift去实现过,但是我觉得和使用哪种语言的关系不大,如果你从缓存中取图片失败,很有可能就是没有缓存成功,你可以去你缓存图片的地方检查一下是否缓存成功了.
  • 266d7524ca47:那个 v.5到底是怎么回事 你下载完成以后回到主线程执行更新 UI 这个时候的 indexpath 如果 单元格已经被划出去了.这个 indexpath对象指的是那个???
    CoderAO:@程小爱 0.5版本里面那两句代码是用来替换0.4版本里面"回主线程展示"部分的那两句(原文有提到),它是在cellForRowAtIndexPath:XX方法里面的,所以这个时候的indexPath就是cellForRowAtIndexPath:XX方法里面传进来的XX,这个方法只有在cell进入视野的时候才会被调用,如果已经被划出屏幕是不会调用这一句.
  • 小凡凡520:good mark
  • 9bfe700d122a:mark!!!!!
  • jiehu5114:其实在 版本V0.5 中 还有一种做法就是取消当前下载

本文标题:利用NSOperation实现UITableView的多图片下载

本文链接:https://www.haomeiwen.com/subject/obypqttx.html