YYCache 源碼剖析:一覽亮點(diǎn)

系列文章:
YYText 源碼剖析:CoreText 與異步繪制
YYAsyncLayer 源碼剖析:異步繪制
YYCache 源碼剖析:一覽亮點(diǎn)
YYModel 源碼剖析:關(guān)注性能
YYImage 源碼剖析:圖片處理技巧
YYWebImage 源碼剖析:線程處理與緩存策略

寫在前面

YYCache 作為當(dāng)下 iOS 圈最流行的緩存框架,有著優(yōu)越的性能和絕佳的設(shè)計(jì)。筆者花了些時(shí)間對其“解剖”了一番翁逞,發(fā)現(xiàn)了很多有意思的東西叙赚,所以寫下本文分享一下痒玩。

考慮到篇幅谨胞,筆者對于源碼的解析不會過多的涉及 API 使用和一些基礎(chǔ)知識,更多的是剖析作者 ibireme 的設(shè)計(jì)思維和重要技術(shù)實(shí)現(xiàn)細(xì)節(jié)凝颇。

YYCache 主要分為兩部分:內(nèi)存緩存和磁盤緩存(對應(yīng) YYMemoryCacheYYDiskCache)筐钟。在日常開發(fā)業(yè)務(wù)使用中揩瞪,多是直接操作 YYCache 類,該類是對內(nèi)存緩存功能和磁盤緩存功能的一個(gè)簡單封裝篓冲。

源碼基于 1.0.4 版本李破。

一、內(nèi)存緩存:YYMemoryCache

總覽 API 壹将,會發(fā)現(xiàn)一些見名知意的方法:

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

可以看出嗤攻,該類主要包含讀寫功能和修剪功能(修剪是為了控制內(nèi)存緩存的大小等)。當(dāng)然诽俯,還有其他一些自定義方法妇菱,比如釋放操作的線程選擇、內(nèi)存警告和進(jìn)入后臺時(shí)是否清除內(nèi)存緩存等暴区。

對該類的基本功能有了了解之后闯团,就可以直接切實(shí)現(xiàn)源碼了。

(1)LRU 緩存淘汰算法

既然有修剪緩存的功能仙粱,必然涉及到一個(gè)緩存淘汰算法房交,YYMemoryCacheYYDiskCache 都是實(shí)現(xiàn)的 LRU (least-recently-used) ,即最近最少使用淘汰算法伐割。

在 YYMemoryCache.m 文件中候味,有如下的代碼:

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

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

熟悉鏈表的朋友應(yīng)該一眼就看出來貓膩,作者是使用的一個(gè)雙向鏈表+散列容器來實(shí)現(xiàn) LRU 的隔心。

鏈表的節(jié)點(diǎn) (_YYLinkedMapNode):

  1. 同時(shí)使用前驅(qū)和后繼指針(即_prev_next)是為了快速找到前驅(qū)和后繼節(jié)點(diǎn)白群。
  2. 這里使用__unsafe_unretained而不使用__weak。雖然兩者都不會持有指針?biāo)赶虻膶ο蠹醚祝窃谥赶驅(qū)ο筢尫艜r(shí),前者并不會自動置空指針辐真,形成野指針须尚,不過經(jīng)過筆者后面的閱讀崖堤,發(fā)現(xiàn)作者避免了野指針的出現(xiàn);而且從性能層面看(作者原話):訪問具有 __weak 屬性的變量時(shí)耐床,實(shí)際上會調(diào)用objc_loadWeak()objc_storeWeak()來完成密幔,這也會帶來很大的開銷,所以要避免使用__weak屬性撩轰。
  3. _key_value就是框架使用者想要存儲的鍵值對胯甩,可以看出作者的設(shè)計(jì)是一個(gè)鍵值對對應(yīng)一個(gè)節(jié)點(diǎn)(_YYLinkedMapNode)。
  4. _cost_time表示該節(jié)點(diǎn)的內(nèi)存大小和最后訪問的時(shí)間堪嫂。

LRU 實(shí)現(xiàn)類 (_YYLinkedMap) :

  1. 包含頭尾指針(_head_tail)偎箫,保證雙端查詢的效率。
  2. _totalCost_totalCount記錄最大內(nèi)存占用限制和數(shù)量限制皆串。
  3. _releaseOnMainThread_releaseAsynchronously分別表示在主線程釋放和在異步線程釋放淹办,它們的實(shí)現(xiàn)后文會講到。
  4. _dic變量是 OC 開發(fā)中常用的散列容器恶复,所有節(jié)點(diǎn)都會在_dic中以 key-value 的形式存在怜森,保證常數(shù)級查詢效率。

