上篇分析了 TMCache中內存緩存TMMemoryCache的實現(xiàn)原理, 這篇文章將詳細分析磁盤緩存的實現(xiàn)原理.
磁盤緩存,顧名思義:將數(shù)據(jù)存儲到磁盤上,由于需要儲存的數(shù)據(jù)量比較大,所以一般讀寫速度都比內存緩存慢, 但也是非常重要的一項功能, 比如能夠實現(xiàn)離線瀏覽等提升用戶體驗.
磁盤緩存的實現(xiàn)形式大致分為三種:
- 基于文件讀寫.
- 基于數(shù)據(jù)庫.
- 基于 mmap 文件內存映射.
前面兩種使用的比較廣泛, SDWebImage和TMDiskCache都是基于文件 I/O 進行存儲的, 也就是一個 value 對應一個文件, 通過讀寫文件來緩存數(shù)據(jù). 根據(jù)上篇可以知道TMMemoryCache內存緩存的主線是按照 key-value的形式把數(shù)據(jù)存進可變字典中, 那么磁盤緩存的主線也是按照 key-value的形式進行對應的, 只不過 value 對應的是一個文件, 換湯不換藥.
通過TMDiskCache的接口 API 可以看到, TMDiskCache提供以下功能:
- 同步/異步的進行讀寫數(shù)據(jù).
- 同步/異步的進行刪除數(shù)據(jù).
- 同步/異步的獲取緩存路徑.
- 同步/異步的根據(jù)緩存時間或者緩存大小來削減磁盤空間.
- 設置磁盤緩存空間上限, 磁盤緩存時間上限.
- 各類 will / did block, 以及監(jiān)聽后臺操作.
- 清空臨時存儲區(qū).
TMDiskCache的同步操作是跟TMMemoryCache操作一樣,都是采用dispatch_semaphore_t信號量的形式來強制把異步轉成同步操作,后面同步操作就一步帶過,除非特別說明. 其實TMDiskCache的難點不在于線程安全,因為它所有的操作都在一個 serial queue 串行隊列中, 不存在競態(tài)情況, 難點在于文件的操作, 了解 Linux 文件系統(tǒng)操作的同學應該知道文件 I/O 的概念, iOS 封裝了操作文件的類, 使用這些高級 API 能更好的操作文件.
初始化方法
在操作之前先看一下TMDiskCache的初始化方法, 提供一個類方法, 兩個實例方法:
+ (instancetype)sharedCache;
- (instancetype)initWithName:(NSString *)name;
- (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath;
從名字應該能猜測出最終調用的方法應該是- (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath, 傳磁盤緩存所在目錄的名字和絕對路徑, 如果調用前兩個方法,在方法內部將默認設置好路徑或者緩存文件夾名字. 我們主要看終極方法主要做了幾件事:
- 創(chuàng)建串行隊列,是單例,即一個單例緩存對象有一個單例串行隊列.
- 初始化兩個可變字典
_dates,_sizes, 分別用于存數(shù)據(jù)最后操作時間和數(shù)據(jù)占用磁盤空間大小. - 創(chuàng)建緩存文件, 設置緩存文件操作時間.
其余的比較簡單, 這里主要說一下設置緩存文件操作時間的相關 API, 首先是處理 key 的方法, 這兩個方法分別對傳入的 key 進行編碼和解碼, 比如在調用setObject:forKey:的時候 key 值傳入了中文字符, 就會調用encodedString和decodedString來編解碼, 可以進入沙盒中看到對應的緩存文件名字是這類編碼后的字符, 形如:%E7%A8%8B%E5%85%88%E7%94%9F.
- (NSString *)encodedString:(NSString *)string {
if (![string length])
return @"";
CFStringRef static const charsToEscape = CFSTR(".:/");
CFStringRef escapedString = CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault,
(__bridge CFStringRef)string,
NULL,
charsToEscape,
kCFStringEncodingUTF8);
return (__bridge_transfer NSString *)escapedString;
}
- (NSString *)decodedString:(NSString *)string {
if (![string length])
return @"";
CFStringRef unescapedString = CFURLCreateStringByReplacingPercentEscapesUsingEncoding(kCFAllocatorDefault,
(__bridge CFStringRef)string,
CFSTR(""),
kCFStringEncodingUTF8);
return (__bridge_transfer NSString *)unescapedString;
}
下面這個初始化設置方法, 只做了一件事:
遍歷緩存文件夾下面所有的已緩存的文件, 更新的操作時間數(shù)組
_dates, 文件大小數(shù)組_sizes以及更新磁盤總使用大小.
這么做的目的是什么呢?第一次創(chuàng)建磁盤緩存目錄肯定是空的文件夾, 里面鐵定沒有緩存文件, 那為什么要遍歷一次所有的緩存文件并更新其操作時間和大小呢? 其實是為了防止不小心再次調用- (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath創(chuàng)建了一個名字和路徑都相同的緩存目錄, 避免里面已經(jīng)緩存的數(shù)據(jù)脫離控制. 用心良苦呀!
- (void)initializeDiskProperties {
NSUInteger byteCount = 0;
NSArray *keys = @[ NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey ];
NSError *error = nil;
NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:_cacheURL
includingPropertiesForKeys:keys
options:NSDirectoryEnumerationSkipsHiddenFiles
error:&error];
TMDiskCacheError(error);
for (NSURL *fileURL in files) {
NSString *key = [self keyForEncodedFileURL:fileURL];
error = nil;
NSDictionary *dictionary = [fileURL resourceValuesForKeys:keys error:&error];
TMDiskCacheError(error);
NSDate *date = [dictionary objectForKey:NSURLContentModificationDateKey];
if (date && key)
[_dates setObject:date forKey:key];
NSNumber *fileSize = [dictionary objectForKey:NSURLTotalFileAllocatedSizeKey];
if (fileSize) {
[_sizes setObject:fileSize forKey:key];
byteCount += [fileSize unsignedIntegerValue];
}
}
if (byteCount > 0)
self.byteCount = byteCount; // atomic
}
- (NSString *)keyForEncodedFileURL:(NSURL *)url {
NSString *fileName = [url lastPathComponent];
if (!fileName)
return nil;
return [self decodedString:fileName];
}
由此看出, 對于緩存數(shù)據(jù)來說, key 經(jīng)過編碼后設為緩存文件名, value 經(jīng)過歸檔后寫入文件.
至此, 所有的準備工作都基本做完, 下面開始存取數(shù)據(jù)了.
同步/異步的進行讀寫數(shù)據(jù)
異步的進行讀寫數(shù)據(jù)
相關 API:
- (void)objectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
- (void)setObject:(id <NSCoding>)object forKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
先來看看寫操作如何實現(xiàn)的, 我就不貼源碼具體實現(xiàn)了, 省的看的費勁, 只看關鍵部位吧~~~你懂的, 嘻嘻.
寫入緩存
- 寫操作被 commit 到串行隊列中, 保證了寫緩存的時候線程安全:
dispatch_async(_queue, ^{
// 寫操作
// ...
}
- 將傳入的對象進行歸檔處理, 所以要緩存的對象一定要遵守
NSCoding協(xié)議, 并實現(xiàn)相關方法:
BOOL written = [NSKeyedArchiver archiveRootObject:object toFile:[fileURL path]];
- 更新緩存文件的修改時間, 不管是新加入的緩存數(shù)據(jù)還是已有的緩存數(shù)據(jù)進行更新, 都會修改對應的時間為當前時間:
[strongSelf setFileModificationDate:now forURL:fileURL];
- 下面是針對緩存空間大小的處理, 比較重要的一步, 根據(jù)最新緩存的數(shù)據(jù)更新總共已經(jīng)使用的磁盤空間大小, 如果超過預設磁盤空間上限, 則需要刪除一些數(shù)據(jù)以達到不超過上限的目的, 那以什么規(guī)則來刪除超過緩存上限的部分數(shù)據(jù)呢?
TMMemoryCache的優(yōu)化策略是根據(jù)操作時間的先后順序, 即操作時間早的數(shù)據(jù), 認為你使用的概率比較低, 所以就優(yōu)先刪除掉,TMDiskCache優(yōu)化策略跟TMMemoryCache相同, 先刪除最早的數(shù)據(jù). 這也是以文件系統(tǒng)的形式緩存數(shù)據(jù)的缺點, 不能進行有效的算法.
- 更新緩存空間大小.
NSNumber *oldEntry = [strongSelf->_sizes objectForKey:key];
if ([oldEntry isKindOfClass:[NSNumber class]]){
strongSelf.byteCount = strongSelf->_byteCount - [oldEntry unsignedIntegerValue];
}
[strongSelf->_sizes setObject:diskFileSize forKey:key];
strongSelf.byteCount = strongSelf->_byteCount + [diskFileSize unsignedIntegerValue]; // atomic
- 刪除超出部分空間的緩存數(shù)據(jù).
if (strongSelf->_byteLimit > 0 && strongSelf->_byteCount > strongSelf->_byteLimit)
[strongSelf trimToSizeByDate:strongSelf->_byteLimit block:nil];
至此異步寫入緩存數(shù)據(jù)完成, 注意:
_dates,_sizes中的 key 并沒有經(jīng)過編碼, 只有緩存文件名才是經(jīng)過編碼的.
讀取緩存
相關 API:
- (id <NSCoding>)objectForKey:(NSString *)key;
- (void)objectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
也是看看異步的讀取緩存, 根據(jù)上面寫入緩存的步驟可以推測讀取的步驟, 無非就是把 key 進行編碼, 找到緩存文件, 再解檔緩存文件內容, 最后更新操作時間, 主線就這幾步, 其余的就是加點"配料" - will / did block 之類的時序控制類操作.
dispatch_async(_queue, ^{
TMDiskCache *strongSelf = weakSelf;
if (!strongSelf)
return;
NSURL *fileURL = [strongSelf encodedFileURLForKey:key];
id <NSCoding> object = nil;
if ([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) {
@try {
object = [NSKeyedUnarchiver unarchiveObjectWithFile:[fileURL path]];
}
@catch (NSException *exception) {
NSError *error = nil;
[[NSFileManager defaultManager] removeItemAtPath:[fileURL path] error:&error];
TMDiskCacheError(error);
}
[strongSelf setFileModificationDate:now forURL:fileURL];
}
block(strongSelf, key, object, fileURL);
});
代碼中通過@ try, @catch拋出異常, 如果解檔緩存文件內容失敗, 直接刪除該緩存文件, 簡單不做作, 直接了當! 額, 也許不近人情, 好歹你告訴我錯誤信息是什么, 讓我來決定刪不刪嘛.
同步的寫入/讀取緩存
都是采用dispatch_semaphore_t信號量的形式來實現(xiàn)的.
同步/異步的進行刪除數(shù)據(jù)
相關 API:
- (void)removeObjectForKey:(NSString *)key;
- (void)removeObjectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
我們只分析異步的刪除緩存數(shù)據(jù), 同步的跟其它同步操作一樣.
既然知道怎么寫入緩存, 那刪除應該也沒什么問題了, 找到要刪除的文件路徑, 刪除該緩存文件即可. 所以步驟應該是:
- key 進行編碼, 再拼接成完整的緩存文件的絕對路徑.
- 刪除文件, 其中刪除文件做了特殊的步驟, 但是不影響整個刪除流程, 后面會講解.
- 刪除
_dates,_sizes中的鍵值對, 更新總用使用的緩存空間大小.
注意刪除文件的時候并沒有直接刪除, 而是把待刪除文件移到臨時目錄
tmp下的緩存目錄里, 創(chuàng)建了一個新的串行隊列進行刪除操作.
BOOL trashed = [TMDiskCache moveItemAtURLToTrash:fileURL];
if (!trashed)
return NO;
[TMDiskCache emptyTrash];
同步/異步的獲取緩存路徑
相關 API:
- (void)fileURLForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
- (NSURL *)fileURLForKey:(NSString *)key;
實現(xiàn)非常簡單:
- 對 key 進行編碼, 拼接完整緩存文件路徑.
- 更新緩存文件操作時間.
NSURL *fileURL = [strongSelf encodedFileURLForKey:key];
if ([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) {
[strongSelf setFileModificationDate:now forURL:fileURL];
} else {
fileURL = nil;
}
同步/異步的根據(jù)緩存時間或者緩存大小來削減磁盤空間
這部分操作跟TMMemoryCache的實現(xiàn)類似, 相關 API:
- (void)trimToDate:(NSDate *)date;
- (void)trimToDate:(NSDate *)date block:(TMDiskCacheBlock)block;
- (void)trimToSize:(NSUInteger)byteCount;
- (void)trimToSize:(NSUInteger)byteCount block:(TMDiskCacheBlock)block;
- (void)trimToSizeByDate:(NSUInteger)byteCount;
- (void)trimToSizeByDate:(NSUInteger)byteCount block:(TMDiskCacheBlock)block;
第一組, 根據(jù)緩存時間來削減緩存空間, 如果緩存數(shù)據(jù)的緩存時間超過了設置的date, 則會被刪除.
第二組, 根據(jù)緩存大小來削減緩存空間, 如果緩存數(shù)據(jù)的緩存大小超過了指定的byteCount, 則會被刪除.
第三組, 根據(jù)操作時間的先后順序, 來削減超過了指定緩存大小的空間.
實現(xiàn)大致都相同, 無非就是對時間進行排序, 然后把 key 進行編碼, 拼接路徑, 移動緩存文件到 tmp目錄下, 再清空 tmp 目錄. 注意一點, 無論是按照緩存時間還是緩存大小, 都是升序排序, 最先刪除的都是最早的或最小的數(shù)據(jù).
設置磁盤緩存空間上限, 磁盤緩存時間上限
源碼實現(xiàn):
- (NSUInteger)byteLimit {
__block NSUInteger byteLimit = 0;
dispatch_sync(_queue, ^{
byteLimit = _byteLimit;
});
return byteLimit;
}
- (void)setByteLimit:(NSUInteger)byteLimit {
__weak TMDiskCache *weakSelf = self;
dispatch_barrier_async(_queue, ^{
TMDiskCache *strongSelf = weakSelf;
if (!strongSelf)
return;
strongSelf->_byteLimit = byteLimit;
if (byteLimit > 0)
[strongSelf trimDiskToSizeByDate:byteLimit];
});
}
設置緩存空間上限的時候采用dispatch_barrier_async柵欄方法, 我不知道作者為何這么寫, 多此一舉! 本來就是串行隊列了, 就能夠保證線程安全, 加柵欄方法沒什么意義. 現(xiàn)在應該注意的不是線程安全, 而是線程死鎖的問題. 所以在 API 接口中有個??警告
@warning Do not read this property on the <sharedQueue> (including asynchronous method blocks).
意思是不要在 shareQueue 和接口里面的任何 API 的異步 block 中去讀這個屬性, 為什么呢? 因為TMDiskCache所有的讀寫刪除操作都是放在Serial Queue串行隊列中的, 也就是shareQueue隊列, 天啦嚕...這不造成死鎖才怪呢! 警告還寫這么不明顯.形如下面的是錯誤?的用法:
[diskCache removeObjectForKey:@"profileKey" block:^(TMDiskCache *cache, NSString *key, id<NSCoding> object, NSURL *fileURL) {
NSLog(@"%ld", diskCache.byteLimit);
}];
因為在removeObjectForKey之類的方法中會同步執(zhí)行傳入的 block 操作, 如果在 block 里面再提交新的任務到串行隊列中, 再同步執(zhí)行, 必然死鎖. 因為外層的 block 需要等待新提交的 block 執(zhí)行完畢才能執(zhí)行完成, 然而新提交的 block 需要等待外層 block 執(zhí)行完才能執(zhí)行, 兩者相互依賴對方執(zhí)行完才能執(zhí)行完成, 就造成死鎖了.
if (block)
block(strongSelf, key, nil, fileURL);
上一篇分析了 TMMemoryCache 容易造成性能消耗嚴重, 而TMDiskCache使用不當容易造成死鎖.
各類 will / did block, 以及后臺操作
will / did block 穿插在各類異步操作中, 非常簡單, 看看即可.
if (strongSelf->_willAddObjectBlock)
strongSelf->_willAddObjectBlock(strongSelf, key, object, fileURL);
其中后臺操作有點意思, 創(chuàng)建一個全局的后臺管理者遵守TMCacheBackgroundTaskManager協(xié)議, 實現(xiàn)其中的兩個方法:
- (UIBackgroundTaskIdentifier)beginBackgroundTask;
- (void)endBackgroundTask:(UIBackgroundTaskIdentifier)identifier;
然后調用設置方法, 給 TMDiskCache對象設置后臺管理者.
+ (void)setBackgroundTaskManager:(id <TMCacheBackgroundTaskManager>)backgroundTaskManager;
在后臺任務開始之前調用 beginBackgroundTask 方法, 結束后臺任務之前調用 endBackgroundTask, 就能在后臺管理者里面監(jiān)聽到什么時候進入后臺操作, 什么時候結束后臺操作了.
具體做法:
UIBackgroundTaskIdentifier taskID = [TMCacheBackgroundTaskManager beginBackgroundTask];
dispatch_async(_queue, ^{
TMDiskCache *strongSelf = weakSelf;
if (!strongSelf) {
[TMCacheBackgroundTaskManager endBackgroundTask:taskID];
return;
}
// 執(zhí)行后臺任務
// 比如: 寫緩存, 取緩存, 刪除緩存等等.
[TMCacheBackgroundTaskManager endBackgroundTask:taskID];
}
因為磁盤的操作可能耗時非常長, 不可能一直等待, 因此通過這種全局的方式來感知異步操作的開始和結束, 從而執(zhí)行響應事件.
清空臨時存儲區(qū)
根據(jù)上面可以知道, 刪除緩存文件的時候, 先會在tmp下創(chuàng)建"回收目錄", 需要刪除的緩存文件統(tǒng)一放進回收目錄下, 下面是獲取回收目錄的URL 路徑, 沒有就創(chuàng)建, 有則返回, 只創(chuàng)建一次:
+ (NSURL *)sharedTrashURL {
static NSURL *sharedTrashURL;
static dispatch_once_t predicate;
dispatch_once(&predicate, ^{
sharedTrashURL = [[[NSURL alloc] initFileURLWithPath:NSTemporaryDirectory()] URLByAppendingPathComponent:TMDiskCachePrefix isDirectory:YES];
if (![[NSFileManager defaultManager] fileExistsAtPath:[sharedTrashURL path]]) {
NSError *error = nil;
[[NSFileManager defaultManager] createDirectoryAtURL:sharedTrashURL
withIntermediateDirectories:YES
attributes:nil
error:&error];
TMDiskCacheError(error);
}
});
return sharedTrashURL;
}
創(chuàng)建一個清空操作專屬的串行隊列TrashQueue, 并且使用dispatch_set_target_queue方法修改TrashQueue的優(yōu)先級, 并與全局并發(fā)隊列global_queue 的后臺優(yōu)先級一致. 因為tmp目錄的情況操作不是那么的重要, 即使我們不手動清除, 系統(tǒng)也會在恰當?shù)臅r候清除, 所以這里把TrashQueue隊列的優(yōu)先級降低.
+ (dispatch_queue_t)sharedTrashQueue {
static dispatch_queue_t trashQueue;
static dispatch_once_t predicate;
dispatch_once(&predicate, ^{
NSString *queueName = [[NSString alloc] initWithFormat:@"%@.trash", TMDiskCachePrefix];
trashQueue = dispatch_queue_create([queueName UTF8String], DISPATCH_QUEUE_SERIAL);
dispatch_set_target_queue(trashQueue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0));
});
return trashQueue;
}
類方法, 把原本在Caches下的緩存文件移動進tmp目錄下的回收目錄.
+ (BOOL)moveItemAtURLToTrash:(NSURL *)itemURL {
if (![[NSFileManager defaultManager] fileExistsAtPath:[itemURL path]])
return NO;
NSError *error = nil;
NSString *uniqueString = [[NSProcessInfo processInfo] globallyUniqueString];
NSURL *uniqueTrashURL = [[TMDiskCache sharedTrashURL] URLByAppendingPathComponent:uniqueString];
BOOL moved = [[NSFileManager defaultManager] moveItemAtURL:itemURL toURL:uniqueTrashURL error:&error];
TMDiskCacheError(error);
return moved;
}
把清除操作添加到TrashQueue中異步執(zhí)行, 在該方法中遍歷回收目錄下所有的緩存文件, 依次進行刪除:
+ (void)emptyTrash {
UIBackgroundTaskIdentifier taskID = [TMCacheBackgroundTaskManager beginBackgroundTask];
dispatch_async([self sharedTrashQueue], ^{
NSError *error = nil;
NSArray *trashedItems = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:[self sharedTrashURL]
includingPropertiesForKeys:nil
options:0
error:&error];
TMDiskCacheError(error);
for (NSURL *trashedItemURL in trashedItems) {
NSError *error = nil;
[[NSFileManager defaultManager] removeItemAtURL:trashedItemURL error:&error];
TMDiskCacheError(error);
}
[TMCacheBackgroundTaskManager endBackgroundTask:taskID];
});
}
其實我們只要看一下刪除操作在哪里執(zhí)行的, 就能明白為何作者要創(chuàng)建一個專門用于刪除數(shù)據(jù)的串行隊列了. emptyTrash方法調用是在讀寫操作的串行隊列queue中, 方法調用后面還有_didRemoveObjectBlock等待執(zhí)行, 如果刪除數(shù)據(jù)量比較大且刪除操作在queue中, 將阻塞當前線程, 那么_didRemoveObjectBlock會等待許久才能回調, 況且刪除操作對于響應用戶事件而言不是那么的重要, 所以把需要刪除的緩存文件放進tmp目錄下, 創(chuàng)建新的低優(yōu)先級的串行隊列來進行刪除操作. 這點值得學習!
[TMDiskCache emptyTrash];
總結
- 使用
TMDiskCache姿勢要正確, 否則容易造成死鎖. - 刪除緩存的思路值得借鑒.
歡迎大家斧正!