• 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

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

  • 相关阅读:
    Ajax配合Node搭建服务器,运用实例
    mapMutations m
    seaJS使用教程
    节流函数
    【Gin-API系列】Gin中间件之日志模块(四)
    【Gin-API系列】配置文件和数据库操作(三)
    【Gin-API系列】请求和响应参数的检查绑定(二)
    【Gin-API系列】需求设计和功能规划(一)
    【ansible】api 调用出现ssh交互式输入
    【ansible】api 调试
  • 原文地址:https://www.cnblogs.com/wsnb/p/4746536.html
Copyright © 2020-2023  润新知