YYCache 原始碼剖析:一覽亮點

波兒菜發表於2019-07-30

YYKit 系列原始碼剖析文章:

寫在前面

YYCache 作為當下 iOS 圈最流行的快取框架,有著優越的效能和絕佳的設計。筆者花了些時間對其“解剖”了一番,發現了很多有意思的東西,所以寫下本文分享一下。

考慮到篇幅,筆者對於原始碼的解析不會過多的涉及 API 使用和一些基礎知識,更多的是剖析作者 ibireme 的設計思維和重要技術實現細節。

YYCache 主要分為兩部分:記憶體快取和磁碟快取(對應 YYMemoryCacheYYDiskCache)。在日常開發業務使用中,多是直接操作 YYCache 類,該類是對記憶體快取功能和磁碟快取功能的一個簡單封裝。

原始碼基於 1.0.4 版本。

一、記憶體快取:YYMemoryCache

總覽 API ,會發現一些見名知意的方法:

- (nullable id)objectForKey:(id)key;
- (void)setObject:(nullable id)object forKey:(id)key;
- (void)trimToCount:(NSUInteger)count;
- (void)trimToCost:(NSUInteger)cost;
- (void)trimToAge:(NSTimeInterval)age;
......
複製程式碼

可以看出,該類主要包含讀寫功能和修剪功能(修剪是為了控制記憶體快取的大小等)。當然,還有其他一些自定義方法,比如釋放操作的執行緒選擇、記憶體警告和進入後臺時是否清除記憶體快取等。

對該類的基本功能有了瞭解之後,就可以直接切實現原始碼了。

(1)LRU 快取淘汰演算法

既然有修剪快取的功能,必然涉及到一個快取淘汰演算法,YYMemoryCacheYYDiskCache 都是實現的 LRU (least-recently-used) ,即最近最少使用淘汰演算法。

在 YYMemoryCache.m 檔案中,有如下的程式碼:

@interface _YYLinkedMapNode : NSObject {
    @package
    __unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic
    __unsafe_unretained _YYLinkedMapNode *_next; // retained by dic
    id _key;
    id _value;
    NSUInteger _cost;
    NSTimeInterval _time;
}

@interface _YYLinkedMap : NSObject {
    @package
    CFMutableDictionaryRef _dic; // do not set object directly
    NSUInteger _totalCost;
    NSUInteger _totalCount;
    _YYLinkedMapNode *_head; // MRU, do not change it directly
    _YYLinkedMapNode *_tail; // LRU, do not change it directly
    BOOL _releaseOnMainThread;
    BOOL _releaseAsynchronously;
}
複製程式碼

熟悉連結串列的朋友應該一眼就看出來貓膩,作者是使用的一個雙向連結串列+雜湊容器來實現 LRU 的。

連結串列的節點 (_YYLinkedMapNode):

  1. 同時使用前驅和後繼指標(即_prev_next)是為了快速找到前驅和後繼節點。
  2. 這裡使用__unsafe_unretained而不使用__weak。雖然兩者都不會持有指標所指向的物件,但是在指向物件釋放時,前者並不會自動置空指標,形成野指標,不過經過筆者後面的閱讀,發現作者避免了野指標的出現;而且從效能層面看(作者原話):訪問具有 __weak 屬性的變數時,實際上會呼叫objc_loadWeak()objc_storeWeak()來完成,這也會帶來很大的開銷,所以要避免使用__weak屬性。
  3. _key_value就是框架使用者想要儲存的鍵值對,可以看出作者的設計是一個鍵值對對應一個節點(_YYLinkedMapNode)。
  4. _cost_time表示該節點的記憶體大小和最後訪問的時間。

LRU 實現類 (_YYLinkedMap) :

  1. 包含頭尾指標(_head_tail),保證雙端查詢的效率。
  2. _totalCost_totalCount記錄最大記憶體佔用限制和數量限制。
  3. _releaseOnMainThread_releaseAsynchronously分別表示在主執行緒釋放和在非同步執行緒釋放,它們的實現後文會講到。
  4. _dic變數是 OC 開發中常用的雜湊容器,所有節點都會在_dic中以 key-value 的形式存在,保證常數級查詢效率。

既然是 LRU 演算法,怎麼能只有資料結構,往下面看 _YYLinkedMap 類實現瞭如下演算法(嗯,挺常規的節點操作,程式碼質量挺高的,就不說明實現了):

- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;
- (void)removeNode:(_YYLinkedMapNode *)node;
- (_YYLinkedMapNode *)removeTailNode;
- (void)removeAll;
複製程式碼

