SDWebImage源码分析(三)

前言

SDWebImage 源码分析的文章之前就写了两篇,然后我就一直没有动笔继续写下去了,一眨眼,已经过去快一个月了。。一是因为忙,二是因为拖,导致直到今天,我才想着不能再这样拖下去了,自己挖了个坑,就要把它填填满。

我做事不算拖,但是经常会在做某个事情的时候突然又想做另外一件事。虽然在做另外的事情了,可是之前的事情不了结呢,我心里又觉得不痛快。看美剧或者电影也是如此,有时候一部美剧看了一两季,忽然发现了另外一部好看的剧,可能我就会把之前的剧先放着转而去看看新剧如何了,但是心里还是会惦记着之前的剧没看完,会有点不舒服想抽个时间把原来的剧看看完收个尾。

我想,如果我能一次做一件事情,那应该比现在的习惯强很多,可能事情也能做的更好,是该改一改了。

啰里啰嗦说了一堆,我其实就想说,接下来我想把SDWebImage源码分析好好的收个尾,当然今天不是最后一篇。

按照之前的故事发展,本次要说的是 SDWebImage 的缓存策略。

SDImageCache

SDWebImage 框架的缓存管理类是 SDImageCache,这我们之前也一直有提到,只是没有深入讲解,今天就通过代码详细分析一下这个类,介绍一下框架的缓存策略。

SDImageCache 其实有一个控制缓存策略配置的类 SDImageCacheConfig:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@interface SDImageCacheConfig : NSObject


@property (assign, nonatomic) BOOL shouldDecompressImages;


@property (assign, nonatomic) BOOL shouldDisableiCloud;


@property (assign, nonatomic) BOOL shouldCacheImagesInMemory;


@property (assign, nonatomic) NSInteger maxCacheAge;


@property (assign, nonatomic) NSUInteger maxCacheSize;

@end

里面主要就是一些缓存相关的设置:是否压缩图片,是否禁止 iCloud,是否将缓存存储在内存中,最大缓存存储时间,最大缓存大小。SDImageCache 在其内部自己创建了这个配置类的实例,然后读取其配置,并没有开放出 API 来设置,这些配置都是写死的,所以说如果要更改配置,直接去这个类里面修改。关于缓存配置就先一笔带过吧。

我们的重点是 SDImageCache。SDImageCache 也是可以单独用的,单独用的意思就是,我们在自己的工程里,可以手动创建 SDImageCache 的实例,然后使用它提供给我们的接口方法来手动管理我们自己的缓存。

首先我们肯定要先看一看 SDImageCache 的 .h 文件给我们提供了什么。我们可以看到它给我们提供了很多的接口方法,而且还分了类,比如一些构造方法、缓存路径的设置、缓存的图片方法、从缓存中取出图片的方法、清理缓存、缓存设置等等,十分清晰。所以说 SDImageCache 本身就是一个功能十分完善的缓存管理类,缓存不仅可以存储在内存,也可以存储在本地沙盒中,而且有各种策略。但是 SDWebImage 框架实际上使用到这个类的地方并不多,最主要的就是将图片进行缓存以及从缓存中取出图片,这还是我们一直在说的执行流程图里面的那两个方法:queryCacheOperationForKeystoreImage 。一些存储路径以及配置,用的也都是默认的设置。

SDImageCache 的 .m 文件一开始,我们就看到了一个名叫 AutoPurgeCache 的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@interface AutoPurgeCache : NSCache
@end

@implementation AutoPurgeCache

- (nonnull instancetype)init {
self = [super init];
if (self) {
#if SD_UIKIT
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(removeAllObjects) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
#endif
}
return self;
}

- (void)dealloc {
#if SD_UIKIT
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
#endif
}

@end

它继承自 NSCache,当接收到内存警告的通知后,就会将内存中的缓存全部清除掉。SDWebImage 最初应该是没有这个类的,直接使用的 NSCache,现在有了这个子类,内存吃紧的时候可以自动将内存中的缓存清理掉,无疑更加合理。

在那些 init 方法中,我们可以看到 SDWebImage 会设置一下缓存在沙盒中的存储路径。
具体存储路径的策略是:首先传入一个 nameSpace 变量,SDImageCache 根据这个变量在系统沙盒的 cache 文件夹下面新建一个文件夹,缓存就存储在里面。

1
2
3
4
5
6
7
8
9
NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];

// Init the disk cache
if (directory != nil) {
_diskCachePath = [directory stringByAppendingPathComponent:fullNamespace];
} else {
NSString *path = [self makeDiskCachePath:ns];
_diskCachePath = path;
}

存储在该路径里面的缓存图片都有一个唯一的 key,通过一定的转化变成缓存图片文件名,对于 SDWebImage 框架来说,这个 key 就是图片的 URL。

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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); // 生成 MD5 字符串 r,r为16进制,所以其为长度为16
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], [key.pathExtension isEqualToString:@""] ? @"" : [NSString stringWithFormat:@".%@", key.pathExtension]];

