iOS缓存 NSCache详解及SDWebImage缓存策略源码分析
iOS缓存 NSCache详解及SDWebImage缓存策略源码分析。本篇文章首先会详细讲解NSCache的基本使用,NSCache是Foundation框架提供的缓存类的实现,使用方式类似于可变字典,由于NSMutableDictionary的存在,很多人在实现缓存时都会使用可变字典,但NSCache在实现缓存功能时比可变字典更方便,最重要的是它是线程安全的,而NSMutableDictionary不是线程安全的,在多线程环境下使用NSCache是更好的选择。接着,会通过源码讲解SDWebImage的缓存策略。
NSCacheNSCache的使用很方便,提供了类似可变字典的使用方式,但它比可变字典更适用于实现缓存,最重要的原因为NSCache是线程安全的,使用NSMutableDictionary自定义实现缓存时需要考虑加锁和释放锁,NSCache已经帮我们做好了这一步。其次,在内存不足时NSCache会自动释放存储的对象,不需要手动干预,如果是自定义实现需要监听内存状态然后做进一步的删除对象的操作。还有一点就是NSCache的键key不会被复制,所以key不需要实现NSCopying协议。
上面讲解的三点就是NSCache相比于NSMutableDictionary实现缓存功能的优点,在需要实现缓存时应当优先考虑使用NSCache。
首先看一下NSCache提供的属性和相关方法:
//名称 @property (copy) NSString *name; //NSCacheDelegate代理 @property (nullable, assign) id delegate; //通过key获取value,类似于字典中通过key取value的操作 - (nullable ObjectType)objectForKey:(KeyType)key; //设置key、value - (void)setObject:(ObjectType)obj forKey:(KeyType)key; // 0 cost /* 设置key、value cost表示obj这个value对象的占用的消耗?可以自行设置每个需要添加进缓存的对象的cost值 这个值与后面的totalCostLimit对应,如果添加进缓存的cost总值大于totalCostLimit就会自动进行删除 感觉在实际开发中直接使用setObject:forKey:方法就可以解决问题了 */ - (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g; //根据key删除value对象 - (void)removeObjectForKey:(KeyType)key; //删除保存的所有的key-value - (void)removeAllObjects; /* NSCache能够占用的消耗?的限制 当NSCache缓存的对象的总cost值大于这个值则会自动释放一部分对象直到占用小于该值 非严格限制意味着如果保存的对象超出这个大小也不一定会被删除 这个值就是与前面setObject:forKey:cost:方法对应 */ @property NSUInteger totalCostLimit; // limits are imprecise/not strict /* 缓存能够保存的key-value个数的最大数量 当保存的数量大于该值就会被自动释放 非严格限制意味着如果超出了这个数量也不一定会被删除 */ @property NSUInteger countLimit; // limits are imprecise/not strict /* 这个值与NSDiscardableContent协议有关,默认为YES 当一个类实现了该协议,并且这个类的对象不再被使用时意味着可以被释放 */ @property BOOL evictsObjectsWithDiscardedContent; @end //NSCacheDelegate协议 @protocol NSCacheDelegate @optional //上述协议只有这一个方法,缓存中的一个对象即将被删除时被回调 - (void)cache:(NSCache *)cache willEvictObject:(id)obj; @end
通过接口可以看出,NSCache提供的方法都很简单,属性的意义也很明确,接下来举一个简单的栗子:
//定义一个CacheTest类实现NSCacheDelegate代理 @interface CacheTest: NSObject @end @implementation CacheTest //当缓存中的一个对象即将被删除时会回调该方法 - (void)cache:(NSCache *)cache willEvictObject:(id)obj { NSLog(@"Remove Object %@", obj); } @end int main(int argc, const char * argv[]) { @autoreleasepool { //创建一个NSCache缓存对象 NSCache *cache = [[NSCache alloc] init]; //设置缓存中的对象个数最大为5个 [cache setCountLimit:5]; //创建一个CacheTest类作为NSCache对象的代理 CacheTest *ct = [[CacheTest alloc] init]; //设置代理 cache.delegate = ct; //创建一个字符串类型的对象添加进缓存中,其中key为Test NSString *test = @"Hello, World"; [cache setObject:test forKey:@"Test"]; //遍历十次用于添加 for (int i = 0; i < 10; i++) { [cache setObject:[NSString stringWithFormat:@"Hello%d", i] forKey:[NSString stringWithFormat:@"World%d", i]]; NSLog(@"Add key:%@ value:%@ to Cache", [NSString stringWithFormat:@"Hello%d", i], [NSString stringWithFormat:@"World%d", i]); } for (int i = 0; i < 10; i++) { NSLog(@"Get value:%@ for key:%@", [cache objectForKey:[NSString stringWithFormat:@"World%d", i]], [NSString stringWithFormat:@"World%d", i]); } [cache removeAllObjects]; for (int i = 0; i < 10; i++) { NSLog(@"Get value:%@ for key:%@", [cache objectForKey:[NSString stringWithFormat:@"World%d", i]], [NSString stringWithFormat:@"World%d", i]); } NSLog(@"Test %@", test); } return 0; }
输出结果如下:
//第一个for循环输出 Add key:Hello0 value:World0 to Cache Add key:Hello1 value:World1 to Cache Add key:Hello2 value:World2 to Cache Add key:Hello3 value:World3 to Cache Remove Object Hello, World Add key:Hello4 value:World4 to Cache Remove Object Hello0 Add key:Hello5 value:World5 to Cache Remove Object Hello1 Add key:Hello6 value:World6 to Cache Remove Object Hello2 Add key:Hello7 value:World7 to Cache Remove Object Hello3 Add key:Hello8 value:World8 to Cache Remove Object Hello4 Add key:Hello9 value:World9 to Cache //第二个for循环输出 Get value:(null) for key:World0 Get value:(null) for key:World1 Get value:(null) for key:World2 Get value:(null) for key:World3 Get value:(null) for key:World4 Get value:Hello5 for key:World5 Get value:Hello6 for key:World6 Get value:Hello7 for key:World7 Get value:Hello8 for key:World8 Get value:Hello9 for key:World9 //removeAllObjects输出 Remove Object Hello5 Remove Object Hello6 Remove Object Hello7 Remove Object Hello8 Remove Object Hello9 //最后一个for循环输出 Get value:(null) for key:World0 Get value:(null) for key:World1 Get value:(null) for key:World2 Get value:(null) for key:World3 Get value:(null) for key:World4 Get value:(null) for key:World5 Get value:(null) for key:World6 Get value:(null) for key:World7 Get value:(null) for key:World8 Get value:(null) for key:World9 //输出test字符串 Test Hello, World
上面的代码创建了一个NSCache对象,设置了其最大可缓存对象的个数为5个,从输出可以看出,当我们要添加第六个对象时NSCache自动删除了我们添加的第一个对象并触发了NSCacheDelegate的回调方法,添加第七个时也是同样的,删除了缓存中的一个对象才能添加进去。
在第二个for循环中,我们通过key取出所有的缓存对象,前五个对象取出都为nil,因为在添加后面的对象时前面的被删除了,所以,当我们从缓存中获取对象时一定要判断是否为空,我们无法保证缓存中的某个对象不会被删除。
接着调用了NSCache的removeAllObjects方法,一旦调用该方法,NSCache就会将其中保存的所有对象都释放掉,所以,可以看到调用该方法后NSCacheDelegate的回调方法执行了五次,将NSCache中的所有缓存对象都清空了。
在最后一个for循环中,根据key获取缓存中的对象时可以发现都为空了,因为都被释放了。
前面还创建了一个字符串的局部变量,在最开始将其加入到了缓存中,后来随着其他对象的添加,该字符串被缓存释放了,但由于局部变量对其持有强引用所以使用test还是可以访问到的,这是最基本的ARC知识,所以,NSCache在释放一个对象时只是不再指向这个对象,即,该对象的引用计数减一,如果有其他指针指向它,这个对象不会被释放。
上面就是NSCache的基本用法了,我们只需要设置对象和获取对象,其他事情NSCache都帮我们做完了,因此,实现缓存功能时,使用NSCache就是我们的不二之选。
再看一个栗子:
- (void)viewWillAppear:(BOOL)animated { self.cache = [[NSCache alloc] init]; [self.cache setCountLimit:5]; self.cache.delegate = self; [self.cache setObject:@"AA" forKey:@"BBB"]; [self.cache setObject:@"MMMM" forKey:@"CCC"]; } - (void)cache:(NSCache *)cache willEvictObject:(id)obj { NSLog(@"REMOVE %@", obj); }
这是一个有视图控制器的栗子,我们创建了一个NSCache对象,并在其中添加了对象,当点击home键,程序进入后台后,可以发现NSCacheDelegate的回调函数触发了,所以,当程序进入后台,NSCache对象会自动释放所有的对象。如果在模拟器上模拟内存警告,也可以发现NSCache会释放所有的对象。所以NSCache删除缓存中的对象会在以下情形中发生:
NSCache缓存对象自身被释放
手动调用removeObjectForKey:方法
手动调用removeAllObjects
缓存中对象的个数大于countLimit,或,缓存中对象的总cost值大于totalCostLimit
程序进入后台后
收到系统的内存警告
SDWebImage的缓存策略在了解了NSCache的基本使用后,现在来通过SDWebImage的源码看看它是怎样进行图片的缓存操作的。由于篇幅的问题,本文将源码中的英文注释删掉了,有需要的读者可以对照着注释源码查阅本文章。本节内容包括了GCD、NSOperation等多线程相关的知识。
首先看一下官方给的设置图片后执行时序图:
整个执行流程非常清晰明了,本篇文章的重点在第四步、第五步和第八步,关于网络下载,以后会在讲解NSURLSession后进行相关的源码分析。查看SDWebImage的源码,与缓存有关的一共有四个文件SDImageCacheConfig和SDImageCache,首先看一下SDImageCacheConfig的头文件:
@interface SDImageCacheConfig : NSObject //是否压缩图片,默认为YES,压缩图片可以提高性能,但是会消耗内存 @property (assign, nonatomic) BOOL shouldDecompressImages; //是否关闭iCloud备份,默认为YES @property (assign, nonatomic) BOOL shouldDisableiCloud; //是否使用内存做缓存,默认为YES @property (assign, nonatomic) BOOL shouldCacheImagesInMemory; /** 缓存图片的最长时间,单位是秒,默认是缓存一周 * 这个缓存图片最长时间是使用磁盘缓存才有意义 * 使用内存缓存在前文中讲解的几种情况下会自动删除缓存对象 * 超过最长时间后,会将磁盘中存储的图片自动删除 */ @property (assign, nonatomic) NSInteger maxCacheAge; //缓存占用最大的空间,单位是字节 @property (assign, nonatomic) NSUInteger maxCacheSize; @end
NSCacheConfig类可以看得出来就是一个配置类,保存一些缓存策略的信息,没有太多可以讲解的地方,看懂就好,看一下NSCacheConfig.m文件的源码:
static const NSInteger kDefaultCacheMaxCacheAge = 60 * 60 * 24 * 7; // 1 week @implementation SDImageCacheConfig - (instancetype)init { if (self = [super init]) { _shouldDecompressImages = YES; _shouldDisableiCloud = YES; _shouldCacheImagesInMemory = YES; _maxCacheAge = kDefaultCacheMaxCacheAge; _maxCacheSize = 0; } return self; } @end
从上面源码可以看出相关属性的默认值,以及maxCacheAge的默认值为一周时间。
接下来,看一下真正执行缓存操作的SDImageCache类的头文件,接下来的源码分析都是按照源码的顺序来的,只是分为了几个小块,读者也可以按顺序对照源码一起查看:
//获取图片的方式类别枚举 typedef NS_ENUM(NSInteger, SDImageCacheType) { //不是从缓存中拿到的,从网上下载的 SDImageCacheTypeNone, //从磁盘中获取的 SDImageCacheTypeDisk, //从内存中获取的 SDImageCacheTypeMemory }; //查找缓存完成后的回调块 typedef void(^SDCacheQueryCompletedBlock)(UIImage * _Nullable image, NSData * _Nullable data, SDImageCacheType cacheType); //在缓存中根据指定key查找图片的回调块 typedef void(^SDWebImageCheckCacheCompletionBlock)(BOOL isInCache); //计算磁盘缓存图片个数和占用内存大小的回调块 typedef void(^SDWebImageCalculateSizeBlock)(NSUInteger fileCount, NSUInteger totalSize);
上面是一些辅助用的定义,获取图片方式的枚举以及各种情况下的回调块。
/* SDWebImage真正执行缓存的类 SDImageCache支持内存缓存,默认也可以进行磁盘存储,也可以选择不进行磁盘存储 */ @interface SDImageCache : NSObject #pragma mark - Properties //SDImageCacheConfig对象,缓存策略的配置 @property (nonatomic, nonnull, readonly) SDImageCacheConfig *config; //内存缓存的最大cost,以像素为单位,后面有具体计算方法 @property (assign, nonatomic) NSUInteger maxMemoryCost; //内存缓存,缓存对象的最大个数 @property (assign, nonatomic) NSUInteger maxMemoryCountLimit;
上面这一部分是属性的声明,属性很少,但我们在NSCache中都见过了,首先是SDImageCacheConfig,即前面讲解的缓存策略配置,maxMemoryCost其实就是NSCache的totalCostLimit,这里它使用像素为单位进行计算,maxMemoryCountLimit其实就是NSCache的countLimit,需要注意的是SDImageCache继承自NSObject没有继承NSCache,所以它需要保存这些属性。
#pragma mark - Singleton and initialization //单例方法用来获取一个SDImageCache对象 + (nonnull instancetype)sharedImageCache; /* 初始化方法,根据指定的namespace创建一个SDImageCache类的对象 这个namespace默认值是default 主要用于磁盘缓存时创建文件夹时作为其名称使用 */ - (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns; //初始化方法,根据指定namespace以及磁盘缓存的文件夹路径来创建一个SDImageCache的对象 - (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns diskCacheDirectory:(nonnull NSString *)directory NS_DESIGNATED_INITIALIZER;
上面几个方法就是其初始化方法,提供了类方法用于获取一个单例对象,使用单例对象就会使用所有的默认配置,下面两个初始化构造函数提供了两个接口但真正进行初始化的是最后一个,通过这样的设计尽可能的抽象出所有共同的部分,简化代码,而且思路更清晰。
#pragma mark - Cache paths //根据fullNamespace构造一个磁盘缓存的文件夹路径 - (nullable NSString *)makeDiskCachePath:(nonnull NSString*)fullNamespace; /* 添加一个只读的缓存路径,以后在查找磁盘缓存时也会从这个路径中查找 主要用于查找提前添加的图片 */ - (void)addReadOnlyCachePath:(nonnull NSString *)path;
上面两个方法主要用于构造磁盘缓存的文件夹路径以及添加一个指定路径到缓存中,以后搜索缓存时也会从这个路径中查找,这样设计就提供了可扩展性,如果以后需要修改缓存路径,只需把之前的路径添加进来即可。
#pragma mark - Store Ops /* 根据给定的key异步存储图片 image 要存储的图片 key 一张图片的唯一ID,一般使用图片的URL completionBlock 完成异步存储后的回调块 该方法并不执行任何实际的操作,而是直接调用下面的下面的那个方法 */ - (void)storeImage:(nullable UIImage *)image forKey:(nullable NSString *)key completion:(nullable SDWebImageNoParamsBlock)completionBlock; /* 同上,该方法并不是真正的执行者,而是需要调用下面的那个方法 根据给定的key异步存储图片 image 要存储的图片 key 唯一ID,一般使用URL toDisk 是否缓存到磁盘中 completionBlock 缓存完成后的回调块 */ - (void)storeImage:(nullable UIImage *)image forKey:(nullable NSString *)key toDisk:(BOOL)toDisk completion:(nullable SDWebImageNoParamsBlock)completionBlock; /* 根据给定的key异步存储图片,真正的缓存执行者 image 要存储的图片 imageData 要存储的图片的二进制数据即NSData数据 key 唯一ID,一般使用URL toDisk 是否缓存到磁盘中 completionBlock */ - (void)storeImage:(nullable UIImage *)image imageData:(nullable NSData *)imageData forKey:(nullable NSString *)key toDisk:(BOOL)toDisk completion:(nullable SDWebImageNoParamsBlock)completionBlock; /* 根据指定key同步存储NSData类型的图片的数据到磁盘中 这是一个同步的方法,需要放在指定的ioQueue中执行,指定的ioQueue在下面会讲 imageData 图片的二进制数据即NSData类型的对象 key 图片的唯一ID,一般使用URL */ - (void)storeImageDataToDisk:(nullable NSData *)imageData forKey:(nullable NSString *)key;
上面几个方法是用来执行存储操作的,提供了内存缓存和磁盘缓存的不同存储方式方法,提供了不同的接口,但真正执行的方法只有一个,这样的设计方式值得我们学习。
#pragma mark - Query and Retrieve Ops /* 异步方式根据指定的key查询磁盘中是否缓存了这个图片 key 图片的唯一ID,一般使用URL completionBlock 查询完成后的回调块,这个回调块默认会在主线程中执行 */ - (void)diskImageExistsWithKey:(nullable NSString *)key completion:(nullable SDWebImageCheckCacheCompletionBlock)completionBlock; /** * Operation that queries the cache asynchronously and call the completion when done. * * @param key The unique key used to store the wanted image * @param doneBlock The completion block. Will not get called if the operation is cancelled * * @return a NSOperation instance containing the cache op */ - (nullable NSOperation *)queryCacheOperationForKey:(nullable NSString *)key done:(nullable SDCacheQueryCompletedBlock)doneBlock; /* 同步查询内存缓存中是否有ID为key的图片 key 图片的唯一ID,一般使用URL */ - (nullable UIImage *)imageFromMemoryCacheForKey:(nullable NSString *)key; /* 同步查询磁盘缓存中是否有ID为key的图片 key 图片的唯一ID,一般使用URL */ - (nullable UIImage *)imageFromDiskCacheForKey:(nullable NSString *)key; /* 同步查询内存缓存和磁盘缓存中是否有ID为key的图片 key 图片的唯一ID,一般使用URL */ - (nullable UIImage *)imageFromCacheForKey:(nullable NSString *)key;
上面几个方法是查询的方法,提供了丰富的根据图片key查找的功能。
#pragma mark - Remove Ops /* 根据给定key异步方式删除缓存 key 图片的唯一ID,一般使用URL completion 操作完成后的回调块 */ - (void)removeImageForKey:(nullable NSString *)key withCompletion:(nullable SDWebImageNoParamsBlock)completion; /* 根据给定key异步方式删除内存中的缓存 key 图片的唯一ID,一般使用URL fromDisk 是否删除磁盘中的缓存,如果为YES那也会删除磁盘中的缓存 completion 操作完成后的回调块 */ - (void)removeImageForKey:(nullable NSString *)key fromDisk:(BOOL)fromDisk withCompletion:(nullable SDWebImageNoParamsBlock)completion; #pragma mark - Cache clean Ops //删除所有的内存缓存,即NSCache中的removeAllObjects - (void)clearMemory; /* 异步方式清空磁盘中的所有缓存 completion 删除完成后的回调块 */ - (void)clearDiskOnCompletion:(nullable SDWebImageNoParamsBlock)completion; /* 异步删除磁盘缓存中所有超过缓存最大时间的图片,即前面属性中的maxCacheAge completionBlock 删除完成后的回调块 */ - (void)deleteOldFilesWithCompletionBlock:(nullable SDWebImageNoParamsBlock)completionBlock;
上面几个方法是用来删除缓存中图片的方法,以及清空内存缓存的方法。
#pragma mark - Cache Info //获取磁盘缓存占用的存储空间大小,单位是字节 - (NSUInteger)getSize; //获取磁盘缓存了多少张图片 - (NSUInteger)getDiskCount; /* 异步方式计算磁盘缓存占用的存储空间大小,单位是字节 completionBlock 计算完成后的回调块 */ - (void)calculateSizeWithCompletionBlock:(nullable SDWebImageCalculateSizeBlock)completionBlock;
上面几个方法提供了查询磁盘缓存占用内存大小以及缓存图片个数的功能。
#pragma mark - Cache Paths /* 根据图片的key以及一个存储文件夹路径,构造一个在本地的图片的路径 key 图片的唯一ID,一般使用URL inPath 本地存储图片的文件夹的路径 比如:图片URL是http:www.baidu.com/test.png inPath是/usr/local/,那么图片存储到本地后的路径为:/usr/local/test.png */ - (nullable NSString *)cachePathForKey:(nullable NSString *)key inPath:(nonnull NSString *)path; /* 根据图片的key获取一个默认的缓存在本地的路径 key 图片的唯一ID,一般使用URL */ - (nullable NSString *)defaultCachePathForKey:(nullable NSString *)key; @end
上面几个方法是用来构造图片保存到磁盘中的路径的功能。