現在 LRU 的資料結構和操作演算法實現都有了,就可以看具體的業務了。

##(2)修剪記憶體的邏輯

正如一開始貼的 API ,該類有三種修剪記憶體的依據:根據快取的記憶體塊數量、根據佔用記憶體大小、根據是否是最近使用。它們的實現邏輯幾乎一樣,這裡就其中一個為例子(程式碼有刪減):

- (void)_trimToAge:(NSTimeInterval)ageLimit {
    ......
    
    NSMutableArray *holder = [NSMutableArray new];
//1 迭代部分
    while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            if (_lru->_tail && (now - _lru->_tail->_time) > ageLimit) {
                _YYLinkedMapNode *node = [_lru removeTailNode];
                if (node) [holder addObject:node];
            } else {
                finish = YES;
            }
            pthread_mutex_unlock(&_lock);
        } else {
            usleep(10 * 1000); //10 ms
        }
    }
//2 釋放部分
    if (holder.count) {
        dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
        dispatch_async(queue, ^{
            [holder count]; // release in queue
        });
    }
}
複製程式碼

這裡有幾個重要技術點,很有意思。

釋放尾節點

通過一個 while 迴圈不斷釋放尾節點removeTailNode,直到滿足引數ageLimit對時間的要求,而該連結串列的排序規則是:最近使用的記憶體塊會移動到連結串列頭部,也就保證了刪除的記憶體永遠是最不常使用的(後面會看到如何實現排序的)。

鎖的處理

不妨思考這樣一個問題:為何要使用pthread_mutex_trylock()方法嘗試獲取鎖,而獲取失敗過後做了一個執行緒掛起操作usleep()

**優先順序反轉:**比如兩個執行緒 A 和 B,優先順序 A < B。當 A 獲取鎖訪問共享資源時,B 嘗試獲取鎖,那麼 B 就會進入忙等狀態,忙等時間越長對 CPU 資源的佔用越大;而由於 A 的優先順序低於 B,A 無法與高優先順序的執行緒爭奪 CPU 資源,從而導致任務遲遲完成不了。解決優先順序反轉的方法有“優先順序天花板”和“優先順序繼承”,它們的核心操作都是提升當前正在訪問共享資源的執行緒的優先順序。

**歷史情況:**在老版本的程式碼中,作者是使用的OSSpinLock自旋鎖來保證執行緒安全,而後來由於OSSpinLock的 bug 問題(存在潛在的優先順序反轉BUG),作者將其替換成了pthread_mutex_t互斥鎖。

筆者的理解: 自動的遞迴修剪邏輯是這樣的:

- (void)_trimInBackground {
    dispatch_async(_queue, ^{
        [self _trimToCost:self->_costLimit];
        [self _trimToCount:self->_countLimit];
        [self _trimToAge:self->_ageLimit];
    });
}
複製程式碼

_queue是一個序列佇列:

_queue = dispatch_queue_create("com.ibireme.cache.memory", DISPATCH_QUEUE_SERIAL);
複製程式碼

可以明確的是,自動修剪過程不存線上程安全問題,當然框架還暴露了修剪記憶體的方法給外部使用,那麼當外部在多執行緒呼叫修剪記憶體方法就可能會出現執行緒安全問題。

這裡做了一個 10ms 的掛起操作然後迴圈嘗試,直接捨棄了互斥鎖的空轉期,但這樣也避免了多執行緒訪問下過多的空轉佔用過多的 CPU 資源。作者這樣處理很可能加長了修剪記憶體的時間,但是卻避免了極限情況下空轉對 CPU 的佔用。

顯然,作者是期望使用者在後臺執行緒修剪記憶體(最好使用者不去顯式的呼叫修剪記憶體方法)。

非同步執行緒釋放資源

這裡作者使用了一個容器將要釋放的節點裝起來,然後在某個佇列(預設是非主佇列)裡面呼叫了一下該容器的方法。雖然看程式碼可能不理解,但是作者寫了一句註釋release in queue:某個物件的方法最後在某個執行緒呼叫,這個物件就會在當前執行緒釋放。很明顯,這裡是作者將節點的釋放放其他執行緒,從而減輕主執行緒的資源開銷。

##(3)檢查記憶體是否超限的定時任務 有這樣一段程式碼:

- (void)_trimRecursively {
    __weak typeof(self) _self = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_autoTrimInterval * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
        __strong typeof(_self) self = _self;
        if (!self) return;
        [self _trimInBackground];
        [self _trimRecursively];
    });
}
複製程式碼

