第二篇的寫在前面
本系列的上一篇文章已經從整個SDWebImage的框架和流程圖入手介紹了WebCache+WebManager模塊。在發布了這個系列的第一篇文章之后,我也去參考了一下相關的同樣介紹SDWebImage框架的的文章,就是希望自己的解析能夠更準確一些。同樣有的文章把源碼中所有英文注釋都去掉替換成了自己翻譯+解釋的中文注釋,但是我覺得如果有一定英文閱讀能力,直接給出源碼中對相關語義的解釋可能更為直接一些,也防止了博客作者一定程度上的曲解。
在本系列末尾會列出參考文章。
好了,廢話不多說,直接進入正題。
從上一篇繼續
上一篇的大部分篇幅,使用了源碼+注釋的方式介紹了SDWebImageManager
模塊下的loadImageWithURL()
方法,該方法用于通過調用者傳入的URL從網絡/緩存獲取圖片。其中有一個重要的方法:
//請求緩存
operation.cacheOperation = [self.imageCache queryCacheOperationForKey:key done:^(UIImage *cachedImage, NSData *cachedData, SDImageCacheType cacheType) {
//do something.?
}];
這個方法是屬于本篇文章主角——SDImageCache
類內部的方法。
SDImageCache + SDImageCacheConfig
SDWebImage的緩存模塊有兩個類:SDImageCache
和一個輔助類SDImageCacheConfig
。 SDImageCacheConfig
用于設置與緩存相關的一些屬性,與上文一樣,在文章中如果有涉及到會單獨將這個屬性拿出來作解釋。
與SDWebImageManager
類似,SDImageCache
同樣被設計為一個單例類,內部提供了一個全能初始化(designated initializer)方法:
/**
* Init a new cache store with a specific namespace and directory
*
* @param ns The namespace to use for this cache store
* @param directory Directory to cache disk images in
*/
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns
diskCacheDirectory:(nonnull NSString *)directory {
if ((self = [super init])) {
NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];
// 初始化了一個串行的隊列賦值給自身成員變量ioQueue
//后面介紹的代碼會使用到這個串行隊列
_ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);
//初始化成員變量config
_config = [[SDImageCacheConfig alloc] init];
// Init the memory cache 初始化內存緩存 使用NSCache類實現
/*AutoPurgeCache 是 繼承于NSCache的一個類 里面封裝了對系統
UIApplicationDidReceiveMemoryWarningNotification
通知的監聽,當收到該通知時,移除內部所有對象
*/
_memCache = [[AutoPurgeCache alloc] init];
_memCache.name = fullNamespace;
//初始化磁盤緩存
//directory是傳入的磁盤緩存將要存放的路徑
if (directory != nil) {// 傳入的directory參數不為nil
_diskCachePath = [directory stringByAppendingPathComponent:fullNamespace];
} else {// 傳入的directory == nil
NSString *path = [self makeDiskCachePath:ns];
/*
makeDiskCachePath: 創建目標文件路徑
NSArray<NSString *> *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
return [paths[0] stringByAppendingPathComponent:fullNamespace];
*/
_diskCachePath = path;
}
//在ioQueue中初始化成員變量fileManager
dispatch_sync(_ioQueue, ^{
_fileManager = [NSFileManager new];
});
#if SD_UIKIT
// Subscribe to app events
//監聽系統通知
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(clearMemory)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(deleteOldFiles)
name:UIApplicationWillTerminateNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(backgroundDeleteOldFiles)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
#endif
}
return self;
}
請求緩存
如果說我們要自己設計一個圖片緩存模塊,那么最基本最核心的功能自然是:
- 從緩存中獲取圖片。
- 將圖片緩存。
- 緩存管理機制
同樣的,SDWebImage在設計圖片緩存模塊的時候也遵循著這個思路。
請求緩存queryCacheOperation方法
上文提及的在loadImageWithURL()
方法中,manager
通過調用queryCacheOperation
方法請求緩存。下面給出這部分代碼:
/*
typedef void(^SDCacheQueryCompletedBlock)(UIImage * _Nullable image, NSData * _Nullable data, SDImageCacheType cacheType);
*/
/*通過傳入的key從緩存中查找圖片,通過回調block的方式返回給調用者*/
/*完成回調SDCacheQueryCompletedBlock的定義在上面給出*/
- (nullable NSOperation *)queryCacheOperationForKey:(nullable NSString *)key done:(nullable SDCacheQueryCompletedBlock)doneBlock {
if (!key) {
//如果key不存在,執行回調,返回
if (doneBlock) {
doneBlock(nil, nil, SDImageCacheTypeNone);
}
return nil;
}
//首先根據key檢查in-memory的緩存中有沒有圖片
UIImage *image = [self imageFromMemoryCacheForKey:key];
if (image) {//在內存中獲取到了圖片
NSData *diskData = nil;
if ([image isGIF]) {
//在所有keyPaths中根據key使用[NSData dataWithContentsOfFile]方法獲取data
diskData = [self diskImageDataBySearchingAllPathsForKey:key];
}
if (doneBlock) {
//執行回調將image 和 data傳出, 然后返回
doneBlock(image, diskData, SDImageCacheTypeMemory);
}
return nil;
}
/*內存中未查找到緩存圖片,繼續從磁盤緩存中查找*/
//創建一個新的operation 由于下面的緩存查找操作會在子線程中異步執行
//所以這里直接返回該operation給manager
NSOperation *operation = [NSOperation new];
dispatch_async(self.ioQueue, ^{
//在ioQueue 執行緩存查找操作 不阻塞主線程
if (operation.isCancelled) {
// do not call the completion if cancelled
return;
}
//生成一個新的autoreleasepool
//獲取磁盤緩存
@autoreleasepool {
//在所有keyPaths中根據key使用[NSData dataWithContentsOfFile]方法獲取data
NSData *diskData = [self diskImageDataBySearchingAllPathsForKey:key];
//根據key獲取image
UIImage *diskImage = [self diskImageForKey:key];
if (diskImage && self.config.shouldCacheImagesInMemory) {
//在磁盤緩存中獲取到image
//訪問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;
}
在這個方法中,有幾個方法的實現在下面會具體介紹:
diskImageForKey方法
這個方法通過訪問磁盤緩存獲取圖片。
//在磁盤緩存中根據key查找圖片
- (nullable UIImage *)diskImageForKey:(nullable NSString *)key {
//1.獲取data
NSData *data = [self diskImageDataBySearchingAllPathsForKey:key];
if (data) {
//2. 根據data 獲取 image
UIImage *image = [UIImage sd_imageWithData:data];
//3. 返回大小比例縮放正確的圖片
image = [self scaledImageForKey:key image:image];
if (self.config.shouldDecompressImages) {
//如果圖片需要解壓 使用SDWebImageDecoder進行解碼
/*
Decompressing images that are downloaded and cached can improve performance but can consume lot of memory.
默認為YES
*/
image = [UIImage decodedImageWithImage:image];
}
return image;
}
else {
return nil;
}
}
在第三步中,image
獲取到一個縮放過后的圖片。通常關心這個函數到底以一個怎樣的系數或者機制來縮放。[self scaledImageForKey:key image:image];
調用了下面的C函數。
//C 內聯函數
inline UIImage *SDScaledImageForKey(NSString * _Nullable key, UIImage * _Nullable image) {
if (!image) {
return nil;
}
if ((image.images).count > 0) {
//對于animated image進行處理
//每一幀的圖片都要進行scale操作
NSMutableArray<UIImage *> *scaledImages = [NSMutableArray array];
for (UIImage *tempImage in image.images) {
[scaledImages addObject:SDScaledImageForKey(key, tempImage)];
}
return [UIImage animatedImageWithImages:scaledImages duration:image.duration];
}
else {
if ([[UIScreen mainScreen] respondsToSelector:@selector(scale)]) {
CGFloat scale = 1;
//獲取縮放比例
if (key.length >= 8) {
NSRange range = [key rangeOfString:@"@2x."];
if (range.location != NSNotFound) {
scale = 2.0;
}
range = [key rangeOfString:@"@3x."];
if (range.location != NSNotFound) {
scale = 3.0;
}
}
//生成縮放后的圖片 然后返回
UIImage *scaledImage = [[UIImage alloc] initWithCGImage:image.CGImage scale:scale orientation:image.imageOrientation];
image = scaledImage;
}
return image;
}
}
根據以上代碼,根據傳入的key
中的關鍵信息來對源圖片進行比例放大以獲取對應大小的圖片。
SDCacheCostForImage計算空間花銷
NSCache提供一套類似于字典的key/value方式來進行存取內部對象。
- (nullable ObjectType)objectForKey:(KeyType)key;
- (void)setObject:(ObjectType)obj forKey:(KeyType)key; // 0 cost
- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g;
@property NSUInteger totalCostLimit; // limits are imprecise/not strict
@property NSUInteger countLimit; // limits are imprecise/not strict
使用方法類似 NSDictionary。可以通過設置 NSCache能占用的最大空間花銷totalCostLimit
或者最大對象緩存數量countLimit
。比如我們設置緩存最多占用20mb,然后每次存入緩存圖片時將圖片大小作為cost
參數傳入,當緩存大小或數量超過限定值時,內部的緩存機制就會自動為我們執行清理操作而且NSCache是線程安全的。
同樣的,在SDImageCache中提供了兩個與此對應的屬性用于管理NSCache的這個特性。
/**
* The maximum "total cost" of the in-memory image cache. The cost function is the number of pixels held in memory.
*/
@property (assign, nonatomic) NSUInteger maxMemoryCost;
/**
* The maximum number of objects the cache should hold.
*/
@property (assign, nonatomic) NSUInteger maxMemoryCountLimit;
但是需要注意的是:在SDWebImage內部,并沒有任何代碼顯式的為內存緩存AutoPurgeCache(前面已經提過,這是繼承于NSCache的一個子類)設置最大空間花銷和最大緩存對象數量,除非使用者(我們)為這個類的以上兩個屬性賦值。
前面舉的例子中,在setObject
方法中傳入cost
的是該對象所占用的內存大小,即字節數的多少。但是是否在本框架中,也使用同樣的計算機制呢。SDImageCache類中,調用下面的C函數進行計算cost
參數。
//為圖片計算空間花銷 以像素點多少為單位
//FOUNDATION_STATIC_INLINE 為 系統定義的宏 (== static inline) 內聯函數定義
//C函數
FOUNDATION_STATIC_INLINE NSUInteger SDCacheCostForImage(UIImage *image) {
return image.size.height * image.size.width * image.scale * image.scale;
}
因此我們可以看到,SDImageCache中計算占用內存大小的方法并不是單純使用字節數為單位,而是以像素點的個數為單位進行計算。這一點在一些博客中并沒有提及或者錯誤的說明了。
流程圖總結
接下來用一個流程圖總結一下本小節內容。
圖片緩存
在了解緩存獲取的設計之后,圖片緩存模塊的設計與其相近。話不多說,直接上代碼。
storeImage方法實現
/**
* Asynchronously store an image into memory and disk cache at the given key.
*
* @param image The image to store
* @param imageData The image data as returned by the server, this representation will be used for disk storage
* instead of converting the given image object into a storable/compressed image format in order
* to save quality and CPU
* @param key The unique image cache key, usually it's image absolute URL
* @param toDisk Store the image to disk cache if YES
* @param completionBlock A block executed after the operation is finished
*/
- (void)storeImage:(nullable UIImage *)image
imageData:(nullable NSData *)imageData
forKey:(nullable NSString *)key
toDisk:(BOOL)toDisk
completion:(nullable SDWebImageNoParamsBlock)completionBlock {
if (!image || !key) {
//image和key都為nil 執行block 返回
if (completionBlock) {
completionBlock();
}
return;
}
// if memory cache is enabled
//1. 如果設置了需要進行memory cache 將圖片緩存到內存
if (self.config.shouldCacheImagesInMemory) {
//1.1 計算空間開銷
NSUInteger cost = SDCacheCostForImage(image);
//1.2 緩存到NSCache中
[self.memCache setObject:image forKey:key cost:cost];
}
//2. 如果需要緩存到磁盤中
if (toDisk) {
// 在ioQueue 異步緩存
dispatch_async(self.ioQueue, ^{
@autoreleasepool {
NSData *data = imageData;
if (!data && image) {
//data為空 則需要計算data
//2.1 根據data獲取SDImageFormat 這是一個 枚舉類型
SDImageFormat imageFormatFromData = [NSData sd_imageFormatForImageData:data];
//2.2 根據圖片格式生成data
data = [image sd_imageDataAsFormat:imageFormatFromData];
}
//2.3 將圖片數據存儲到磁盤中,以key為索引
[self storeImageDataToDisk:data forKey:key];
}
//3.主線程執行回調
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
} else {//3.不需要進行磁盤緩存,直接執行回調
if (completionBlock) {
completionBlock();
}
}
}
緩存的基本過程已經在注釋寫清楚。核心方法是調用:
- (void)storeImageDataToDisk:(nullable NSData *)imageData forKey:(nullable NSString *)key;
進行磁盤緩存。
storeImageDataToDisk方法
storeImageDataToDisk方法將key
(通常是URL)和上面方法生成的data
緩存到磁盤。
/**
* Synchronously store image NSData into disk cache at the given key.
*
* @warning This method is synchronous, make sure to call it from the ioQueue
*
* @param imageData The image data to store
* @param key The unique image cache key, usually it's image absolute URL
*/
- (void)storeImageDataToDisk:(nullable NSData *)imageData forKey:(nullable NSString *)key {
if (!imageData || !key) {
return;
}
//確保該方法在ioQueue中同步地執行
[self checkIfQueueIsIOQueue];
//判斷緩存文件路徑是否存在,如果不存在則使用fileManager新建
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];
}
}
這個方法需要在串行隊列ioQueue
中同步執行,主要任務就是新建存儲圖片數據的文件夾,并使用[_fileManager createFileAtPath:cachePathForKey contents:imageData attributes:nil]
把imageData
寫入該路徑下。
補充:緩存文件名
磁盤緩存使用了傳入的key的MD5轉換之后的結果作為該圖片的磁盤緩存文件名。
- (nullable NSString *)cachedFileNameForKey:(nullable NSString *)key {
const char *str = key.UTF8String;
if (str == NULL) {
str = "";
}
//開辟一個16字節的空間
//#define CC_MD5_DIGEST_LENGTH 16 /* digest length in bytes */
unsigned char r[CC_MD5_DIGEST_LENGTH];
//執行加密
CC_MD5(str, (CC_LONG)strlen(str), r);
//轉換為字符串 x% 為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;
}
緩存清理
在SDWebImage框架下,緩存清理情況分為兩種:
- Cache clear 即清除所有緩存。
- Delete old files 即整理緩存空間。
第一種情況比較簡單,直接刪除所有緩存數據即可。
#pragma mark - Cache clean Ops
//1. 清理內存
- (void)clearMemory {
[self.memCache removeAllObjects];
}
//2. 清理磁盤
- (void)clearDiskOnCompletion:(nullable SDWebImageNoParamsBlock)completion {
dispatch_async(self.ioQueue, ^{
//2.1 刪除緩存目錄下所有文件
[_fileManager removeItemAtPath:self.diskCachePath error:nil];
//2.2 新建一個同名文件夾
[_fileManager createDirectoryAtPath:self.diskCachePath
withIntermediateDirectories:YES
attributes:nil
error:NULL];
if (completion) {
dispatch_async(dispatch_get_main_queue(), ^{
completion();
});
}
});
}
第二種情況用于整理磁盤緩存文件。
- (void)deleteOldFilesWithCompletionBlock:(nullable SDWebImageNoParamsBlock)completionBlock {
dispatch_async(self.ioQueue, ^{
NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
/**
這里給出key對應的信息
NSURLContentModificationDateKey-> The time the resource content was last modified (Read-write, value type NSDate)
NSURLIsDirectoryKey -> True for directories (Read-only, value type boolean NSNumber)
NSURLTotalFileAllocatedSizeKey -> Total allocated size of the file in bytes (this may include space used by metadata), or nil if not available. (Read-only, value type NSNumber)
*/
NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey];
//初始化enumerator,在后續會遍歷diskCachePath目錄下的文件,通過resoureceKeys獲取相關屬性
NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
//獲取config類中調用者設置的最大緩存壽命
NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.config.maxCacheAge];
NSMutableDictionary<NSURL *, NSDictionary<NSString *, id> *> *cacheFiles = [NSMutableDictionary dictionary];
NSUInteger currentCacheSize = 0;
//遍歷cache directory下所有文件,并進行以下操作:
//1. 移除所有生成日期早于expirationDate的文件
//2. 保存文件大小相關的屬性,用于基于文件大小的緩存清理操作
//初始化一個數組保存要移除的文件url
NSMutableArray<NSURL *> *urlsToDelete = [[NSMutableArray alloc] init];
for (NSURL *fileURL in fileEnumerator) {
NSError *error;
//1. 提取出與resourceKeys對應的值
NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
//2. 跳過目錄和錯誤情況
if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
continue;
}
//3. 記錄所有生成日期早于expirationDate的文件
NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
[urlsToDelete addObject:fileURL];
continue;
}
//4. 記錄文件相關屬性 保存在cacheFiles字典中 url -> key/value (string -> id)
//currentCacheSize記錄當前目錄下所有緩存文件的大小
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize += totalAllocatedSize.unsignedIntegerValue;
cacheFiles[fileURL] = resourceValues;
}
//5. 清除第三步記錄的過期文件
for (NSURL *fileURL in urlsToDelete) {
[_fileManager removeItemAtURL:fileURL error:nil];
}
// 6. 如果currentCacheSize > config中配置的最大文件大小
// 執行第二步清理操作,首先清理最早被緩存的文件
if (self.config.maxCacheSize > 0 && currentCacheSize > self.config.maxCacheSize) {
// Target half of our maximum cache size for this cleanup pass.
// 6.1 清理的目標為最大緩存大小的一半
const NSUInteger desiredCacheSize = self.config.maxCacheSize / 2;
// 6.2 按文件的最后修改時間進行排序(舊文件在前)
NSArray<NSURL *> *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
usingComparator:^NSComparisonResult(id obj1, id obj2) {
return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
}];
// 6.3 按排序數組從前往后刪除文件 直到清理目標大小
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;
}
}
}
}
// 7. 清理完成 主線程執行回調
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
}
SDImageCache首先清理過期文件。如果設置了最大緩存空間config.maxCacheSize
,且清理完過期文件后發現占用的磁盤大小仍大于self.config.maxCacheSize
,則對文件按照其修改日期的先后進行排序,舊文件排在前面。最后從排序數組中根據其URL一個一個從磁盤中移除,直到
currentCacheSize < 0.5 * maxCacheSize;
緩存清理的時機
這個時候我們再回頭看SDImageCache的全能初始化方法中注冊通知監聽系統通知的代碼。
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(clearMemory)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(deleteOldFiles)
name:UIApplicationWillTerminateNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(backgroundDeleteOldFiles)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
緩存清理的時機有如下幾個:
- 接收到
UIApplicationDidReceiveMemoryWarningNotification
內存警告通知時,清除所有內存緩存。 - 接收到
name:UIApplicationWillTerminateNotification
應用即將被關閉通知時,整理磁盤緩存。 - 接收到
name:UIApplicationWillTerminateNotification
應用即將進入后臺通知時,在后臺整理磁盤緩存。
總結
SDWebImage的緩存模塊本文章大致總結到這里,主要的功能和函數都給出。篇幅較長,也說明了本模塊的重要性,同樣是找工作面試常常會問到的地方。盡管如此,本模塊的核心邏輯非常簡單:先內存后磁盤(如有沒有額外設置的情況下)。無論是獲取緩存圖片還是將圖片緩存。
與緩存類SDImageCache配合使用的還有SDImageCacheConfig類,用于配置與緩存的相關信息,例如最大緩存數量等。
下一篇將對SDWebImage的圖片解碼器進行解析。