最近终于有些时间重读SD的源码了,本篇文章侧重分析SDWebImage缓存部分逻辑,以及其中的一些细节。
一.SDImageCache提供的功能
SDWebImage为整个图片加载逻辑提供缓存支持,包括内存缓存(NSCache实现)和磁盘缓存,且支持同步和异步操作。提供单例对象可进行全局操作。
SDImageCache提供了两个枚举:
三种缓存选项SDImageCacheType
- SDImageCacheTypeNone 不缓存
- SDImageCacheTypeDisk 磁盘缓存
- SDImageCacheTypeMemory 内存缓存
三种查询操作的选项SDImageCacheOptions,这是一个按位枚举可以多选:
- SDImageCacheQueryDataWhenInMemory 在查询缓存数据时会强制查询磁盘缓存数据
- SDImageCacheQueryDiskSync 查询磁盘缓存的时候会强制同步查询
- SDImageCacheScaleDownLargeImages 会根据屏幕比例对图片进行尺寸调整
至于这两个枚举怎么用看后面的细节里会讲到
二、细节
2.1 .命名空间
每个ImageCache对象在创建的时候都必须提供命名空间,目的是区分磁盘缓存的路径,使每一个SDImageCache对象都有独立的存储空间不至于搞混,默认的命名空间为default。磁盘缓存都会在.../Library/Caches/namespace/com.hackemist.SDWebImageCache.namespace
文件夹下。
下面看一下几个重要的方法:
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns
diskCacheDirectory:(nonnull NSString *)directory {
if ((self = [super init])) {
//命名空间磁盘缓存都会存储在/Users/xingfan/Library/Developer/CoreSimulator/Devices/68BF3925-CD38-4A3C-AFAB-C2660D4D40AF/data/Containers/Data/Application/0889706E-09A2-4DEF-930A-18DE125E859D/Library/Caches/命名空间/com.hackemist.SDWebImageCache.命名空间/这个文件夹下
NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];
// 创建穿行串行队列执行任务
_ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);
_config = [[SDImageCacheConfig alloc] init];
// 创建SDMemoryCache继承自NSCache,提供内存缓存功能
_memCache = [[SDMemoryCache alloc] initWithConfig:_config];
_memCache.name = fullNamespace;
// 添加disk地址如下:/Users/xingfan/Library/Developer/CoreSimulator/Devices/68BF3925-CD38-4A3C-AFAB-C2660D4D40AF/data/Containers/Data/Application/0889706E-09A2-4DEF-930A-18DE125E859D/Library/Caches/default/com.hackemist.SDWebImageCache.default
if (directory != nil) {
_diskCachePath = [directory stringByAppendingPathComponent:fullNamespace];
} else {
NSString *path = [self makeDiskCachePath:ns];
_diskCachePath = path;
}
//最终磁盘缓存的文件夹路径被存储在_diskCachePath这个成员变量里面
dispatch_sync(_ioQueue, ^{
self.fileManager = [NSFileManager new];
});
}
return self;
}
2.2. Cache path
Cache Path有如下几个功能
- 添加自定义存储路径
- 查询命名空间对应的磁盘缓存路径
- 提供文件名生成方法,并不会用明文key存储,(key.utf8.md5+扩展名)的方式存储在本地
看下几个重要的方法。
//添加自定义文件路径,在查找缓存的时候会同时在self.customPaths里面查找
- (void)addReadOnlyCachePath:(nonnull NSString *)path {
if (!self.customPaths) {
self.customPaths = [NSMutableArray new];
}
if (![self.customPaths containsObject:path]) {
[self.customPaths addObject:path];
}
}
//文件名生成规则,对图片url的utf8String进行md5,链接最后如果有扩展名会拼接上扩展名
- (nullable NSString *)cachedFileNameForKey:(nullable 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);
NSURL *keyURL = [NSURL URLWithString:key];
NSString *ext = keyURL ? keyURL.pathExtension : key.pathExtension;
// File system has file name length limit, we need to check if ext is too long, we don't add it to the filename
if (ext.length > SD_MAX_FILE_EXTENSION_LENGTH) {
ext = nil;
}
//%02x 格式控制: x意思是以十六进制输出,2为指定的输出字段的宽度.如果位数小于2,则左端补0
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], ext.length == 0 ? @"" : [NSString stringWithFormat:@".%@", ext]];
return filename;
}
2.3. 存储操作
根据给出的key存储对应的image数据,主要有两个方法。如下。
//异步缓存图片
- (void)storeImage:(nullable UIImage *)image
imageData:(nullable NSData *)imageData
forKey:(nullable NSString *)key
toDisk:(BOOL)toDisk
completion:(nullable SDWebImageNoParamsBlock)completionBlock {
if (!image || !key) {
if (completionBlock) {
completionBlock();
}
return;
}
// 判断配置中是否需要进行内存缓存,如果需要存入memCache
if (self.config.shouldCacheImagesInMemory) {
NSUInteger cost = image.sd_memoryCost;
[self.memCache setObject:image forKey:key cost:cost];
}
if (toDisk) {
//异步执行磁盘缓存,因为磁盘缓存耗时
dispatch_async(self.ioQueue, ^{
//用自动释放池可以提前释放局部变量,减少内存峰值
@autoreleasepool {
NSData *data = imageData;
//如果data为空这里生成imageData
if (!data && image) {
// 根据是否存在alpha通道判断图片类型
SDImageFormat format;
//判断image格式
if (SDCGImageRefContainsAlpha(image.CGImage)) {
format = SDImageFormatPNG;
} else {
format = SDImageFormatJPEG;
}
//根据图片类型将图片转成nsdata,过程之后的文章里会讲,这里侧重于缓存逻辑
data = [[SDWebImageCodersManager sharedInstance] encodedDataWithImage:image format:format];
}
//执行磁盘缓存
[self _storeImageDataToDisk:data forKey:key];
}
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
} else {
if (completionBlock) {
completionBlock();
}
}
}
// 这里确保是从io队列中调用,将imageData存储到磁盘缓存中
- (void) _storeImageDataToDisk:(nullable NSData *)imageData forKey:(nullable NSString *)key {
if (!imageData || !key) {
return;
}
//判断目标文件夹是否存在,如果不存在创建
if (![self.fileManager fileExistsAtPath:_diskCachePath]) {
[self.fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
}
// 获取缓存文件的文件名并拼接到diskCachePath后面,md5+后缀,.../Library/Caches/命名空间/com.hackemist.SDWebImageCache.命名空间/urlmd5+后缀名
NSString *cachePathForKey = [self defaultCachePathForKey:key];
// transform to NSUrl
NSURL *fileURL = [NSURL fileURLWithPath:cachePathForKey];
//将二进制文件写入目标文件夹
[imageData writeToURL:fileURL options:self.config.diskCacheWritingOptions error:nil];
// iclould操作
if (self.config.shouldDisableiCloud) {
[fileURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
}
2.4.查询和检索
对于查询和检索SDImageCache提供了如下功能:
- 查询当前key在磁盘中是否有对应的缓存数据(提供了同步和异步方法)
- 查询当前key在内存中是否有对应的缓存数据(同步)
-
检索出当前key在磁盘中存储的数据,同时提供block返回UIImage和NSData。方法默认情况下会先在内存缓存中查找,再到磁盘缓存查找,如果在磁盘缓存中查找到,会加入到内存缓存中(异步操作)
看下关键方法:
- (nullable NSOperation *)queryCacheOperationForKey:(nullable NSString *)key options:(SDImageCacheOptions)options done:(nullable SDCacheQueryCompletedBlock)doneBlock {
if (!key) {
if (doneBlock) {
doneBlock(nil, nil, SDImageCacheTypeNone);
}
return nil;
}
// 先检测NSCache里面是否有缓存数据
UIImage *image = [self imageFromMemoryCacheForKey:key];
//这里就是开头讲到的查询策略,如果SDImageCacheQueryDataWhenInMemory则强制查询磁盘缓存Data数据。跳过此步骤,如果没有这个选项直接返回,但是不会返回Data数据。
BOOL shouldQueryMemoryOnly = (image && !(options & SDImageCacheQueryDataWhenInMemory));
if (shouldQueryMemoryOnly) {
if (doneBlock) {
doneBlock(image, nil, SDImageCacheTypeMemory);
}
return nil;
}
//这个operation内并不包含存储操作,只是在异步执行磁盘缓存的时候,在外部可以对operation 进行cancel操作,可以中断磁盘缓存的逻辑。
NSOperation *operation = [NSOperation new];
void(^queryDiskBlock)(void) = ^{
if (operation.isCancelled) {
// do not call the completion if cancelled
return;
}
//这里跟前面一样,用自动释放池来减少内存峰值。
@autoreleasepool {
//此方法会搜索所有路径下的磁盘缓存数据,包括customPath和namespace下。
NSData *diskData = [self diskImageDataBySearchingAllPathsForKey:key];
UIImage *diskImage;
SDImageCacheType cacheType = SDImageCacheTypeNone;
//判断刚刚内存缓存中是否有image数据。
if (image) {
// 如果有的话,返回内存中获取的image和磁盘中获取的data并且cache类型是SDImageCacheTypeMemory
diskImage = image;
cacheType = SDImageCacheTypeMemory;
} else if (diskData) {
cacheType = SDImageCacheTypeDisk;
//如果没有的话,那么由data转成UIImage,返回这时候返回的数据都是来自于磁盘缓存。
diskImage = [self diskImageForKey:key data:diskData options:options];
if (diskImage && self.config.shouldCacheImagesInMemory) {
NSUInteger cost = diskImage.sd_memoryCost;
[self.memCache setObject:diskImage forKey:key cost:cost];
}
}
if (doneBlock) {
if (options & SDImageCacheQueryDiskSync) {
doneBlock(diskImage, diskData, cacheType);
} else {
dispatch_async(dispatch_get_main_queue(), ^{
doneBlock(diskImage, diskData, cacheType);
});
}
}
}
};
//这里如果选项包括强制同步执行磁盘缓存操作,那么同步执行,否则异步执行
if (options & SDImageCacheQueryDiskSync) {
queryDiskBlock();
} else {
dispatch_async(self.ioQueue, queryDiskBlock);
}
return operation;
}
//该方法作用就是将磁盘取出来的数据转成UIImage
- (nullable UIImage *)diskImageForKey:(nullable NSString *)key data:(nullable NSData *)data options:(SDImageCacheOptions)options {
if (data) {
//由data获取到image,
UIImage *image = [[SDWebImageCodersManager sharedInstance] decodedImageWithData:data];
//根据key的名字设置图片的scale。因为这里不会像imageName方法自动添加scale
image = [self scaledImageForKey:key image:image];
//看存取策略如果需要返回解压后的image,那么解压image
if (self.config.shouldDecompressImages) {
BOOL shouldScaleDown = options & SDImageCacheScaleDownLargeImages;
image = [[SDWebImageCodersManager sharedInstance] decompressedImageWithImage:image data:&data options:@{SDWebImageCoderScaleDownLargeImagesKey: @(shouldScaleDown)}];
}
//返回解压后的image。
return image;
} else {
return nil;
}
}
2.4.删除操作
删除操作相对简单的多,源码就不写了,没啥好说的😂
- 从内存中删除对应数据
- 从磁盘中删除对应数据
2.5.内存清理操作
这部分是保证内存性能,和磁盘缓存大小的关键
- NSCache本身就会在内存占用较高的时候自动清理内存,所以这部分不用过多关心,SD也只是在收到内存警告的时候将NSCache清空。
-
而保证磁盘空间用的是LRU(Least recently used,最近最少使用)缓存策略,有两个选项以最近访问时间为基准,以最近修改时间为基准。
下面我们看一下LRU缓存淘汰机制是如何实现的。
//清理旧磁盘文件,这段代码稍微多一点
- (void)deleteOldFilesWithCompletionBlock:(nullable SDWebImageNoParamsBlock)completionBlock {
dispatch_async(self.ioQueue, ^{
//获取磁盘缓存的文件夹
NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
// 设置时间基准
NSURLResourceKey cacheContentDateKey = NSURLContentModificationDateKey;
switch (self.config.diskCacheExpireType) {
//最近访问时间
case SDImageCacheConfigExpireTypeAccessDate:
cacheContentDateKey = NSURLContentAccessDateKey;
break;
//最近修改时间
case SDImageCacheConfigExpireTypeModificationDate:
cacheContentDateKey = NSURLContentModificationDateKey;
break;
default:
break;
}
NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey];
// 获取缓存文件夹下所有的文件
NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
//设置过期时间,默认是一周
NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.config.maxCacheAge];
NSMutableDictionary<NSURL *, NSDictionary<NSString *, id> *> *cacheFiles = [NSMutableDictionary dictionary];
//存储着未过期文件的总大小
NSUInteger currentCacheSize = 0;
// 枚举缓存目录中的所有文件,有两个目的
//
// 1. 筛选出过期文件,添加进urlsToDelete数组中
// 2. 存储并计算出未过期文件的总大小
NSMutableArray<NSURL *> *urlsToDelete = [[NSMutableArray alloc] init];
for (NSURL *fileURL in fileEnumerator) {
NSError *error;
NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
// 跳过目录、和错误
if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
continue;
}
// 将过期文件添加进urlsToDelete中
NSDate *modifiedDate = resourceValues[cacheContentDateKey];
if ([[modifiedDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
[urlsToDelete addObject:fileURL];
continue;
}
// 存储未删除的文件信息,并计算总大小
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize += totalAllocatedSize.unsignedIntegerValue;
cacheFiles[fileURL] = resourceValues;
}
//删除过期文件
for (NSURL *fileURL in urlsToDelete) {
[self.fileManager removeItemAtURL:fileURL error:nil];
}
// 如果剩余的磁盘文件大小超过了设置的最大值,那么执行LRU淘汰策略,删除最老的文件
if (self.config.maxCacheSize > 0 && currentCacheSize > self.config.maxCacheSize) {
// 清理的目标为最大缓存值得一般
const NSUInteger desiredCacheSize = self.config.maxCacheSize / 2;
// 讲目录下的文件按照时间书序排序,老的排在前面
NSArray<NSURL *> *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
usingComparator:^NSComparisonResult(id obj1, id obj2) {
return [obj1[cacheContentDateKey] compare:obj2[cacheContentDateKey]];
}];
// 遍历删除,直到当前大小小于目标大小。
for (NSURL *fileURL in sortedFiles) {
if ([self.fileManager removeItemAtURL:fileURL error:nil]) {
NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize -= totalAllocatedSize.unsignedIntegerValue;
if (currentCacheSize < desiredCacheSize) {
break;
}
}
}
}
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
}
2.6. Cache信息
提供接口返回当前磁盘缓存的信息
- 当前磁盘缓存的总大小(同步)
- 当前磁盘缓存的总数量(同步)
- 计算磁盘缓存的总大小和数量(异步)
这个也不上代码了,没什么特殊之处。
三、Q&A
Q:SDImageCache中有很多异步操作也没见里面用线程锁,那么是如何保证线程安全的呢?
A:对于内存缓存NSCache本身就是内存安全的,磁盘缓存使用一个全局的串行队列保证的,串行队列的性质决定了无论同步执行还是异步执行,都会等之前的任务执行完才会执行下一个任务。
Q:SDImageCache的好处有哪些?
A:采用二级缓存机制(先从内存中去找,如果没有再到磁盘里去找,如果在没有再去下载,下载过后再存储到内存和磁盘当中),避免了图片的多次下载。有LRU缓存淘汰机制。
本人能力有限,有问题忘大神们及时指出。