tableView异步下载图片/SDWebImage图片缓存原理

问题说明:假设tableView的每个cell上的imageView的image都是从网络上获取的数据。如何解决图片延迟加载(显示很慢)、程序卡顿、图片错误显示、图片跳动的问题。

需要解决的问题:

1.程序运行过程中,每次滚动tableView让新的cell进入视野的时候,都要从网络获取image,浪费了大量的用户流量,严重影响了手机性能和流畅度。

2.每次程序启动 ,都要再次从网络上获取image,浪费了大量的用户流量,严重影响了的手机性能和流畅度。

3.快速拖动tableView,会出现程序卡顿、无反应的现象(主线程阻塞),导致人机交互延迟,严重影响了用户体验。

4.快速拖动tableView,会出现图片显示错位、图片跳动的现象,严重影响用户体验。

5.快速拖动tableView,会出现程序占用内存飙升,程序不流畅的现象,严重影响用户体验。

针对于以上问题,解决方案依次如下:

1、声明可变字典属性,把下载好的图片放入这个可变字典属性(以下简称“图片内存缓存”或“内存缓存”或“缓存”),以图片的下载地址作为key来唯一标识区别其他图片。

2、获取本地cache目录(以下简称“本地缓存”或“本地”),把下载好的图片存入本地缓存

3、开启子线程(新线程),把下载图片这种耗时操作交给子线程来完成,图片下载完成后,跳回主线程更新UI,解决主线程中下载图片岛主主线程阻塞的问题

4、 多线程重复设置问题:多线程会存在这么一种情况:当cell的图片下载的时候,会开启一个新的子线程,由于多种原因(用户滑动的比较快、网速太差、图片太 大)。假如,下载当前cell的这一张图片需要十分钟,用户滚动tableView的时候,cell的图片还没下载完cell就被回收到 tableView的缓存池中,而此时被回收的cell的图片仍然在子线程中努力的下载中。当缓存池中的这个cell被重用的时候(此时cell的图片还 没下载完成),系统又会开启一个新的线程给这个cell下载对应的新图片(无论cell被重用到原来的位置还是新的位置,只要缓存或者本地没有对应的图片 都会再开启一个新的线程去下载),当第一个图片下载完后会显示到cell上(此时导致了图片的错误显示),当第二个图片下载完也会显示到cell上(此时 导致图片的快速跳动)
解决方案:更新UI的时候只刷新指定行[self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];不要使用cell.imageView.image = image;

5、多线程重复下载问题:和重复设置问题类似,多线程会存在这么一种情况,当cell的图片下载的时候,会开启一个新的子线 程,由于多种原因(用户滑动的比较快、网速太差、图片太大)。假如,下载当前cell的这一张图片需要十分钟,用户滚动tableView的时 候,cell的图片还没下载完cell就被回收到tableView的缓存池中,而此时被回收的cell的图片仍然在子线程中努力的下载中。此时用户又回 滚tableView,缓存池中的cell又被重用到原来的位置,而此时无论是缓存中还是本地都没有这个cell对应的图片,所以系统又会开启一个新的线 程下载这个cell对应的图片。所以,这样一来就导致同一个cell的图片有两个线程在下载。如果用户抽风,不断的上下滚动tableView,导致同一 个cell不但的在缓存池和tableView之间切换(也就是系统不断的回收同一个cell到缓存池,然后又重用缓存池中的这个cell到cell原来 的位置(cell被回收之前在tableView上的位置))那么这种情况下,同一个cell的图片不止只是有两个子线程在下载,可能会有更多个子线程在 同事下载同一张图片,这样开辟了多个不必要的子线程,极大地浪费了用户手机的内存。
解决方案:增加NSMutableDictionary类型的 成员变量,开启NSOperation缓存,把每个正在执行的操作添加到字典,以图片的下载地址作为key来唯一标识其他NSOperation。每次开 启新线程下载图片之前,先判断字典中是否已经存在该key对应的操作,如果不存在,则开启子线程进行下载,否则什么都不做。

另外,需要注意的是,操作完成或失败,需要在字典中移除该操作,如果下载操作失败但没有从字典中移除,那么下次检测到字典中有这个key对应的操作,就永远不会开启新线程。

还 要考虑因为网络或者服务器宕机等其他不可控原因造成的下载数据data为nil的情况。这种情况下,需要判断data是否为nil,如果为nil,则直接 return,不需要再执行后面的代码。否则造成的后果是:data生成的image是空,把空的image赋值给图片缓存(字典),系统报错:
reason: '*** setObjectForKey: object cannot be nil (key: http://p0.qhimg.com/t01ad71850a5fae7e97.png)'。PS:后面()中的key为调试时候系统打印的,因为这 里我把image的下载地址作为了key。根据每个人自己程序中字典key的具体情况key的打印信息会存在差异。

本例采用MVC模式,需要根据plist的存储结构来构建数据模型,以下为程序用到的所有文件 以及 plist文件的存储结构:

根据plist文件的存储结构构建数据模型:

 数据模型的.h文件:

#import <Foundation/Foundation.h>

@interface WSAppItem : NSObject

@property (nonatomic,copy) NSString *name;
@property (nonatomic,copy) NSString *download;
@property (nonatomic,copy) NSString *icon;

- (instancetype)initWithDict:(NSDictionary *)dict;
+ (instancetype)itemWithDict:(NSDictionary *)dict;

@end

 数据模型的.m文件:

#import "WSAppItem.h"

@implementation WSAppItem

- (instancetype)initWithDict:(NSDictionary *)dict
{
    if (self = [super init]) {
        [self setValuesForKeysWithDictionary:dict];
    }
    return self;
}

+ (instancetype)itemWithDict:(NSDictionary *)dict
{
    return [[self alloc] initWithDict:dict];
}
@end

NSString的分类的.h文件:

#import <Foundation/Foundation.h>

@interface NSString (WS)
/** 用于生成文件在caches目录中的路径 */
- (instancetype)cacheDir;
/** 用于生成文件在document目录中的路径 */
- (instancetype)docDir;
/** 用于生成文件在tmp目录中的路径 */
- (instancetype)tmpDir;
@end

NSString的分类的.m文件:

本程序中,以下方法只用到了cacheDir

#import "NSString+WS.h"

@implementation NSString (WS)

- (instancetype)cacheDir
{
    // 获取cache(本地缓存)目录
    NSString *path = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
    NSLog(@"%@",path);
    // 拼接绝对路径
    return [path stringByAppendingPathComponent:[self lastPathComponent]];
}

- (instancetype)docDir
{
    NSString *path = [NSSearchPathForDirectoriesInDomains(NSDocumentationDirectory, NSUserDomainMask, YES) lastObject];
    return [path stringByAppendingString:[self lastPathComponent]];
}

- (instancetype)tmpDir
{
    NSString *path = NSTemporaryDirectory(); // 临时文件夹
    return [path stringByAppendingString:[self lastPathComponent]];
}
@end

控制器.m文件:

#import "ViewController.h"
#import "WSAppItem.h"
#import "NSString+WS.h"

@interface ViewController ()
/** 模型数组 */
@property(nonatomic,strong) NSArray *apps;
/** 图片缓存 */
@property(nonatomic,strong) NSMutableDictionary *imageCaches;
/** 操作缓存 */
@property(nonatomic,strong) NSMutableDictionary *operations;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 设置storyBoard中的cell的高度:
    // 1.需要拖动cell来设置cell的高度,不能通过尺寸检查器中的rowHeight设置
    // 2.通过代码设置
    // 3.通过代理设置
}

#pragma mark - 懒加载
- (NSArray *)apps
{
    if (_apps == nil) {
        _apps = [NSArray array];
        // 加载plist->数组
        NSString *path = [[NSBundle mainBundle] pathForResource:@"apps.plist" ofType:nil];
        NSArray *appsArr = [NSArray arrayWithContentsOfFile:path];
        
        NSMutableArray *arrM = [NSMutableArray arrayWithCapacity:appsArr.count];
        for (NSDictionary *dict in appsArr) {
            WSAppItem *appItem = [WSAppItem itemWithDict:dict];
            [arrM addObject:appItem];
        }
        // 创建不可变副本
        _apps = [arrM copy];
    }
    return _apps;
}

- (NSMutableDictionary *)imageCaches
{
    if (_imageCaches == nil) {
        _imageCaches = [[NSMutableDictionary alloc] init];
    }
    return _imageCaches;
}

