问题说明:假设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
版权说明:此博客由博主本人编写而成,转载请注明出处,如有不正确或者有待改进之处还请指正,谢谢!