YYCache理解

想想還是算了發表於2019-03-04

前言

本篇文章將帶來YYCache的解讀,YYCache支援記憶體和本地兩種方式的資料儲存。我們先丟擲兩個問題:

  • YYCache是如何把資料寫入記憶體之中的?又是如何實現的高效讀取?
  • YYCache採用了何種方式把資料寫入磁碟?

先來看看YYCache整體框架設計圖

YYCache理解

**分析下這個框架 **
從上圖可以很清晰的看出這個框架的基礎框架為:
互動層-邏輯層-處理層。
無論是最上層的YYCache或下面兩個獨立體YYMemoryCache和YYDiskCache都是這樣設計的。邏輯清晰。 來看看YYMemoryCache,邏輯層是負責對交付層的一個轉接和對處理層釋出一些命令,比如增刪改查。而處理層則是對邏輯層釋出的命令進行具體對應的處理。元件層則是對處理層的一種補充,也是後設資料,擴充套件性很好。這兩個類我們都是可以單獨拿出來使用的。而多新增一層YYCache,則可以讓我們使用的更加方便,自動為我們同時進行記憶體快取和磁碟快取,讀取的時候獲得記憶體快取的高速讀取。
YYMemoryCache
我們使用YYMemoryCache可以把資料快取進記憶體之中,它內部會建立了一個YYMemoryCache物件,然後把資料儲存進這個物件之中。
但凡涉及到類似這樣的操作,程式碼都需要設計成執行緒安全的。所謂的執行緒安全就是指充分考慮多執行緒條件下的增刪改查操作。
要想高效的查詢資料,使用字典是一個很好的方法。字典的原理跟雜湊有關,總之就是把key直接對映成記憶體地址,然後處理衝突和和擴容的問題。對這方面有興趣的可以自行搜尋資料
YYMemoryCache內部封裝了一個物件_YYLinkedMap,包含了下邊這些屬性:

@interface _YYLinkedMap : NSObject { 
@package 
CFMutableDictionaryRef _dic; //雜湊字典,存放快取資料 
NSUInteger _totalCost; //快取總大小 
NSUInteger _totalCount; //快取節點總個數 
_YYLinkedMapNode *_head; //頭結點 
_YYLinkedMapNode *_tail; //尾結點 
BOOL _releaseOnMainThread; //在主執行緒釋放 
BOOL _releaseAsynchronously;//在非同步執行緒釋放 
} 
@end 
複製程式碼

_dic是雜湊字典,負責存放快取資料,_head和_tail分別是雙連結串列中指向頭節點和尾節點的指標,連結串列中的節點單元是_YYLinkedMapNode物件,該物件封裝了快取資料的資訊。

可以看出來,CFMutableDictionaryRef _dic將被用來儲存資料。這裡使用了CoreFoundation的字典,效能更好。字典裡邊儲存著的是_YYLinkedMapNode物件。

@interface _YYLinkedMapNode : NSObject { 
@package 
__unsafe_unretained _YYLinkedMapNode *_prev; //前向前一個節點的指標 
__unsafe_unretained _YYLinkedMapNode *_next; //指向下一個節點的指標 
id _key; //快取資料key 
id _value; //快取資料value 
NSUInteger _cost; //節點佔用大小 
NSTimeInterval _time; //節點操作時間戳 
} 
@end 
複製程式碼

上邊的程式碼,就能知道使用了連結串列的知識。但是有一個疑問,單用字典我們就能很快的查詢出資料,為什麼還要實現連結串列這一資料結構呢?
答案就是淘汰演算法,YYMemoryCache使用了LRU淘汰演算法,也就是當資料超過某個限制條件後,我們會從連結串列的尾部開始刪除資料,直到達到要求為止。
LRU
LRU全稱是Least recently used,基於最近最少使用的原則,屬於一種快取淘汰演算法。實現思路是維護一個雙向連結串列資料結構,每次有新資料要快取時,將快取資料包裝成一個節點,插入雙向連結串列的頭部,如果訪問連結串列中的快取資料,同樣將該資料對應的節點移動至連結串列的頭部。這樣的做法保證了被使用的資料(儲存/訪問)始終位於連結串列的前部。當快取的資料總量超出容量時,先刪除末尾的快取資料節點,因為末尾的資料最少被使用過。如下圖:

YYCache理解
通過這種方式,就實現了類似陣列的功能,是原本無序的字典成了有序的集合。
如果有一列資料已經按順序排好了,我使用了中間的某個資料,那麼就要把這個資料插入到最開始的位置,這就是一條規則,越是最近使用的越靠前。
在設計上,YYMemoryCache還提供了是否非同步釋放資料這一選項,在這裡就不提了,我們在來看看在YYMemoryCache中用到的鎖的知識。
pthread_mutex_lock是一種互斥所:

pthread_mutex_init(&_lock, NULL); // 初始化
pthread_mutex_lock(&_lock); // 加鎖
pthread_mutex_unlock(&_lock); // 解鎖
pthread_mutex_trylock(&_lock) == 0 // 是否加鎖,0:未鎖住,其他值:鎖住
複製程式碼

在OC中有很多種鎖可以用,pthread_mutex_lock就是其中的一種。YYMemoryCache有這樣一種設定,每隔一個固定的時間就要處理資料,及邊界檢測,程式碼如下:

