YYCache 原始碼解析

J_Knight_發表於1970-01-01

YYCache是國內開發者ibireme開源的一個執行緒安全的高效能快取元件,程式碼風格簡潔清晰,在GitHub上已經有了1600+顆星。

閱讀它的原始碼有助於建立比較完整的快取設計的思路,同時也能鞏固一下雙向連結串列,執行緒鎖,資料庫操作相關的知識。如果你還沒有看過YYCache的原始碼,那麼恭喜你,閱讀此文會對理解YYCache的原始碼有比較大的幫助。

在正式開始講解原始碼之前,先簡單看一下該框架的使用方法。

基本使用方法

舉一個快取使用者姓名的例子來看一下YYCache的幾個API:

    //需要快取的物件
    NSString *userName = @"Jack";
   
   //需要快取的物件在快取裡對應的鍵
    NSString *key = @"user_name";
    
    //建立一個YYCache例項:userInfoCache
    YYCache *userInfoCache = [YYCache cacheWithName:@"userInfo"];
    
    //存入鍵值對
    [userInfoCache setObject:userName forKey:key withBlock:^{
        NSLog(@"caching object succeed");
    }];
    
    //判斷快取是否存在
    [userInfoCache containsObjectForKey:key withBlock:^(NSString * _Nonnull key, BOOL contains) {
        if (contains){
            NSLog(@"object exists");
        }
    }];

    //根據key讀取資料
    [userInfoCache objectForKey:key withBlock:^(NSString * _Nonnull key, id<NSCoding>  _Nonnull object) {
        NSLog(@"user name : %@",object);
    }];

    //根據key移除快取
    [userInfoCache removeObjectForKey:key withBlock:^(NSString * _Nonnull key) {
        NSLog(@"remove user name %@",key);
    }];
    
    //移除所有快取
    [userInfoCache removeAllObjectsWithBlock:^{
        NSLog(@"removing all cache succeed");
    }];

    //移除所有快取帶進度
    [userInfoCache removeAllObjectsWithProgressBlock:^(int removedCount, int totalCount) {
        NSLog(@"remove all cache objects: removedCount :%d  totalCount : %d",removedCount,totalCount);
    } endBlock:^(BOOL error) {
        if(!error){
            NSLog(@"remove all cache objects: succeed");
        }else{
            NSLog(@"remove all cache objects: failed");
        }
    }];
複製程式碼

總體來看這些API與NSCache是差不多的。 再來看一下框架的架構圖與成員職責劃分。

架構圖與成員職責劃分

架構圖

YYCache 原始碼解析

成員職責劃分

從架構圖上來看,該元件裡面的成員並不多:

  • YYCache:提供了最外層的介面,呼叫了YYMemoryCache與YYDiskCache的相關方法。
  • YYMemoryCache:負責處理容量小,相對高速的記憶體快取。執行緒安全,支援自動和手動清理快取等功能。
  • _YYLinkedMap:YYMemoryCache使用的雙向連結串列類。
  • _YYLinkedMapNode:是_YYLinkedMap使用的節點類。
  • YYDiskCache:負責處理容量大,相對低速的磁碟快取。執行緒安全,支援非同步操作,自動和手動清理快取等功能。
  • YYKVStorage:YYDiskCache的底層實現類,用於管理磁碟快取。
  • YYKVStorageItem:內建在YYKVStorage中,是YYKVStorage內部用於封裝某個快取的類。

程式碼講解

知道了YYCache的架構圖與成員職責劃分以後,現在結合程式碼開始正式講解。 講解分為下面6個部分:

  • YYCache
  • YYMemoryCache
  • YYDiskCache
  • 保證執行緒安全的不同方案
  • 提高快取效能的幾個嘗試
  • 其他知識點

YYCache

YYCache給使用者提供所有最外層的快取操作介面,而這些介面的內部內部實際上是呼叫了YYMemoryCache和YYDiskCache物件的相關方法。

我們來看一下YYCache的屬性和介面:

YYCache的屬性和介面


@interface YYCache : NSObject


@property (copy, readonly) NSString *name;//快取名稱
@property (strong, readonly) YYMemoryCache *memoryCache;//記憶體快取
@property (strong, readonly) YYDiskCache *diskCache;//磁碟快取

//是否包含某快取,無回撥
- (BOOL)containsObjectForKey:(NSString *)key;
//是否包含某快取,有回撥
- (void)containsObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, BOOL contains))block;

//獲取快取物件,無回撥
- (nullable id<NSCoding>)objectForKey:(NSString *)key;
//獲取快取物件,有回撥
- (void)objectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, id<NSCoding> object))block;

//寫入快取物件,無回撥
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key;
//寫入快取物件,有回撥
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key withBlock:(nullable void(^)(void))block;

//移除某快取,無回撥
- (void)removeObjectForKey:(NSString *)key;
//移除某快取,有回撥
- (void)removeObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key))block;

//移除所有快取,無回撥
- (void)removeAllObjects;
//移除所有快取,有回撥
- (void)removeAllObjectsWithBlock:(void(^)(void))block;
//移除所有快取,有進度和完成的回撥
- (void)removeAllObjectsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress
                                 endBlock:(nullable void(^)(BOOL error))end;

@end
複製程式碼

從上面的介面可以看出YYCache的介面和NSCache很相近,而且在介面上都區分了有無回撥的功能。 下面結合程式碼看一下這些介面是如何實現的:

YYCache的介面實現

下面省略了帶有回撥的介面,因為與無回撥的介面非常接近。

- (BOOL)containsObjectForKey:(NSString *)key {
    
    //先檢查記憶體快取是否存在,再檢查磁碟快取是否存在
    return [_memoryCache containsObjectForKey:key] || [_diskCache containsObjectForKey:key];
}

- (id<NSCoding>)objectForKey:(NSString *)key {
    
    //首先嚐試獲取記憶體快取,然後獲取磁碟快取
    id<NSCoding> object = [_memoryCache objectForKey:key];
    
    //如果記憶體快取不存在,就會去磁碟快取裡面找:如果找到了,則再次寫入記憶體快取中;如果沒找到,就返回nil
    if (!object) {
        object = [_diskCache objectForKey:key];
        if (object) {
            [_memoryCache setObject:object forKey:key];
        }
    }
    return object;
}


- (void)setObject:(id<NSCoding>)object forKey:(NSString *)key {
    //先寫入記憶體快取,後寫入磁碟快取
    [_memoryCache setObject:object forKey:key];
    [_diskCache setObject:object forKey:key];
}


- (void)removeObjectForKey:(NSString *)key {
    //先移除記憶體快取,後移除磁碟快取
    [_memoryCache removeObjectForKey:key];
    [_diskCache removeObjectForKey:key];
}

- (void)removeAllObjects {
    //先全部移除記憶體快取,後全部移除磁碟快取
    [_memoryCache removeAllObjects];
    [_diskCache removeAllObjects];
}

複製程式碼

從上面的介面實現可以看出:在YYCache中,永遠都是先訪問記憶體快取,然後再訪問磁碟快取(包括了寫入,讀取,查詢,刪除快取的操作)。而且關於記憶體快取(_memoryCache)的操作,是不存在block回撥的。

值得一提的是:在讀取快取的操作中,如果在記憶體快取中無法獲取對應的快取,則會去磁碟快取中尋找。如果在磁碟快取中找到了對應的快取,則會將該物件再次寫入記憶體快取中,保證在下一次嘗試獲取同一快取時能夠在記憶體中就能返回,提高速度

