利用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