邊界檢測

- (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];//遞迴呼叫本方法
    });
}
複製程式碼

上邊的程式碼中,每隔_autoTrimInterval時間就會在後臺嘗試處理資料,然後再次呼叫自身,這樣就實現了一個類似定時器的功能。這一個小技巧可以學習一下。

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

_trimInBackground分別呼叫_trimToCost、_trimToCount和_trimToAge方法檢測。
_trimToCost方法判斷連結串列中所有節點佔用大小之和totalCost是否大於costLimit,如果超過,則從連結串列末尾開始刪除節點,直到totalCost小於等於costLimit為止。程式碼註釋如下:

- (void)_trimToCost:(NSUInteger)costLimit {
    BOOL finish = NO;
    pthread_mutex_lock(&_lock);
    if (costLimit == 0) {
        [_lru removeAll];
        finish = YES;
    } else if (_lru->_totalCost <= costLimit) {
        finish = YES;
    }
    pthread_mutex_unlock(&_lock);
    if (finish) return;
    
    NSMutableArray *holder = [NSMutableArray new];
    while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            if (_lru->_totalCost > costLimit) {
                _YYLinkedMapNode *node = [_lru removeTailNode];//刪除末尾節點
                if (node) [holder addObject:node];
            } else {
                finish = YES; //totalCost<=costLimit,檢測完成 
            }
            pthread_mutex_unlock(&_lock);
        } else {
            usleep(10 * 1000); //10 ms
        }
    }
    if (holder.count) {
        dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
        dispatch_async(queue, ^{
            [holder count]; // release in queue 釋放了資源
        });
    }
}
複製程式碼

其中每個節點的cost是人為指定的,預設是0,且costLimit預設是NSUIntegerMax,所以在預設情況下,_trimToCost方法不會刪除末尾的節點。

_trimToCount方法判斷連結串列中的所有節點個數之和是否大於countLimit,如果超過,則從連結串列末尾開始刪除節點,直到個數之和小於等於countLimit為止。程式碼註釋如下:

- (void)_trimToCount:(NSUInteger)countLimit { 
    BOOL finish = NO; 
    ... 
    NSMutableArray *holder = [NSMutableArray new]; 
    while (!finish) { 
        if (pthread_mutex_trylock(&;_lock) == 0) { 
            if (_lru->_totalCount > countLimit) { 
                _YYLinkedMapNode *node = [_lru removeTailNode]; //刪除末尾節點 
                if (node) [holder addObject:node]; 
            } else { 
                finish = YES; //totalCount<=countLimit,檢測完成 
            } 
            pthread_mutex_unlock(&;_lock); 
        } else { 
            usleep(10 * 1000); //10 ms等待一小段時間
        } 
    } 
... 
} 
複製程式碼

初始化時countLimit預設是NSUIntegerMax,如果不指定countLimit,節點的總個數永遠不會超過這個限制,所以_trimToCount方法不會刪除末尾節點。

_trimToAge方法遍歷連結串列中的節點,刪除那些和now時刻的時間間隔大於ageLimit的節點,程式碼如下:

- (void)_trimToAge:(NSTimeInterval)ageLimit { 
    BOOL finish = NO; 
    ... 
    NSMutableArray *holder = [NSMutableArray new]; 
    while (!finish) { 
        if (pthread_mutex_trylock(&;_lock) == 0) { 
            if (_lru->_tail &;&; (now - _lru->_tail->_time) > ageLimit) { //間隔大於ageLimit 
                _YYLinkedMapNode *node = [_lru removeTailNode]; //刪除末尾節點 
                if (node) [holder addObject:node]; 
            } else { 
                finish = YES; 
            } 
            pthread_mutex_unlock(&;_lock); 
        } else { 
            usleep(10 * 1000); //10 ms 
        } 
    } 
... 
} 
複製程式碼

由於連結串列中從頭部至尾部的節點,訪問時間由晚至早,所以尾部節點和now時刻的時間間隔較大,從尾節點開始刪除。ageLimit的預設值是DBL_MAX,如果不人為指定ageLimit,則連結串列中節點不會被刪除。

當某個變數在出了自己的作用域之後,正常情況下就會被自動釋放。

儲存資料
呼叫setObject: forKey:方法儲存快取資料,程式碼如下:

- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost { 
    if (!key) return; 
    if (!object) { 
        [self removeObjectForKey:key]; 
        return; 
    } 
    pthread_mutex_lock(&;_lock); //上鎖 
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); //從字典中取節點 
    NSTimeInterval now = CACurrentMediaTime(); 
    if (node) { //如果能取到,說明連結串列中之前存在key對應的快取資料 
        //更新totalCost 
        _lru->_totalCost -= node->_cost; 
        _lru->_totalCost += cost; 
        node->_cost = cost; 
        node->_time = now; //更新節點的訪問時間 
        node->_value = object; //更新節點中存放的快取資料 
        [_lru bringNodeToHead:node]; //將節點移至連結串列頭部 
    } else { //如果不能取到,說明連結串列中之前不存在key對應的快取資料 
        node = [_YYLinkedMapNode new]; //建立新的節點 
        node->_cost = cost; 
        node->_time = now; //設定節點的時間 
        node->_key = key; //設定節點的key 
        node->_value = object; //設定節點中存放的快取資料 
        [_lru insertNodeAtHead:node]; //將新的節點加入連結串列頭部 
    } 
    if (_lru->_totalCost > _costLimit) { 
        dispatch_async(_queue, ^{ 
            [self trimToCost:_costLimit]; 
        }); 
    } 
    if (_lru->_totalCount > _countLimit) { 
        _YYLinkedMapNode *node = [_lru removeTailNode]; 
    ... 
    } 
    pthread_mutex_unlock(&;_lock); //解鎖 
} 
複製程式碼