return filename;
}

具体将 key 转化为文件名的做法如上,首先将 key 用 MD5 算法转化为 16个字节(128位)长度的信息摘要,然后将该摘要的第16个字节用图片原先的后缀来替代,这样做既规范了缓存文件名,又保留了后缀。

缓存路径的问题我也简单介绍一下就好了,真正比较重要的地方还是我下面要说的如何将图片缓存起来。本来我是想按照 SDWebImage 官方那张流程图来说的,那就会是先介绍如何从缓存中取出图片。但是我又想了一下,还是按照我们自然一点的思考过程来说吧,先把图片缓存起来,再去缓存中去图片。所以下面先介绍 storeImage 这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
- (void)storeImage:(nullable UIImage *)image
imageData:(nullable NSData *)imageData
forKey:(nullable NSString *)key
toDisk:(BOOL)toDisk
completion:(nullable SDWebImageNoParamsBlock)completionBlock {
// 如果照片不存在或者缓存键不存在,就直接执行回调 completionBlock
if (!image || !key) {
if (completionBlock) {
completionBlock();
}
return;
}
// 如果允许内存缓存,就将其存储到内存中
if (self.config.shouldCacheImagesInMemory) {
NSUInteger cost = SDCacheCostForImage(image); // 计算缓存占用大小
[self.memCache setObject:image forKey:key cost:cost]; // 添加到缓存中(内存缓存)
}

// 如果允许磁盘缓存
if (toDisk) {
// 异步调用
dispatch_async(self.ioQueue, ^{
NSData *data = imageData;

// 如果 data 数据不存在,则手动计算
if (!data && image) {
SDImageFormat imageFormatFromData = [NSData sd_imageFormatForImageData:data]; // 先获取图片格式
data = [image sd_imageDataAsFormat:imageFormatFromData]; // 在生成图片 NSData 数据
}

// 存储到磁盘中
[self storeImageDataToDisk:data forKey:key];
// 在主线程执行回调
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
}
// 否则直接执行回调
else {
if (completionBlock) {
completionBlock();
}
}
}

在 SDWebImage 这个框架中,缓存图片的这个方法主要由 SDWebImageManager 在外面调用,它主要分为两部分,一是将图片缓存到内存中,二是将图片缓存到磁盘中,这分别由配置来控制的,默认两者都是开启的,即既要缓存到内存中,又要缓存到本地磁盘中。缓存到内存中比较简单,就是通过 AutoPurgeCache 的 setObject:forKey:cost 来存储。AutoPurgeCache 的父类 NSCache 和 NSDictionary 比较相似,包括各种 API 接口的用法,当然 NSCache 主要用来缓存,用途不一样。

缓存到磁盘中相对复杂一点,因为要进行 IO 操作,所以把这一部分放在了其他线程中操作。 SDImageCache 专门开辟了一条串行队列用来执行缓存图片的 IO 操作。具体磁盘缓存的方法在 storeImageDataToDisk 这个方法中,说复杂也不复杂,其实就是文件写入操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (void)storeImageDataToDisk:(nullable NSData *)imageData forKey:(nullable NSString *)key {
if (!imageData || !key) {
return;
}

[self checkIfQueueIsIOQueue];

// 如果路径不存在,就创建缓存路径
if (![_fileManager fileExistsAtPath:_diskCachePath]) {
[_fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
}

// get cache Path for image key
NSString *cachePathForKey = [self defaultCachePathForKey:key];
// transform to NSUrl
NSURL *fileURL = [NSURL fileURLWithPath:cachePathForKey];

// 创建缓存图片文件
[_fileManager createFileAtPath:cachePathForKey contents:imageData attributes:nil];

// disable iCloud backup
if (self.config.shouldDisableiCloud) {
[fileURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
}

可以看出存储缓存图片还是比较简单的,从缓存中取出缓存图片,其实也不难,无非是有点上述过程倒过来的意思。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
- (nullable NSOperation *)queryCacheOperationForKey:(nullable NSString *)key done:(nullable SDCacheQueryCompletedBlock)doneBlock {
if (!key) {
if (doneBlock) {
doneBlock(nil, nil, SDImageCacheTypeNone);
}
return nil;
}

// 先从内存缓存中取,如果能取到,就直接返回,不再从磁盘缓存中寻找
UIImage *image = [self imageFromMemoryCacheForKey:key];
if (image) {
NSData *diskData = nil;
if ([image isGIF]) {
diskData = [self diskImageDataBySearchingAllPathsForKey:key];
}
if (doneBlock) {
doneBlock(image, diskData, SDImageCacheTypeMemory);
}
return nil;
}

NSOperation *operation = [NSOperation new];
// 不在主队列获取磁盘中的缓存,防止阻塞
dispatch_async(self.ioQueue, ^{
if (operation.isCancelled) {
// do not call the completion if cancelled
return;
}

// 取出照片,因为可能耗内存,所以用 @autoreleasepool 包围
@autoreleasepool {
NSData *diskData = [self diskImageDataBySearchingAllPathsForKey:key];
UIImage *diskImage = [self diskImageForKey:key];
if (diskImage && self.config.shouldCacheImagesInMemory) {
NSUInteger cost = SDCacheCostForImage(diskImage);
[self.memCache setObject:diskImage forKey:key cost:cost];
}

if (doneBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
doneBlock(diskImage, diskData, SDImageCacheTypeDisk);
});
}
}
});

return operation;
}

上面的这个方法也是在 SDWebImageManage 被调用,主要的过程就是根据缓存图片的 key,先去内存缓存中查找,如果没有,再去磁盘缓存中查找。同样的,从本地磁盘中获取缓存照片,也要放在其他线程里面操作,防止阻塞主线程。另外取出照片的过程中,可能会生成许多临时变量,例如因为裁剪图片等操作。所以手动创建了一个 autoreleasepool 来包裹,使这些临时变量尽早释放,免得内存峰值过高。

从上面的代码中可以看到获取缓存图片其实还可以细化下去,它还调用了一些方法我没有深入介绍。其实都差不多,只不过可能做了一些额外的工作,比如图片裁剪,从所有路径搜寻等等,我不想一行一行讲解代码,所以这些就跳过不讲了。

缓存管理的其他功能

如果仅仅从 SDWebImage 用到的部分来说,要说的基本已经说好了。但是我之前也讲了,SDImageCache 这个类算是一个比较完善的缓存管理类,所以说,它其实还是有一些另外比较强大的功能的。

我主要想讲两个比较实用和有意思的功能:删除过期的的缓存控制缓存整体大小。第一个说的是我们可以设置缓存一个有效期,比如一天、一周,过了这个有效期的那部分缓存,就会被自动删除。第二个是我们可以设置缓存最多可以有多大,比如100M,超过100M,就删除部分缓存,控制缓存最多就那么大。我们来看看 SDImageCache 中是如何实现这两个功能的。

我说的这两个功能,其实都在一个方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
- (void)deleteOldFilesWithCompletionBlock:(nullable SDWebImageNoParamsBlock)completionBlock {
dispatch_async(self.ioQueue, ^{
NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey];

// This enumerator prefetches useful properties for our cache files.
NSDirectoryEnumerator *fileEnumerator = [_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;

// 删除超时的缓存
// 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];

// 跳过文件夹
if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
continue;
}

// 删除过期的缓存文件
NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
[urlsToDelete addObject:fileURL];
continue;
}

// 计算一下当前缓存占据的大小
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize += totalAllocatedSize.unsignedIntegerValue;
cacheFiles[fileURL] = resourceValues;
}

for (NSURL *fileURL in urlsToDelete) {
[_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.
if (self.config.maxCacheSize > 0 && currentCacheSize > self.config.maxCacheSize) {
// 默认超出限制大小后,将目标缓存大小定为设定大小的一半
const NSUInteger desiredCacheSize = self.config.maxCacheSize / 2;

// Sort the remaining cache files by their last modification time (oldest first).
NSArray<NSURL *> *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<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();
});
}
});
}

