YYCache 源码剖析:一览亮点

1,836 阅读17分钟

YYKit 系列源码剖析文章:

写在前面

YYCache 作为当下 iOS 圈最流行的缓存框架,有着优越的性能和绝佳的设计。笔者花了些时间对其“解剖”了一番,发现了很多有意思的东西,所以写下本文分享一下。

考虑到篇幅,笔者对于源码的解析不会过多的涉及 API 使用和一些基础知识,更多的是剖析作者 ibireme 的设计思维和重要技术实现细节。

YYCache 主要分为两部分:内存缓存和磁盘缓存(对应 YYMemoryCacheYYDiskCache)。在日常开发业务使用中,多是直接操作 YYCache 类,该类是对内存缓存功能和磁盘缓存功能的一个简单封装。

源码基于 1.0.4 版本。

一、内存缓存:YYMemoryCache

总览 API ,会发现一些见名知意的方法:

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

可以看出,该类主要包含读写功能和修剪功能(修剪是为了控制内存缓存的大小等)。当然,还有其他一些自定义方法,比如释放操作的线程选择、内存警告和进入后台时是否清除内存缓存等。

对该类的基本功能有了了解之后,就可以直接切实现源码了。

(1)LRU 缓存淘汰算法

既然有修剪缓存的功能,必然涉及到一个缓存淘汰算法,YYMemoryCacheYYDiskCache 都是实现的 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;
}

熟悉链表的朋友应该一眼就看出来猫腻,作者是使用的一个双向链表+散列容器来实现 LRU 的。

链表的节点 (_YYLinkedMapNode):

  1. 同时使用前驱和后继指针(即_prev_next)是为了快速找到前驱和后继节点。
  2. 这里使用__unsafe_unretained而不使用__weak。虽然两者都不会持有指针所指向的对象,但是在指向对象释放时,前者并不会自动置空指针,形成野指针,不过经过笔者后面的阅读,发现作者避免了野指针的出现;而且从性能层面看(作者原话):访问具有 __weak 属性的变量时,实际上会调用objc_loadWeak()objc_storeWeak()来完成,这也会带来很大的开销,所以要避免使用__weak属性。
  3. _key_value就是框架使用者想要存储的键值对,可以看出作者的设计是一个键值对对应一个节点(_YYLinkedMapNode)。
  4. _cost_time表示该节点的内存大小和最后访问的时间。

LRU 实现类 (_YYLinkedMap) :

  1. 包含头尾指针(_head_tail),保证双端查询的效率。
  2. _totalCost_totalCount记录最大内存占用限制和数量限制。
  3. _releaseOnMainThread_releaseAsynchronously分别表示在主线程释放和在异步线程释放,它们的实现后文会讲到。
  4. _dic变量是 OC 开发中常用的散列容器,所有节点都会在_dic中以 key-value 的形式存在,保证常数级查询效率。

既然是 LRU 算法,怎么能只有数据结构,往下面看 _YYLinkedMap 类实现了如下算法(嗯,挺常规的节点操作,代码质量挺高的,就不说明实现了):

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

现在 LRU 的数据结构和操作算法实现都有了,就可以看具体的业务了。

##(2)修剪内存的逻辑

正如一开始贴的 API ,该类有三种修剪内存的依据:根据缓存的内存块数量、根据占用内存大小、根据是否是最近使用。它们的实现逻辑几乎一样,这里就其中一个为例子(代码有删减):

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

这里有几个重要技术点,很有意思。

释放尾节点

通过一个 while 循环不断释放尾节点removeTailNode,直到满足参数ageLimit对时间的要求,而该链表的排序规则是:最近使用的内存块会移动到链表头部,也就保证了删除的内存永远是最不常使用的(后面会看到如何实现排序的)。

锁的处理

不妨思考这样一个问题:为何要使用pthread_mutex_trylock()方法尝试获取锁,而获取失败过后做了一个线程挂起操作usleep()

**优先级反转:**比如两个线程 A 和 B,优先级 A < B。当 A 获取锁访问共享资源时,B 尝试获取锁,那么 B 就会进入忙等状态,忙等时间越长对 CPU 资源的占用越大;而由于 A 的优先级低于 B,A 无法与高优先级的线程争夺 CPU 资源,从而导致任务迟迟完成不了。解决优先级反转的方法有“优先级天花板”和“优先级继承”,它们的核心操作都是提升当前正在访问共享资源的线程的优先级。

**历史情况:**在老版本的代码中,作者是使用的OSSpinLock自旋锁来保证线程安全,而后来由于OSSpinLock的 bug 问题(存在潜在的优先级反转BUG),作者将其替换成了pthread_mutex_t互斥锁。

笔者的理解: 自动的递归修剪逻辑是这样的:

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

_queue是一个串行队列:

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

