iOS开发笔录iOS开发技术收集 -- 完整应用及开发技巧篇iOS Developer - Multithread

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

2015-07-20  本文已影响1008人  CoderAO

单独使用多线程不难,单独解决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需要获得图片的时候,先从缓存中取,如果没有再进行下载.


图片缓存是一个可变字典:

版本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版本,肉眼基本已经看不出什么问题了.但潜在的问题还是有的,我们一个个解决.

接下来的问题是由两个基本要素造成的:

将两者结合起来考虑,当在图片还在下载过程中,如果快速上下滑动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

上一篇 下一篇

猜你喜欢

热点阅读