既然是 LRU 算法谤牡,怎么能只有數(shù)據(jù)結(jié)構(gòu)副硅,往下面看 _YYLinkedMap 類實(shí)現(xiàn)了如下算法(嗯,挺常規(guī)的節(jié)點(diǎn)操作翅萤,代碼質(zhì)量挺高的恐疲,就不說明實(shí)現(xiàn)了):

- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;
- (void)removeNode:(_YYLinkedMapNode *)node;
- (_YYLinkedMapNode *)removeTailNode;
- (void)removeAll;

現(xiàn)在 LRU 的數(shù)據(jù)結(jié)構(gòu)和操作算法實(shí)現(xiàn)都有了,就可以看具體的業(yè)務(wù)了断序。

(2)修剪內(nèi)存的邏輯

正如一開始貼的 API 流纹,該類有三種修剪內(nèi)存的依據(jù):根據(jù)緩存的內(nèi)存塊數(shù)量、根據(jù)占用內(nèi)存大小违诗、根據(jù)是否是最近使用漱凝。它們的實(shí)現(xiàn)邏輯幾乎一樣,這里就其中一個(gè)為例子(代碼有刪減):

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

這里有幾個(gè)重要技術(shù)點(diǎn)诸迟,很有意思茸炒。

釋放尾節(jié)點(diǎn)

通過一個(gè) while 循環(huán)不斷釋放尾節(jié)點(diǎn)removeTailNode,直到滿足參數(shù)ageLimit對時(shí)間的要求阵苇,而該鏈表的排序規(guī)則是:最近使用的內(nèi)存塊會移動到鏈表頭部壁公,也就保證了刪除的內(nèi)存永遠(yuǎn)是最不常使用的(后面會看到如何實(shí)現(xiàn)排序的)。

鎖的處理

不妨思考這樣一個(gè)問題:為何要使用pthread_mutex_trylock()方法嘗試獲取鎖绅项,而獲取失敗過后做了一個(gè)線程掛起操作usleep()紊册?

優(yōu)先級反轉(zhuǎn):比如兩個(gè)線程 A 和 B,優(yōu)先級 A < B快耿。當(dāng) A 獲取鎖訪問共享資源時(shí)囊陡,B 嘗試獲取鎖芳绩,那么 B 就會進(jìn)入忙等狀態(tài),忙等時(shí)間越長對 CPU 資源的占用越大撞反;而由于 A 的優(yōu)先級低于 B妥色,A 無法與高優(yōu)先級的線程爭奪 CPU 資源,從而導(dǎo)致任務(wù)遲遲完成不了遏片。解決優(yōu)先級反轉(zhuǎn)的方法有“優(yōu)先級天花板”和“優(yōu)先級繼承”嘹害,它們的核心操作都是提升當(dāng)前正在訪問共享資源的線程的優(yōu)先級。

歷史情況:在老版本的代碼中吮便,作者是使用的OSSpinLock自旋鎖來保證線程安全笔呀,而后來由于OSSpinLock的 bug 問題(存在潛在的優(yōu)先級反轉(zhuǎn)BUG),作者將其替換成了pthread_mutex_t互斥鎖线衫。

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

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

_queue是一個(gè)串行隊(duì)列:

_queue = dispatch_queue_create("com.ibireme.cache.memory", DISPATCH_QUEUE_SERIAL);

可以明確的是凿可,自動修剪過程不存在線程安全問題,當(dāng)然框架還暴露了修剪內(nèi)存的方法給外部使用授账,那么當(dāng)外部在多線程調(diào)用修剪內(nèi)存方法就可能會出現(xiàn)線程安全問題枯跑。

這里做了一個(gè) 10ms 的掛起操作然后循環(huán)嘗試,直接舍棄了互斥鎖的空轉(zhuǎn)期白热,但這樣也避免了多線程訪問下過多的空轉(zhuǎn)占用過多的 CPU 資源敛助。作者這樣處理很可能加長了修剪內(nèi)存的時(shí)間,但是卻避免了極限情況下空轉(zhuǎn)對 CPU 的占用屋确。