可以看到,作者是使用一個遞迴+延時來實現定時任務的,這裡可以自定義檢測的時間間隔。

##(4)進入後臺和記憶體警告的處理 在該類初始化時,作者寫了記憶體警告和進入後臺兩個監聽:

[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidReceiveMemoryWarningNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidEnterBackgroundNotification) name:UIApplicationDidEnterBackgroundNotification object:nil];
複製程式碼

然後可以由使用者定義在觸發響應時是否需要清除記憶體(簡化了一下程式碼):

- (void)_appDidReceiveMemoryWarningNotification {
    if (self.didReceiveMemoryWarningBlock) self.didReceiveMemoryWarningBlock(self);
    if (self.shouldRemoveAllObjectsOnMemoryWarning) [self removeAllObjects];
}
- (void)_appDidEnterBackgroundNotification {
    if (self.didEnterBackgroundBlock) self.didEnterBackgroundBlock(self);
    if (self.shouldRemoveAllObjectsWhenEnteringBackground) [self removeAllObjects];
}
複製程式碼

使用者還可以通過閉包實時監聽。

##(5)讀資料

- (id)objectForKey:(id)key {
    if (!key) return nil;
    pthread_mutex_lock(&_lock);
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    if (node) {
        node->_time = CACurrentMediaTime();
        [_lru bringNodeToHead:node];
    }
    pthread_mutex_unlock(&_lock);
    return node ? node->_value : nil;
}
複製程式碼

邏輯很簡單,關鍵的一步是 node->_time = CACurrentMediaTime()[_lru bringNodeToHead:node] ;即更新這塊記憶體的時間,然後將該節點移動到連結串列頭部,實現了基於時間的優先順序排序,為 LRU 的實現提供了可靠的資料結構基礎。

##(6)寫資料 程式碼有刪減,解析寫在程式碼中:

- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
    ......
    pthread_mutex_lock(&_lock);
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    NSTimeInterval now = CACurrentMediaTime();
    if (node) {
//1 若快取中有:修改node的變數,將該節點移動到頭部
        ......
        [_lru bringNodeToHead:node];
    } else {
//2 若快取中沒有,建立一個記憶體,將該節點插入到頭部
        node = [_YYLinkedMapNode new];
        ......
        [_lru insertNodeAtHead:node];
    }
//3 判斷是否需要修剪記憶體佔用,若需要:非同步修剪,保證寫入的效能
    if (_lru->_totalCost > _costLimit) {
        dispatch_async(_queue, ^{
            [self trimToCost:_costLimit];
        });
    }
//4 判斷是否需要修剪記憶體塊數量,若需要:預設在非主佇列釋放無用記憶體,保證寫入的效能
    if (_lru->_totalCount > _countLimit) {
        _YYLinkedMapNode *node = [_lru removeTailNode];
        if (_lru->_releaseAsynchronously) {
            dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
            dispatch_async(queue, ^{
                [node class]; //hold and release in queue
            });
        } else if (_lru->_releaseOnMainThread && !pthread_main_np()) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [node class]; //hold and release in queue
            });
        }
    }
    pthread_mutex_unlock(&_lock);
}
複製程式碼

二、磁碟快取:YYDiskCache

在暴露給使用者的 API 中,磁碟快取的功能和記憶體快取很像,同樣有讀寫資料和修剪資料等功能。

YYDiskCache的磁碟快取處理效能非常優越,作者測試了資料庫和檔案儲存的讀寫效率:iPhone 6 64G 下,SQLite 寫入效能比直接寫檔案要高,但讀取效能取決於資料大小:當單條資料小於 20K 時,資料越小 SQLite 讀取效能越高;單條資料大於 20K 時,直接寫為檔案速度會更快一些。(更詳細的說明看文末連結)

所以作者對磁碟快取的處理方式為 SQLite 結合檔案儲存的方式。

磁碟快取的核心類是YYKVStorage,注意該類是非執行緒安全的,它主要封裝了 SQLite 資料庫的操作和檔案儲存操作。

後文的剖析大部分的程式碼都是在YYKVStorage檔案中。

##(1)磁碟快取的檔案結構 首先,需要了解一下作者設計的在磁碟中的檔案結構(在YYKVStorage.m中作者的註釋):