OK,現在瞭解了YYCache的介面以及實現,下面我分別講解一下YYMemoryCache(記憶體快取)和YYDiskCache(磁碟快取)這兩個類。

YYMemoryCache

YYMemoryCache負責處理容量小,相對高速的記憶體快取:它將需要快取的物件與傳入的key關聯起來,操作類似於NSCache。

但是與NSCache不同的是,YYMemoryCache的內部有:

  • 快取淘汰演算法:使用LRU(least-recently-used) 演算法來淘汰(清理)使用頻率較低的快取。
  • 快取清理策略:使用三個維度來標記,分別是count(快取數量),cost(開銷),age(距上一次的訪問時間)。YYMemoryCache提供了分別針對這三個維度的清理快取的介面。使用者可以根據不同的需求(策略)來清理在某一維度超標的快取。

一個是淘汰演算法,另一個是清理維度,乍一看可能沒什麼太大區別。我在這裡先簡單區分一下:

快取淘汰演算法的目的在於區分出使用頻率高和使用頻率低的快取,當快取數量達到一定限制的時候會優先清理那些使用頻率低的快取。因為使用頻率已經比較低的快取在將來的使用頻率也很有可能會低

快取清理維度是給每個快取新增的標記:

  • 如果使用者需要刪除age(距上一次的訪問時間)超過1天的快取,在YYMemoryCache內部,就會從使用頻率最低的那個快取開始查詢,直到所有距上一次的訪問時間超過1天的快取都清理掉為止。

  • 如果使用者需要將快取總開銷清理到總開銷小於或等於某個值,在YYMemoryCache內部,就會從使用頻率最低的那個快取開始清理,直到總開銷小於或等於這個值。

  • 如果使用者需要將快取總數清理到總開銷小於或等於某個值,在YYMemoryCache內部,就會從使用頻率最低的那個快取開始清理,直到總開銷小於或等於這個值。

可以看出,無論是以哪個維度來清理快取,都是從快取使用頻率最低的那個快取開始清理。而YYMemoryCache保留的所有快取的使用頻率的高低,是由LRU這個演算法決定的。

現在知道了這二者的區別,下面來具體講解一下快取淘汰演算法和快取清理策略:

YYMemoryCache的快取淘汰演算法

在詳細講解這個演算法之前我覺得有必要先說一下該演算法的核心:

我個人認為LRU快取替換策略的核心在於如果某個快取訪問的頻率越高,就認定使用者在將來越有可能訪問這個快取。 所以在這個演算法中,將那些最新訪問(寫入),最多次被訪問的快取移到最前面,然後那些很早之前寫入,不經常訪問的快取就被自動放在了後面。這樣一來,在保留的快取個數一定的情況下,留下的快取都是訪問頻率比較高的,這樣一來也就提升了快取的命中率。誰都不想留著一些很難被使用者再次訪問的快取,畢竟快取本身也佔有一定的資源不是麼?

其實這個道理和一些商城類app的商品推薦邏輯是一樣的: 如果首頁只能展示10個商品,對於一個程式設計師使用者來說,可能推薦的是於那些他最近購買商品類似的機械鍵盤滑鼠,技術書籍或者螢幕之類的商品,而不是一些洋娃娃或是鋼筆之類的商品。

那麼LRU演算法具體是怎麼做的呢?

在YYMemoryCache中,使用了雙向連結串列這個資料結構來儲存這些快取:

  • 當寫入一個新的快取時,要把這個快取節點放在連結串列頭部,並且並且原連結串列頭部的快取節點要變成現在連結串列的第二個快取節點。
  • 當訪問一個已有的快取時,要把這個快取節點移動到連結串列頭部,原位置兩側的快取要接上,並且原連結串列頭部的快取節點要變成現在連結串列的第二個快取節點。
  • (根據清理維度)自動清理快取時,要從連結串列的最後端逐個清理。

這樣一來,就可以保證連結串列前端的快取是最近寫入過和經常訪問過的。而且該演算法總是從連結串列的最後端刪除快取,這也就保證了留下的都是一些“比較新鮮的”快取。

下面結合程式碼來講解一下這個演算法的實現:

YYMemoryCache用一個連結串列節點類來儲存某個單獨的記憶體快取的資訊(鍵,值,快取時間等),然後用一個雙向連結串列類來儲存和管理這些節點。這兩個類的名稱分別是:

  • _YYLinkedMapNode:連結串列內的節點類,可以看做是對某個單獨記憶體快取的封裝。
  • _YYLinkedMap:雙向連結串列類,用於儲存和管理所有記憶體快取(節點)

_YYLinkedMapNode

_YYLinkedMapNode可以被看做是對某個快取的封裝:它包含了該節點上一個和下一個節點的指標,以及快取的key和對應的值(物件),還有該快取的開銷和訪問時間。

@interface _YYLinkedMapNode : NSObject {
    
    @package
    __unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic
    __unsafe_unretained _YYLinkedMapNode *_next; // retained by dic
    id _key;              		  //快取key
    id _value;              	          //key對應值
    NSUInteger _cost;                     //快取開銷
    NSTimeInterval _time;                 //訪問時間
    
}
@end

@implementation _YYLinkedMapNode
@end
複製程式碼

下面看一下雙向連結串列類:

_YYLinkedMap

@interface _YYLinkedMap : NSObject {
    @package
    CFMutableDictionaryRef _dic; 	// 用於存放節點
    NSUInteger _totalCost;   		//總開銷
    NSUInteger _totalCount;  		//節點總數
    _YYLinkedMapNode *_head;            // 連結串列的頭部結點
    _YYLinkedMapNode *_tail; 		// 連結串列的尾部節點
    BOOL _releaseOnMainThread; 	        //是否在主執行緒釋放,預設為NO
    BOOL _releaseAsynchronously; 	//是否在子執行緒釋放,預設為YES
}

//在連結串列頭部插入某節點
- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;

//將連結串列內部的某個節點移到連結串列頭部
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;

//移除某個節點
- (void)removeNode:(_YYLinkedMapNode *)node;

//移除連結串列的尾部節點並返回它
- (_YYLinkedMapNode *)removeTailNode;

//移除所有節點(預設在子執行緒操作)
- (void)removeAll;

@end
複製程式碼

從連結串列類的屬性上看:連結串列類內建了CFMutableDictionaryRef,用於儲存節點的鍵值對,它還持有了連結串列內節點的總開銷,總數量,頭尾節點等資料。

可以參考下面這張圖來看一下二者的關係:

YYCache 原始碼解析

看一下_YYLinkedMap的介面的實現:

將節點插入到連結串列頭部:

- (void)insertNodeAtHead:(_YYLinkedMapNode *)node {
    
    //設定該node的值
    CFDictionarySetValue(_dic, (__bridge const void *)(node->_key), (__bridge const void *)(node));
    
    //增加開銷和總快取數量
    _totalCost += node->_cost;
    _totalCount++;
    
    if (_head) {
        
        //如果連結串列內已經存在頭節點,則將這個頭節點賦給當前節點的尾指標(原第一個節點變成了現第二個節點)
        node->_next = _head;
        
        //將該節點賦給現第二個節點的頭指標(此時_head指向的節點是先第二個節點)
        _head->_prev = node;
        
        //將該節點賦給連結串列的頭結點指標(該節點變成了現第一個節點)
        _head = node;
        
    } else {
        
        //如果連結串列內沒有頭結點,說明是空連結串列。說明是第一次插入,則將連結串列的頭尾節點都設定為當前節點
        _head = _tail = node;
    }
}
複製程式碼

