深入理解YYCache


前言

本篇文章將帶來YYCache的解讀,YYCache支持內存和本地兩種方式的數據存儲。我們先拋出兩個問題:

  • YYCache是如何把數據寫入內存之中的?又是如何實現的高效讀取?
  • YYCache采用了何種方式把數據寫入磁盤?

這次的解讀跟之前的源碼解讀不同,我只會展示重要部分的代碼,因為我們學習YYCache的目的是學習作者的思路,順便學習一下實現這些功能所用到的技術。

YYMemoryCache

我們使用YYMemoryCache可以把數據緩存進內存之中,它內部會創建了一個YYMemoryCache對象,然后把數據保存進這個對象之中。

但凡涉及到類似這樣的操作,代碼都需要設計成線程安全的。所謂的線程安全就是指充分考慮多線程條件下的增刪改查操作。

我們應該養成這樣的習慣:在寫任何類的時候都把該類當做框架來寫,因此需要設計好暴露出來的接口,這也正符合代碼封裝的思想。

YYMemoryCache暴露出來的接口我們在此就略過了,我們都知道要想高效的查詢數據,使用字典是一個很好的方法。字典的原理跟哈希有關,總之就是把key直接映射成內存地址,然后處理沖突和和擴容的問題。對這方面有興趣的可以自行搜索資料。

YYMemoryCache內部封裝了一個對象_YYLinkedMap,包含了下邊這些屬性:

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

可以看出來,CFMutableDictionaryRef _dic將被用來保存數據。這里使用了CoreFoundation的字典,性能更好。字典里邊保存着的是_YYLinkedMapNode 對象。

/**
 A node in linked map.
 Typically, you should not use this class directly.
 */
@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;
}
@end

但看上邊的代碼,就能知道使用了鏈表的知識。但是有一個疑問,單用字典我們就能很快的查詢出數據,為什么還要實現鏈表這一數據結構呢?

答案就是淘汰算法,YYMemoryCache使用了LRU淘汰算法,也就是當數據超過某個限制條件后,我們會從鏈表的尾部開始刪除數據,直到達到要求為止。

通過這種方式,就實現了類似數組的功能,是原本無序的字典成了有序的集合。

我們簡單看一段把一個節點插入到最開始位置的代碼:

- (void)bringNodeToHead:(_YYLinkedMapNode *)node {
    if (_head == node) return;
    
    if (_tail == node) {
        _tail = node->_prev;
        _tail->_next = nil;
    } else {
        node->_next->_prev = node->_prev;
        node->_prev->_next = node->_next;
    }
    node->_next = _head;
    node->_prev = nil;
    _head->_prev = node;
    _head = node;
}

如果有一列數據已經按順序排好了,我使用了中間的某個數據,那么就要把這個數據插入到最開始的位置,這就是一條規則,越是最近使用的越靠前。

在設計上,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];
    });
}

可以看出處理數據,做了三件事,他們內部的實現基本是一樣的,我們選取第一個方法來看看代碼:

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

這段代碼很經典,可以直接拿來用,我們在某個處理數據的類中,可以直接使用類似這樣的代碼。如果鎖正在使用,那么可以使用usleep(10 * 1000); //10 ms等待一小段時間。上邊的代碼把需要刪除的數據,首先添加到一個數組中,然后使用[holder count]; // release in queue釋放了資源。

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

YYKVStorage

我發現隨着編碼經驗的不斷增加,會不經意間學會模仿這一技能。但有一點,我們必須發現那些出彩的地方,因此,我認為深入理解的本質就是學習該框架的核心思想。

上一小節中,我們已經明白了YYMemoryCache實際上就是創建了一個對象實例,該對象內部使用字典和雙向鏈表實現。YYKVStorage最核心的思想是KV這兩個字母,表示key-value的意思,目的是讓使用者像使用字典一樣操作數據。

我們應該明白,封裝具有層次性,不建議用一層封裝來封裝復雜的功能。

YYKVStorage讓我們只關心3件事:

  1. 數據保存的路徑
  2. 保存數據,並為該數據關聯一個key
  3. 根據key取出數據或刪除數據

同理,YYKVStorage在設計接口的時候,也從這3個方面進行了考慮。這數據功能設計層面的思想。