可以明确的是,自动修剪过程不存在线程安全问题,当然框架还暴露了修剪内存的方法给外部使用,那么当外部在多线程调用修剪内存方法就可能会出现线程安全问题。

这里做了一个 10ms 的挂起操作然后循环尝试,直接舍弃了互斥锁的空转期,但这样也避免了多线程访问下过多的空转占用过多的 CPU 资源。作者这样处理很可能加长了修剪内存的时间,但是却避免了极限情况下空转对 CPU 的占用。

显然,作者是期望使用者在后台线程修剪内存(最好使用者不去显式的调用修剪内存方法)。

异步线程释放资源

这里作者使用了一个容器将要释放的节点装起来,然后在某个队列(默认是非主队列)里面调用了一下该容器的方法。虽然看代码可能不理解,但是作者写了一句注释release in queue:某个对象的方法最后在某个线程调用,这个对象就会在当前线程释放。很明显,这里是作者将节点的释放放其他线程,从而减轻主线程的资源开销。

##(3)检查内存是否超限的定时任务 有这样一段代码:

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

可以看到,作者是使用一个递归+延时来实现定时任务的,这里可以自定义检测的时间间隔。

##(4)进入后台和内存警告的处理 在该类初始化时,作者写了内存警告和进入后台两个监听:

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

使用者还可以通过闭包实时监听。

##(5)读数据

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

逻辑很简单,关键的一步是 node->_time = CACurrentMediaTime()[_lru bringNodeToHead:node] ;即更新这块内存的时间,然后将该节点移动到链表头部,实现了基于时间的优先级排序,为 LRU 的实现提供了可靠的数据结构基础。

##(6)写数据 代码有删减,解析写在代码中:

- (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的变量,将该节点移动到头部
        ......
        [_lru bringNodeToHead:node];
    } else {
//2 若缓存中没有,创建一个内存,将该节点插入到头部
        node = [_YYLinkedMapNode new];
        ......
        [_lru insertNodeAtHead:node];
    }
//3 判断是否需要修剪内存占用,若需要:异步修剪,保证写入的性能
    if (_lru->_totalCost > _costLimit) {
        dispatch_async(_queue, ^{
            [self trimToCost:_costLimit];
        });
    }
//4 判断是否需要修剪内存块数量,若需要:默认在非主队列释放无用内存,保证写入的性能
    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 中,磁盘缓存的功能和内存缓存很像,同样有读写数据和修剪数据等功能。

YYDiskCache的磁盘缓存处理性能非常优越,作者测试了数据库和文件存储的读写效率:iPhone 6 64G 下,SQLite 写入性能比直接写文件要高,但读取性能取决于数据大小:当单条数据小于 20K 时,数据越小 SQLite 读取性能越高;单条数据大于 20K 时,直接写为文件速度会更快一些。(更详细的说明看文末链接)

所以作者对磁盘缓存的处理方式为 SQLite 结合文件存储的方式。

磁盘缓存的核心类是YYKVStorage,注意该类是非线程安全的,它主要封装了 SQLite 数据库的操作和文件存储操作。

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

##(1)磁盘缓存的文件结构 首先,需要了解一下作者设计的在磁盘中的文件结构(在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 是一个初始化时使用的变量,不同的 path 对应不同的数据库。在 path 下面有 sqlite 数据库相关的三个文件,以及两个目录(/data 和 /trash),这两个目录就是文件存储方便直接读取的地方,也就是为了实现上文说的在高于某个临界值时直接读取文件比从数据库读取快的理论。

在数据库中,建了一个表,表的结构如上代码所示:

  1. key 唯一标识
  2. size 当前内存块的大小。
  3. inline_data 使用者存储内容(value)的二进制数据。
  4. last_access_time 最后访问时间,便于磁盘缓存实现 LRU 算法的数据结构排序。
  5. filename 文件名,它指向直接存文件情况下的文件名,具体交互请往下看~

如何实现 SQLite 结合文件存储

这一个重点问题,就像之前说的,在某个临界值时,直接读取文件的效率要高于从数据库读取,第一反应可能是写文件和写数据库分离,也就是上面的结构中,manifest.sqlite 数据库文件和 /data 文件夹内容无关联,让 /data 去存储高于临界值的数据,让 sqlite 去存储低于临界值的数据。

然而这样会带来两个问题:

  1. /data 目录下的缓存数据无法高速查找(可能只有遍历)
  2. 无法统一管理磁盘缓存

为了完美处理该问题,作者将它们结合了起来,所有关于用户存储数据的相关信息都会放在数据库中(即刚才说的那个table中),而待存储数据的二进制文件,却根据情况分别处理:要么存在数据库表的 inline_data 下,要么直接存储在 /data 文件夹下。

