前言
继上篇SDWebImage源码阅读1——整体脉络结构捋了下SDWebImage
整体的脉络结构后,本篇主要研究其缓存机制,这是其重点。
分析
上篇我们说了SDWebImageManager
这个类是其完成图片加载的核心类,它是整个代码逻辑的中心,可以把它叫做图片加载管理器。因为它拥有两个非常核心的属性:SDImageCache
和SDWebImageDownloader
两者的实例对象作为其属性,在该图片加载管理器里完成了有关缓存和网络下载图片的处理。
比如imageCache
这个属性在随着管理器初始化后,当管理器获取图片时它先在缓存中查找了缓存图片;然后从网络下载新图片后又** 将图片存入了缓存;除此外,还做了某图片是否有缓存**等功能。
本篇我们单就研究SDImageCache
这个有关缓存的类。
代码
代码很长,我们分为几部分来研究。
——初始化——
+ (SDImageCache *)sharedImageCache {
static dispatch_once_t once;
static id instance;
dispatch_once(&once, ^{
instance = [self new];
kPNGSignatureData = [NSData dataWithBytes:kPNGSignatureBytes length:8];
});
return instance;
}
- (id)init {
return [self initWithNamespace:@"default"]; // 默认是使用"default"命名空间的
}
- (id)initWithNamespace:(NSString *)ns {
if ((self = [super init])) {
NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];
// 初始化默认的缓存时间
_maxCacheAge = kDefaultCacheMaxCacheAge; // 1 week
_memCache = [[NSCache alloc] init]; // 内存缓存是直接使用的NSCache
_memCache.name = fullNamespace;
// 磁盘缓存的路径
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
_diskCachePath = [paths[0] stringByAppendingPathComponent:fullNamespace];
// 创建一个专门的串行队列,用于磁盘读写
_ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);
// 初始化文件管理器(在自己创建的队列中同步执行)
dispatch_sync(_ioQueue, ^{
_fileManager = [NSFileManager new];
});
#if TARGET_OS_IPHONE
// Subscribe to app events
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(clearMemory)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(cleanDisk)
name:UIApplicationWillTerminateNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(backgroundCleanDisk)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
#endif
}
return self;
}
// 在dealloc中移除观察和销毁队列
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
SDDispatchQueueRelease(_ioQueue);
}
First:可以看到第一个方法是非常熟悉的单例方法,用于生成一个唯一的实例。需要注意的是其中也同时初始化了
kPNGSignatureData
这个变量,它是NSData
型的,代表PNG图片的签名数据(或者叫前缀数据也可),它是用来判断图片是否是PNG格式图片的。其原理是:PNG图片很容易检测,因为它拥有一个独特的签名,PNG文件的前八字节经常包含如下(十进制)的数值137 80 78 71 13 10 26 10。我们正可据此鉴别PNG文件。其实该类的一开头就有以下一段代码,声明并定义了C函数ImageDataHasPNGPreffix
来完成此功能。
// PNG signature bytes and data (below)
static unsigned char kPNGSignatureBytes[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
static NSData *kPNGSignatureData = nil;
// data数据是否以PNG开头
BOOL ImageDataHasPNGPreffix(NSData *data); // C函数声明
BOOL ImageDataHasPNGPreffix(NSData *data) { // C函数定义
NSUInteger pngSignatureLength = [kPNGSignatureData length];
if ([data length] >= pngSignatureLength) {
if ([[data subdataWithRange:NSMakeRange(0, pngSignatureLength)] isEqualToData:kPNGSignatureData]) {
return YES;
}
}
return NO;
}
** Second:**然后我们看到重写了
init
初始化方法,在其中调用的是方法initWithNamespace:
(全能初始化方法)。在初始化方法内首先设置了最大缓存时间,默认为一周;然后初始化了内存缓存,内存缓存用的原生的NSCache
;然后初始化了磁盘缓存的路径;接着又自己创建了一个专门用于磁盘读写的串行队列,紧接着初始化了文件管理器_fileManager
。
最后添加了三个观察者,用于监听3种APP的状态,每种状态都会触发执行一个方法。分别是,当收到内存警告时便执行
clearMemory
方法,清空内存缓存;当程序被终止时执行cleanDisk
方法,清理磁盘缓存;当程序进入后台状态时执行backgroundCleanDisk
方法,向系统“借时间”清理磁盘缓存。(请注意"清空-clear"和"清扫-clean"的差别)
// 收到内存警告时,清空内存缓存
- (void)clearMemory {
[self.memCache removeAllObjects];
}
// 当程序被终止时,清扫磁盘
- (void)cleanDisk {
[self cleanDiskWithCompletionBlock:nil];
}
// 当程序进入后台状态时,向系统“借时间”完成清扫磁盘的动作
- (void)backgroundCleanDisk {
UIApplication *application = [UIApplication sharedApplication];
// 开启后台长时间任务
__block UIBackgroundTaskIdentifier bgTask = [application beginBackgroundTaskWithExpirationHandler:^{
[application endBackgroundTask:bgTask]; // 若到了系统规定的时间(一般是10分钟),则会在此调用这个方法,结束后台运行任务。
bgTask = UIBackgroundTaskInvalid;
}];
[self cleanDiskWithCompletionBlock:^{
[application endBackgroundTask:bgTask]; // 当任务完成时,也调用此方法,主动结束后台运行任务。
bgTask = UIBackgroundTaskInvalid;
}];
}
第一个方法很简单,调用
NSCache
的实例方法removeAllObjects
就行了;第二个和第三个方法都是调用了cleanDiskWithCompletionBlock:
这个方法来实现清扫磁盘,所不同的是程序进入后台状态时,iOS系统默认只留给程序秒级别的时间处理一些未完成的动作,而我们清扫磁盘是个耗时的任务,所以得向系统“多借点时间”以保证我们能完成磁盘清扫的任务。开启后台长时间任务的代码上面已经注释的比较清楚了,若要详细了解可以看看这篇文章:ios在后台 完成一个长期任务。这儿我们的重点是搞明白清扫缓存的方法cleanDiskWithCompletionBlock
。
——清扫磁盘缓存——
其清扫磁盘缓存的逻辑简单来说是:一上来就清除过期的文件;然后判断此时的缓存文件大小是否小于设置的最大大小。若大于最大大小,则进行第二轮的清扫,清扫到缓存文件大小为设置的最大大小的一半。
// 清扫磁盘
- (void)cleanDiskWithCompletionBlock:(void (^)())completionBlock {
dispatch_async(self.ioQueue, ^{
NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES]; // 缓存文件的路径
// 将要获取文件的3个属性(URL是否为目录;内容最后更新日期;文件总的分配大小)
NSArray *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey,NSURLTotalFileAllocatedSizeKey];
// 使用目录枚举器获取缓存文件有用的属性
NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
NSMutableDictionary *cacheFiles = [NSMutableDictionary dictionary];
NSUInteger currentCacheSize = 0;
// 枚举缓存目录的所有文件,此循环有两个目的:
// 1.清除超过过期日期的文件;
// 2.
NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
for (NSURL *fileURL in fileEnumerator) {
NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:NULL]; // 传入想获得的该URL路径文件的属性数组,得到这些属性字典。
// 若该URL是目录,则跳过。
if ([resourceValues[NSURLIsDirectoryKey] boolValue]) {
continue;
}
// 清除过期文件
NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
[urlsToDelete addObject:fileURL]; // 把过期的文件url暂时先置于urlsToDelete数组中
continue;
}
// 计算文件总的大小并保存保留下来的文件的引用。
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize += [totalAllocatedSize unsignedIntegerValue];
[cacheFiles setObject:resourceValues forKey:fileURL];
}
for (NSURL *fileURL in urlsToDelete) {
[_fileManager removeItemAtURL:fileURL error:nil];
}
// 如果剩下的磁盘缓存文件仍然大于我们设置的最大大小,则要执行以大小为基础的第二轮清除
if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
// 此轮清理的目标是最大缓存的一半
const NSUInteger desiredCacheSize = self.maxCacheSize / 2;
// 以剩下的文件最后更新时间排序(最老的最先被清除)
NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
usingComparator:^NSComparisonResult(id obj1, id obj2) {
return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
}];
// 删除已排好序的文件,直到达到最大缓存限制的一半
for (NSURL *fileURL in sortedFiles) {
if ([_fileManager removeItemAtURL:fileURL error:nil]) {
NSDictionary *resourceValues = cacheFiles[fileURL];
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize -= [totalAllocatedSize unsignedIntegerValue];
if (currentCacheSize < desiredCacheSize) {
break;
}
}
}
}
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
}
上面的代码中比较重要的是,先以文件管理器
_fileManager
的迭代器遍历出了所有缓存文件,并且,该迭代器我们传入一个“感兴趣属性数组”,则遍历后我们就可以拿到文件的这些属性值,有“URL是否为目录”、“内容最后更新日期”、“文件总的分配大小”三个属性,这是很重要的一个步骤,因为我们后续要依此判断文件是否过期,总缓存文件大小是否超过预设最大值。
当我们清除了过期文件,并已更新当前总缓存文件大小,且将“幸存”下来的所有文件存入
cacheFiles
字典中,以fileURL
为key
,resourceValues
为value
。
这时需要判断幸存下来的文件们的大小是否大于缓存预设最大值。若大于,则需要继续清扫文件大小至预设值的一半。此时依据的是越早的文件优先被清扫,所以得根据“ 内容最后更新日期”这个属性来进行排序。然后遍历排序后的sortedFiles
数组,边遍历边删除,同时更新幸存文件们的总大小,一旦达到预设值的一半,则退出。
——写入缓存——
其存入缓存的逻辑是:首先将图片存入内存缓存,若需要存入磁盘,则存入磁盘。其中的细节是存入本地的是NSData
数据,因此需要判断数据源image
是何种格式的,再相应的由image
生成data
。最后以图片url
MD5计算过后的字符串再拼接出完成文件路径,遂新建一个文件,将data
存入。代码中的注释很全了,就不再多解释了。
- (void)storeImage:(UIImage *)image forKey:(NSString *)key {
[self storeImage:image recalculateFromImage:YES imageData:nil forKey:key toDisk:YES];
}
- (void)storeImage:(UIImage *)image forKey:(NSString *)key toDisk:(BOOL)toDisk {
[self storeImage:image recalculateFromImage:YES imageData:nil forKey:key toDisk:toDisk];
}
// 存入缓存
- (void)storeImage:(UIImage *)image recalculateFromImage:(BOOL)recalculate imageData:(NSData *)imageData forKey:(NSString *)key toDisk:(BOOL)toDisk {
if (!image || !key) {
return;
}
// 先存入内存缓存
// cost意为"成本"(http://www.jianshu.com/p/9a9fb9c4110f)
[self.memCache setObject:image forKey:key cost:image.size.height * image.size.width * image.scale];
if (toDisk) {
dispatch_async(self.ioQueue, ^{
NSData *data = imageData;
if (image && (recalculate || !data)) {
// 如果imageData为nil(也就是说,如果试图直接保存一个UIImage或者图片是由下载转换得来)并且图片有alpha通道,
// 我们将认为它是PNG文件以避免丢失透明度信息。
BOOL imageIsPng = YES;
// // 但是如果我们有image data,我们将查询数据前缀来判断是否是PNG图片
if ([imageData length] >= [kPNGSignatureData length]) {
imageIsPng = ImageDataHasPNGPreffix(imageData);
}
if (imageIsPng) {
data = UIImagePNGRepresentation(image);
}
else {
data = UIImageJPEGRepresentation(image, (CGFloat)1.0);
}
}
if (data) {
if (![_fileManager fileExistsAtPath:_diskCachePath]) {
[_fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
}
// 在磁盘新建专门的文件,并写入图片数据
[_fileManager createFileAtPath:[self defaultCachePathForKey:key] contents:data attributes:nil];
}
});
}
}
上面的代码有个需要说明的地方是:上面的代码中调用的是方法
defaultCachePathForKey:
,它们的细节是下面这样的。** 它把图片的url
字符串先MD5计算成新的字符串,然后拼接出缓存文件的完整路径。**
- (NSString *)defaultCachePathForKey:(NSString *)key {
return [self cachePathForKey:key inPath:self.diskCachePath];
}
- (NSString *)cachePathForKey:(NSString *)key inPath:(NSString *)path {
NSString *filename = [self cachedFileNameForKey:key];
return [path stringByAppendingPathComponent:filename];
}
// MD5计算:将图片的URL字符串进行MD5计算
- (NSString *)cachedFileNameForKey:(NSString *)key {
const char *str = [key UTF8String];
if (str == NULL) {
str = "";
}
unsigned char r[CC_MD5_DIGEST_LENGTH];
CC_MD5(str, (CC_LONG)strlen(str), r);
NSString *filename = [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x",
r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10], r[11], r[12], r[13], r[14], r[15]];
return filename;
}
——读出缓存——
读出缓存的逻辑是:** 一上来先从内存缓存中读取,若有则回调,一切结束;若无则继续从磁盘缓存中查找。找到后,先将图片存入内存缓存,随即再回调。**
// 从缓存中查找图片
- (NSOperation *)queryDiskCacheForKey:(NSString *)key done:(void (^)(UIImage *image, SDImageCacheType cacheType))doneBlock {
if (!doneBlock) {
return nil;
}
if (!key) {
doneBlock(nil, SDImageCacheTypeNone);
return nil;
}
// 首先从内存缓存中查找(NSCache中以url字符串为key)
UIImage *image = [self imageFromMemoryCacheForKey:key];
if (image) {
doneBlock(image, SDImageCacheTypeMemory);
return nil;
}
// 执行到此,说明在内存缓存中没有找到,需要在磁盘中查找。
NSOperation *operation = [NSOperation new];
dispatch_async(self.ioQueue, ^{
if (operation.isCancelled) {
return;
}
@autoreleasepool {
UIImage *diskImage = [self diskImageForKey:key]; // 从磁盘查找
if (diskImage) {
CGFloat cost = diskImage.size.height * diskImage.size.width * diskImage.scale;
[self.memCache setObject:diskImage forKey:key cost:cost]; // 若从磁盘找到,则先将其添加到内存缓存中。
}
dispatch_async(dispatch_get_main_queue(), ^{
doneBlock(diskImage, SDImageCacheTypeDisk); // 然后将其block回调
});
}
});
return operation;
}
// 某key对应的内存缓存
- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key {
return [self.memCache objectForKey:key];
}
可以看到上面这个方法返回值类型是
NSOperation
的,当查询磁盘时创建了一个operation
对象作为return
对象,这是为了管理查询动作,能取消操作等。另外,查询磁盘的动作是** 异步在串行队列 **执行的。同时,还自建了自动释放池,以能及时释放对象内存。最后查找到图片后要回到主线程回调,别忘记此时是异步的哦。
其实查询磁盘缓存的核心是方法:
diskImageForKey:
,它的实现是这样的:
// 以key为索引在磁盘中查找出image
- (UIImage *)diskImageForKey:(NSString *)key {
NSData *data = [self diskImageDataBySearchingAllPathsForKey:key]; // 由key查找出图片,不过是NSData型的
if (data) {
UIImage *image = [UIImage sd_imageWithData:data]; // 由NSData转换为UIImage
image = [self scaledImageForKey:key image:image];
image = [UIImage decodedImageWithImage:image];
return image;
}
else {
return nil;
}
}
结尾
SDWebImage
的缓存机制都封装在了SDImageCache
里,这个类至此也说得差不多了。可能还要研究研究SDWebImage
网络图片下载的代码,下篇吧。
边写边循环了好多遍朴树的《平凡之路》
更新 10.27
最近项目中要代码出了个bug,记录一下。这儿的“智慧校园”是个UITableViewCell
,图片是网络图片。这里需要解决的是不仅要将网络图片显示出来,还要保证图片不变形。得根据网络图片的尺寸,结合手机屏幕的宽度计算出应该显示的高度。这就和UITableViewCell
的刷新cell的内容方法refreshContent:
,和计算cell高度方法cellHeight:
有关了。由url
得到UIImage
一开始我这么写的。
UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:imgUrl]]];
但是这么写是有问题的,每次进入这个界面时会有个卡顿感,这是因为这个加载时同步的,当然会卡顿,所以这种方式要慎用。我们应该异步加载这个图片。此时完全可以用SDWebImage
这个库的方法:异步加载网络图片,然后在block回调里返回UIImage
。
__block CustomImageView *weakContentView = _contentView;
__weak CourseSelectionCell *weakSelf = self;
[_contentView setImageWithURL:url placeholderImage:IMAGE(@"zhihuixiaoyuan") options:SDWebImageRetryFailed completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType) {
if (image) {
weakContentView.image = image;
weakSelf.imgHasLoadedBlock();
}else{
weakContentView.image = IMAGE(@"zhihuixiaoyuan");
}
虽然它是异步请求的,但也不能每次都从网络加载啊,毕竟SDWebImage
是有缓存功能的。SDWebImage
缓存是以NSURL
为键,以UIImage
为值进行缓存的。我们先判断该url是否有对应的缓存图片,若有则取对应的缓存图片。若无则再从网络加载。
SDWebImageManager *manager = [SDWebImageManager sharedManager];
NSURL *url=[NSURL URLWithString:imgUrl];
if ([manager diskImageExistsForURL:url]) {
UIImage *img = [[manager imageCache] imageFromDiskCacheForKey:url.absoluteString];
[_contentView setImage:img];
}
else
{
__block CustomImageView *weakContentView = _contentView;
__weak CourseSelectionCell *weakSelf = self;
[_contentView setImageWithURL:url placeholderImage:IMAGE(@"zhihuixiaoyuan") options:SDWebImageRetryFailed completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType) {
if (image) {
weakContentView.image = image;
weakSelf.imgHasLoadedBlock();
}else{
weakContentView.image = IMAGE(@"zhihuixiaoyuan");
}
}] ;
}
接下来就是在计算UITableViewCell
高度这个方法的问题了。这儿的高度是根据图片的尺寸动态算出来的,所以说也得从网络加载,但是该方法是个类方法,它无法像上面一样调用SDWebImage
的方法setImageWithURL:
。
** 注意,下面这个计算高度的方法里,都是取缓存的图片。那要是某url没缓存呢?**上面刷新cell内容的方法里其实已经写了,在block回调里得到图片后不仅给视图赋了值,而且还调用了一个定义好的imgHasLoadedBlock
block。该block的实现刷新了该row,就会重新执行该cell里面的方法,此时就能在cellHeight:
方法里拿到缓存图片了,因为已经在第一次执行时加载过了。我们来看VC中该block的实现:
selectCell.imgHasLoadedBlock = ^{
[_myTableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
};
+ (CGFloat)cellHeight:(NSString *)imgUrl
{
// UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:imgUrl]]];
if(imgUrl.length>0){
SDWebImageManager *manager = [SDWebImageManager sharedManager];
NSURL *url=[NSURL URLWithString:imgUrl];
if ([manager diskImageExistsForURL:url]) {
UIImage *img = [[manager imageCache] imageFromDiskCacheForKey:url.absoluteString];
return ((PDWidth_mainScreen-30.f)*img.size.height)/img.size.width+20.f;
}
}
return 70.f;
}