TMCache原始碼分析(二)---TMDiskCache磁碟快取

蹺腳啖牛肉發表於2018-05-17

原文在這裡

上篇分析TMCache中記憶體快取TMMemoryCache的實現原理, 這篇文章將詳細分析磁碟快取的實現原理.

磁碟快取,顧名思義:將資料儲存到磁碟上,由於需要儲存的資料量比較大,所以一般讀寫速度都比記憶體快取慢, 但也是非常重要的一項功能, 比如能夠實現離線瀏覽等提升使用者體驗.

磁碟快取的實現形式大致分為三種:

  • 基於檔案讀寫.
  • 基於資料庫.
  • 基於 mmap 檔案記憶體對映.

前面兩種使用的比較廣泛, SDWebImageTMDiskCache都是基於檔案 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 值傳入了中文字元, 就會呼叫encodedStringdecodedString來編解碼, 可以進入沙盒中看到對應的快取檔名字是這類編碼後的字元, 形如:%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;
複製程式碼

先來看看寫操作如何實現的, 我就不貼原始碼具體實現了, 省的看的費勁, 只看關鍵部位吧~~~你懂的, 嘻嘻.

寫入快取
  1. 寫操作被 commit 到序列佇列中, 保證了寫快取的時候執行緒安全:
dispatch_async(_queue, ^{ 
    // 寫操作
    // ...
}
複製程式碼
  1. 將傳入的物件進行歸檔處理, 所以要快取的物件一定要遵守NSCoding協議, 並實現相關方法:
BOOL written = [NSKeyedArchiver archiveRootObject:object toFile:[fileURL path]];
複製程式碼
  1. 更新快取檔案的修改時間, 不管是新加入的快取資料還是已有的快取資料進行更新, 都會修改對應的時間為當前時間:
[strongSelf setFileModificationDate:now forURL:fileURL];
複製程式碼
  1. 下面是針對快取空間大小的處理, 比較重要的一步, 根據最新快取的資料更新總共已經使用的磁碟空間大小, 如果超過預設磁碟空間上限, 則需要刪除一些資料以達到不超過上限的目的, 那以什麼規則來刪除超過快取上限的部分資料呢? 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;
複製程式碼

我們只分析非同步的刪除快取資料, 同步的跟其它同步操作一樣. 既然知道怎麼寫入快取, 那刪除應該也沒什麼問題了, 找到要刪除的檔案路徑, 刪除該快取檔案即可. 所以步驟應該是:

  1. key 進行編碼, 再拼接成完整的快取檔案的絕對路徑.
  2. 刪除檔案, 其中刪除檔案做了特殊的步驟, 但是不影響整個刪除流程, 後面會講解.
  3. 刪除_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;
複製程式碼

實現非常簡單:

  1. 對 key 進行編碼, 拼接完整快取檔案路徑.
  2. 更新快取檔案操作時間.
    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];
複製程式碼

總結

  1. 使用TMDiskCache姿勢要正確, 否則容易造成死鎖.
  2. 刪除快取的思路值得借鑑.

歡迎大家斧正!

相關文章