要看懂節點操作的程式碼只要瞭解雙向連結串列的特性即可。在雙向連結串列中:

  • 每個節點都有兩個分別指向前後節點的指標。所以說每個節點都知道它前一個節點和後一個節點是誰。
  • 連結串列的頭部節點指向它前面節點的指標為空;連結串列尾部節點指向它後側節點的指標也為空。

為了便於理解,我們可以把這個抽象概念類比於幼兒園手拉手的小朋友們: 每個小朋友的左手都拉著前面小朋友的右手;每個小朋友的右手都拉著後面小朋友的左手; 而且最前面的小朋友的左手和最後面的小朋友的右手都沒有拉任何一個小朋友。

將某個節點移動到連結串列頭部:


- (void)bringNodeToHead:(_YYLinkedMapNode *)node {
    
    //如果該節點已經是連結串列頭部節點,則立即返回,不做任何操作
    if (_head == node) return;
    
    
    if (_tail == node) {
        
        //如果該節點是連結串列的尾部節點
        //1. 將該節點的頭指標指向的節點變成連結串列的尾節點(將倒數第二個節點變成倒數第一個節點,即尾部節點)
        _tail = node->_prev;
        
        //2. 將新的尾部節點的尾部指標置空
        _tail->_next = nil;
        
    } else {
        
        //如果該節點是連結串列頭部和尾部以外的節點(中間節點)
        //1. 將該node的頭指標指向的節點賦給其尾指標指向的節點的頭指標
        node->_next->_prev = node->_prev;
        
        //2. 將該node的尾指標指向的節點賦給其頭指標指向的節點的尾指標
        node->_prev->_next = node->_next;
    }
    
    //將原頭節點賦給該節點的尾指標(原第一個節點變成了現第二個節點)
    node->_next = _head;
    
    //將當前節點的頭節點置空
    node->_prev = nil;
    
    //將現第二個節點的頭結點指向當前節點(此時_head指向的節點是現第二個節點)
    _head->_prev = node;
    
    //將該節點設定為連結串列的頭節點
    _head = node;
}
複製程式碼

第一次看上面的程式碼我自己是懵逼的,不過如果結合上面小朋友拉手的例子就可以快一點理解。 如果要其中一個小朋友放在隊伍的最前面,需要

  • 將原來這個小朋友前後的小朋友的手拉上。
  • 然後將這個小朋友的右手和原來排在第一位的小朋友的左手拉上。

上面說的比較簡略,但是相信對大家理解整個過程會有幫助。

也可以再結合連結串列的圖解來看一下:

YYCache 原始碼解析

讀者同樣可以利用這種思考方式理解下面這段程式碼:

移除連結串列中的某個節點:

- (void)removeNode:(_YYLinkedMapNode *)node {
    
    //除去該node的鍵對應的值
    CFDictionaryRemoveValue(_dic, (__bridge const void *)(node->_key));
    
    //減去開銷和總快取數量
    _totalCost -= node->_cost;
    _totalCount--;
    
    //節點操作
    //1. 將該node的頭指標指向的節點賦給其尾指標指向的節點的頭指標
    if (node->_next) node->_next->_prev = node->_prev;
    
    //2. 將該node的尾指標指向的節點賦給其頭指標指向的節點的尾指標
    if (node->_prev) node->_prev->_next = node->_next;
    
    //3. 如果該node就是連結串列的頭結點,則將該node的尾部指標指向的節點賦給連結串列的頭節點(第二變成了第一)
    if (_head == node) _head = node->_next;
    
    //4. 如果該node就是連結串列的尾節點,則將該node的頭部指標指向的節點賦給連結串列的尾節點(倒數第二變成了倒數第一)
    if (_tail == node) _tail = node->_prev;
}
複製程式碼

移除並返回尾部的node:

- (_YYLinkedMapNode *)removeTailNode {
    
    //如果不存在尾節點,則返回nil
    if (!_tail) return nil;
    
    _YYLinkedMapNode *tail = _tail;
    
    //移除尾部節點對應的值
    CFDictionaryRemoveValue(_dic, (__bridge const void *)(_tail->_key));
    
    //減少開銷和總快取數量
    _totalCost -= _tail->_cost;
    _totalCount--;
    
    if (_head == _tail) {
        //如果連結串列的頭尾節點相同,說明連結串列只有一個節點。將其置空
        _head = _tail = nil;
        
    } else {
        
        //將連結串列的尾節指標指向的指標賦給連結串列的尾指標(倒數第二變成了倒數第一)
        _tail = _tail->_prev;
        //將新的尾節點的尾指標置空
        _tail->_next = nil;
    }
    return tail;
}
複製程式碼

OK,現在瞭解了YYMemoryCache底層的節點操作的程式碼。現在來看一下YYMemoryCache是如何使用它們的。

YYMemoryCache的屬性和介面

//YYMemoryCache.h
@interface YYMemoryCache : NSObject

#pragma mark - Attribute

//快取名稱,預設為nil
@property (nullable, copy) NSString *name;

//快取總數量
@property (readonly) NSUInteger totalCount;

//快取總開銷
@property (readonly) NSUInteger totalCost;


#pragma mark - Limit

//數量上限,預設為NSUIntegerMax,也就是無上限
@property NSUInteger countLimit;

//開銷上限,預設為NSUIntegerMax,也就是無上限
@property NSUInteger costLimit;

//快取時間上限,預設為DBL_MAX,也就是無上限
@property NSTimeInterval ageLimit;

//清理超出上限之外的快取的操作間隔時間,預設為5s
@property NSTimeInterval autoTrimInterval;

//收到記憶體警告時是否清理所有快取,預設為YES
@property BOOL shouldRemoveAllObjectsOnMemoryWarning;

//app進入後臺是是否清理所有快取,預設為YES
@property BOOL shouldRemoveAllObjectsWhenEnteringBackground;

//收到記憶體警告的回撥block
@property (nullable, copy) void(^didReceiveMemoryWarningBlock)(YYMemoryCache *cache);

//進入後臺的回撥block
@property (nullable, copy) void(^didEnterBackgroundBlock)(YYMemoryCache *cache);

//快取清理是否在後臺進行,預設為NO
@property BOOL releaseOnMainThread;

//快取清理是否非同步執行,預設為YES
@property BOOL releaseAsynchronously;


#pragma mark - Access Methods

//是否包含某個快取
- (BOOL)containsObjectForKey:(id)key;

//獲取快取物件
- (nullable id)objectForKey:(id)key;

//寫入快取物件
- (void)setObject:(nullable id)object forKey:(id)key;

//寫入快取物件,並新增對應的開銷
- (void)setObject:(nullable id)object forKey:(id)key withCost:(NSUInteger)cost;

//移除某快取
- (void)removeObjectForKey:(id)key;

//移除所有快取
- (void)removeAllObjects;

#pragma mark - Trim

// =========== 快取清理介面 =========== 
//清理快取到指定個數
- (void)trimToCount:(NSUInteger)count;

//清理快取到指定開銷
- (void)trimToCost:(NSUInteger)cost;