- (NSMutableDictionary *)operations
{
    if (_operations == nil) {
        _operations = [NSMutableDictionary dictionary];
    }
    return _operations;
}
#pragma mark - 数据源 - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.apps.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { // 加载storyBoard中的cell UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"app"]; // 给cell设置数据 WSAppItem *appItem = self.apps[indexPath.row]; cell.textLabel.text =appItem.name; cell.detailTextLabel.text = appItem.download; // 设置占位图 cell.imageView.image = [UIImage imageNamed:@"temp"]; // 在block内部访问外面的对象,外面的对象必须要用__block修饰 __block UIImage *image = self.imageCaches[appItem.icon]; // 1.1、如果缓存中的图片为空,判断本地是否为空 if (image == nil) { // 拼接image在本地存储的路径 NSString *iconPath = [appItem.icon cacheDir]; // 获取image的存储在本地的二进制数据 NSData *data = [NSData dataWithContentsOfFile:iconPath]; // 2.1、如果本地存储的图片为空,则再判断operation缓存中是否已经开启了对应的操作 if (data == nil) { // 3.0、获取operation中对应的操作 NSOperation *op = self.operations[appItem.icon]; // 3.1、如果在operation缓存中获取的op为空,则开启新线程下载 if (op == nil) {
// 创建队列 NSOperationQueue *queue = [[NSOperationQueue alloc] init]; // 开启子线程下载图片 NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{ NSLog(@"下载的 图片"); // 把路径转换为URL->二进制数据 NSURL *url = [NSURL URLWithString:appItem.icon]; NSData *data = [NSData dataWithContentsOfURL:url]; // 如果下载失败或者data为空,则也要把操作从操作缓存中移除 if (data == nil) { [self.operations removeObjectForKey:appItem.icon]; return; } NSLog(@"如果data为nil不能执行到这"); // 根据data获取图片 image = [UIImage imageWithData:data]; // 把下载好的图片放入缓存中 self.imageCaches[appItem.icon] = image; // 把下载好的图片写入本地 [data writeToFile:iconPath atomically:YES]; // 回到主线程更新UI [[NSOperationQueue mainQueue] addOperationWithBlock:^{ // cell.imageView.image = image; // 刷新指定行,避免重复设置 [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; // 下载成功,把操作从操作缓存移除 [self.operations removeObjectForKey:appItem.icon]; }]; }]; // 把操作添加到操作缓存 self.operations[appItem.icon] = operation; // 把操作添加到队列 [queue addOperation:operation];
}else{ // 3.2、如果operatin缓存中有对应的操作,那么什么都不做 } }else{ NSLog(@"本地的 图片"); // 2.2、如果本地不为空,则加载本地图片 image = [UIImage imageWithData:data]; // 将本地图片缓存到缓存中,以后就直接从缓存中取
       // 注意:如果不添加到缓存中,那么每次程序启动都是从本地读取而非动缓存读取图片
         self.imageCaches[appItem.icon] = image; // 更新UI cell.imageView.image = image; } }else{ NSLog(@"缓存的 图片"); // 1.2、如果缓存中的图片不为空,就加载缓存中的图片 image = self.imageCaches[appItem.icon]; // 更新UI cell.imageView.image = image; } return cell; } @end

注意:为什么"重启程序"后显示读取的是本地图片,不是应该先本地后缓存吗???
 因为,受if语句嵌套的影响,外层if...else语句是判断缓存中有没有图片,内层if...else语句是判断本地有没有图片。所以,每次程序启动加载图片的顺序是,先判断缓存中有没有,再判断本地有没有;显然程序启动后,缓存中没有,那么就会去本地中查找,如果本地中也没有就会开启子线程下载图片,然后跳回主线程显示图片(也就是执行内层if语句);如果本地查找有相应图片的话,那么就会加载本地的图片(也就是执行内层if语句的else语句),所以这种情况下,永远不会加载缓存中的图片(也就是永远不会执行外层if语句的else语句)。解决这种问题的方式,可以在加载本地图片的时候,把本地图片添加到缓存当再次显示图片的时候,缓存不为空,所以就会加载缓存的图片,这样直接和缓存交互,速度和效率会更快一些。

self.imageCaches[appItem.icon] = image;

为什么有时候程序启动没有图片?设置占位图的作用?

1.如果程序第一次启动,那么肯定会开启子线程下载图片,如果不设置占位图,主线程执行完成,子线程图片没有下载完成,这种情况下,图片下载完成后因为没有刷新表格所以不会显示图片。

2.如果在没有联网的情况下第一次启动程序,没有设置占位图,程序会崩溃。(事实证明这句话是错误的,程序崩溃是因为data为空,根据data生成的image也是空,空对象赋值给字典自然会崩溃)

3.如果程序不是第一次启动,则不会开启子线程,直接加载缓存或者本地图片。

 

 

cell.imageView.image = [UIImage imageNamed:@"temp"];

总结:

预先准备:
 1>、声明可变字典属性,把下载好的图片放入缓存(字典)
 2>、声明可变字典属性,把正在执行的操作放入operation缓存(字典)


 1.1、加载图片的时候,先判断内存缓存中有没有对应的图片。如果没有,则再判断本地缓存是否有对应图片
 2.1、如果本地缓存中没有对应图片,则再判断operation缓存中有没有对应的操作(有对应的操作说明该图片正在下载中,不需要再次开启新线程下载)

 3.1、如果operation缓存中也没有对应操作,则真正开启子线程下载图片

 注意:操作加入队列之前,把操作添加到operation缓存,操作完成或者失败,把操作从operation缓存移除

 3.2、如果operation缓存中有对应的操作,则什么都不做
 2.2、如果本地有对应图片则获取本地图片
 1.2、如果内存缓存中有对应的图片,则加载缓存中的图片
 
 这样可以保证程序再次启动后,不会去下载图片,除非本地没有可用的图片
 面试主要针对以下几个方面回答:

1.重复下载  : 图片内存缓存和磁盘缓存

2.主线程阻塞  :  开启子线程

3.重复下载  :  增加NSOperation字典

4.重复设置  :  刷新指定行

5.下载失败或无网络  :  判断data是否为nil

 

 

版权说明:此博客由博主本人编写而成,转载请注明出处,如有不正确或者有待改进之处还请指正,谢谢!

 

posted @ 2015-08-20 23:25  oneSong  阅读(1565)  评论(2编辑  收藏  举报