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

单独使用多线程不难,单独解决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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 无知带来的骗局和狡诈带来的骗局,即使突破了它们我也没动力活着 除了悲伤我什么都不会,也什么都不想会
    七宝儿阅读 53评论 0 0
  • 人生一世数春秋, 酷热严寒几度忧。 沧海桑田年年易, 不见青山铸史留。
    孙然義阅读 169评论 0 1
  • as usual,像往常一样。 有一颗平常心。 有时候很想养成一种习惯,告诉自己,坚持21天就可以了。可是真的坚持...
    一枚桂员阅读 850评论 0 0