//清理快取時間小於指定時間的快取
- (void)trimToAge:(NSTimeInterval)age;
複製程式碼

YYMemoryCache的介面實現

在YYMemoryCache的初始化方法裡,會例項化一個_YYLinkedMap的例項來賦給_lru這個成員變數。


- (instancetype)init{
      ....
      _lru = [_YYLinkedMap new];
      ...
  
  }
  
複製程式碼

然後所有的關於快取的操作,都要用到_lru這個成員變數,因為它才是在底層持有這些快取(節點)的雙向連結串列類。下面我們來看一下這些快取操作介面的實現:


//是否包含某個快取物件
- (BOOL)containsObjectForKey:(id)key {

  //嘗試從內建的字典中獲得快取物件
  if (!key) return NO;
  pthread_mutex_lock(&_lock);
  BOOL contains = CFDictionaryContainsKey(_lru->_dic, (__bridge const void *)(key));
  pthread_mutex_unlock(&_lock);
  return contains;
}

//獲取某個快取物件
- (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;
}

//寫入某個快取物件,開銷預設為0
- (void)setObject:(id)object forKey:(id)key {
  [self setObject:object forKey:key withCost:0];
}


//寫入某個快取物件,並存入快取開銷
- (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值匹配的node,則更新該node的value,cost,time,並將這個node移到連結串列頭部
      
      //更新總cost
      _lru->_totalCost -= node->_cost;
      _lru->_totalCost += cost;
      
      //更新node
      node->_cost = cost;
      node->_time = now;
      node->_value = object;
      
      //將node移動至連結串列頭部
      [_lru bringNodeToHead:node];
      
  } else {
      
      //如果不存在與傳入的key值匹配的node,則新建一個node,將key,value,cost,time賦給它,並將這個node插入到連結串列頭部
      //新建node,並賦值
      node = [_YYLinkedMapNode new];
      node->_cost = cost;
      node->_time = now;
      node->_key = key;
      node->_value = object;
      
      //將node插入至連結串列頭部
      [_lru insertNodeAtHead:node];
  }
  
  //如果cost超過了限制,則進行刪除快取操作(從連結串列尾部開始刪除,直到符合限制要求)
  if (_lru->_totalCost > _costLimit) {
      dispatch_async(_queue, ^{
          [self trimToCost:_costLimit];
      });
  }
  
  //如果total count超過了限制,則進行刪除快取操作(從連結串列尾部開始刪除,刪除一次即可)
  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);
}

//移除某個快取物件
- (void)removeObjectForKey:(id)key {
  
  if (!key) return;
  
  pthread_mutex_lock(&_lock);
  _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
  if (node) {
  
      //內部呼叫了連結串列的removeNode:方法
      [_lru removeNode:node];
      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);
}


//內部呼叫了連結串列的removeAll方法
- (void)removeAllObjects {
  pthread_mutex_lock(&_lock);
  [_lru removeAll];
  pthread_mutex_unlock(&_lock);
}
複製程式碼

上面的實現是針對快取的查詢,寫入,獲取操作的,接下來看一下快取的清理策略。

YYMemoryCache的快取清理策略

如上文所說,在YYCache中,快取的清理可以從快取總數量,快取總開銷,快取距上一次的訪問時間來清理快取。而且每種維度的清理操作都可以分為自動和手動的方式來進行。

快取自動清理

快取的自動清理功能在YYMemoryCache初始化之後就開始了,是一個遞迴呼叫的實現:

//YYMemoryCache.m
- (instancetype)init{
    
    ...
    
    //開始定期清理
    [self _trimRecursively];
    
    ...
}


//遞迴清理,相隔時間為_autoTrimInterval,在初始化之後立即執行
- (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];
            
    });
}

//清理所有不符合限制的快取,順序為:cost,count,age
- (void)_trimInBackground {
    dispatch_async(_queue, ^{
        
        [self _trimToCost:self->_costLimit];
        [self _trimToCount:self->_countLimit];
        [self _trimToAge:self->_ageLimit];
        
    });
}

複製程式碼
//YYMemoryCache.m
- (void)trimToCount:(NSUInteger)count {
    if (count == 0) {
        [self removeAllObjects];
        return;
    }
    [self _trimToCount:count];
}

- (void)trimToCost:(NSUInteger)cost {
    [self _trimToCost:cost];
}

- (void)trimToAge:(NSTimeInterval)age {
    [self _trimToAge:age];
}
複製程式碼

可以看到,YYMemoryCache是按照快取數量,快取開銷,快取時間的順序來自動清空快取的。我們結合程式碼看一下它是如何按照快取數量來清理快取的(其他兩種清理方式類似,暫不給出):

//YYMemoryCache.m

//將記憶體快取數量降至等於或小於傳入的數量;如果傳入的值為0,則刪除全部記憶體快取
- (void)_trimToCount:(NSUInteger)countLimit {
    
    BOOL finish = NO;
    
    pthread_mutex_lock(&_lock);
    
    //如果傳入的引數=0,則刪除所有記憶體快取
    if (countLimit == 0) {
        
        [_lru removeAll];
        finish = YES;
        
    } else if (_lru->_totalCount <= countLimit) {
    
        //如果當前快取的總數量已經小於或等於傳入的數量,則直接返回YES,不進行清理
        finish = YES;
    }
    pthread_mutex_unlock(&_lock);
    if (finish) return;
    
    NSMutableArray *holder = [NSMutableArray new];
    while (!finish) {
        
        //==0的時候說明在嘗試加鎖的時候,獲取鎖成功,從而可以進行操作;否則等待10秒(但是不知道為什麼是10s而不是2s,5s,等等)
        if (pthread_mutex_trylock(&_lock) == 0) {
            if (_lru->_totalCount > countLimit) {
                _YYLinkedMapNode *node = [_lru removeTailNode];
                if (node) [holder addObject:node];
            } else {
                finish = YES;
            }
            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
        });
    }
}
複製程式碼

快取手動清理

其實上面這三種清理的方法在YYMemoryCache封裝成了介面,所以使用者也可以通過YYCache的memoryCache這個屬性來手動清理相應維度上不符合傳入標準的快取:

//YYMemoryCache.h

// =========== 快取清理介面 =========== 
//清理快取到指定個數
- (void)trimToCount:(NSUInteger)count;

//清理快取到指定開銷
- (void)trimToCost:(NSUInteger)cost;

//清理快取時間小於指定時間的快取
- (void)trimToAge:(NSTimeInterval)age;
複製程式碼

看一下它們的實現:

//清理快取到指定個數
- (void)trimToCount:(NSUInteger)count {
    if (count == 0) {
        [self removeAllObjects];
        return;
    }
    [self _trimToCount:count];
}

//清理快取到指定開銷
- (void)trimToCost:(NSUInteger)cost {
    [self _trimToCost:cost];
}

//清理快取時間小於指定時間的快取
- (void)trimToAge:(NSTimeInterval)age {
    [self _trimToAge:age];
}
複製程式碼

YYDiskCache

YYDiskCache負責處理容量大,相對低速的磁碟快取。執行緒安全,支援非同步操作。作為YYCache的第二級快取,它與第一級快取YYMemoryCache的相同點是:

  • 都具有查詢,寫入,讀取,刪除快取的介面。
  • 不直接操作快取,也是間接地通過另一個類(YYKVStorage)來操作快取。
  • 它使用LRU演算法來清理快取。
  • 支援按 cost,count 和 age 這三個維度來清理不符合標準的快取。