/*
 File:
 /path/
      /manifest.sqlite
      /manifest.sqlite-shm
      /manifest.sqlite-wal
      /data/
           /e10adc3949ba59abbe56e057f20f883e
           /e10adc3949ba59abbe56e057f20f883e
      /trash/
            /unused_file_or_folder
 
 SQL:
 create table if not exists manifest (
    key                 text,
    filename            text,
    size                integer,
    inline_data         blob,
    modification_time   integer,
    last_access_time    integer,
    extended_data       blob,
    primary key(key)
 ); 
 create index if not exists last_access_time_idx on manifest(last_access_time);
 */
複製程式碼

path 是一個初始化時使用的變數,不同的 path 對應不同的資料庫。在 path 下面有 sqlite 資料庫相關的三個檔案,以及兩個目錄(/data 和 /trash),這兩個目錄就是檔案儲存方便直接讀取的地方,也就是為了實現上文說的在高於某個臨界值時直接讀取檔案比從資料庫讀取快的理論。

在資料庫中,建了一個表,表的結構如上程式碼所示:

  1. key 唯一標識
  2. size 當前記憶體塊的大小。
  3. inline_data 使用者儲存內容(value)的二進位制資料。
  4. last_access_time 最後訪問時間,便於磁碟快取實現 LRU 演算法的資料結構排序。
  5. filename 檔名,它指向直接存檔案情況下的檔名,具體互動請往下看~

如何實現 SQLite 結合檔案儲存

這一個重點問題,就像之前說的,在某個臨界值時,直接讀取檔案的效率要高於從資料庫讀取,第一反應可能是寫檔案和寫資料庫分離,也就是上面的結構中,manifest.sqlite 資料庫檔案和 /data 資料夾內容無關聯,讓 /data 去儲存高於臨界值的資料,讓 sqlite 去儲存低於臨界值的資料。

然而這樣會帶來兩個問題:

  1. /data 目錄下的快取資料無法高速查詢(可能只有遍歷)
  2. 無法統一管理磁碟快取

為了完美處理該問題,作者將它們結合了起來,所有關於使用者儲存資料的相關資訊都會放在資料庫中(即剛才說的那個table中),而待儲存資料的二進位制檔案,卻根據情況分別處理:要麼存在資料庫表的 inline_data 下,要麼直接儲存在 /data 資料夾下。

如此一來,一切問題迎刃而解,下文根據原始碼進行驗證和探究。

##(2)資料庫表的OC模型體現 當然,為了讓介面可讀性更高,作者寫了一個對應資料庫表的模型,作為使用者實際業務使用的類:

@interface YYKVStorageItem : NSObject
@property (nonatomic, strong) NSString *key;                ///< key
@property (nonatomic, strong) NSData *value;                ///< value
@property (nullable, nonatomic, strong) NSString *filename; ///< filename (nil if inline)
@property (nonatomic) int size;                             ///< value's size in bytes
@property (nonatomic) int modTime;                          ///< modification unix timestamp
@property (nonatomic) int accessTime;                       ///< last access unix timestamp
@property (nullable, nonatomic, strong) NSData *extendedData; ///< extended data (nil if no extended data)
@end
複製程式碼

該類的屬性和資料庫表的鍵一一對應。

##(3)資料庫的操作封裝

對於 sqlite 的封裝比較常規,作者的容錯處理做得很好,下面就一些重點地方做一些講解,對資料庫操作感興趣的朋友可以直接去看原始碼。

sqlite3_stmt 快取

YYKVStorage 類有這樣一個變數:CFMutableDictionaryRef _dbStmtCache; 通過 sql 生成 sqlite3_stmt 的封裝方法是這樣的:

- (sqlite3_stmt *)_dbPrepareStmt:(NSString *)sql {
    if (![self _dbCheck] || sql.length == 0 || !_dbStmtCache) return NULL;
    sqlite3_stmt *stmt = (sqlite3_stmt *)CFDictionaryGetValue(_dbStmtCache, (__bridge const void *)(sql));
    if (!stmt) {
        int result = sqlite3_prepare_v2(_db, sql.UTF8String, -1, &stmt, NULL);
        if (result != SQLITE_OK) {
            if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite stmt prepare error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db));
            return NULL;
        }
        CFDictionarySetValue(_dbStmtCache, (__bridge const void *)(sql), stmt);
    } else {
        sqlite3_reset(stmt);
    }
    return stmt;
}
複製程式碼

作者使用了一個 hash 容器來快取 stmt, 每次根據 sql 生成 stmt 時,若已經存在快取就執行一次 sqlite3_reset(stmt); 讓 stmt 回到初始狀態。

如此一來,提高了資料庫讀寫的效率,是一個小 tip。