首先判斷key和object是否為空,object如果為空,刪除快取中key對應的資料。然後從字典中查詢key對應的快取資料,分為兩種情況,如果訪問到節點,說明快取資料存在,則根據最近最少使用原則,將本次操作的節點移動至連結串列的頭部,同時更新節點的訪問時間。如果訪問不到節點,說明是第一次新增key和資料,需要建立一個新的節點,把節點存入字典中,並且加入連結串列頭部。cost是指定的,預設是0。

訪問資料
呼叫objectForKey:方法訪問快取資料,程式碼註釋如下:

- (id)objectForKey:(id)key { 
    if (!key) return nil; 
    pthread_mutex_lock(&;_lock); 
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); //從字典中讀取key相應的節點 
    if (node) { 
        node->_time = CACurrentMediaTime(); //更新節點訪問時間 
        [_lru bringNodeToHead:node]; //將節點移動至連結串列頭部 
    } 
    pthread_mutex_unlock(&;_lock); 
    return node ? node->_value : nil; 
} 
複製程式碼

該方法從字典中獲取快取資料,如果key對應的資料存在,則更新訪問時間,根據最近最少使用原則,將本次操作的節點移動至連結串列的頭部。如果不存在,則直接返回nil。
執行緒同步

- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost { 
    pthread_mutex_lock(&;_lock); 
    //操作連結串列,寫快取資料 
    pthread_mutex_unlock(&;_lock); 
} 
- (id)objectForKey:(id)key { 
    pthread_mutex_lock(&;_lock); 
    //訪問快取資料 
    pthread_mutex_unlock(&;_lock); 
} 
複製程式碼

如果存線上程A和B,執行緒A在寫快取的時候,上鎖,執行緒B讀取快取資料時,被阻塞,需要等到執行緒A執行完寫快取的操作,呼叫pthread_mutex_unlock後,執行緒B才能讀快取資料,這個時候新的快取資料已經寫完,保證了操作的資料的同步。

總結
YYMemoryCache操作了記憶體快取,相較於硬碟快取需要進行I/O操作,在效能上快很多,因此YYCache訪問快取時,優先用的是YYMemoryCache。
YYMemoryCache實際上就是建立了一個物件例項,該物件內部使用字典和雙向連結串列實現

YYDiskCache

YYDiskCache通過檔案和SQLite資料庫兩種方式儲存快取資料.YYKVStorage核心功能類,實現了檔案讀寫和資料庫讀寫的功能。YYKVStorageYYKVStorage定義了讀寫快取資料的三種列舉型別,即typedefNS_ENUM(NSUInteger,YYKVStorageType)

YYKVStorage
YYKVStorage最核心的思想是KV這兩個字母,表示key-value的意思,目的是讓使用者像使用字典一樣運算元據
YYKVStorage讓我們只關心3件事:

  • 資料儲存的路徑
  • 儲存資料,併為該資料關聯一個key
  • 根據key取出資料或刪除資料

同理,YYKVStorage在設計介面的時候,也從這3個方面進行了考慮。這資料功能設計層面的思想。
YYKVStorage定義了讀寫快取資料的三種列舉型別,即

typedef NS_ENUM(NSUInteger, YYKVStorageType) { 
//檔案讀取 
YYKVStorageTypeFile = 0, 
//資料庫讀寫 
YYKVStorageTypeSQLite = 1, 
//根據策略決定使用檔案還是資料庫讀寫資料 
YYKVStorageTypeMixed = 2, 
}; 
複製程式碼

初始化
呼叫initWithPath: type:方法進行初始化,指定了儲存方式,建立了快取資料夾和SQLite資料庫用於存放快取,開啟並初始化資料庫。下面是部分程式碼註釋:

- (instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type { 
    ... 
    self = [super init]; 
    _path = path.copy; 
    _type = type; //指定儲存方式,是資料庫還是檔案儲存 
    _dataPath = [path stringByAppendingPathComponent:kDataDirectoryName]; //快取資料的檔案路徑 
    _trashPath = [path stringByAppendingPathComponent:kTrashDirectoryName]; //存放垃圾快取資料的檔案路徑 
    _trashQueue = dispatch_queue_create("com.ibireme.cache.disk.trash", DISPATCH_QUEUE_SERIAL); 
    _dbPath = [path stringByAppendingPathComponent:kDBFileName]; //資料庫路徑 
    _errorLogsEnabled = YES; 
    NSError *error = nil; 
    //建立快取資料的資料夾和垃圾快取資料的資料夾 
    if (![[NSFileManager defaultManager] createDirectoryAtPath:path 
    withIntermediateDirectories:YES 
    attributes:nil 
    error:&;error] || 
    ![[NSFileManager defaultManager] createDirectoryAtPath:[path stringByAppendingPathComponent:kDataDirectoryName] 
    withIntermediateDirectories:YES 
    attributes:nil 
    error:&;error] || 
    ![[NSFileManager defaultManager] createDirectoryAtPath:[path stringByAppendingPathComponent:kTrashDirectoryName] 
    withIntermediateDirectories:YES 
    attributes:nil 
    error:&;error]) { 
        NSLog(@"YYKVStorage init error:%@", error); 
        return nil; 
    } 
    //建立並開啟資料庫、在資料庫中建表 
    //_dbOpen方法建立和開啟資料庫manifest.sqlite
    //呼叫_dbInitialize方法建立資料庫中的表
    if (![self _dbOpen] || ![self _dbInitialize]) { 
        // db file may broken... 
        [self _dbClose]; 
        [self _reset]; // rebuild 
        if (![self _dbOpen] || ![self _dbInitialize]) { 
            [self _dbClose]; 
            NSLog(@"YYKVStorage init error: fail to open sqlite db."); 
            return nil; 
        } 
    } 
    //呼叫_fileEmptyTrashInBackground方法將trash目錄中的快取資料刪除
    [self _fileEmptyTrashInBackground]; // empty the trash if failed at last time 
    return self; 
} 
複製程式碼

_dbInitialize方法呼叫sql語句在資料庫中建立一張表,程式碼如下:

- (BOOL)_dbInitialize { 
    NSString *sql = @"pragma journal_mode = wal; pragma synchronous = normal; 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);"; 
    return [self _dbExecute:sql]; 
} 
複製程式碼

"pragma journal_mode = wal"表示使用WAL模式進行資料庫操作,如果不指定,預設DELETE模式,是"journal_mode=DELETE"。使用WAL模式時,改寫運算元據庫的操作會先寫入WAL檔案,而暫時不改動資料庫檔案,當執行checkPoint方法時,WAL檔案的內容被批量寫入資料庫。checkPoint操作會自動執行,也可以改為手動。WAL模式的優點是支援讀寫併發,效能更高,但是當wal檔案很大時,需要呼叫checkPoint方法清空wal檔案中的內容
dataPath和trashPath用於檔案的方式讀寫快取資料,當dataPath中的部分快取資料需要被清除時,先將其移至trashPath中,然後統一清空trashPath中的資料,類似回收站的思路。_dbPath是資料庫檔案,需要建立並初始化,下面是路徑: 在真實的程式設計中,往往需要把資料封裝成一個物件:

YYCache理解

呼叫_dbOpen方法建立和開啟資料庫manifest.sqlite,呼叫_dbInitialize方法建立資料庫中的表。呼叫_fileEmptyTrashInBackground方法將trash目錄中的快取資料刪除

/**
 YYKVStorageItem is used by `YYKVStorage` to store key-value pair and meta data.
 Typically, you should not use this class directly.
 */
@interface YYKVStorageItem : NSObject
@property (nonatomic, strong) NSString *key;                 //快取資料的key 
@property (nonatomic, strong) NSData *value;                 //快取資料的value 
@property (nullable, nonatomic, strong) NSString *filename;  //快取檔名(檔案快取時有用) 
@property (nonatomic) int size;                              //資料大小 
@property (nonatomic) int modTime;                           //資料修改時間(用於更新相同key的快取) 
@property (nonatomic) int accessTime;                        //資料訪問時間 
@property (nullable, nonatomic, strong) NSData *extendedData; //附加資料 
@end
複製程式碼

快取資料是按一條記錄的格式存入資料庫的,這條SQL記錄包含的欄位如下:
key(鍵)、fileName(檔名)、size(大小)、inline_data(value/二進位制資料)、modification_time(修改時間)、last_access_time(最後訪問時間)、extended_data(附加資料)
**寫入快取資料 **

- (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(NSString *)filename extendedData:(NSData *)extendedData { 
    if (key.length == 0 || value.length == 0) return NO; 
    if (_type == YYKVStorageTypeFile && filename.length == 0) { 
        return NO; 
    } 
    //如果有檔名,說明需要寫入檔案中 
    if (filename.length) { 
        if (![self _fileWriteWithName:filename data:value]) { //寫資料進檔案 
            return NO; 
        } 
        //寫檔案進資料庫 
        if (![self _dbSaveWithKey:key value:value fileName:filename extendedData:extendedData]) { 
            [self _fileDeleteWithName:filename]; //寫失敗,同時刪除檔案中的資料 
            return NO; 
        } 
        return YES; 
    } else { 
        if (_type != YYKVStorageTypeSQLite) { 
            NSString *filename = [self _dbGetFilenameWithKey:key]; //從檔案中刪除快取 
            if (filename) { 
                [self _fileDeleteWithName:filename]; 
            } 
        } 
        //寫入資料庫 
        return [self _dbSaveWithKey:key value:value fileName:nil extendedData:extendedData]; 
    } 
} 
複製程式碼

該方法首先判斷fileName即檔名是否為空,如果存在,則呼叫_fileWriteWithName方法將快取的資料寫入檔案系統中,同時將資料寫入資料庫,需要注意的是,呼叫_dbSaveWithKey:value:fileName:extendedData:方法會建立一條SQL記錄寫入表中
程式碼註釋如下:

- (BOOL)_dbSaveWithKey:(NSString *)key value:(NSData *)value fileName:(NSString *)fileName extendedData:(NSData *)extendedData { 
    //構建sql語句,將一條記錄新增進manifest表 
    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]; //準備sql語句,返回stmt指標 
    if (!stmt) return NO; 
    int timestamp = (int)time(NULL); 
    sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL); //繫結引數值對應"?1" 
    sqlite3_bind_text(stmt, 2, fileName.UTF8String, -1, NULL); //繫結引數值對應"?2" 
    sqlite3_bind_int(stmt, 3, (int)value.length); 
    if (fileName.length == 0) { //如果fileName不存在,繫結引數值value.bytes對應"?4" 
        sqlite3_bind_blob(stmt, 4, value.bytes, (int)value.length, 0); 
    } else { //如果fileName存在,不繫結,"?4"對應的引數值為null 
        sqlite3_bind_blob(stmt, 4, NULL, 0, 0); 
    } 
    sqlite3_bind_int(stmt, 5, timestamp); //繫結引數值對應"?5" 
    sqlite3_bind_int(stmt, 6, timestamp); //繫結引數值對應"?6" 
    sqlite3_bind_blob(stmt, 7, extendedData.bytes, (int)extendedData.length, 0); //繫結引數值對應"?7" 
    int result = sqlite3_step(stmt); //開始執行sql語句 
    if (result != SQLITE_DONE) { 
        if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite insert error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db)); 
        return NO; 
    } 
    return YES; 
} 
複製程式碼