它與YYMemoryCache不同點是:

    1. 根據快取資料的大小來採取不同的形式的快取:
    • 資料庫sqlite: 針對小容量快取,快取的data和後設資料都儲存在資料庫裡。
    • 檔案+資料庫的形式: 針對大容量快取,快取的data寫在檔案系統裡,其後設資料儲存在資料庫裡。
    1. 除了 cost,count 和 age 三個維度之外,還新增了一個磁碟容量的維度。

這裡需要說明的是: 對於上面的第一條:我看原始碼的時候只看出來有這兩種快取形式,但是從內部的快取type列舉來看,其實是分為三種的:

typedef NS_ENUM(NSUInteger, YYKVStorageType) {
    
    YYKVStorageTypeFile = 0,
    YYKVStorageTypeSQLite = 1,
    YYKVStorageTypeMixed = 2,
};
複製程式碼

也就是說我只找到了第二,第三種快取形式,而第一種純粹的檔案儲存(YYKVStorageTypeFile)形式的實現我沒有找到:當type為 YYKVStorageTypeFile和YYKVStorageTypeMixed的時候的快取實現都是一致的:都是講data存在檔案裡,將後設資料放在資料庫裡面。

在YYDiskCache的初始化方法裡,沒有發現正確的將快取型別設定為YYKVStorageTypeFile的方法:

//YYDiskCache.m

- (instancetype)init {
    @throw [NSException exceptionWithName:@"YYDiskCache init error" reason:@"YYDiskCache must be initialized with a path. Use 'initWithPath:' or 'initWithPath:inlineThreshold:' instead." userInfo:nil];
    return [self initWithPath:@"" inlineThreshold:0];
}

- (instancetype)initWithPath:(NSString *)path {
    return [self initWithPath:path inlineThreshold:1024 * 20]; // 20KB
}

- (instancetype)initWithPath:(NSString *)path
             inlineThreshold:(NSUInteger)threshold {

   ...    
    YYKVStorageType type;
    if (threshold == 0) {
        type = YYKVStorageTypeFile;
    } else if (threshold == NSUIntegerMax) {
        type = YYKVStorageTypeSQLite;
    } else {
        type = YYKVStorageTypeMixed;
    }
    
   ...
}

複製程式碼

從上面的程式碼可以看出來,當給指定初始化方法initWithPath:inlineThreshold:的第二個引數傳入0的時候,快取型別才是YYKVStorageTypeFile。而且比較常用的初始化方法initWithPath:的實現裡,是將20kb傳入了指定初始化方法裡,結果就是將type設定成了YYKVStorageTypeMixed。

而且我也想不出如果只有檔案形式的快取的話,其後設資料如何儲存。如果有讀者知道的話,麻煩告知一下,非常感謝了~~

在本文暫時對於上面提到的”檔案+資料庫的形式”在下文統一說成檔案快取了。

在介面的設計上,YYDiskCache與YYMemoryCache是高度一致的,只不過因為有些時候大檔案的訪問可能會比較耗時,所以框架作者在保留了與YYMemoryCache一樣的介面的基礎上,還在原來的基礎上新增了block回撥,避免阻塞執行緒。來看一下YYDiskCache的介面(省略了註釋):

//YYDiskCache.h

- (BOOL)containsObjectForKey:(NSString *)key;
- (void)containsObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key, BOOL contains))block;


- (nullable id<NSCoding>)objectForKey:(NSString *)key;
- (void)objectForKey:(NSString *)key withBlock:(void(^)(NSString *key, id<NSCoding> _Nullable object))block;


- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key;
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key withBlock:(void(^)(void))block;


- (void)removeObjectForKey:(NSString *)key;
- (void)removeObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key))block;


- (void)removeAllObjects;
- (void)removeAllObjectsWithBlock:(void(^)(void))block;
- (void)removeAllObjectsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress
                                 endBlock:(nullable void(^)(BOOL error))end;


- (NSInteger)totalCount;
- (void)totalCountWithBlock:(void(^)(NSInteger totalCount))block;


- (NSInteger)totalCost;
- (void)totalCostWithBlock:(void(^)(NSInteger totalCost))block;


#pragma mark - Trim
- (void)trimToCount:(NSUInteger)count;
- (void)trimToCount:(NSUInteger)count withBlock:(void(^)(void))block;


- (void)trimToCost:(NSUInteger)cost;
- (void)trimToCost:(NSUInteger)cost withBlock:(void(^)(void))block;

- (void)trimToAge:(NSTimeInterval)age;
- (void)trimToAge:(NSTimeInterval)age withBlock:(void(^)(void))block;
複製程式碼

從上面的介面程式碼可以看出,YYDiskCache與YYMemoryCache在介面設計上是非常相似的。但是,YYDiskCache有一個非常重要的屬性,它作為用sqlite做快取還是用檔案做快取的分水嶺

//YYDiskCache.h
@property (readonly) NSUInteger inlineThreshold;
複製程式碼

這個屬性的預設值是20480byte,也就是20kb。即是說,如果快取資料的長度大於這個值,就使用檔案儲存;如果小於這個值,就是用sqlite儲存。來看一下這個屬性是如何使用的:

首先我們會在YYDiskCache的指定初始化方法裡看到這個屬性:

//YYDiskCache.m
- (instancetype)initWithPath:(NSString *)path
             inlineThreshold:(NSUInteger)threshold {
   ...
    _inlineThreshold = threshold;
    ...
}
複製程式碼

在這裡將_inlineThreshold賦值,也是唯一一次的賦值。然後在寫入快取的操作裡判斷寫入快取的大小是否大於這個臨界值,如果是,則使用檔案快取:

//YYDiskCache.m
- (void)setObject:(id<NSCoding>)object forKey:(NSString *)key {
   
   ...
    NSString *filename = nil;
    if (_kv.type != YYKVStorageTypeSQLite) {
        //如果長度大臨界值,則生成檔名稱,使得filename不為nil
        if (value.length > _inlineThreshold) {
            filename = [self _filenameForKey:key];
        }
    }
    
    Lock();
    //在該方法內部判斷filename是否為nil,如果是,則使用sqlite進行快取;如果不是,則使用檔案快取
    [_kv saveItemWithKey:key value:value filename:filename extendedData:extendedData];
    Unlock();
}
複製程式碼

現在我們知道了YYDiskCache相對於YYMemoryCache最大的不同之處是快取型別的不同。 細心的朋友會發現上面這個寫入快取的方法(saveItemWithKey:value:filename:extendedData:)實際上是屬於_kv的。這個_kv就是上面提到的YYKVStorage的例項,它在YYDiskCache的初始化方法裡被賦值:

//YYDiskCache.m
- (instancetype)initWithPath:(NSString *)path
             inlineThreshold:(NSUInteger)threshold {
    ...
    
    YYKVStorage *kv = [[YYKVStorage alloc] initWithPath:path type:type];
    if (!kv) return nil;
    _kv = kv;
    ...
}
複製程式碼

同樣地,再舉其他兩個介面為例,內部也是呼叫了_kv的方法:

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

- (void)removeObjectForKey:(NSString *)key {
    if (!key) return;
    Lock();
    [_kv removeItemForKey:key];
    Unlock();
} 
複製程式碼

所以是時候來看一下YYKVStorage的介面和實現了:

