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

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

寫(xiě)在前面

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

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

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

源碼基于 1.0.4 版本。

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

總覽 API ,會(huì)發(fā)現(xiàn)一些見(jià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;
......

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

對(duì)該類的基本功能有了了解之后,就可以直接切實(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)該一眼就看出來(lái)貓膩,作者是使用的一個(gè)雙向鏈表+散列容器來(lái)實(shí)現(xiàn) LRU 的。

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

  1. 同時(shí)使用前驅(qū)和后繼指針(即_prev_next)是為了快速找到前驅(qū)和后繼節(jié)點(diǎn)。
  2. 這里使用__unsafe_unretained而不使用__weak。雖然兩者都不會(huì)持有指針?biāo)赶虻膶?duì)象,但是在指向?qū)ο筢尫艜r(shí),前者并不會(huì)自動(dòng)置空指針,形成野指針,不過(guò)經(jīng)過(guò)筆者后面的閱讀,發(fā)現(xiàn)作者避免了野指針的出現(xiàn);而且從性能層面看(作者原話):訪問(wèn)具有 __weak 屬性的變量時(shí),實(shí)際上會(huì)調(diào)用objc_loadWeak()objc_storeWeak()來(lái)完成,這也會(huì)帶來(lái)很大的開(kāi)銷(xiāo),所以要避免使用__weak屬性。
  3. _key_value就是框架使用者想要存儲(chǔ)的鍵值對(duì),可以看出作者的設(shè)計(jì)是一個(gè)鍵值對(duì)對(duì)應(yīng)一個(gè)節(jié)點(diǎn)(_YYLinkedMapNode)。
  4. _cost_time表示該節(jié)點(diǎn)的內(nèi)存大小和最后訪問(wèn)的時(shí)間。

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

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

既然是 LRU 算法,怎么能只有數(shù)據(jù)結(jié)構(gòu),往下面看 _YYLinkedMap 類實(shí)現(xiàn)了如下算法(嗯,挺常規(guī)的節(jié)點(diǎn)操作,代碼質(zhì)量挺高的,就不說(shuō)明實(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)存的邏輯

正如一開(kā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)

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

鎖的處理

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

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

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

筆者的理解:
自動(dòng)的遞歸修剪邏輯是這樣的:

- (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)修剪過(guò)程不存在線程安全問(wèn)題,當(dāng)然框架還暴露了修剪內(nèi)存的方法給外部使用,那么當(dāng)外部在多線程調(diào)用修剪內(nèi)存方法就可能會(huì)出現(xiàn)線程安全問(wèn)題。

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

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

異步線程釋放資源

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

(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í)來(lái)實(shí)現(xiàn)定時(shí)任務(wù)的,這里可以自定義檢測(cè)的時(shí)間間隔。

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

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

[[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)存(簡(jiǎn)化了一下代碼):

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

使用者還可以通過(guò)閉包實(shí)時(shí)監(jiān)聽(tīng)。

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

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

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

代碼有刪減,解析寫(xiě)在代碼中:

- (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)移動(dòng)到頭部
        ......
        [_lru bringNodeToHead:node];
    } else {
//2 若緩存中沒(méi)有,創(chuàng)建一個(gè)內(nèi)存,將該節(jié)點(diǎn)插入到頭部
        node = [_YYLinkedMapNode new];
        ......
        [_lru insertNodeAtHead:node];
    }
//3 判斷是否需要修剪內(nèi)存占用,若需要:異步修剪,保證寫(xiě)入的性能
    if (_lru->_totalCost > _costLimit) {
        dispatch_async(_queue, ^{
            [self trimToCost:_costLimit];
        });
    }
//4 判斷是否需要修剪內(nèi)存塊數(shù)量,若需要:默認(rèn)在非主隊(duì)列釋放無(wú)用內(nèi)存,保證寫(xiě)入的性能
    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);
}

二、磁盤(pán)緩存:YYDiskCache

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

YYDiskCache的磁盤(pán)緩存處理性能非常優(yōu)越,作者測(cè)試了數(shù)據(jù)庫(kù)和文件存儲(chǔ)的讀寫(xiě)效率:iPhone 6 64G 下,SQLite 寫(xiě)入性能比直接寫(xiě)文件要高,但讀取性能取決于數(shù)據(jù)大小:當(dāng)單條數(shù)據(jù)小于 20K 時(shí),數(shù)據(jù)越小 SQLite 讀取性能越高;單條數(shù)據(jù)大于 20K 時(shí),直接寫(xiě)為文件速度會(huì)更快一些。(更詳細(xì)的說(shuō)明看文末鏈接)