顯然纳击,作者是期望使用者在后臺線程修剪內(nèi)存(最好使用者不去顯式的調(diào)用修剪內(nèi)存方法)。

異步線程釋放資源

這里作者使用了一個(gè)容器將要釋放的節(jié)點(diǎn)裝起來攻臀,然后在某個(gè)隊(duì)列(默認(rèn)是非主隊(duì)列)里面調(diào)用了一下該容器的方法焕数。雖然看代碼可能不理解,但是作者寫了一句注釋release in queue:某個(gè)對象的方法最后在某個(gè)線程調(diào)用刨啸,這個(gè)對象就會在當(dāng)前線程釋放堡赔。很明顯,這里是作者將節(jié)點(diǎn)的釋放放其他線程设联,從而減輕主線程的資源開銷善已。

(3)檢查內(nèi)存是否超限的定時(shí)任務(wù)

有這樣一段代碼:

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

可以看到,作者是使用一個(gè)遞歸+延時(shí)來實(shí)現(xiàn)定時(shí)任務(wù)的离例,這里可以自定義檢測的時(shí)間間隔换团。

(4)進(jìn)入后臺和內(nèi)存警告的處理

在該類初始化時(shí),作者寫了內(nèi)存警告和進(jìn)入后臺兩個(gè)監(jiān)聽:

[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidReceiveMemoryWarningNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidEnterBackgroundNotification) name:UIApplicationDidEnterBackgroundNotification object:nil];

然后可以由使用者定義在觸發(fā)響應(yīng)時(shí)是否需要清除內(nèi)存(簡化了一下代碼):

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

使用者還可以通過閉包實(shí)時(shí)監(jiān)聽宫蛆。

(5)讀數(shù)據(jù)

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

邏輯很簡單艘包,關(guān)鍵的一步是 node->_time = CACurrentMediaTime()[_lru bringNodeToHead:node] ;即更新這塊內(nèi)存的時(shí)間,然后將該節(jié)點(diǎn)移動到鏈表頭部想虎,實(shí)現(xiàn)了基于時(shí)間的優(yōu)先級排序衰絮,為 LRU 的實(shí)現(xiàn)提供了可靠的數(shù)據(jù)結(jié)構(gòu)基礎(chǔ)。

(6)寫數(shù)據(jù)

代碼有刪減磷醋,解析寫在代碼中:

- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
    ......
    pthread_mutex_lock(&_lock);
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    NSTimeInterval now = CACurrentMediaTime();
    if (node) {
//1 若緩存中有:修改node的變量,將該節(jié)點(diǎn)移動到頭部
        ......
        [_lru bringNodeToHead:node];
    } else {
//2 若緩存中沒有胡诗,創(chuàng)建一個(gè)內(nèi)存邓线,將該節(jié)點(diǎn)插入到頭部
        node = [_YYLinkedMapNode new];
        ......
        [_lru insertNodeAtHead:node];
    }
//3 判斷是否需要修剪內(nèi)存占用,若需要:異步修剪煌恢,保證寫入的性能
    if (_lru->_totalCost > _costLimit) {
        dispatch_async(_queue, ^{
            [self trimToCost:_costLimit];
        });
    }
//4 判斷是否需要修剪內(nèi)存塊數(shù)量骇陈,若需要:默認(rèn)在非主隊(duì)列釋放無用內(nèi)存,保證寫入的性能
    if (_lru->_totalCount > _countLimit) {
        _YYLinkedMapNode *node = [_lru removeTailNode];
        if (_lru->_releaseAsynchronously) {
            dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
            dispatch_async(queue, ^{
                [node class]; //hold and release in queue
            });
        } else if (_lru->_releaseOnMainThread && !pthread_main_np()) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [node class]; //hold and release in queue
            });
        }
    }
    pthread_mutex_unlock(&_lock);
}

二瑰抵、磁盤緩存:YYDiskCache

在暴露給用戶的 API 中你雌,磁盤緩存的功能和內(nèi)存緩存很像,同樣有讀寫數(shù)據(jù)和修剪數(shù)據(jù)等功能二汛。

