上篇分析了 TMCache
中記憶體快取TMMemoryCache
的實現原理, 這篇文章將詳細分析磁碟快取的實現原理.
磁碟快取,顧名思義:將資料儲存到磁碟上,由於需要儲存的資料量比較大,所以一般讀寫速度都比記憶體快取慢, 但也是非常重要的一項功能, 比如能夠實現離線瀏覽等提升使用者體驗.
磁碟快取的實現形式大致分為三種:
- 基於檔案讀寫.
- 基於資料庫.
- 基於 mmap 檔案記憶體對映.
前面兩種使用的比較廣泛, SDWebImage
和TMDiskCache
都是基於檔案 I/O 進行儲存的, 也就是一個 value 對應一個檔案, 通過讀寫檔案來快取資料. 根據上篇可以知道TMMemoryCache
記憶體快取的主線是按照 key-value的形式把資料存進可變字典中, 那麼磁碟快取的主線也是按照 key-value的形式進行對應的, 只不過 value 對應的是一個檔案, 換湯不換藥.
通過TMDiskCache
的介面 API 可以看到, TMDiskCache
提供以下功能:
- 同步/非同步的進行讀寫資料.
- 同步/非同步的進行刪除資料.
- 同步/非同步的獲取快取路徑.
- 同步/非同步的根據快取時間或者快取大小來削減磁碟空間.
- 設定磁碟快取空間上限, 磁碟快取時間上限.
- 各類 will / did block, 以及監聽後臺操作.
- 清空臨時儲存區.
TMDiskCache
的同步操作是跟TMMemoryCache
操作一樣,都是採用dispatch_semaphore_t
訊號量的形式來強制把非同步轉成同步操作,後面同步操作就一步帶過,除非特別說明. 其實TMDiskCache
的難點不在於執行緒安全,因為它所有的操作都在一個 serial queue 序列佇列中, 不存在競態情況, 難點在於檔案的操作, 瞭解 Linux 檔案系統操作的同學應該知道檔案 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
, 傳磁碟快取所在目錄的名字和絕對路徑, 如果呼叫前兩個方法,在方法內部將預設設定好路徑或者快取資料夾名字. 我們主要看終極方法主要做了幾件事:
- 建立序列佇列,是單例,即一個單例快取物件有一個單例序列佇列.
- 初始化兩個可變字典
_dates
,_sizes
, 分別用於存資料最後操作時間和資料佔用磁碟空間大小. - 建立快取檔案, 設定快取檔案操作時間.
其餘的比較簡單, 這裡主要說一下設定快取檔案操作時間
的相關 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;
}
複製程式碼
下面這個初始化設定方法, 只做了一件事:
遍歷快取資料夾下面所有的已快取的檔案, 更新的操作時間陣列
_dates
, 檔案大小陣列_sizes
以及更新磁碟總使用大小.
這麼做的目的是什麼呢?第一次建立磁碟快取目錄肯定是空的資料夾, 裡面鐵定沒有快取檔案, 那為什麼要遍歷一次所有的快取檔案並更新其操作時間和大小呢? 其實是為了防止不小心再次呼叫- (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath
建立了一個名字和路徑都相同的快取目錄, 避免裡面已經快取的資料脫離控制
. 用心良苦呀!
- (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];
}
複製程式碼
由此看出, 對於快取資料來說, key 經過編碼後設為快取檔名, value 經過歸檔後寫入檔案
.
至此, 所有的準備工作都基本做完, 下面開始存取資料了.
同步/非同步的進行讀寫資料
非同步的進行讀寫資料
相關 API:
- (void)objectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
- (void)setObject:(id <NSCoding>)object forKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
複製程式碼
先來看看寫操作如何實現的, 我就不貼原始碼具體實現了, 省的看的費勁, 只看關鍵部位吧~~~你懂的, 嘻嘻.
寫入快取
- 寫操作被 commit 到序列佇列中, 保證了寫快取的時候執行緒安全:
dispatch_async(_queue, ^{
// 寫操作
// ...
}
複製程式碼
- 將傳入的物件進行歸檔處理, 所以要快取的物件一定要遵守
NSCoding
協議, 並實現相關方法:
BOOL written = [NSKeyedArchiver archiveRootObject:object toFile:[fileURL path]];
複製程式碼
- 更新快取檔案的修改時間, 不管是新加入的快取資料還是已有的快取資料進行更新, 都會修改對應的時間為當前時間:
[strongSelf setFileModificationDate:now forURL:fileURL];
複製程式碼
- 下面是針對快取空間大小的處理, 比較重要的一步, 根據最新快取的資料更新總共已經使用的磁碟空間大小, 如果超過預設磁碟空間上限, 則需要刪除一些資料以達到不超過上限的目的, 那以什麼規則來刪除超過快取上限的部分資料呢?
TMMemoryCache
的優化策略是根據操作時間的先後順序, 即操作時間早的資料, 認為你使用的概率比較低, 所以就優先刪除掉,TMDiskCache
優化策略跟TMMemoryCache
相同, 先刪除最早的資料. 這也是以檔案系統的形式快取資料的缺點, 不能進行有效的演算法.
- 更新快取空間大小.
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
複製程式碼
- 刪除超出部分空間的快取資料.
if (strongSelf->_byteLimit > 0 && strongSelf->_byteCount > strongSelf->_byteLimit)
[strongSelf trimToSizeByDate:strongSelf->_byteLimit block:nil];
複製程式碼
至此非同步寫入快取資料完成, 注意:
_dates
,_sizes
中的 key 並沒有經過編碼, 只有快取檔名才是經過編碼的.
讀取快取
相關 API:
- (id <NSCoding>)objectForKey:(NSString *)key;
- (void)objectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
複製程式碼
也是看看非同步的讀取快取, 根據上面寫入快取的步驟可以推測讀取的步驟, 無非就是把 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
訊號量的形式來實現的.
同步/非同步的進行刪除資料
相關 API:
- (void)removeObjectForKey:(NSString *)key;
- (void)removeObjectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
複製程式碼
我們只分析非同步的刪除快取資料, 同步的跟其它同步操作一樣. 既然知道怎麼寫入快取, 那刪除應該也沒什麼問題了, 找到要刪除的檔案路徑, 刪除該快取檔案即可. 所以步驟應該是:
- key 進行編碼, 再拼接成完整的快取檔案的絕對路徑.
- 刪除檔案, 其中刪除檔案做了特殊的步驟, 但是不影響整個刪除流程, 後面會講解.
- 刪除
_dates
,_sizes
中的鍵值對, 更新總用使用的快取空間大小.
注意刪除檔案的時候並沒有直接刪除, 而是把待刪除檔案移到臨時目錄
tmp
下的快取目錄裡, 建立了一個新的序列佇列進行刪除操作.
BOOL trashed = [TMDiskCache moveItemAtURLToTrash:fileURL];
if (!trashed)
return NO;
[TMDiskCache emptyTrash];
複製程式碼
同步/非同步的獲取快取路徑
相關 API:
- (void)fileURLForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
- (NSURL *)fileURLForKey:(NSString *)key;
複製程式碼
實現非常簡單:
- 對 key 進行編碼, 拼接完整快取檔案路徑.
- 更新快取檔案操作時間.
NSURL *fileURL = [strongSelf encodedFileURLForKey:key];
if ([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) {
[strongSelf setFileModificationDate:now forURL:fileURL];
} else {
fileURL = nil;
}
複製程式碼
同步/非同步的根據快取時間或者快取大小來削減磁碟空間
這部分操作跟TMMemoryCache
的實現類似, 相關 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;
複製程式碼
第一組, 根據快取時間來削減快取空間, 如果快取資料的快取時間超過了設定的date
, 則會被刪除.
第二組, 根據快取大小來削減快取空間, 如果快取資料的快取大小超過了指定的byteCount
, 則會被刪除.
第三組, 根據操作時間的先後順序, 來削減超過了指定快取大小的空間.
實現大致都相同, 無非就是對時間進行排序, 然後把 key 進行編碼, 拼接路徑, 移動快取檔案到 tmp目錄下, 再清空 tmp 目錄. 注意一點, 無論是按照快取時間還是快取大小, 都是升序排序, 最先刪除的都是最早的或最小的
資料.
設定磁碟快取空間上限, 磁碟快取時間上限
原始碼實現:
- (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
柵欄方法, 我不知道作者為何這麼寫, 多此一舉! 本來就是序列佇列了, 就能夠保證執行緒安全, 加柵欄
方法沒什麼意義. 現在應該注意的不是執行緒安全, 而是執行緒死鎖
的問題. 所以在 API 介面中有個⚠️警告
@warning Do not read this property on the (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
之類的方法中會同步執行傳入的 block 操作, 如果在 block 裡面再提交新的任務到序列佇列中, 再同步執行, 必然死鎖. 因為外層的 block 需要等待新提交的 block 執行完畢才能執行完成, 然而新提交的 block 需要等待外層 block 執行完才能執行, 兩者相互依賴對方執行完才能執行完成, 就造成死鎖
了.
if (block)
block(strongSelf, key, nil, fileURL);
複製程式碼
上一篇分析了 TMMemoryCache
容易造成效能消耗嚴重, 而TMDiskCache
使用不當容易造成死鎖.
各類 will / did block, 以及後臺操作
will / did block 穿插在各類非同步操作中, 非常簡單, 看看即可.
if (strongSelf->_willAddObjectBlock)
strongSelf->_willAddObjectBlock(strongSelf, key, object, fileURL);
複製程式碼
其中後臺操作有點意思, 建立一個全域性的後臺管理者
遵守TMCacheBackgroundTaskManager
協議, 實現其中的兩個方法:
- (UIBackgroundTaskIdentifier)beginBackgroundTask;
- (void)endBackgroundTask:(UIBackgroundTaskIdentifier)identifier;
複製程式碼
然後呼叫設定方法, 給 TMDiskCache
物件設定後臺管理者.
+ (void)setBackgroundTaskManager:(id <TMCacheBackgroundTaskManager>)backgroundTaskManager;
複製程式碼
在後臺任務開始之前呼叫 beginBackgroundTask
方法, 結束後臺任務之前呼叫 endBackgroundTask
, 就能在後臺管理者裡面監聽到什麼時候進入後臺操作, 什麼時候結束後臺操作了.
具體做法:
UIBackgroundTaskIdentifier taskID = [TMCacheBackgroundTaskManager beginBackgroundTask];
dispatch_async(_queue, ^{
TMDiskCache *strongSelf = weakSelf;
if (!strongSelf) {
[TMCacheBackgroundTaskManager endBackgroundTask:taskID];
return;
}
// 執行後臺任務
// 比如: 寫快取, 取快取, 刪除快取等等.
[TMCacheBackgroundTaskManager endBackgroundTask:taskID];
}
複製程式碼
因為磁碟的操作可能耗時非常長, 不可能一直等待, 因此通過這種全域性的方式來感知非同步操作的開始和結束, 從而執行響應事件.
清空臨時儲存區
根據上面可以知道, 刪除快取檔案的時候, 先會在tmp
下建立"回收目錄", 需要刪除的快取檔案統一放進回收目錄下, 下面是獲取回收目錄的URL 路徑, 沒有就建立, 有則返回, 只建立一次:
+ (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;
}
複製程式碼
建立一個清空操作專屬的序列佇列TrashQueue
, 並且使用dispatch_set_target_queue
方法修改TrashQueue
的優先順序, 並與全域性併發佇列global_queue
的後臺優先順序一致. 因為tmp
目錄的情況操作不是那麼的重要, 即使我們不手動清除, 系統也會在恰當的時候清除, 所以這裡把TrashQueue
佇列的優先順序降低.
+ (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
中非同步執行, 在該方法中遍歷回收目錄下所有的快取檔案, 依次進行刪除:
+ (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];
});
}
複製程式碼
其實我們只要看一下刪除操作在哪裡執行的, 就能明白為何作者要建立一個專門用於刪除資料的序列佇列了. emptyTrash
方法呼叫是在讀寫操作的序列佇列queue
中, 方法呼叫後面還有_didRemoveObjectBlock
等待執行, 如果刪除資料量比較大且刪除操作在queue
中, 將阻塞當前執行緒, 那麼_didRemoveObjectBlock
會等待許久才能回撥, 況且刪除操作對於響應使用者事件而言不是那麼的重要, 所以把需要刪除的快取檔案放進tmp
目錄下, 建立新的低優先順序的序列佇列來進行刪除操作. 這點值得學習!
[TMDiskCache emptyTrash];
複製程式碼
總結
- 使用
TMDiskCache
姿勢要正確, 否則容易造成死鎖
. - 刪除快取的思路值得借鑑.
歡迎大家斧正!