如此一来,一切问题迎刃而解,下文根据源码进行验证和探究。

##(2)数据库表的OC模型体现 当然,为了让接口可读性更高,作者写了一个对应数据库表的模型,作为使用者实际业务使用的类:

@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

该类的属性和数据库表的键一一对应。

##(3)数据库的操作封装

对于 sqlite 的封装比较常规,作者的容错处理做得很好,下面就一些重点地方做一些讲解,对数据库操作感兴趣的朋友可以直接去看源码。

sqlite3_stmt 缓存

YYKVStorage 类有这样一个变量: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;
}

作者使用了一个 hash 容器来缓存 stmt, 每次根据 sql 生成 stmt 时,若已经存在缓存就执行一次 sqlite3_reset(stmt); 让 stmt 回到初始状态。

如此一来,提高了数据库读写的效率,是一个小 tip。

利用 sql 语句操作数据库实现 LRU

数据库操作,仍然有根据占用内存大小、最后访问时间、内存块数量进行修剪内存的方法,下面就根据最后访问时间进行修剪方法做为例子:

- (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 语句,很轻松的实现了内存的修剪。

写入时的核心逻辑

写入时,作者根据是否有 filename 判断是否需要将写入的数据二进制存入数据库(代码有删减):

- (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 ,虽然不会写入数据库,但是会直接写入 /data 文件夹,这个逻辑是在本类的 public 方法中做的。

##(4)文件操作的封装 主要是 NSFileManager 相关方法的基本使用,比较独特的是,作者使用了一个“垃圾箱”,也就是磁盘文件存储结构中的 /trash 目录。

可以看到两个方法:

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

上面个方法是将 /data 目录下的文件移动到 /trash 目录下,下面个方法是将 /trash 目录下的文件在异步线程清理掉。

**笔者的理解:**很容易想到,删除文件是一个比较耗时的操作,所以作者把它放到了一个专门的队列处理。而删除的文件用一个专门的路径 /trash 放置,避免了写入数据和删除数据之间发生冲突。试想,若删除的逻辑和写入的逻辑都是对 /data 目录进行操作,而删除逻辑比较耗时,那么就会很容易出现误删等情况。

##(5)YYDiskCache 对 YYKVStorage 的二次封装 对于 YYKVStorage 类的公有方法,笔者不做解析,就是对数据库操作和写文件操作的一个结合封装,很简单一看便知。

作者不提倡直接使用非线程安全的 YYKVStorage 类,所以封装了一个线程安全的 YYDiskCache 类便于大家使用。

所以,YYDiskCache 类中主要是做了一些操作磁盘缓存的线程安全机制,是基于信号量(dispatch_semaphore)来处理的,暴露的接口中类似 YYMemoryCache 类的一系列方法。

剩余磁盘空间的限制

磁盘缓存中,多了一个如下修剪方法:

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

根据剩余的磁盘空间的限制进行修剪,作者确实想得很周到。_YYDiskSpaceFree()是作者写的一个 c 方法,用于获取剩余磁盘空间。

MD5 加密 key

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

filename 是作者根据使用者传入的 key 做一次 MD5 加密所得的字符串,所以不要误以为文件名就是你传入的 key (_YYNSStringMD5()是作者写的一个加密方法)。当然,框架提供了一个 _customFileNameBlock 允许你自定义文件名。

同时提供同步和异步接口

可以看到诸如此类的设计:

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

由于可能存储的文件过大,在读写时会占用过多的资源,所以作者对于这些操作都分别提供了同步和异步的接口,可谓非常人性化,这也是接口设计的一些值得学习的地方。

三、综合封装:YYCache

实际上上文的剖析已经囊括了 YYCache 框架的核心了。YYCache 类主要是对内存缓存和磁盘缓存的结合封装,代码很简单,有一点需要提出来:

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

优先查找内存缓存_memoryCache中的数据,若查不到,就查询磁盘缓存_diskCache,查询磁盘缓存成功,将数据同步到内存缓存中,方便下次查找。

这么做的理由很简单:根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于低速设备。所以内存缓存的读写速度远高于磁盘缓存。这也是开发中缓存设计的核心问题,我们既要保证缓存读写的效率,又要考虑到空间占用,其实又回到了空间和时间的权衡问题了。

写在后面

YYCache 核心逻辑思路、接口设计、代码组织架构、容错处理、性能优化、内存管理、线程安全这些方面都做得很好很极致,阅读起来非常舒服。

阅读开源框架,第一步一定是通读一下 API 了解该框架是干什么的,然后采用“分治”的思路逐个击破,类比“归并算法”:先拆开再合并,切勿想一口吃成胖子,特别是对于某些“重量级”框架。

希望读者朋友们阅读过后有所收获😁。

参考文献:作者 ibireme 的博客 YYCache 设计思路