YYKVStorage

YYKVStorage例項負責儲存和管理所有磁碟快取。和YYMemoryCache裡面的_YYLinkedMap將快取封裝成節點類_YYLinkedMapNode類似,YYKVStorage也將某個單獨的磁碟快取封裝成了一個類,這個類就是YYKVStorageItem,它儲存了某個快取所對應的一些資訊(key, value, 檔名,大小等等):

//YYKVStorageItem.h

@interface YYKVStorageItem : NSObject

@property (nonatomic, strong) NSString *key;                //鍵
@property (nonatomic, strong) NSData *value;                //值
@property (nullable, nonatomic, strong) NSString *filename; //檔名
@property (nonatomic) int size;                             //值的大小,單位是byte
@property (nonatomic) int modTime;                          //修改時間戳
@property (nonatomic) int accessTime;                       //最後訪問的時間戳
@property (nullable, nonatomic, strong) NSData *extendedData; //extended data

@end
複製程式碼

既然在這裡將快取封裝成了YYKVStorageItem例項,那麼作為快取的管理者,YYKVStorage就必然有操作YYKVStorageItem的介面了:

//YYKVStorage.h

//寫入某個item
- (BOOL)saveItem:(YYKVStorageItem *)item;

//寫入某個鍵值對,值為NSData物件
- (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value;

//寫入某個鍵值對,包括檔名以及data資訊
- (BOOL)saveItemWithKey:(NSString *)key
                  value:(NSData *)value
               filename:(nullable NSString *)filename
           extendedData:(nullable NSData *)extendedData;

#pragma mark - Remove Items

//移除某個鍵的item
- (BOOL)removeItemForKey:(NSString *)key;

//移除多個鍵的item
- (BOOL)removeItemForKeys:(NSArray<NSString *> *)keys;

//移除大於引數size的item
- (BOOL)removeItemsLargerThanSize:(int)size;

//移除時間早於引數時間的item
- (BOOL)removeItemsEarlierThanTime:(int)time;

//移除item,使得快取總容量小於引數size
- (BOOL)removeItemsToFitSize:(int)maxSize;

//移除item,使得快取數量小於引數size
- (BOOL)removeItemsToFitCount:(int)maxCount;

//移除所有的item
- (BOOL)removeAllItems;

//移除所有的item,附帶進度與結束block
- (void)removeAllItemsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress
                               endBlock:(nullable void(^)(BOOL error))end;


#pragma mark - Get Items
//讀取引數key對應的item
- (nullable YYKVStorageItem *)getItemForKey:(NSString *)key;

//讀取引數key對應的data
- (nullable NSData *)getItemValueForKey:(NSString *)key;

//讀取引數陣列對應的item陣列
- (nullable NSArray<YYKVStorageItem *> *)getItemForKeys:(NSArray<NSString *> *)keys;

//讀取引數陣列對應的item字典
- (nullable NSDictionary<NSString *, NSData *> *)getItemValueForKeys:(NSArray<NSString *> *)keys;
複製程式碼

大家最關心的應該是寫入快取的介面是如何實現的,下面重點講一下寫入快取的介面:

//寫入某個item
- (BOOL)saveItem:(YYKVStorageItem *)item;

//寫入某個鍵值對,值為NSData物件
- (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value;

//寫入某個鍵值對,包括檔名以及data資訊
- (BOOL)saveItemWithKey:(NSString *)key
                  value:(NSData *)value
               filename:(nullable NSString *)filename
           extendedData:(nullable NSData *)extendedData;
複製程式碼

這三個介面都比較類似,上面的兩個方法都會呼叫最下面引數最多的方法。在詳細講解寫入快取的程式碼之前,我先講一下寫入快取的大致邏輯,有助於讓大家理解整個YYDiskCache寫入快取的流程:

  1. 首先判斷傳入的key和value是否符合要求,如果不符合要求,則立即返回NO,快取失敗。
  2. 再判斷是否type==YYKVStorageTypeFile並且檔名為空字串(或nil):如果是,則立即返回NO,快取失敗。
  3. 判斷filename是否為空字串:
  4. 如果不為空:寫入檔案,並將快取的key,等資訊寫入資料庫,但是不將key對應的data寫入資料庫。
  5. 如果為空:
  6. 如果快取型別為YYKVStorageTypeSQLite:將快取檔案刪除
  7. 如果快取型別不為YYKVStorageTypeSQLite:則將快取的key和對應的data等其他資訊存入資料庫。
- (BOOL)saveItem:(YYKVStorageItem *)item {
    return [self saveItemWithKey:item.key value:item.value filename:item.filename extendedData:item.extendedData];
}

- (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value {
    return [self saveItemWithKey:key value:value filename:nil extendedData:nil];
}

- (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];
            }
        }
        
        // 快取型別是資料庫快取,把後設資料和value寫入資料庫
        return [self _dbSaveWithKey:key value:value fileName:nil extendedData:extendedData];
    }
}
複製程式碼

從上面的程式碼可以看出,在底層寫入快取的方法是_dbSaveWithKey:value:fileName:extendedData:,這個方法使用了兩次:

  • 在以檔案(和資料庫)儲存快取時
  • 在以資料庫儲存快取時

不過雖然呼叫了兩次,我們可以從傳入的引數是有差別的:第二次filename傳了nil。那麼我們來看一下_dbSaveWithKey:value:fileName:extendedData:內部是如何區分有無filename的情況的:

  • 當filename為空時,說明在外部沒有寫入該快取的檔案:則把data寫入資料庫裡
  • 當filename不為空時,說明在外部有寫入該快取的檔案:則不把data也寫入了資料庫裡

下面結合程式碼看一下:

//資料庫儲存
- (BOOL)_dbSaveWithKey:(NSString *)key value:(NSData *)value fileName:(NSString *)fileName extendedData:(NSData *)extendedData {
    
    //sql語句
    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;
    
    int timestamp = (int)time(NULL);
    
    //key
    sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL);
    
    //filename
    sqlite3_bind_text(stmt, 2, fileName.UTF8String, -1, NULL);
    
    //size
    sqlite3_bind_int(stmt, 3, (int)value.length);
    
    //inline_data
    if (fileName.length == 0) {
        
        //如果檔名長度==0,則將value存入資料庫
        sqlite3_bind_blob(stmt, 4, value.bytes, (int)value.length, 0);
        
    } else {
        
        //如果檔名長度不為0,則不將value存入資料庫
        sqlite3_bind_blob(stmt, 4, NULL, 0, 0);
    }
    
    //modification_time
    sqlite3_bind_int(stmt, 5, timestamp);
    
    //last_access_time
    sqlite3_bind_int(stmt, 6, timestamp);
    
    //extended_data
    sqlite3_bind_blob(stmt, 7, extendedData.bytes, (int)extendedData.length, 0);
    
    int result = sqlite3_step(stmt);
    
    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;
}
複製程式碼

框架作者用資料庫的一條記錄來儲存關於某個快取的所有資訊。 而且資料庫的第四個欄位是儲存快取對應的data的,從上面的程式碼可以看出當filename為空和不為空的時候的處理的差別。

上面的sqlite3_stmt可以看作是一個已經把sql語句解析了的、用sqlite自己標記記錄的內部資料結構。 而sqlite3_bind_text和sqlite3_bind_int是繫結函式,可以看作是將變數插入到欄位的操作。