YYDiskCache的磁盤緩存處理性能非常優(yōu)越婿崭,作者測試了數(shù)據(jù)庫和文件存儲的讀寫效率:iPhone 6 64G 下,SQLite 寫入性能比直接寫文件要高肴颊,但讀取性能取決于數(shù)據(jù)大忻フ弧:當(dāng)單條數(shù)據(jù)小于 20K 時(shí),數(shù)據(jù)越小 SQLite 讀取性能越高婿着;單條數(shù)據(jù)大于 20K 時(shí)授瘦,直接寫為文件速度會更快一些。(更詳細(xì)的說明看文末鏈接)

所以作者對磁盤緩存的處理方式為 SQLite 結(jié)合文件存儲的方式竟宋。

磁盤緩存的核心類是YYKVStorage提完,注意該類是非線程安全的,它主要封裝了 SQLite 數(shù)據(jù)庫的操作和文件存儲操作丘侠。

后文的剖析大部分的代碼都是在YYKVStorage文件中徒欣。

(1)磁盤緩存的文件結(jié)構(gòu)

首先,需要了解一下作者設(shè)計(jì)的在磁盤中的文件結(jié)構(gòu)(在YYKVStorage.m中作者的注釋):

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

path 是一個(gè)初始化時(shí)使用的變量婉陷,不同的 path 對應(yīng)不同的數(shù)據(jù)庫帚称。在 path 下面有 sqlite 數(shù)據(jù)庫相關(guān)的三個(gè)文件,以及兩個(gè)目錄(/data 和 /trash)秽澳,這兩個(gè)目錄就是文件存儲方便直接讀取的地方闯睹,也就是為了實(shí)現(xiàn)上文說的在高于某個(gè)臨界值時(shí)直接讀取文件比從數(shù)據(jù)庫讀取快的理論。

在數(shù)據(jù)庫中担神,建了一個(gè)表楼吃,表的結(jié)構(gòu)如上代碼所示:

  1. key 唯一標(biāo)識
  2. size 當(dāng)前內(nèi)存塊的大小。
  3. inline_data 使用者存儲內(nèi)容(value)的二進(jìn)制數(shù)據(jù)。
  4. last_access_time 最后訪問時(shí)間孩锡,便于磁盤緩存實(shí)現(xiàn) LRU 算法的數(shù)據(jù)結(jié)構(gòu)排序酷宵。
  5. filename 文件名,它指向直接存文件情況下的文件名躬窜,具體交互請往下看~

如何實(shí)現(xiàn) SQLite 結(jié)合文件存儲

這一個(gè)重點(diǎn)問題浇垦,就像之前說的,在某個(gè)臨界值時(shí)荣挨,直接讀取文件的效率要高于從數(shù)據(jù)庫讀取男韧,第一反應(yīng)可能是寫文件和寫數(shù)據(jù)庫分離,也就是上面的結(jié)構(gòu)中默垄,manifest.sqlite 數(shù)據(jù)庫文件和 /data 文件夾內(nèi)容無關(guān)聯(lián)此虑,讓 /data 去存儲高于臨界值的數(shù)據(jù),讓 sqlite 去存儲低于臨界值的數(shù)據(jù)口锭。

然而這樣會帶來兩個(gè)問題:

  1. /data 目錄下的緩存數(shù)據(jù)無法高速查找(可能只有遍歷)
  2. 無法統(tǒng)一管理磁盤緩存

為了完美處理該問題朦前,作者將它們結(jié)合了起來,所有關(guān)于用戶存儲數(shù)據(jù)的相關(guān)信息都會放在數(shù)據(jù)庫中(即剛才說的那個(gè)table中)鹃操,而待存儲數(shù)據(jù)的二進(jìn)制文件韭寸,卻根據(jù)情況分別處理:要么存在數(shù)據(jù)庫表的 inline_data 下,要么直接存儲在 /data 文件夾下荆隘。

如此一來棒仍,一切問題迎刃而解,下文根據(jù)源碼進(jìn)行驗(yàn)證和探究臭胜。

(2)數(shù)據(jù)庫表的OC模型體現(xiàn)

當(dāng)然莫其,為了讓接口可讀性更高,作者寫了一個(gè)對應(yīng)數(shù)據(jù)庫表的模型耸三,作為使用者實(shí)際業(yè)務(wù)使用的類:

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

該類的屬性和數(shù)據(jù)庫表的鍵一一對應(yīng)乱陡。

(3)數(shù)據(jù)庫的操作封裝