在真實的編程中,往往需要把數據封裝成一個對象:

/**
 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; ///< 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

上邊的代碼就是對每條數據的一個封裝,在我封裝的MCDownloader(iOS下載器)說明書中,也是用了類似的技術。當然,在YYKVStorage中,我們並不需要是用上邊的對象。

我們看一些借口設計方面的內容:

#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

我們現在來分析YYKVStorage.m的代碼:

static const NSUInteger kMaxErrorRetryCount = 8;
static const NSTimeInterval kMinRetryTimeInterval = 2.0;
static const int kPathLengthMax = PATH_MAX - 64;
static NSString *const kDBFileName = @"manifest.sqlite";
static NSString *const kDBShmFileName = @"manifest.sqlite-shm";
static NSString *const kDBWalFileName = @"manifest.sqlite-wal";
static NSString *const kDataDirectoryName = @"data";
static NSString *const kTrashDirectoryName = @"trash";

代碼的這種寫法,應該不用我說了吧,如果你平時開發沒用到過,那么就要認真去查資料了。

/*
 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);
 */

在我看來這是超級贊的注釋了。在我個人角度來說,我認為大多數人的注釋都寫不好,也包括我自己。從上邊的注釋的內容,我們能夠很容易明白YYKVStorage的數據保存結構,和數據庫的設計細節。

上圖中這些函數都是跟數據庫有關的函數,我們在這里也不會把代碼弄上來。我個人對這些函數的總結是:

  • 每個函數只實現先單一功能,函數組合使用形成新的功能
  • 對於類內部的私有方法,前邊添加_
  • 使用預處理stmt對數據庫進行了優化,避免不必要的開銷
  • 健壯的錯誤處理機制
  • 可以說是使用iOS自帶sqlite3的經典代碼,在項目中可以直接拿來用

這也許就是函數的魅力,有了這些函數,那么在給接口中的函數寫邏輯的時候就會變得很簡單。

有一個很重要的前提,這些函數都是線程不安全的。因此在使用中需要考慮多線程的問題,這也正是我們下一小節YYDiskCache的內容。

數據庫增刪改查的思想基本上都差不多,我以后會寫一篇介紹數據庫的文章。

建議大家一定要讀讀YYKVStorage這個類的源碼,這是一個類的典型設計。它內部使用了兩種方式保存數據:一種是保存到數據庫中,另一種是直接寫入文件。當數據較大時,使用文件寫入性能更好,反之數據庫更好。

YYDiskCache

上一小節我們已經明白了YYKVStorage實現了所有的數據存儲的功能,但缺點是它不是線程安全的,因此在YYKVStorage的基礎之上,YYDiskCache保證了線程的安全。

一個類提供什么樣的功能,這屬於程序設計的范疇,YYDiskCache的接口設計在YYKVStorage的基礎上添加了一些新的特性。比如:

/**
 If this block is not nil, then the block will be used to archive object instead
 of NSKeyedArchiver. You can use this block to support the objects which do not
 conform to the `NSCoding` protocol.
 
 The default value is nil.
 */
@property (nullable, copy) NSData *(^customArchiveBlock)(id object);

/**
 If this block is not nil, then the block will be used to unarchive object instead
 of NSKeyedUnarchiver. You can use this block to support the objects which do not
 conform to the `NSCoding` protocol.
 
 The default value is nil.
 */
@property (nullable, copy) id (^customUnarchiveBlock)(NSData *data);

使用上邊的屬性可以設置對象與NSData之間轉化的規則,這和很多框架一樣,目的是給該類增加一些額外的特性。

還是那句話,設計一個存儲類,需要考慮下邊幾個特性:

  • 標識,在YYDiskCache中使用path作為存儲位置的標識,使用key作為value的標識
  • 操作方法 包含增刪改查
  • 限制條件 包括count,cost,age
  • 其他

我們來看看YYDiskCache.m的核心內容。我們來分析分析下邊這段代碼:

static YYDiskCache *_YYDiskCacheGetGlobal(NSString *path) {
    if (path.length == 0) return nil;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    id cache = [_globalInstances objectForKey:path];
    dispatch_semaphore_signal(_globalInstancesLock);
    return cache;
}

