前言
本篇文章將帶來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,基於最近最少使用的原則,屬於一種快取淘汰演算法。實現思路是維護一個雙向連結串列資料結構,每次有新資料要快取時,將快取資料包裝成一個節點,插入雙向連結串列的頭部,如果訪問連結串列中的快取資料,同樣將該資料對應的節點移動至連結串列的頭部。這樣的做法保證了被使用的資料(儲存/訪問)始終位於連結串列的前部。當快取的資料總量超出容量時,先刪除末尾的快取資料節點,因為末尾的資料最少被使用過。如下圖:
通過這種方式,就實現了類似陣列的功能,是原本無序的字典成了有序的集合。
如果有一列資料已經按順序排好了,我使用了中間的某個資料,那麼就要把這個資料插入到最開始的位置,這就是一條規則,越是最近使用的越靠前。
在設計上,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是資料庫檔案,需要建立並初始化,下面是路徑:
在真實的程式設計中,往往需要把資料封裝成一個物件:
呼叫_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機制。文中許多地方的分析和思路,表達的不是很準確和清楚,希望通過今後的學習和練習,提升自己的水平,總之路漫漫其修遠兮…