代码中我都写了注释,但我也大致的介绍一下它的原理。
首先现根据设置的缓存生命周期来算出相对于当前时间,哪一天之前的缓存是过期了。然后开始遍历整个缓存目录,找出缓存修改时间在过期时间之前的缓存,然后删掉。在遍历的过程中,同时做另外一件事情,就是统计缓存总共占用了多少存储空间。等删除完过期的缓存之后,开始处理缓存是否超过了设定的限制。假如超过最大存储空间限制,那么就再次遍历缓存,删除部分缓存,直到缓存占据的存储大小降至设定大小的一半。具体删除哪些缓存呢?它是这么做的:先将所有的缓存文件的 URL 按照修改时间排个序存入数组中,然后遍历该数组,删除掉时间比较老的缓存,当存储空间满足要求了,就退出循环。最后再执行以下回调 block 就好了。

有朋友可能会好奇,那什么时候做这件事情呢?答案是每次程序进入后台的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#if SD_UIKIT
- (void)backgroundDeleteOldFiles {
Class UIApplicationClass = NSClassFromString(@"UIApplication");
if(!UIApplicationClass || ![UIApplicationClass respondsToSelector:@selector(sharedApplication)]) {
return;
}
UIApplication *application = [UIApplication performSelector:@selector(sharedApplication)];
__block UIBackgroundTaskIdentifier bgTask = [application beginBackgroundTaskWithExpirationHandler:^{
// Clean up any unfinished task business by marking where you
// stopped or ending the task outright.
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];

// Start the long-running task and return immediately.
[self deleteOldFilesWithCompletionBlock:^{
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];
}
#endif

后记

SDWebImage 的缓存策略我大概介绍了一下,当然并没有面面俱到,但大致的过程我应该都说了。下一次,就讲一下本次源码分析的最后一节,有关 SDWebImageDownloader 的一切。