OK,現在看完了寫入快取,我們再來看一下獲取快取的操作:

//YYKVSorage.m
- (YYKVStorageItem *)getItemForKey:(NSString *)key {
    
    if (key.length == 0) return nil;
    
    YYKVStorageItem *item = [self _dbGetItemWithKey:key excludeInlineData:NO];
    
    if (item) {
        //更新記憶體訪問的時間
        [self _dbUpdateAccessTimeWithKey:key];
        
        if (item.filename) {
            //如果有檔名,則嘗試獲取檔案資料
            item.value = [self _fileReadWithName:item.filename];
            //如果此時獲取檔案資料失敗,則刪除對應的item
            if (!item.value) {
                [self _dbDeleteItemWithKey:key];
                item = nil;
            }
        }
    }
    return item;
}
複製程式碼

從上面這段程式碼我們可以看到獲取YYKVStorageItem的例項的方法是_dbGetItemWithKey:excludeInlineData: 我們來看一下它的實現:

  1. 首先根據查詢key的sql語句生成stmt
  2. 然後將傳入的key與該stmt進行繫結
  3. 最後通過這個stmt來查詢出與該key對應的有關該快取的其他資料並生成item。

來看一下程式碼:

- (YYKVStorageItem *)_dbGetItemWithKey:(NSString *)key excludeInlineData:(BOOL)excludeInlineData {
    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];
    if (!stmt) return nil;
    sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL);
    
    YYKVStorageItem *item = nil;
    int result = sqlite3_step(stmt);
    if (result == SQLITE_ROW) {
        //傳入stmt來生成YYKVStorageItem例項
        item = [self _dbGetItemFromStmt:stmt excludeInlineData:excludeInlineData];
    } else {
        if (result != SQLITE_DONE) {
            if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite query error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db));
        }
    }
    return item;
}
複製程式碼

我們可以看到最終生成YYKVStorageItem例項的是通過_dbGetItemFromStmt:excludeInlineData:來實現的:

- (YYKVStorageItem *)_dbGetItemFromStmt:(sqlite3_stmt *)stmt excludeInlineData:(BOOL)excludeInlineData {
    
    //提取資料
    int i = 0;
    char *key = (char *)sqlite3_column_text(stmt, i++);
    char *filename = (char *)sqlite3_column_text(stmt, i++);
    int size = sqlite3_column_int(stmt, i++);
    
    //判斷excludeInlineData
    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++);
    
    //將資料賦給item的屬性
    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的例項

需要注意的是:

  1. 字串型別需要使用stringWithUTF8String:來轉成NSString型別。
  2. 這裡面會判斷excludeInlineData
  • 如果為TRUE,就提取存入的data資料
  • 如果為FALSE,就不提取

保證執行緒安全的方案

我相信對於某個設計來說,它的產生一定是基於某種個特定問題下的某個場景的

由上文可以看出:

  • YYMemoryCache 使用了 pthread_mutex 執行緒鎖(互斥鎖)來確保執行緒安全
  • YYDiskCache 則選擇了更適合它的 dispatch_semaphore。

記憶體快取操作的互斥鎖

在YYMemoryCache中,是使用互斥鎖來保證執行緒安全的。 首先在YYMemoryCache的初始化方法中得到了互斥鎖,並在它的所有介面裡都加入了互斥鎖來保證執行緒安全,包括setter,getter方法和快取操作的實現。舉幾個例子:

- (NSUInteger)totalCost {
    pthread_mutex_lock(&_lock);
    NSUInteger totalCost = _lru->_totalCost;
    pthread_mutex_unlock(&_lock);
    return totalCost;
}

- (void)setReleaseOnMainThread:(BOOL)releaseOnMainThread {
    pthread_mutex_lock(&_lock);
    _lru->_releaseOnMainThread = releaseOnMainThread;
    pthread_mutex_unlock(&_lock);
}

- (BOOL)containsObjectForKey:(id)key {
    
    if (!key) return NO;
    pthread_mutex_lock(&_lock);
    BOOL contains = CFDictionaryContainsKey(_lru->_dic, (__bridge const void *)(key));
    pthread_mutex_unlock(&_lock);
    return contains;
}

- (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;
}

複製程式碼

而且需要在dealloc方法中銷燬這個鎖頭:

- (void)dealloc {
    
    ...
    
    //銷燬互斥鎖
    pthread_mutex_destroy(&_lock);
}
複製程式碼

磁碟快取使用訊號量來代替鎖

框架作者採用了訊號量的方式來給 首先在初始化的時候例項化了一個訊號量:

- (instancetype)initWithPath:(NSString *)path
             inlineThreshold:(NSUInteger)threshold {
    ...
    _lock = dispatch_semaphore_create(1);
    _queue = dispatch_queue_create("com.ibireme.cache.disk", DISPATCH_QUEUE_CONCURRENT);
    ...
複製程式碼

然後使用了巨集來代替加鎖解鎖的程式碼:

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

簡單說一下訊號量:

dispatch_semaphore是GCD用來同步的一種方式,與他相關的共有三個函式,分別是

  • dispatch_semaphore_create:定義訊號量
  • dispatch_semaphore_signal:使訊號量+1
  • dispatch_semaphore_wait:使訊號量-1

當訊號量為0時,就會做等待處理,這是其他執行緒如果訪問的話就會讓其等待。所以如果訊號量在最開始的的時候被設定為1,那麼就可以實現“鎖”的功能:

  • 執行某段程式碼之前,執行dispatch_semaphore_wait函式,讓訊號量-1變為0,執行這段程式碼。
  • 此時如果其他執行緒過來訪問這段程式碼,就要讓其等待。
  • 當這段程式碼在當前執行緒結束以後,執行dispatch_semaphore_signal函式,令訊號量再次+1,那麼如果有正在等待的執行緒就可以訪問了。

需要注意的是:如果有多個執行緒等待,那麼後來訊號量恢復以後訪問的順序就是執行緒遇到dispatch_semaphore_wait的順序。

這也就是訊號量和互斥鎖的一個區別:互斥量用於執行緒的互斥,訊號線用於執行緒的同步。

  • 互斥:是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的

  • 同步:是指在互斥的基礎上(大多數情況),通過其它機制實現訪問者對資源的有序訪問。在大多數情況下,同步已經實現了互斥,特別是所有寫入資源的情況必定是互斥的。也就是說使用訊號量可以使多個執行緒有序訪問某個資源。

那麼問題來了:為什麼記憶體快取使用的是互斥鎖(pthread_mutex),而磁碟快取使用的就是訊號量(dispatch_semaphore)呢?

答案在框架作者的文章YYCache 設計思路裡可以找到:

為什麼記憶體快取使用互斥鎖(pthread_mutex)?

框架作者在最初使用的是自旋鎖(OSSpinLock)作為記憶體快取的執行緒鎖,但是後來得知其不夠安全,所以退而求其次,使用了pthread_mutex。

為什麼磁碟快取使用的是訊號量(dispatch_semaphore)?

dispatch_semaphore 是訊號量,但當訊號總量設為 1 時也可以當作鎖來。在沒有等待情況出現時,它的效能比 pthread_mutex 還要高,但一旦有等待情況出現時,效能就會下降許多。相對於 OSSpinLock 來說,它的優勢在於等待時不會消耗 CPU 資源。對磁碟快取來說,它比較合適。

因為YYDiskCache在寫入比較大的快取時,可能會有比較長的等待時間,而dispatch_semaphore在這個時候是不消耗CPU資源的,所以比較適合。

提高快取效能的幾個嘗試

選擇合適的執行緒鎖

可以參考上一部分YYMemoryCache 和YYDiskCache使用的不同的鎖以及原因。

選擇合適的資料結構

在YYMemoryCache中,作者選擇了雙向連結串列來儲存這些快取節點。那麼可以思考一下,為什麼要用雙向連結串列而不是單向連結串列或是陣列呢?

  • 為什麼不選擇單向連結串列:單連結串列的節點只知道它後面的節點(只有指向後一節點的指標),而不知道前面的。所以如果想移動其中一個節點的話,其前後的節點不好做銜接。

  • 為什麼不選擇陣列:陣列中元素在記憶體的排列是連續的,對於定址操作非常便利;但是對於插入,刪除操作很不方便,需要整體移動,移動的元素個數越多,代價越大。而連結串列恰恰相反,因為其節點的關聯僅僅是靠指標,所以對於插入和刪除操作會很便利,而定址操作缺比較費時。由於在LRU策略中會有非常多的移動,插入和刪除節點的操作,所以使用雙向連結串列是比較有優勢的。

選擇合適的執行緒來操作不同的任務

無論快取的自動清理和釋放,作者預設把這些任務放到子執行緒去做:

看一下釋放所有記憶體快取的操作:

- (void)removeAll {
    
    //將開銷,快取數量置為0
    _totalCost = 0;
    _totalCount = 0;
    
    //將連結串列的頭尾節點置空
    _head = nil;
    _tail = nil;
    
    if (CFDictionaryGetCount(_dic) > 0) {
        
        CFMutableDictionaryRef holder = _dic;
        _dic = CFDictionaryCreateMutable(CFAllocatorGetDefault(), 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
        
        //是否在子執行緒操作
        if (_releaseAsynchronously) {
            dispatch_queue_t queue = _releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
            dispatch_async(queue, ^{
                CFRelease(holder); // hold and release in specified queue
            });
        } else if (_releaseOnMainThread && !pthread_main_np()) {
            dispatch_async(dispatch_get_main_queue(), ^{
                CFRelease(holder); // hold and release in specified queue
            });
        } else {
            CFRelease(holder);
        }
    }
}
複製程式碼

這裡的YYMemoryCacheGetReleaseQueue()使用了行內函數,返回了低優先順序的併發佇列。

//行內函數,返回優先順序最低的全域性併發佇列
static inline dispatch_queue_t YYMemoryCacheGetReleaseQueue() {
    return dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
}
複製程式碼

選擇底層的類

同樣是字典實現,但是作者使用了更底層且快速的CFDictionary而沒有用NSDictionary來實現。

其他知識點

禁用原生初始化方法並標明新定義的指定初始化方法

YYCache有4個供外部呼叫的初始化介面,無論是物件方法還是類方法都需要傳入一個字串(名稱或路徑)。

而兩個原生的初始化方法被框架作者禁掉了:

- (instancetype)init UNAVAILABLE_ATTRIBUTE;
+ (instancetype)new UNAVAILABLE_ATTRIBUTE;
複製程式碼

如果使用者使用了上面兩個初始化方法就會在編譯期報錯。

而剩下的四個可以使用的初始化方法中,有一個是指定初始化方法,被作者用NS_DESIGNATED_INITIALIZER標記了。

- (nullable instancetype)initWithName:(NSString *)name;
- (nullable instancetype)initWithPath:(NSString *)path NS_DESIGNATED_INITIALIZER;

+ (nullable instancetype)cacheWithName:(NSString *)name;
+ (nullable instancetype)cacheWithPath:(NSString *)path;
複製程式碼

指定初始化方法就是所有可使用的初始化方法都必須呼叫的方法。更詳細的介紹可以參考我的下面兩篇文章:

非同步釋放物件的技巧

為了非同步將某個物件釋放掉,可以通過在GCD的block裡面給它發個訊息來實現。這個技巧在該框架中很常見,舉一個刪除一個記憶體快取的例子:

首先將這個快取的node類取出,然後非同步將其釋放掉。

- (void)removeObjectForKey:(id)key {
    
    if (!key) return;
    
    pthread_mutex_lock(&_lock);
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    if (node) {
        [_lru removeNode:node];
        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);
}
複製程式碼

為了釋放掉這個node物件,在一個非同步執行的(主佇列或自定義佇列裡)block裡給其傳送了class這個訊息。不需要糾結這個訊息具體是什麼,他的目的是為了避免編譯錯誤,因為我們無法在block裡面硬生生地將某個物件寫進去。

其實關於上面這一點我自己也有點拿不準,希望理解得比較透徹的同學能在下面留個言~ ^^

記憶體警告和進入後臺的監聽

YYCache預設在收到記憶體警告和進入後臺時,自動清除所有記憶體快取。所以在YYMemoryCache的初始化方法裡,我們可以看到這兩個監聽的動作:

//YYMemoryCache.m

- (instancetype)init{
    
    ...
      
    //監聽app生命週期
    [[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];
    }
}

複製程式碼

判斷標頭檔案的匯入

#if __has_include(<YYCache/YYCache.h>)
#import <YYCache/YYMemoryCache.h>
#import <YYCache/YYDiskCache.h>
#import <YYCache/YYKVStorage.h>
#elif __has_include(<YYWebImage/YYCache.h>)
#import <YYWebImage/YYMemoryCache.h>
#import <YYWebImage/YYDiskCache.h>
#import <YYWebImage/YYKVStorage.h>
#else
#import "YYMemoryCache.h"
#import "YYDiskCache.h"
#import "YYKVStorage.h"
#endif
複製程式碼

在這裡作者使用__has_include來檢查Frameworks是否引入某個類。 因為YYWebImage已經整合YYCache,所以如果匯入過YYWebImage的話就無需重再匯入YYCache了。

最後的話

通過看該元件的原始碼,我收穫的不僅有快取設計的思路,還有:

  • 雙向連結串列的概念以及相關操作
  • 資料庫的使用
  • 互斥鎖,訊號量的使用
  • 實現執行緒安全的方案
  • 變數,方法的命名以及介面的設計

相信讀過這篇文章的你也會有一些收穫~ 如果能趁熱打鐵,下載一個YYCache原始碼看就更好啦~


本篇已同步到個人部落格:傳送門

---------------------------- 2018年7月17日更新 ----------------------------

注意注意!!!

筆者在近期開通了個人公眾號,主要分享程式設計,讀書筆記,思考類的文章。

  • 程式設計類文章:包括筆者以前釋出的精選技術文章,以及後續釋出的技術文章(以原創為主),並且逐漸脫離 iOS 的內容,將側重點會轉移到提高程式設計能力的方向上。
  • 讀書筆記類文章:分享程式設計類思考類心理類職場類書籍的讀書筆記。
  • 思考類文章:分享筆者平時在技術上生活上的思考。

因為公眾號每天釋出的訊息數有限制,所以到目前為止還沒有將所有過去的精選文章都發布在公眾號上,後續會逐步釋出的。

而且因為各大部落格平臺的各種限制,後面還會在公眾號上釋出一些短小精幹,以小見大的乾貨文章哦~

掃下方的公眾號二維碼並點選關注,期待與您的共同成長~

公眾號:程式設計師維他命

相關文章