YYDiskCache內部實現了一種這樣的機制,他會把開發者創建的每一個YYDiskCache對象保存到一個全局的集合中,YYDiskCache根據path創建,如果開發者創建了相同path的YYDiskCache,那么就會返回全局集合中的YYDiskCache。

這里就產生了一個很重要的概念,在全局對象中的YYDiskCache是可以釋放的。為什么會發生這種事呢?按理說全局對象引用了YYDiskCache,它就不應該被釋放的。這個問題我們馬上就會給出答案。

繼續分析上邊的代碼:

static YYDiskCache *_YYDiskCacheGetGlobal(NSString *path)這種風格的代碼是值得學習的第一點,如果在一個文件中,有一些方法是不依賴某個對象的,那么我們就可以寫成這種形式,它可以跨對象調用,因此這算是私有函數的一種寫法吧。

if (path.length == 0) return nil;這個不用多說,健壯的函數內部都要有檢驗參數的代碼。

_YYDiskCacheInitGlobal();從函數的名字,我們可以猜測出它是一個初始化全局對象的方法,它內部引出了一個很重要的對象:

static void _YYDiskCacheInitGlobal() {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _globalInstancesLock = dispatch_semaphore_create(1);
        _globalInstances = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
    });
}

大家對NSMapTable可能不太熟悉,他其實和NSMutableDictionary非常相似,我們都知道字典的key值copy的,他必須實現NSCopying協議,如果key的值改變了,就無法獲取value了。而NSMapTable使用起來更加自由,我們可以操縱key,value的weak和strong特性,關於NSMapTable的詳細使用方法,大家可以自行去搜索相關的內容。在上邊的代碼中,_globalInstances的中value被設置為NSPointerFunctionsWeakMemory,也就是說,當_globalInstances添加了一個對象后,該對象的引用計數器不會加1.當該對象沒有被任何其他對象引用的時候就會釋放。

在網上看着這樣一個例子:

Person *p1 = [[Person alloc] initWithName:@"jack"];
Favourite *f1 = [[Favourite alloc] initWithName:@"ObjC"];
 
Person *p2 = [[Person alloc] initWithName:@"rose"];
Favourite *f2 = [[Favourite alloc] initWithName:@"Swift"];
 
NSMapTable *MapTable = [NSMapTable mapTableWithKeyOptions:NSMapTableWeakMemory valueOptions:NSMapTableWeakMemory];
// 設置對應關系表
// p1 => f1;
// p2 => f2
[MapTable setObject:f1 forKey:p1];
[MapTable setObject:f2 forKey:p2];
 
NSLog(@"%@ %@", p1, [MapTable objectForKey:p1]);
NSLog(@"%@ %@", p2, [MapTable objectForKey:p2]);

上邊的代碼中,使用NSMapTable讓不同類型的對象一一對應起來,這種方式的最大好處是我們可以把一個View或者Controller當做key都沒問題,怎么使用全憑想象啊。

在網上看到一個這樣的例子,他把一些控制器保存到了MapTable之中,然后在想要使用的時候直接讀取出來就行了。不會對控制器造成任何影響。

我們繼續分析代碼:

dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
id cache = [_globalInstances objectForKey:path];
dispatch_semaphore_signal(_globalInstancesLock);

dispatch_semaphore_wait配合dispatch_semaphore_signal實現加鎖解鎖的功能,這個沒什么好說的,可以大膽使用。

沒有讀過源碼的同學,一定要讀一讀YYDiskCache的源碼,和YYKVStorage一樣有很多代碼可以直接拿來用。

YYCache

當我們讀到YYCache的時候,感覺一下子就輕松了很多,YYCache就是對YYMemoryCache和YYDiskCache的綜合運用,創建YYCache對象后,就創建了一個YYMemoryCache對象和一個YYDiskCache對象。唯一新增的特性就是可以根據name來創建YYCache,內部會根據那么來創建一個path,本質上還是使用path定位的。

Summary

第一次以這樣的方式寫博客,我發現好處很多,把很大一部分不是學習重點的代碼過濾掉為我節省了大量時間。我們不可能記住所有的代碼,當要用某些知識的時候,知道去哪找就可以了。

寫代碼就是一個不斷模仿,不斷進步的過程。

感謝YYCache的作者開源了這么好的東西


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM