写在前面
缓存模块的功能最主要的就是"存"和"取","取"(查找)缓存已经在SDWebImage主线梳理(一)和SDWebImage主线梳理(二)里跟随主线流程介绍过,本篇不再赘述。
本篇主要介绍缓存模块的"存"。"存"主要分成两条线,一条线是没有缓存需要网络请求,接收到图片后要保存到缓存;另一条线是有缓存不需要网络请求,但是磁盘缓存取出后要存到内存缓存一份。
还有内存缓存(SDMemoryCache)和磁盘缓存(SDDiskCache)单个类的解析。
另外再介绍一下SD的缓存过期问题,以及缓存过期的灵魂六问。
保存缓存
没有缓存需要网络请求
没有缓存或第一次使用这张图片的时候,需要走网络请求,然后解码,接着保存到内存缓存,最后保存到磁盘缓存
-
-[SDWebImageManager callCacheProcessForOperation]
分成两个方向- 查询缓存(必定走):
-[SDImageCache queryImageForKey]
- 下载图片(等回调):等待查询结果,如果没缓存则正常走网络请求
- 查询缓存(必定走):
保存缓存的大致调用流程:
-[SDWebImageManager callDownloadProcessForOperation]
-[SDWebImageDownloader requestImageWithURL]
方法的 block(LoaderCompletedBlock) 实现走啊走,走到开始网络请求。请参考SDWebImage主线梳理(二)
网络请求已回调
-[SDWebImageDownloaderOperation URLSession:task:didCompleteWithError:]
-[SDWebImageDownloaderOperation callCompletionBlocksWithImage:imageData:error:finished:]
调用LoaderCompletedBlock回调
LoaderCompletedBlock 实现中的最后一个 else 分支调用
-[SDWebImageManager callStoreCacheProcessForOperation]
-[SDImageCache storeImage:imageData:forKey:cacheType:completion:]
-
-[SDImageCache storeImage:imageData:forKey:toMemory:toDisk:completion:]
- 内存缓存
if分支,默认走内存缓存
-
计算图片占用的内存空间
UIImage 的关联属性 sd_memoryCost,key=@selector(sd_memoryCost)
-
SDMemoryCacheCostForImage()函数
- 从 UIImage 获取 CGImageRef
- CGImageGetBytesPerRow() * CGImageGetHeight() 获取一帧的全部字节量
- 如果是动图还需要乘以帧数,否则直接返回全部字节量
-
SETTER:
-[SDMemoryCache setObject:forKey:cost:]
- 上来就走 -[super setObject:forKey:cost:],SDMemoryCache 继承于 NSCache
- shouldUseWeakMemoryCache == YES 继续,否则直接返回
- 保存到 weakCache(强键-弱值)的NSMapTable中
- 磁盘缓存
if分支,判断入参 toDisk;
整个分支内都是在 ioQueue(异步、串行)队列中
-
处理特殊情况01:没有data只有image;需要先确定图片格式,即如果包含alpha通道就定义为PNG,否则定义为JPEG;
- 确定是否包含alpha通道:
-[SDImageCoderHelper CGImageContainsAlpha:]
- 调用CGImageGetAlphaInfo()函数获取alpha信息
- kCGImageAlphaNone、kCGImageAlphaNoneSkipFirst、kCGImageAlphaNoneSkipLast,只要是alpha信息等于其中一个就代表不包含alpha通道
- 确定是否包含alpha通道:
处理特殊情况02:将image转码为data;
-[SDImageCodersManager encodedDataWithImage:format:options:]
-
-[SDImageCache _storeImageDataToDisk:forKey:]
- -[SDDiskCache setData:forKey:]
fileManager 判断该路径下是否存在文件 self.diskCachePath,不存在就创建一个文件夹
-
-[SDDiskCache cachePathForKey:],一顿调整key和path,得到新的path
- -[SDDiskCache cachePathForKey:inPath:],在这里把self.diskCachePath传进去一起折腾
- SDDiskCacheFileNameForKey(key),专门处理key,看起来像是搞成MD5的样子,然后返回处理完的key
- 把处理成“MD5”的key拼接在self.diskCachePath后面就OK了
- -[SDDiskCache cachePathForKey:inPath:],在这里把self.diskCachePath传进去一起折腾
path 转为 NSURL
data 保存到 URL 路径下
- -[SDDiskCache setData:forKey:]
- 内存缓存
有缓存不需要网络请求
有缓存的情况时,先查询,然后返回缓存,使用图片
-[SDWebImageManager callCacheProcessForOperation]
-[SDImageCache queryImageForKey]
-
-[SDImageCache queryCacheOperationForKey]
- 调用自己实现的 queryDiskBlock
-[SDImageCache diskImageDataBySearchingAllPathsForKey:]
, 拿出磁盘缓存;-
来到没内存缓存 && 有磁盘缓存的分支;
-[SDImageCache diskImageForKey:data:options:context:]
,返回解码后的UIImage-
SDImageCacheDecodeImageData()
; SDImageCacheDefine.m 唯一的函数, 真真是在解码
-
计算 image 的 cost, 把 image 保存到 memoryCache 中
-
异步调用 doneBlock(diskImage, diskData, cacheType)
-
-[SDImageCache queryCacheOperationForKey...]
的 doneBlock - 实现在
-[SDWebImageManager callCacheProcessForOperation...]
的实现中
-
- 调用自己实现的 queryDiskBlock
回到 SDWebImageManager,
-[SDWebImageManager callCacheProcessForOperation...]
, else if 分支-
-[SDWebImageManager callCompletionBlockForOperation]
, 调用 completionBlock- completionBlock 是
-[SDWebImageManager loadImageWithURL...]
的 block(InternalBlock2) - 实现在
-[UIView(WebCache) sd_internalSetImageWithURL...]
- completionBlock 是
-
回到 UIView(WebCache) InternalBlock2 的实现中,
-[UIView(WebCache) sd_setImage:imageData:basedOnClassOrViaCustomSetImageBlock:transition:cacheType:imageURL:]
- 自己实现的 finalSetImageBlock ,自己调用
SDMemoryCache解析
SDMemoryCache 继承自 NSCache
唯一公开属性 SDImageCacheConfig
私有属性 NSMapTable *weakCach:strong-weak cache, 用来弱引用 UIImage
-
配置中的 shouldUseWeakMemoryCache 选项
- SDMemoryCache 之所以支持弱内存缓存,是为了避免重复从磁盘加载,因为内存警告时会清除内存缓存,以致于SD失去了对图片的持有,无法进一步操作,需要重新从磁盘加载图片。
- 可以解决因App进入后台或者内存警告时清空内存而引起进入前台时的cell闪烁问题
当系统发出内存警告通知时,SDMemoryCache 只会移除内存缓存,而留下弱缓存(weakCache)
SETTER 方法:先保存一份 UIImage 到内存缓存(NSCache), 如果 shouldUseWeakMemoryCache 再保存一份到 weakCache
-
GETTER 方法:
- 与SETTER类似,上来走
-[super objectForKey:]
,然后判断 shouldUseWeakMemoryCache,YES继续,否则直接返回对象。 - 唯一值得说的地方是在从 weakCache 取完值后,有可能 weakCache 存的值和内存缓存的值不一致。因此做一次同步操作,将 weakCache 的值赋值给内存缓存。
- 与SETTER类似,上来走
内存缓存(SDMemoryCache)只保存 UIImage, 不保存NSData。image 已解码。
单说储存, SDMemoryCache 的内存缓存完全由其父类 NSCache 完成; 弱缓存由 strong-weak 属性 weakCache 完成;
再说存取, 存只是普通的向 NSCache 或 NSMapTable 赋值; 取也是从 NSCache 和 NSMapTable 普通的取值,只不过如果是从弱缓存取值还要同步给内存缓存。
SDDiskCache解析
SDDiskCache 继承自 NSObject
两个私有属性:
1.diskCachePath, 初始化就要有
2.fileManager, 初始化时没有则 new 一个-
-[SDDiskCache cachePathForKey:]
- 入参只有一个key(图片的URL地址), 还有一个隐藏参数就是 diskCachePath;
- 处理 key, 就是 MD5 散列;
- 将key(MD5散列值) 拼接在 diskCachePath 后面,返回新路径;
SETTER 方法:
1.如果 diskCachePath 文件夹不存在则创建一个;
2.获取新路径 diskCachePath/key(MD5); 新路径转成 NSURL
3.图片data writeToURL:
4.禁止iCloud备份GETTER 方法:
1.获取新路径 diskCachePath/key(MD5);
2.从新路径恢复(初始化) NSData
3.如果data不存在,则去掉新路径的扩展名再试一次-
-[NSFileManager createDirectoryAtPath:withIntermediateDirectories:attributes:error:]
- 这个 withIntermediateDirectories 是说,目前真实路径是 AAA/BBB/CCC, 给定路径(想创建的文件夹路径)AAA/BBB/CCC/DDD/EEE, 如果是YES则D和E的文件夹都会被创建,否则啥也不创建
当调用
-[SDDiskCache containsDataForKey:]
和-[SDDiskCache dataForKey:]
两个方法的时候,都会有一个操作就是当把key重新组装完之后还是找不到data,那么会尝试将key的扩展名去掉再试一遍。-
存取, SDDiskCache 的磁盘缓存全靠 NSFileManager 和 NSData 二者配合; NSFileManager 来确定文件夹路径是否存在以及创建文件夹
-
-[NSFileManager fileExistsAtPath]
-[NSFileManager createDirectoryAtPath]
-[NSFileManager removeItemAtPath]
- NSData:按照路径读写即可
- 存
-[NSData writeToURL]
- 取
-[NSData dataWithContentsOfFile]
- 存
-
磁盘缓存(SDDiskCache)只保存 NSData, 不保存 UIImage。data就是二进制流,不存在解码还是不解码的问题,如果非要问,就是未解码。
removeExpiredData 详情见下下节
FAQ
-
Q:
网络请求完成时,我们缓存的是已解码的图片吗? -
A:
是解码的图片
-
Q:
既然缓存的是已解码的图片,那在查询缓存时是否有解码操作,为什么? -
A:
decode image data only if in-memory cache missed,只有在没有内存缓存且有data的情况下才再次解码data;也就是App下一次启动,磁盘有缓存,内存没有缓存- 详情请在源码中搜:
-[SDImageCache queryCacheOperationForKey]
- 详情请在源码中搜:
-
Q:
图片存取的key是什么? -
A:
图片的URL地址
-
Q:
储存到 SDDiskCache 的缓存是什么? -
A:
是 imageData; 在 URLSession 的回调方法 didReceiveData 中一点一点拼接完成的data;在 didCompleteWithError 通过 block 回调回来
-
Q:
储存到 SDDiskCache 的 imageData 是解码过的吗? -
A:
imageData 就是二进制流(不存在解不解码的问题),而 didCompleteWithError 中说的解码是 imageData 解码变成位图保存到 UIImage 的产物;
缓存过期
源码解析:
-[SDDiskCache removeExpiredData]
- (void)removeExpiredData {
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;
}
// NSURLIsDirectoryKey: 判断文件是否是文件夹; cacheContentDateKey: 获取文件最后一次修改的时间; NSURLTotalFileAllocatedSizeKey: 整个文件被分配的磁盘空间大小
NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey];
// 枚举器(Enumerator) 预先把需要的信息都保存下来
NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
// 默认从此刻倒推7天
NSDate *expirationDate = (self.config.maxDiskAge < 0) ? nil: [NSDate dateWithTimeIntervalSinceNow:-self.config.maxDiskAge];
// 一会用来保存文件信息(resourceValues), {fileURL : resourceValues}
NSMutableDictionary<NSURL *, NSDictionary<NSString *, id> *> *cacheFiles = [NSMutableDictionary dictionary];
// 保存所有文件占用的磁盘空间大小
NSUInteger currentCacheSize = 0;
// 清除操作分为两部分:1.删除过期的缓存文件; 2.磁盘缓存大小超过限制后,删除一部分(从最旧的文件开始),直到剩下的缓存大小低于“限制的一半”大小
// Enumerate all of the files in the cache directory. This loop has two purposes:
//
// 1. Removing files that are older than the expiration date.
// 2. Storing file attributes for the size-based cleanup pass.
NSMutableArray<NSURL *> *urlsToDelete = [[NSMutableArray alloc] init];
for (NSURL *fileURL in fileEnumerator) {
NSError *error;
// 获取文件信息
NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
// Skip directories and errors.
if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
continue;
}
// Remove files that are older than the expiration date;
NSDate *modifiedDate = resourceValues[cacheContentDateKey];
// laterDate: 会返回调用者和入参两者中最晚的日期
if (expirationDate && [[modifiedDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
[urlsToDelete addObject:fileURL];
continue;
}
// Store a reference to this file and account for its total size.
// 获取文件的大小, 字节数
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
// 累加
currentCacheSize += totalAllocatedSize.unsignedIntegerValue;
// 保存文件信息
cacheFiles[fileURL] = resourceValues;
}
// 逐个删除过期的缓存文件
for (NSURL *fileURL in urlsToDelete) {
[self.fileManager removeItemAtURL:fileURL error:nil];
}
// If our remaining disk cache exceeds a configured maximum size, perform a second
// size-based cleanup pass. We delete the oldest files first.
NSUInteger maxDiskSize = self.config.maxDiskSize;
if (maxDiskSize > 0 && currentCacheSize > maxDiskSize) {
// Target half of our maximum cache size for this cleanup pass.
// 定个小目标:从最旧的文件开始删除,一直删,一直删,直到剩下的缓存总字节数小于 maxDiskSize 的一半
const NSUInteger desiredCacheSize = maxDiskSize / 2;
// Sort the remaining cache files by their last modification time or last access time (oldest first).
// 通过比较value(NSDate)之间的大小来排序key,时间最早的key排在前面
NSArray<NSURL *> *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
usingComparator:^NSComparisonResult(id obj1, id obj2) {
return [obj1[cacheContentDateKey] compare:obj2[cacheContentDateKey]];
}];
// Delete files until we fall below our desired cache size.
for (NSURL *fileURL in sortedFiles) {
if ([self.fileManager removeItemAtURL:fileURL error:nil]) {
// 刚才在上一个for循环中保存的文件信息,按照fileURL取出来
NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
// 从文件信息中取出总字节数(占用磁盘空间的大小)
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
// 刚才一直在累加,现在删除一个往下减一个
currentCacheSize -= totalAllocatedSize.unsignedIntegerValue;
// 一直删,一直删,直到缓存总字节数小于目标字节数(前面定的小目标)
if (currentCacheSize < desiredCacheSize) {
break;
}
}
}
}
}
缓存过期之灵魂六问
-
Q1:
缓存过期后,如何在代码中编写清除功能? -
A1:
参照源码解析,分成两部分:一是缓存文件有过期的需要进行清除,一是缓存文件占用磁盘空间超过大小限制需要进行清除
-
Q2:
怎么确定这个缓存已过期? -
A2:
过期时间检测不用我们费心,因为文件自己会记录自己的最后一次时间(最后一次访问时间和最后一次修改时间),通过 NSDirectoryEnumerator 就可以获取
-
Q3:
怎么知道缓存文件是否超过限制大小? -
A3:
文件占用的磁盘空间大小也不用我们费心,同样可以通过 NSDirectoryEnumerator 获取
-
Q4:
什么时机清除? -
A4:
在 SDImageCache 中注册了系统进入后台的通知,当App进入后台时,会开启一个后台任务,调用 -[SDImageCache deleteOldFilesWithCompletionBlock:] -> -[SDDiskCache removeExpiredData] 进行一次清除。目前看是能持续50s。
-
Q5:
多久清除一次? -
A5:
每次进入后台都会调用清除方法,超过了限制(时间或者空间大小)即进行相应的清除工作。
-
Q6:
按什么顺序清除? -
A6:
过期的缓存不看顺序,只要是过期的就毫不留情的删除; 但是当缓存占用磁盘空间大小超过限制的时候,我们开始不断的删除缓存文件中最旧的,最后删到还剩 maxDiskSize 的一半大小的时候为止。