該方法首先建立sql語句,value括號中的引數"?"表示引數需要通過變數繫結,"?"後面的數字表示繫結變數對應的索引號,如果VALUES (?1, ?1, ?2),則可以用同一個值繫結多個變數

然後呼叫_dbPrepareStmt方法構建資料位置指標stmt,標記查詢到的資料位置,sqlite3_prepare_v2()方法進行資料庫操作的準備工作,第一個引數為成功開啟的資料庫指標db,第二個引數為要執行的sql語句,第三個引數為stmt指標的地址,這個方法也會返回一個int值,作為標記狀態是否成功

接著呼叫sqlite3_bind_text()方法將實際值作為變數繫結sql中的"?"引數,序號對應"?"後面對應的數字。不同型別的變數呼叫不同的方法,例如二進位制資料是sqlite3_bind_blob方法

同時判斷如果fileName存在,則生成的sql語句只繫結資料的相關描述,不繫結inline_data,即實際儲存的二進位制資料,因為該快取之前已經將二進位制資料寫進檔案。這樣做可以防止快取資料同時寫入檔案和資料庫,造成快取空間的浪費。如果fileName不存在,則只寫入資料庫中,這時sql語句繫結inline_data,不繫結fileName

最後執行sqlite3_step方法執行sql語句,對stmt指標進行移動,並返回一個int值。

刪除快取資料

removeItemForKey:方法
該方法刪除指定key對應的快取資料,區分type,如果是YYKVStorageTypeSQLite,呼叫_dbDeleteItemWithKey:從資料庫中刪除對應key的快取記錄,如下:

- (BOOL)_dbDeleteItemWithKey:(NSString *)key { 
    NSString *sql = @"delete from manifest where key = ?1;"; //sql語句 
    sqlite3_stmt *stmt = [self _dbPrepareStmt:sql]; //準備stmt 
    if (!stmt) return NO; 
    sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL); //繫結引數 
    int result = sqlite3_step(stmt); //執行sql語句 
    ... 
    return YES; 
} 
複製程式碼

如果是YYKVStorageTypeFile或者YYKVStorageTypeMixed,說明可能快取資料之前可能被寫入檔案中,判斷方法是呼叫_dbGetFilenameWithKey:方法從資料庫中查詢key對應的SQL記錄的fileName欄位。該方法的流程和上面的方法差不多,只是sql語句換成了select查詢語句。如果查詢到fileName,說明資料之前寫入過檔案中,呼叫_fileDeleteWithName方法刪除資料,同時刪除資料庫中的記錄。否則只從資料庫中刪除SQL記錄

removeItemForKeys:方法

該方法和上一個方法類似,刪除一組key對應的快取資料,同樣區分type,對於YYKVStorageTypeSQLite,呼叫_dbDeleteItemWithKeys:方法指定sql語句刪除一組記錄,如下:

- (BOOL)_dbDeleteItemWithKeys:(NSArray *)keys { 
    if (![self _dbCheck]) return NO; 
    //構建sql語句 
    NSString *sql = [NSString stringWithFormat:@"delete from manifest where key in (%@);", [self _dbJoinedKeys:keys]]; 
    sqlite3_stmt *stmt = NULL; 
    int result = sqlite3_prepare_v2(_db, sql.UTF8String, -1, &;stmt, NULL); 
    ... 
    //繫結變數 
    [self _dbBindJoinedKeys:keys stmt:stmt fromIndex:1]; 
    result = sqlite3_step(stmt); //執行引數 
    sqlite3_finalize(stmt); //對stmt指標進行關閉 
    ... 
    return YES; 
}
複製程式碼

其中_dbJoinedKeys:方法是拼裝,?,?,?格式,_dbBindJoinedKeys:stmt:fromIndex:方法繫結變數和引數,如果?後面沒有引數,則sqlite3_bind_text方法的第二個引數,索引值依次對應sql後面的"?"