對于 sqlite 的封裝比較常規(guī),作者的容錯(cuò)處理做得很好仪壮,下面就一些重點(diǎn)地方做一些講解憨颠,對數(shù)據(jù)庫操作感興趣的朋友可以直接去看源碼。

sqlite3_stmt 緩存

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

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

作者使用了一個(gè) hash 容器來緩存 stmt积锅, 每次根據(jù) sql 生成 stmt 時(shí)爽彤,若已經(jīng)存在緩存就執(zhí)行一次 sqlite3_reset(stmt); 讓 stmt 回到初始狀態(tài)。

如此一來缚陷,提高了數(shù)據(jù)庫讀寫的效率适篙,是一個(gè)小 tip。

利用 sql 語句操作數(shù)據(jù)庫實(shí)現(xiàn) LRU

數(shù)據(jù)庫操作箫爷,仍然有根據(jù)占用內(nèi)存大小嚷节、最后訪問時(shí)間聂儒、內(nèi)存塊數(shù)量進(jìn)行修剪內(nèi)存的方法,下面就根據(jù)最后訪問時(shí)間進(jìn)行修剪方法做為例子:

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

可以看到硫痰,作者利用 sql 語句衩婚,很輕松的實(shí)現(xiàn)了內(nèi)存的修剪。

寫入時(shí)的核心邏輯

寫入時(shí)效斑,作者根據(jù)是否有 filename 判斷是否需要將寫入的數(shù)據(jù)二進(jìn)制存入數(shù)據(jù)庫(代碼有刪減):

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

若存在 filename 非春,雖然不會寫入數(shù)據(jù)庫,但是會直接寫入 /data 文件夾缓屠,這個(gè)邏輯是在本類的 public 方法中做的税娜。

(4)文件操作的封裝

主要是 NSFileManager 相關(guān)方法的基本使用,比較獨(dú)特的是藏研,作者使用了一個(gè)“垃圾箱”,也就是磁盤文件存儲結(jié)構(gòu)中的 /trash 目錄概行。

可以看到兩個(gè)方法:

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

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

上面?zhèn)€方法是將 /data 目錄下的文件移動到 /trash 目錄下蠢挡,下面?zhèn)€方法是將 /trash 目錄下的文件在異步線程清理掉。

筆者的理解:很容易想到凳忙,刪除文件是一個(gè)比較耗時(shí)的操作业踏,所以作者把它放到了一個(gè)專門的隊(duì)列處理。而刪除的文件用一個(gè)專門的路徑 /trash 放置涧卵,避免了寫入數(shù)據(jù)和刪除數(shù)據(jù)之間發(fā)生沖突勤家。試想,若刪除的邏輯和寫入的邏輯都是對 /data 目錄進(jìn)行操作柳恐,而刪除邏輯比較耗時(shí)伐脖,那么就會很容易出現(xiàn)誤刪等情況。

(5)YYDiskCache 對 YYKVStorage 的二次封裝

對于 YYKVStorage 類的公有方法乐设,筆者不做解析讼庇,就是對數(shù)據(jù)庫操作和寫文件操作的一個(gè)結(jié)合封裝,很簡單一看便知近尚。

作者不提倡直接使用非線程安全的 YYKVStorage 類蠕啄,所以封裝了一個(gè)線程安全的 YYDiskCache 類便于大家使用。

所以戈锻,YYDiskCache 類中主要是做了一些操作磁盤緩存的線程安全機(jī)制歼跟,是基于信號量(dispatch_semaphore)來處理的,暴露的接口中類似 YYMemoryCache 類的一系列方法格遭。

剩余磁盤空間的限制

磁盤緩存中哈街,多了一個(gè)如下修剪方法:

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

根據(jù)剩余的磁盤空間的限制進(jìn)行修剪,作者確實(shí)想得很周到拒迅。_YYDiskSpaceFree()是作者寫的一個(gè) c 方法叹卷,用于獲取剩余磁盤空間撼港。

MD5 加密 key

- (NSString *)_filenameForKey:(NSString *)key {
    NSString *filename = nil;
    if (_customFileNameBlock) filename = _customFileNameBlock(key);
    if (!filename) filename = _YYNSStringMD5(key);
    return filename;
}

