SDMemoryCache 源码分析
上次我们分析过 AFN 的源码,也分析过 AFN 的 内存缓存,这里再分析下 SD 的缓存,AFN 是 自己设计的缓存,然后超过了预期的大小之后,开始循环清除,直到剩下预期可接受的大小为止,这里 SD 的缓存策略怎么设计的呢?
/**
A memory cache which auto purge the cache on memory warning and support weak cache.
*/
@interface SDMemoryCache <KeyType, ObjectType> : NSCache <KeyType, ObjectType> <SDMemoryCache>
@property (nonatomic, strong, nonnull, readonly) SDImageCacheConfig *config;
@end
这里是集成系统 NSCache 的,并且是个泛型类,实现 SDMemoryCache 这个协议,这个协议也就告诉我们,我们可以自定义实现内存缓存,实现协议的这些方法就可以。
}
- (void)commonInit {
SDImageCacheConfig *config = self.config;
self.totalCostLimit = config.maxMemoryCost;
self.countLimit = config.maxMemoryCount;
[config addObserver:self forKeyPath:NSStringFromSelector(@selector(maxMemoryCost)) options:0 context:SDMemoryCacheContext];
[config addObserver:self forKeyPath:NSStringFromSelector(@selector(maxMemoryCount)) options:0 context:SDMemoryCacheContext];
#if SD_UIKIT
self.weakCache = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
self.weakCacheLock = dispatch_semaphore_create(1);
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceiveMemoryWarning:)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
#endif
}
SDImageCacheConfig 是一个配置,可以设置缓存的数量限制和成本内存限制,当前类是继承自NSCache这个类的,所以直接设置给父类就可以,并且当前缓存类还建立了一个弱缓存,是基于 NSMapTable 数据结构的,key 是 strong ,value 是 weak 的,然后监听系统的内存警告,NSCache 是不能自动监听内存警告的,当收到内存警告的时候,会清楚系统 nscache 存储的所有数据
- (void)didReceiveMemoryWarning:(NSNotification *)notification {
// Only remove cache, but keep weak cache
[super removeAllObjects];
}
// `setObject:forKey:` just call this with 0 cost. Override this is enough
- (void)setObject:(id)obj forKey:(id)key cost:(NSUInteger)g {
[super setObject:obj forKey:key cost:g];
if (!self.config.shouldUseWeakMemoryCache) {
return;
}
if (key && obj) {
// Store weak cache
SD_LOCK(self.weakCacheLock);
[self.weakCache setObject:obj forKey:key];
SD_UNLOCK(self.weakCacheLock);
}
}
- (id)objectForKey:(id)key {
id obj = [super objectForKey:key];
if (!self.config.shouldUseWeakMemoryCache) {
return obj;
}
if (key && !obj) {
// Check weak cache
SD_LOCK(self.weakCacheLock);
obj = [self.weakCache objectForKey:key];
SD_UNLOCK(self.weakCacheLock);
if (obj) {
// Sync cache
NSUInteger cost = 0;
if ([obj isKindOfClass:[UIImage class]]) {
cost = [(UIImage *)obj sd_memoryCost];
}
[super setObject:obj forKey:key cost:cost];
}
}
return obj;
}
- (void)removeObjectForKey:(id)key {
[super removeObjectForKey:key];
if (!self.config.shouldUseWeakMemoryCache) {
return;
}
if (key) {
// Remove weak cache
SD_LOCK(self.weakCacheLock);
[self.weakCache removeObjectForKey:key];
SD_UNLOCK(self.weakCacheLock);
}
}
- (void)removeAllObjects {
[super removeAllObjects];
if (!self.config.shouldUseWeakMemoryCache) {
return;
}
// Manually remove should also remove weak cache
SD_LOCK(self.weakCacheLock);
[self.weakCache removeAllObjects];
SD_UNLOCK(self.weakCacheLock);
}
添加删除,都是通过信号量来控制线程安全的,可以看到没有给父类 nscache 加锁,因为系统的nscache是 线程安全的,我们可以看到设置值会先给父类设置,如果配置没有设置弱引用缓存直接返回,如果设置了,就往弱引用缓存里面添加一份,获取值的时候先从父类里面取,如果配置没有设置从弱引用取的话,直接返回,如果设置了,接着判断,nscache是否能够获取到,因为,nscache 会根据系统自己的策略,自己清楚缓存,这时候就从我们的弱引用缓存中获取,获取到了之后,再往nscache里面放一份,只有从nscache没有获取到才会执行此操作,可以看出,set方法是nscache和自己的弱引用缓存都存储,get的时候,如果nscache没有,才从弱引用缓存中去,然后存储到nscache中,
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == SDMemoryCacheContext) {
if ([keyPath isEqualToString:NSStringFromSelector(@selector(maxMemoryCost))]) {
self.totalCostLimit = self.config.maxMemoryCost;
} else if ([keyPath isEqualToString:NSStringFromSelector(@selector(maxMemoryCount))]) {
self.countLimit = self.config.maxMemoryCount;
}
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
对于 kvo ,监听totalCostLimit和maxMemoryCost的设置,然后动态设置给nscache。
这就是 SD 的内存缓存,要比 AFN 还要简单,但我会提出如下几点疑问,欢迎评论区发表意见
- 为什么要用 NSCache 和 自己再创建一个弱引用缓存,要存储两份?就用弱引用缓存不行吗?
- 信号量这里面是单读单写的,没有多读单写性能好,这里是有什么考虑吗?
SDDiskCache 源码分析
最开始我理解的内存缓存和磁盘缓存,我以为磁盘缓存会像 YYCache 一样,存储到数据库,过段时间写两片 YYCache 的文章,结果看到代码后,我发现我错了,我们看他的初始化,你就明白了
- (void)commonInit {
if (self.config.fileManager) {
self.fileManager = self.config.fileManager;
} else {
self.fileManager = [NSFileManager new];
}
}
如果看到这里你还不明白怎么存储,那就往后看吧
- (BOOL)containsDataForKey:(NSString *)key {
NSParameterAssert(key);
NSString *filePath = [self cachePathForKey:key];
BOOL exists = [self.fileManager fileExistsAtPath:filePath];
// fallback because of https://github.com/rs/SDWebImage/pull/976 that added the extension to the disk file name
// checking the key with and without the extension
if (!exists) {
exists = [self.fileManager fileExistsAtPath:filePath.stringByDeletingPathExtension];
}
return exists;
}
- (NSData *)dataForKey:(NSString *)key {
NSParameterAssert(key);
NSString *filePath = [self cachePathForKey:key];
NSData *data = [NSData dataWithContentsOfFile:filePath options:self.config.diskCacheReadingOptions error:nil];
if (data) {
return data;
}
// fallback because of https://github.com/rs/SDWebImage/pull/976 that added the extension to the disk file name
// checking the key with and without the extension
data = [NSData dataWithContentsOfFile:filePath.stringByDeletingPathExtension options:self.config.diskCacheReadingOptions error:nil];
if (data) {
return data;
}
return nil;
}
- (void)setData:(NSData *)data forKey:(NSString *)key {
NSParameterAssert(data);
NSParameterAssert(key);
if (![self.fileManager fileExistsAtPath:self.diskCachePath]) {
[self.fileManager createDirectoryAtPath:self.diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
}
// get cache Path for image key
NSString *cachePathForKey = [self cachePathForKey:key];
// transform to NSURL
NSURL *fileURL = [NSURL fileURLWithPath:cachePathForKey];
[data writeToURL:fileURL options:self.config.diskCacheWritingOptions error:nil];
// disable iCloud backup
if (self.config.shouldDisableiCloud) {
// ignore iCloud backup resource value error
[fileURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
}
- (NSData *)extendedDataForKey:(NSString *)key {
NSParameterAssert(key);
// get cache Path for image key
NSString *cachePathForKey = [self cachePathForKey:key];
NSData *extendedData = [SDFileAttributeHelper extendedAttribute:SDDiskCacheExtendedAttributeName atPath:cachePathForKey traverseLink:NO error:nil];
return extendedData;
}
- (void)setExtendedData:(NSData *)extendedData forKey:(NSString *)key {
NSParameterAssert(key);
// get cache Path for image key
NSString *cachePathForKey = [self cachePathForKey:key];
if (!extendedData) {
// Remove
[SDFileAttributeHelper removeExtendedAttribute:SDDiskCacheExtendedAttributeName atPath:cachePathForKey traverseLink:NO error:nil];
} else {
// Override
[SDFileAttributeHelper setExtendedAttribute:SDDiskCacheExtendedAttributeName value:extendedData atPath:cachePathForKey traverseLink:NO overwrite:YES error:nil];
}
}
- (void)removeDataForKey:(NSString *)key {
NSParameterAssert(key);
NSString *filePath = [self cachePathForKey:key];
[self.fileManager removeItemAtPath:filePath error:nil];
}
- (void)removeAllData {
[self.fileManager removeItemAtPath:self.diskCachePath error:nil];
[self.fileManager createDirectoryAtPath:self.diskCachePath
withIntermediateDirectories:YES
attributes:nil
error:NULL];
}
这里是对数据的增删查的处理,get 方法是通过 dataWithContentsOfFile
api,set 是通过 [data writeToURL:fileURL options:self.config.diskCacheWritingOptions error:nil]; 这个 api,然后根据配置判断是否进行 iCloud 存储
// ignore iCloud backup resource value error
[fileURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
删除是通过 [self.fileManager removeItemAtPath:filePath error:nil];
api ,都是通过系统 api,接下来我们来看看是怎么处理过期数据的
// 移除过期数据
- (void)removeExpiredData {
NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
// Compute content date key to be used for tests
// 过期时间的依据,访问时间,内容修改时间,创建时间或者属性修改时间
NSURLResourceKey cacheContentDateKey = NSURLContentModificationDateKey;
switch (self.config.diskCacheExpireType) {
case SDImageCacheConfigExpireTypeAccessDate:
cacheContentDateKey = NSURLContentAccessDateKey;
break;
case SDImageCacheConfigExpireTypeModificationDate:
cacheContentDateKey = NSURLContentModificationDateKey;
break;
case SDImageCacheConfigExpireTypeCreationDate:
cacheContentDateKey = NSURLCreationDateKey;
break;
case SDImageCacheConfigExpireTypeChangeDate:
cacheContentDateKey = NSURLAttributeModificationDateKey;
break;
default:
break;
}
// 想要了解的属性
NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey];
// This enumerator prefetches useful properties for our cache files.
// 遍历文件,跳过隐藏文件
NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
// 过期时间
NSDate *expirationDate = (self.config.maxDiskAge < 0) ? nil: [NSDate dateWithTimeIntervalSinceNow:-self.config.maxDiskAge];
NSMutableDictionary<NSURL *, NSDictionary<NSString *, id> *> *cacheFiles = [NSMutableDictionary dictionary];
NSUInteger currentCacheSize = 0;
// 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;
// 根据 url 获取返回结果,从中获取想要或崎岖的属性
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];
// 过期了
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.
const NSUInteger desiredCacheSize = maxDiskSize / 2;
// Sort the remaining cache files by their last modification time or last access time (oldest first).
// 将剩余的文件按照访问时间进行排序,最老的放在最前面
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]) {
NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize -= totalAllocatedSize.unsignedIntegerValue;
// 挨个删除,知道大小达到我们预期的值,也就是设置的最大值的一半
if (currentCacheSize < desiredCacheSize) {
break;
}
}
}
}
}
先是根据配置,设置选择文件的访问时间,是以何种策略来淘汰文件,是根据创建时间超时,还是内容修改时间超时等依据,然后创建一个 数据,想要查看文件的哪些属性 NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey];
,这里是查看文件是否为目录,访问时间属性,和文件的总大小属性,然后拿出所有文件的 url
// 遍历文件,跳过隐藏文件
NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
拿到总的结果之后,然后根据配置算出真正的过期时间
// 过期时间
NSDate *expirationDate = (self.config.maxDiskAge < 0) ? nil: [NSDate dateWithTimeIntervalSinceNow:-self.config.maxDiskAge];
for (NSURL *fileURL in fileEnumerator) {
NSError *error;
// 根据 url 获取返回结果,从中获取想要或崎岖的属性
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];
// 过期了
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;
}
然后根据遍历的 url 结果,遍历每一个文件,在从每一个文件中取出我们想要的三个属性,// 根据 url 获取返回结果,从中获取想要或崎岖的属性 NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
这里是取出我们关心的属性,然后接下来,判断下,如果这个文件是目录额话,那么就continue跳过,然后再获取修改时间属性,
// 获取修改时间
NSDate *modifiedDate = resourceValues[cacheContentDateKey];
// 过期了
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];
}
将上面的过期文件删除
NSUInteger maxDiskSize = self.config.maxDiskSize;
// 如果当前磁盘存储文件的总大小大于配置的大小
if (maxDiskSize > 0 && currentCacheSize > maxDiskSize) {
// Target half of our maximum cache size for this cleanup pass.
const NSUInteger desiredCacheSize = maxDiskSize / 2;
// Sort the remaining cache files by their last modification time or last access time (oldest first).
// 将剩余的文件按照访问时间进行排序,最老的放在最前面
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]) {
NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize -= totalAllocatedSize.unsignedIntegerValue;
// 挨个删除,知道大小达到我们预期的值,也就是设置的最大值的一半
if (currentCacheSize < desiredCacheSize) {
break;
}
}
}
}
这段代码的意思就是取出配置设置的磁盘允许缓存的总的文件大小,因为已经删除过期的文件了,接下来就是删除超出总大小的文件,这里是将剩下的文件按照时间排序,然后遍历剩下的文件,将最老的文件删除,然后拿出删除额这个文件的大小,将总大小减去这个大小和配置文件大小作比较,这里面有个策略,就是超出总大小之后,删除配置设置的总大小的一一半,也就是说每次留下配置设置的总大小缓存额一半。
// 获取存储的文件的总大小
- (NSUInteger)totalSize {
NSUInteger size = 0;
NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtPath:self.diskCachePath];
for (NSString *fileName in fileEnumerator) {
NSString *filePath = [self.diskCachePath stringByAppendingPathComponent:fileName];
NSDictionary<NSString *, id> *attrs = [self.fileManager attributesOfItemAtPath:filePath error:nil];
size += [attrs fileSize];
}
return size;
}
// 获取缓存的总个数
- (NSUInteger)totalCount {
NSUInteger count = 0;
NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtPath:self.diskCachePath];
count = fileEnumerator.allObjects.count;
return count;
}
这里就是获取文件的总大小和总数量
// 移动缓存
- (void)moveCacheDirectoryFromPath:(nonnull NSString *)srcPath toPath:(nonnull NSString *)dstPath {
NSParameterAssert(srcPath);
NSParameterAssert(dstPath);
// Check if old path is equal to new path
if ([srcPath isEqualToString:dstPath]) {
return;
}
BOOL isDirectory;
// Check if old path is directory
if (![self.fileManager fileExistsAtPath:srcPath isDirectory:&isDirectory] || !isDirectory) {
return;
}
// Check if new path is directory
if (![self.fileManager fileExistsAtPath:dstPath isDirectory:&isDirectory] || !isDirectory) {
if (!isDirectory) {
// New path is not directory, remove file
[self.fileManager removeItemAtPath:dstPath error:nil];
}
NSString *dstParentPath = [dstPath stringByDeletingLastPathComponent];
// Creates any non-existent parent directories as part of creating the directory in path
if (![self.fileManager fileExistsAtPath:dstParentPath]) {
[self.fileManager createDirectoryAtPath:dstParentPath withIntermediateDirectories:YES attributes:nil error:NULL];
}
// New directory does not exist, rename directory
[self.fileManager moveItemAtPath:srcPath toPath:dstPath error:nil];
} else {
// New directory exist, merge the files
NSDirectoryEnumerator *dirEnumerator = [self.fileManager enumeratorAtPath:srcPath];
NSString *file;
while ((file = [dirEnumerator nextObject])) {
[self.fileManager moveItemAtPath:[srcPath stringByAppendingPathComponent:file] toPath:[dstPath stringByAppendingPathComponent:file] error:nil];
}
// Remove the old path
[self.fileManager removeItemAtPath:srcPath error:nil];
}
}
这个就是文件路径的移动切换,也很简单
问题总结
为什么用 nscache,和 mapTable,我的疑问是,为什么nscache和maptable一起使用,maptable是弱引用
额,那么nscache和maptable都存储一份,那么nacache里面的经过系统自动清理缓存之后,maptable中不也
清除了吗?有什么用?为什么要这样用,用一个maptable不也能解决吗,带着这个问题,我在一些 ios 技术
讨论群里面抛出了这个问题,最后有一个兄弟的话点醒了我,因为可能会存在这种场景,比如我们的
nsacache和maptable都存储一张图片,界面上的imageview也用了这张图片,对这张图片进行强引用,当
nscache中的数据被清理之后,如果下次再来照这张图片,那么如果没有maptable二次存储,这时候内存缓存就不能命中了,就需要去磁盘缓存中取了,有了这个弱引用存储,如若nacache中找不到,那么就去maptable中找,如果找到了,就nscache再设置一次,这样就增加了命中率,提高了效率,不用去磁盘中取了,说到这里,是不是豁然开朗,但是,我还是有疑问,为什么不只用maptable一个数据结构去存储呢?为什么存储两份?
网友评论