如果是YYKVStorageTypeFile或者YYKVStorageTypeMixed,通過_dbGetFilenameWithKeys:方法返回一組fileName,根據每一個fileName刪除檔案中的快取資料,同時刪除資料庫中的記錄,否則只從資料庫中刪除SQL記錄

removeItemsLargerThanSize:方法刪除那些size大於指定size的快取資料。同樣是區分type,刪除的邏輯也和上面的方法一致。_dbDeleteItemsWithSizeLargerThan方法除了sql語句不同,運算元據庫的步驟相同。_dbCheckpoint方法呼叫sqlite3_wal_checkpoint方法進行checkpoint操作,將資料同步到資料庫中

讀取快取資料

getItemValueForKey:方法

該方法通過key訪問快取資料value,區分type,如果是YYKVStorageTypeSQLite,呼叫_dbGetValueWithKey:方法從資料庫中查詢key對應的記錄中的inline_data。如果是YYKVStorageTypeFile,首先呼叫_dbGetFilenameWithKey:方法從資料庫中查詢key對應的記錄中的filename,根據filename從檔案中刪除對應快取資料。如果是YYKVStorageTypeMixed,同樣先獲取filename,根據filename是否存在選擇用相應的方式訪問。程式碼註釋如下:

- (NSData *)getItemValueForKey:(NSString *)key { 
    if (key.length == 0) return nil; 
    NSData *value = nil; 
    switch (_type) { 
        case YYKVStorageTypeFile: 
        { 
            NSString *filename = [self _dbGetFilenameWithKey:key]; //從資料庫中查詢filename 
            if (filename) { 
                value = [self _fileReadWithName:filename]; //根據filename讀取資料 
                if (!value) { 
                    [self _dbDeleteItemWithKey:key]; //如果沒有讀取到快取資料,從資料庫中刪除記錄,保持資料同步 
                    value = nil; 
                } 
            } 
        } 
        break; 
        case YYKVStorageTypeSQLite: 
        { 
            value = [self _dbGetValueWithKey:key]; //直接從資料中取inline_data 
        } 
        break; 
        case YYKVStorageTypeMixed: { 
            NSString *filename = [self _dbGetFilenameWithKey:key]; //從資料庫中查詢filename 
            if (filename) { 
                value = [self _fileReadWithName:filename]; //根據filename讀取資料 
                if (!value) { 
                    [self _dbDeleteItemWithKey:key]; //保持資料同步 
                    value = nil; 
                } 
            } else { 
                value = [self _dbGetValueWithKey:key]; //直接從資料中取inline_data 
            } 
        } 
        break; 
    } 
    if (value) { 
        [self _dbUpdateAccessTimeWithKey:key]; //更新訪問時間 
    } 
    return value; 
} 
複製程式碼

呼叫方法用於更新該資料的訪問時間,即sql記錄中的last_access_time欄位。

getItemForKey:方法
該方法通過key訪問資料,返回YYKVStorageItem封裝的快取資料。首先呼叫_dbGetItemWithKey:excludeInlineData:從資料庫中查詢,下面是程式碼註釋:

- (YYKVStorageItem *)_dbGetItemWithKey:(NSString *)key excludeInlineData:(BOOL)excludeInlineData { 
    //查詢sql語句,是否排除inline_data 
    NSString *sql = excludeInlineData ? @"select key, filename, size, modification_time, last_access_time, extended_data from manifest where key = ?1;" : @"select key, filename, size, inline_data, modification_time, last_access_time, extended_data from manifest where key = ?1;"; 
    sqlite3_stmt *stmt = [self _dbPrepareStmt:sql]; //準備工作,構建stmt 
    if (!stmt) return nil; 
    sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL); //繫結引數 
    YYKVStorageItem *item = nil; 
    int result = sqlite3_step(stmt); //執行sql語句 
    if (result == SQLITE_ROW) { 
        item = [self _dbGetItemFromStmt:stmt excludeInlineData:excludeInlineData]; //取出查詢記錄中的各個欄位,用YYKVStorageItem封裝並返回 
    } else { 
        if (result != SQLITE_DONE) { 
            if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite query error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db)); 
        } 
    } 
    return item; 
} 
複製程式碼

sql語句是查詢符合key值的記錄中的各個欄位,例如快取的key、大小、二進位制資料、訪問時間等資訊, excludeInlineData表示查詢資料時,是否要排除inline_data欄位,即是否查詢二進位制資料,執行sql語句後,通過stmt指標和_dbGetItemFromStmt:excludeInlineData:方法取出各個欄位,並建立YYKVStorageItem物件,將記錄的各個欄位賦值給各個屬性,程式碼註釋如下:

- (YYKVStorageItem *)_dbGetItemFromStmt:(sqlite3_stmt *)stmt excludeInlineData:(BOOL)excludeInlineData { 
    int i = 0; 
    char *key = (char *)sqlite3_column_text(stmt, i++); //key 
    char *filename = (char *)sqlite3_column_text(stmt, i++); //filename 
    int size = sqlite3_column_int(stmt, i++); //資料大小 
    const void *inline_data = excludeInlineData ? NULL : sqlite3_column_blob(stmt, i); //二進位制資料 
    int inline_data_bytes = excludeInlineData ? 0 : sqlite3_column_bytes(stmt, i++); 
    int modification_time = sqlite3_column_int(stmt, i++); //修改時間 
    int last_access_time = sqlite3_column_int(stmt, i++); //訪問時間 
    const void *extended_data = sqlite3_column_blob(stmt, i); //附加資料 
    int extended_data_bytes = sqlite3_column_bytes(stmt, i++); 
    //用YYKVStorageItem物件封裝 
    YYKVStorageItem *item = [YYKVStorageItem new]; 
    if (key) item.key = [NSString stringWithUTF8String:key]; 
    if (filename && *filename != 0) item.filename = [NSString stringWithUTF8String:filename]; 
    item.size = size; 
    if (inline_data_bytes > 0 &;&; inline_data) item.value = [NSData dataWithBytes:inline_data length:inline_data_bytes]; 
    item.modTime = modification_time; 
    item.accessTime = last_access_time; 
    if (extended_data_bytes > 0 &;&; extended_data) item.extendedData = [NSData dataWithBytes:extended_data length:extended_data_bytes]; 
    return item; //返回YYKVStorageItem物件 
} 
複製程式碼

最後取出YYKVStorageItem物件後,判斷filename屬性是否存在,如果存在說明快取的二進位制資料寫進了檔案中,此時返回的YYKVStorageItem物件的value屬性是nil,需要呼叫_fileReadWithName:方法從檔案中讀取資料,並賦值給YYKVStorageItem的value屬性。程式碼註釋如下:

- (YYKVStorageItem *)getItemForKey:(NSString *)key { 
    if (key.length == 0) return nil; 
    //從資料庫中查詢記錄,返回YYKVStorageItem物件,封裝了快取資料的資訊 
    YYKVStorageItem *item = [self _dbGetItemWithKey:key excludeInlineData:NO]; 
    if (item) { 
        [self _dbUpdateAccessTimeWithKey:key]; //更新訪問時間 
        if (item.filename) { //filename存在,按照item.value從檔案中讀取 
            item.value = [self _fileReadWithName:item.filename]; 
        ... 
        } 
    } 
    return item; 
} 
複製程式碼

getItemForKeys:方法

返回一組YYKVStorageItem物件資訊,呼叫_dbGetItemWithKeys:excludeInlineData:方法獲取一組YYKVStorageItem物件。訪問邏輯和getItemForKey:方法類似,sql語句的查詢條件改為多個key匹配。

getItemValueForKeys:方法

返回一組快取資料,呼叫getItemForKeys:方法獲取一組YYKVStorageItem物件後,取出其中的value,存入一個臨時字典物件後返回。

YYDiskCache
YYDiskCache是上層呼叫YYKVStorage的類,對外提供了存、刪、查、邊界控制的方法。內部維護了三個變數,如下:

@implementation YYDiskCache { 
    YYKVStorage *_kv; 
    dispatch_semaphore_t _lock; 
    dispatch_queue_t _queue; 
} 
複製程式碼

_kv用於快取資料,_lock是訊號量變數,用於多執行緒訪問資料時的同步操作。

初始化方法
nitWithPath:inlineThreshold:方法用於初始化,下面是程式碼註釋:

- (instancetype)initWithPath:(NSString *)path 
inlineThreshold:(NSUInteger)threshold { 
    ... 
    YYKVStorageType type; 
    if (threshold == 0) { 
        type = YYKVStorageTypeFile; 
    } else if (threshold == NSUIntegerMax) { 
        type = YYKVStorageTypeSQLite; 
    } else { 
        type = YYKVStorageTypeMixed; 
    } 
    YYKVStorage *kv = [[YYKVStorage alloc] initWithPath:path type:type]; 
    if (!kv) return nil; 
    _kv = kv; 
    _path = path; 
    _lock = dispatch_semaphore_create(1); 
    _queue = dispatch_queue_create("com.ibireme.cache.disk", DISPATCH_QUEUE_CONCURRENT); 
    _inlineThreshold = threshold; 
    _countLimit = NSUIntegerMax; 
    _costLimit = NSUIntegerMax; 
    _ageLimit = DBL_MAX; 
    _freeDiskSpaceLimit = 0; 
    _autoTrimInterval = 60; 
    [self _trimRecursively]; 
    ... 
    return self; 
}
複製程式碼

根據threshold引數決定快取的type,預設threshold是20KB,會選擇YYKVStorageTypeMixed方式,即根據快取資料的size進一步決定。然後初始化YYKVStorage物件,訊號量、各種limit引數。

寫快取
setObject:forKey:方法儲存資料,首先判斷type,如果是YYKVStorageTypeSQLite,則直接將資料存入資料庫中,filename傳nil,如果是YYKVStorageTypeFile或者YYKVStorageTypeMixed,則判斷要儲存的資料的大小,如果超過threshold(預設20KB),則需要將資料寫入檔案,並通過key生成filename。YYCache的作者認為當資料代銷超過20KB時,寫入檔案速度更快。程式碼註釋如下:

- (void)setObject:(id<NSCoding>)object forKey:(NSString *)key { 
... 
    value = [NSKeyedArchiver archivedDataWithRootObject:object]; //序列化 
    ... 
    NSString *filename = nil; 
    if (_kv.type != YYKVStorageTypeSQLite) { 
        if (value.length > _inlineThreshold) { //value大於閾值,用檔案方式儲存value 
            filename = [self _filenameForKey:key]; 
        } 
    } 
    Lock(); 
    [_kv saveItemWithKey:key value:value filename:filename extendedData:extendedData]; //filename存在,資料庫中不寫入value,即inline_data欄位為空 
    Unlock(); 
} 
//讀快取 
objectForKey:方法呼叫YYKVStorage物件的getItemForKey:方法讀取資料,返回YYKVStorageItem物件,取出value屬性,進行反序列化。
//刪除快取 
removeObjectForKey:方法呼叫YYKVStorage物件的removeItemForKey:方法刪除快取資料
複製程式碼

邊界控制

在前一篇文章中,YYMemoryCache實現了記憶體快取的LRU演算法,YYDiskCache也試了LRU演算法,在初始化的時候呼叫_trimRecursively方法每個一定時間檢測一下快取資料大小是否超過容量。

資料同步

YYMemoryCache使用了互斥鎖來實現多執行緒訪問資料的同步性,YYDiskCache使用了訊號量來實現,下面是兩個巨集:

#define Lock() dispatch_semaphore_wait(self->_lock, DISPATCH_TIME_FOREVER) 
#define Unlock() dispatch_semaphore_signal(self->_lock) 
複製程式碼

讀寫快取資料的方法中都呼叫了巨集:

- (void)setObject:(id<NSCoding>)object forKey:(NSString *)key 
{ 
... 
    Lock(); 
    [_kv saveItemWithKey:key value:value filename:filename extendedData:extendedData]; 
    Unlock(); 
} 

- (id<NSCoding>)objectForKey:(NSString *)key { 
    Lock(); 
    YYKVStorageItem *item = [_kv getItemForKey:key]; 
    Unlock(); 
    ... 
} 
複製程式碼

初始化方法建立訊號量,dispatch_semaphore_create(1),值是1。當執行緒呼叫寫快取的方法時,呼叫dispatch_semaphore_wait方法使訊號量-1。同時執行緒B在讀快取時,由於訊號量為0,遇到dispatch_semaphore_wait方法時會被阻塞。直到執行緒A寫完資料時,呼叫dispatch_semaphore_signal方法時,訊號量+1,執行緒B繼續執行,讀取資料。關於iOS中各種互斥鎖效能的對比。
我們看一些介面設計方面的內容:

#pragma mark - Attribute
///=============================================================================
/// @name Attribute
///=============================================================================

@property (nonatomic, readonly) NSString *path;        ///< The path of this storage.
@property (nonatomic, readonly) YYKVStorageType type;  ///< The type of this storage.
@property (nonatomic) BOOL errorLogsEnabled;           ///< Set `YES` to enable error logs for debug.

#pragma mark - Initializer
///=============================================================================
/// @name Initializer
///=============================================================================
- (instancetype)init UNAVAILABLE_ATTRIBUTE;
+ (instancetype)new UNAVAILABLE_ATTRIBUTE;

/**
 The designated initializer. 
 
 @param path  Full path of a directory in which the storage will write data. If
    the directory is not exists, it will try to create one, otherwise it will 
    read the data in this directory.
 @param type  The storage type. After first initialized you should not change the 
    type of the specified path.
 @return  A new storage object, or nil if an error occurs.
 @warning Multiple instances with the same path will make the storage unstable.
 */
- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;
複製程式碼

介面中的屬性都是很重要的資訊,我們應該儘量利用好它的讀寫屬性,儘量設計成只讀屬性。預設情況下,不是隻讀的,都很容易讓其他開發者認為,該屬性是可以設定的。

對於初始化方法而言,如果某個類需要提供一個指定的初始化方法,那麼就要使用NS_DESIGNATED_INITIALIZER給予提示。同時使用UNAVAILABLE_ATTRIBUTE禁用掉預設的方法。接下來要重寫禁用的初始化方法,在其內部丟擲異常:

- (instancetype)init {
    @throw [NSException exceptionWithName:@"YYKVStorage init error" reason:@"Please use the designated initializer and pass the 'path' and 'type'." userInfo:nil];
    return [self initWithPath:@"" type:YYKVStorageTypeFile];
}
複製程式碼

上邊的程式碼大家可以直接拿來用,千萬不要怕程式丟擲異常,在釋出之前,能夠發現潛在的問題是一件好事。使用了上邊的一個小技巧後呢,編碼水平是不是有所提升?
再給大家簡單分析分析下邊一樣程式碼:

- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;
複製程式碼

上邊我們關心的是nullable關鍵字,表示可能為空,與之對應的是nonnull,表示不為空。可以說,他們都跟swift有關係,swift中屬性或引數是否為空都有嚴格的要求。因此我們在設計屬性,引數,返回值等等的時候,要考慮這些可能為空的情況。

// 設定中間的內容預設都是nonnull
NS_ASSUME_NONNULL_BEGIN
NS_ASSUME_NONNULL_END
複製程式碼

總結
YYCache庫的分析到此為止,其中有許多程式碼值得學習。例如二級快取的思想,LRU的實現,SQLite的WAL機制。文中許多地方的分析和思路,表達的不是很準確和清楚,希望通過今後的學習和練習,提升自己的水平,總之路漫漫其修遠兮...