filename 是作者根據(jù)使用者傳入的 key 做一次 MD5 加密所得的字符串,所以不要誤以為文件名就是你傳入的 key (_YYNSStringMD5()是作者寫的一個(gè)加密方法)骤竹。當(dāng)然帝牡,框架提供了一個(gè) _customFileNameBlock 允許你自定義文件名。

同時(shí)提供同步和異步接口

可以看到諸如此類的設(shè)計(jì):

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

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

由于可能存儲的文件過大蒙揣,在讀寫時(shí)會占用過多的資源靶溜,所以作者對于這些操作都分別提供了同步和異步的接口,可謂非常人性化懒震,這也是接口設(shè)計(jì)的一些值得學(xué)習(xí)的地方罩息。

三、綜合封裝:YYCache

實(shí)際上上文的剖析已經(jīng)囊括了 YYCache 框架的核心了个扰。YYCache 類主要是對內(nèi)存緩存和磁盤緩存的結(jié)合封裝瓷炮,代碼很簡單,有一點(diǎn)需要提出來:

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

優(yōu)先查找內(nèi)存緩存_memoryCache中的數(shù)據(jù)递宅,若查不到娘香,就查詢磁盤緩存_diskCache,查詢磁盤緩存成功办龄,將數(shù)據(jù)同步到內(nèi)存緩存中逗旁,方便下次查找朵你。

這么做的理由很簡單:根據(jù)機(jī)械原理抹缕,較大的存儲設(shè)備要比較小的存儲設(shè)備運(yùn)行得慢俊嗽,而快速設(shè)備的造價(jià)遠(yuǎn)高于低速設(shè)備。所以內(nèi)存緩存的讀寫速度遠(yuǎn)高于磁盤緩存英融。這也是開發(fā)中緩存設(shè)計(jì)的核心問題盏檐,我們既要保證緩存讀寫的效率,又要考慮到空間占用驶悟,其實(shí)又回到了空間和時(shí)間的權(quán)衡問題了糯笙。

寫在后面

YYCache 核心邏輯思路、接口設(shè)計(jì)撩银、代碼組織架構(gòu)给涕、容錯(cuò)處理、性能優(yōu)化额获、內(nèi)存管理够庙、線程安全這些方面都做得很好很極致,閱讀起來非常舒服抄邀。

閱讀開源框架耘眨,第一步一定是通讀一下 API 了解該框架是干什么的,然后采用“分治”的思路逐個(gè)擊破境肾,類比“歸并算法”:先拆開再合并剔难,切勿想一口吃成胖子胆屿,特別是對于某些“重量級”框架。

希望讀者朋友們閱讀過后有所收獲??偶宫。

參考文獻(xiàn):作者 ibireme 的博客 YYCache 設(shè)計(jì)思路

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末非迹,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子纯趋,更是在濱河造成了極大的恐慌憎兽,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件吵冒,死亡現(xiàn)場離奇詭異纯命,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)痹栖,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進(jìn)店門亿汞,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人揪阿,你說我怎么就攤上這事疗我。” “怎么了图甜?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長鳖眼。 經(jīng)常有香客問我黑毅,道長,這世上最難降的妖魔是什么钦讳? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任矿瘦,我火速辦了婚禮,結(jié)果婚禮上愿卒,老公的妹妹穿的比我還像新娘缚去。我一直安慰自己,他們只是感情好琼开,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布易结。 她就那樣靜靜地躺著,像睡著了一般柜候。 火紅的嫁衣襯著肌膚如雪搞动。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天渣刷,我揣著相機(jī)與錄音鹦肿,去河邊找鬼。 笑死辅柴,一個(gè)胖子當(dāng)著我的面吹牛箩溃,可吹牛的內(nèi)容都是我干的瞭吃。 我是一名探鬼主播,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼涣旨,長吁一口氣:“原來是場噩夢啊……” “哼歪架!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起开泽,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤牡拇,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后穆律,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體惠呼,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年峦耘,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了剔蹋。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,137評論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡辅髓,死狀恐怖泣崩,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情洛口,我是刑警寧澤矫付,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站第焰,受9級特大地震影響买优,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜挺举,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一杀赢、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧湘纵,春花似錦脂崔、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至铺敌,卻和暖如春绊困,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背适刀。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工秤朗, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人笔喉。 一個(gè)月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓取视,卻偏偏與公主長得像硝皂,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子作谭,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容