利用 sql 語句運算元據庫實現 LRU

資料庫操作,仍然有根據佔用記憶體大小、最後訪問時間、記憶體塊數量進行修剪記憶體的方法,下面就根據最後訪問時間進行修剪方法做為例子:

- (BOOL)_dbDeleteItemsWithTimeEarlierThan:(int)time {
    NSString *sql = @"delete from manifest where last_access_time < ?1;";
    sqlite3_stmt *stmt = [self _dbPrepareStmt:sql];
    if (!stmt) return NO;
    sqlite3_bind_int(stmt, 1, time);
    int result = sqlite3_step(stmt);
    if (result != SQLITE_DONE) {
        if (_errorLogsEnabled)  NSLog(@"%s line:%d sqlite delete error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db));
        return NO;
    }
    return YES;
}
複製程式碼

可以看到,作者利用 sql 語句,很輕鬆的實現了記憶體的修剪。

寫入時的核心邏輯

寫入時,作者根據是否有 filename 判斷是否需要將寫入的資料二進位制存入資料庫(程式碼有刪減):

- (BOOL)_dbSaveWithKey:(NSString *)key value:(NSData *)value fileName:(NSString *)fileName extendedData:(NSData *)extendedData {
    NSString *sql = @"insert or replace into manifest (key, filename, size, inline_data, modification_time, last_access_time, extended_data) values (?1, ?2, ?3, ?4, ?5, ?6, ?7);";
    sqlite3_stmt *stmt = [self _dbPrepareStmt:sql];
    if (!stmt) return NO;
    
    ......
    if (fileName.length == 0) {
        sqlite3_bind_blob(stmt, 4, value.bytes, (int)value.length, 0);
    } else {
        sqlite3_bind_blob(stmt, 4, NULL, 0, 0);
    }
    ....
}
複製程式碼

若存在 filename ,雖然不會寫入資料庫,但是會直接寫入 /data 資料夾,這個邏輯是在本類的 public 方法中做的。

##(4)檔案操作的封裝 主要是 NSFileManager 相關方法的基本使用,比較獨特的是,作者使用了一個“垃圾箱”,也就是磁碟檔案儲存結構中的 /trash 目錄。

可以看到兩個方法:

- (BOOL)_fileMoveAllToTrash {
    CFUUIDRef uuidRef = CFUUIDCreate(NULL);
    CFStringRef uuid = CFUUIDCreateString(NULL, uuidRef);
    CFRelease(uuidRef);
    NSString *tmpPath = [_trashPath stringByAppendingPathComponent:(__bridge NSString *)(uuid)];
    BOOL suc = [[NSFileManager defaultManager] moveItemAtPath:_dataPath toPath:tmpPath error:nil];
    if (suc) {
        suc = [[NSFileManager defaultManager] createDirectoryAtPath:_dataPath withIntermediateDirectories:YES attributes:nil error:NULL];
    }
    CFRelease(uuid);
    return suc;
}

- (void)_fileEmptyTrashInBackground {
    NSString *trashPath = _trashPath;
    dispatch_queue_t queue = _trashQueue;
    dispatch_async(queue, ^{
        NSFileManager *manager = [NSFileManager new];
        NSArray *directoryContents = [manager contentsOfDirectoryAtPath:trashPath error:NULL];
        for (NSString *path in directoryContents) {
            NSString *fullPath = [trashPath stringByAppendingPathComponent:path];
            [manager removeItemAtPath:fullPath error:NULL];
        }
    });
}
複製程式碼

上面個方法是將 /data 目錄下的檔案移動到 /trash 目錄下,下面個方法是將 /trash 目錄下的檔案在非同步執行緒清理掉。

**筆者的理解:**很容易想到,刪除檔案是一個比較耗時的操作,所以作者把它放到了一個專門的佇列處理。而刪除的檔案用一個專門的路徑 /trash 放置,避免了寫入資料和刪除資料之間發生衝突。試想,若刪除的邏輯和寫入的邏輯都是對 /data 目錄進行操作,而刪除邏輯比較耗時,那麼就會很容易出現誤刪等情況。

##(5)YYDiskCache 對 YYKVStorage 的二次封裝 對於 YYKVStorage 類的公有方法,筆者不做解析,就是對資料庫操作和寫檔案操作的一個結合封裝,很簡單一看便知。

作者不提倡直接使用非執行緒安全的 YYKVStorage 類,所以封裝了一個執行緒安全的 YYDiskCache 類便於大家使用。

所以,YYDiskCache 類中主要是做了一些操作磁碟快取的執行緒安全機制,是基於訊號量(dispatch_semaphore)來處理的,暴露的介面中類似 YYMemoryCache 類的一系列方法。

剩餘磁碟空間的限制

磁碟快取中,多了一個如下修剪方法:

- (void)_trimToFreeDiskSpace:(NSUInteger)targetFreeDiskSpace {
    if (targetFreeDiskSpace == 0) return;
    int64_t totalBytes = [_kv getItemsSize];
    if (totalBytes <= 0) return;
    int64_t diskFreeBytes = _YYDiskSpaceFree();
    if (diskFreeBytes < 0) return;
    int64_t needTrimBytes = targetFreeDiskSpace - diskFreeBytes;
    if (needTrimBytes <= 0) return;
    int64_t costLimit = totalBytes - needTrimBytes;
    if (costLimit < 0) costLimit = 0;
    [self _trimToCost:(int)costLimit];
}
複製程式碼

根據剩餘的磁碟空間的限制進行修剪,作者確實想得很周到。_YYDiskSpaceFree()是作者寫的一個 c 方法,用於獲取剩餘磁碟空間。

MD5 加密 key

- (NSString *)_filenameForKey:(NSString *)key {
    NSString *filename = nil;
    if (_customFileNameBlock) filename = _customFileNameBlock(key);
    if (!filename) filename = _YYNSStringMD5(key);
    return filename;
}
複製程式碼

filename 是作者根據使用者傳入的 key 做一次 MD5 加密所得的字串,所以不要誤以為檔名就是你傳入的 key (_YYNSStringMD5()是作者寫的一個加密方法)。當然,框架提供了一個 _customFileNameBlock 允許你自定義檔名。

同時提供同步和非同步介面

可以看到諸如此類的設計:

- (BOOL)containsObjectForKey:(NSString *)key {
    if (!key) return NO;
    Lock();
    BOOL contains = [_kv itemExistsForKey:key];
    Unlock();
    return contains;
}

- (void)containsObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key, BOOL contains))block {
    if (!block) return;
    __weak typeof(self) _self = self;
    dispatch_async(_queue, ^{
        __strong typeof(_self) self = _self;
        BOOL contains = [self containsObjectForKey:key];
        block(key, contains);
    });
}
複製程式碼