所以作者對(duì)磁盤(pán)緩存的處理方式為 SQLite 結(jié)合文件存儲(chǔ)的方式。

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

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

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

首先,需要了解一下作者設(shè)計(jì)的在磁盤(pán)中的文件結(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 對(duì)應(yīng)不同的數(shù)據(jù)庫(kù)。在 path 下面有 sqlite 數(shù)據(jù)庫(kù)相關(guān)的三個(gè)文件,以及兩個(gè)目錄(/data 和 /trash),這兩個(gè)目錄就是文件存儲(chǔ)方便直接讀取的地方,也就是為了實(shí)現(xiàn)上文說(shuō)的在高于某個(gè)臨界值時(shí)直接讀取文件比從數(shù)據(jù)庫(kù)讀取快的理論。

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

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

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

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

然而這樣會(huì)帶來(lái)兩個(gè)問(wèn)題:

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

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

如此一來(lái),一切問(wèn)題迎刃而解,下文根據(jù)源碼進(jìn)行驗(yàn)證和探究。

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

當(dāng)然,為了讓接口可讀性更高,作者寫(xiě)了一個(gè)對(duì)應(yīng)數(shù)據(jù)庫(kù)表的模型,作為使用者實(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ù)庫(kù)表的鍵一一對(duì)應(yīng)。

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

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

sqlite3_stmt 緩存

YYKVStorage 類有這樣一個(gè)變量:CFMutableDictionaryRef _dbStmtCache;
通過(guò) 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 容器來(lái)緩存 stmt, 每次根據(jù) sql 生成 stmt 時(shí),若已經(jīng)存在緩存就執(zhí)行一次 sqlite3_reset(stmt); 讓 stmt 回到初始狀態(tài)。

如此一來(lái),提高了數(shù)據(jù)庫(kù)讀寫(xiě)的效率,是一個(gè)小 tip。

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

數(shù)據(jù)庫(kù)操作,仍然有根據(jù)占用內(nèi)存大小、最后訪問(wèn)時(shí)間、內(nèi)存塊數(shù)量進(jìn)行修剪內(nèi)存的方法,下面就根據(jù)最后訪問(wèn)時(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 語(yǔ)句,很輕松的實(shí)現(xiàn)了內(nèi)存的修剪。

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

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

- (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 ,雖然不會(huì)寫(xiě)入數(shù)據(jù)庫(kù),但是會(huì)直接寫(xiě)入 /data 文件夾,這個(gè)邏輯是在本類的 public 方法中做的。

(4)文件操作的封裝

主要是 NSFileManager 相關(guān)方法的基本使用,比較獨(dú)特的是,作者使用了一個(gè)“垃圾箱”,也就是磁盤(pán)文件存儲(chǔ)結(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 目錄下的文件移動(dòng)到 /trash 目錄下,下面?zhèn)€方法是將 /trash 目錄下的文件在異步線程清理掉。

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

(5)YYDiskCache 對(duì) YYKVStorage 的二次封裝

對(duì)于 YYKVStorage 類的公有方法,筆者不做解析,就是對(duì)數(shù)據(jù)庫(kù)操作和寫(xiě)文件操作的一個(gè)結(jié)合封裝,很簡(jiǎn)單一看便知。

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

所以,YYDiskCache 類中主要是做了一些操作磁盤(pán)緩存的線程安全機(jī)制,是基于信號(hào)量(dispatch_semaphore)來(lái)處理的,暴露的接口中類似 YYMemoryCache 類的一系列方法。

剩余磁盤(pán)空間的限制

磁盤(pán)緩存中,多了一個(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ù)剩余的磁盤(pán)空間的限制進(jìn)行修剪,作者確實(shí)想得很周到。_YYDiskSpaceFree()是作者寫(xiě)的一個(gè) c 方法,用于獲取剩余磁盤(pán)空間。

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()是作者寫(xiě)的一個(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);
    });
}

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

三、綜合封裝:YYCache

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

- (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ù),若查不到,就查詢磁盤(pán)緩存_diskCache,查詢磁盤(pán)緩存成功,將數(shù)據(jù)同步到內(nèi)存緩存中,方便下次查找。

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

寫(xiě)在后面

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

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

希望讀者朋友們閱讀過(guò)后有所收獲??。

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容