由於可能儲存的檔案過大,在讀寫時會佔用過多的資源,所以作者對於這些操作都分別提供了同步和非同步的介面,可謂非常人性化,這也是介面設計的一些值得學習的地方。

三、綜合封裝:YYCache

實際上上文的剖析已經囊括了 YYCache 框架的核心了。YYCache 類主要是對記憶體快取和磁碟快取的結合封裝,程式碼很簡單,有一點需要提出來:

- (void)objectForKey:(NSString *)key withBlock:(void (^)(NSString *key, id<NSCoding> object))block {
    if (!block) return;
    id<NSCoding> object = [_memoryCache objectForKey:key];
    if (object) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            block(key, object);
        });
    } else {
        [_diskCache objectForKey:key withBlock:^(NSString *key, id<NSCoding> object) {
            if (object && ![_memoryCache objectForKey:key]) {
                [_memoryCache setObject:object forKey:key];
            }
            block(key, object);
        }];
    }
}
複製程式碼

優先查詢記憶體快取_memoryCache中的資料,若查不到,就查詢磁碟快取_diskCache,查詢磁碟快取成功,將資料同步到記憶體快取中,方便下次查詢。

這麼做的理由很簡單:根據機械原理,較大的儲存裝置要比較小的儲存裝置執行得慢,而快速裝置的造價遠高於低速裝置。所以記憶體快取的讀寫速度遠高於磁碟快取。這也是開發中快取設計的核心問題,我們既要保證快取讀寫的效率,又要考慮到空間佔用,其實又回到了空間和時間的權衡問題了。

寫在後面

YYCache 核心邏輯思路、介面設計、程式碼組織架構、容錯處理、效能優化、記憶體管理、執行緒安全這些方面都做得很好很極致,閱讀起來非常舒服。

閱讀開源框架,第一步一定是通讀一下 API 瞭解該框架是幹什麼的,然後採用“分治”的思路逐個擊破,類比“歸併演算法”:先拆開再合併,切勿想一口吃成胖子,特別是對於某些“重量級”框架。

希望讀者朋友們閱讀過後有所收穫?。

參考文獻:作者 ibireme 的部落格 YYCache 設計思路

相關文章