YYCache源码简析

作者设计思路

1.YYMemoryCache

YYMemoryCache负责管理内存缓存。这个类是线程安全的。

LRU算法的实现

用双向链表和 CFMutableDictionary 实现。存储单元是_YYLinkedMapNode(相当于链表结点)。

@interface _YYLinkedMapNode : NSObject {
    //@package 是框架级别的实例变量作用域修饰符,只要处于同一个框架中就可以直接通过变量名访问
    @package
    __unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic 前驱
    __unsafe_unretained _YYLinkedMapNode *_next; // retained by dic 后继
    
    id _key;
    id _value;
    NSUInteger _cost; //内存开销大小
    NSTimeInterval _time;//创建时间?
}
@end

@interface _YYLinkedMap : NSObject {
    @package
    CFMutableDictionaryRef _dic; // do not set object directly 字典,存储结点。使用CFMutableDictionaryRef要比使用oc字典效率高,但是要自己管理内存
    NSUInteger _totalCost; //链表总开销
    NSUInteger _totalCount;//缓存总对象数目
    _YYLinkedMapNode *_head; // MRU, do not change it directly 头结点
    _YYLinkedMapNode *_tail; // LRU, do not change it directly 尾结点
    BOOL _releaseOnMainThread;
    BOOL _releaseAsynchronously;
}

_YYLinkedMapNode除了包含key value外,还包含该结点的前驱、后继结点地址。
_YYLinkedMap双向链表包含了链表首尾结点。双向链表里的对象是按访问时间排序的,因为LRU算法,最后使用的最先淘汰,因此使用双向链表去操作各个Node,一个Node被使用到了就移到链表头。而为了优化查找时间,就使用了一个字典来保存数据关系。这个字典用的是CFMutableDictionary而不是NSMutableDictionary,原因可能是前者的效率比较高,毕竟是c的操作,但是要注意手动管理内存的问题。

Node的value值就是要存储的数据对象;CFMutableDictionary字典的value值是Node,key就是Node的key。比如在查询某个node时,根据某个key在字典里面取出对应的node,然后把这个node移到链表头,如果缓存超过设定的上限了,就把链表尾的结点淘汰掉。

另外,可以看到,在_YYLinkedMapNode中使用了__unsafe_unretained这个属性。作者在它的另一篇文章中提到:

避免多余的内存管理方法
在 ARC 条件下,默认声明的对象是 __strong 类型的,赋值时有可能会产生 retain/release 调用,如果一个变量在其生命周期内不会被释放,则使用 __unsafe_unretained 会节省很大的开销。
评论区:关于 __unsafe_unretained 这个属性,我只提到需要在性能优化时才需要尝试使用,平时开发自然是不推荐用的。

_YYLinkedMap实现的功能:

  • 在链表头部插入结点
  • 把结点移到链表头部,一般在结点访问和更新时候会做这个事情。
  • 删除结点
    都是一些比较简单的链表知识,应该很容易就能看懂,所以就不展开谈了。

YYMemoryCache的内部实现

成员变量:

    pthread_mutex_t _lock;//锁
    _YYLinkedMap *_lru; //双向链表
    dispatch_queue_t _queue;//串行队列

1.初始化
init:
主要是对属性进行初始化,以及添加UIApplicationDidReceiveMemoryWarningNotificationUIApplicationDidEnterBackgroundNotification通知,在程序进入后台以及收到内存不足警告时,清除所有内存缓存。
最后,递归调用_trimRecursively方法:

//递归淘汰缓存
- (void)_trimRecursively {
    __weak typeof(self) _self = self;
    //定时清理 5s
    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];
    });
}

在一个优先级LOW的全局队列中,每5秒执行一次方法_trimInBackground。

//在后台线程进行缓存淘汰
- (void)_trimInBackground {
    //异步串行
    //lock是为了保证内部数据的线程安全,所有访问接口都要经过这个lock。_queue只是用来执行后台检查和移除的逻辑,它内部还是要用Lock来锁住数据的。
    dispatch_async(_queue, ^{
        [self _trimToCost:self->_costLimit];
        [self _trimToCount:self->_countLimit];
        [self _trimToAge:self->_ageLimit];
    });
}

在串行队列中异步执行方法_trimToCost_trimToCount_trimToAge。_queue只是用来执行后台检查和移除的逻辑,并不能保证线程安全,因此所有的数据访问接口都要lock,比如:

//根据object数量来淘汰
- (void)_trimToCount:(NSUInteger)countLimit {
    BOOL finish = NO;
    //上锁
    pthread_mutex_lock(&_lock);
    if (countLimit == 0) {//数量最大限制=0
        [_lru removeAll];//清空所有数据
        finish = YES;
    } else if (_lru->_totalCount <= countLimit) {//还没达到最大上限
        finish = YES;
    }
    pthread_mutex_unlock(&_lock);//解锁
    if (finish) return;
    
    NSMutableArray *holder = [NSMutableArray new];
    //已缓存>容量上限。从尾后结点开始清除,知道存储总数目<上限
    while (!finish) {
    //非阻塞的锁定互斥锁,pthread_mutex_lock的非阻塞版本,成功返回0
        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
        });
    }
}

淘汰到某个大小,如果数量上限是0就全部清空,如果当前链表数目小于数量上限,就不需要淘汰直接返回。如果需要淘汰结点,就在CF字典中删除对应k-v项,把该结点移除出链表,并把链表尾的node拿出来放到一个holder 数组中。直到链表结点总数目小于数量上限。

线程安全是使用pthread_mutex_lock来实现的,因为OSSpinLock已经不再安全了,所以作者后来换用pthread_mutex了。另外,当已缓存对象数目超过容量数目上限,需要从链表尾开始淘汰结点时,使用了pthread_mutex_lock的非阻塞版本的锁:pthread_mutex_trylock,如果锁失败了,就把调用该函数的线程挂起一段时间。

另外在这里有两句代码:

dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
dispatch_async(queue, ^{
    [holder count]; // release in queue
});

一开始确实没看懂[holder count]的调用意图,而且在该代码所在文件中有多处使用了同样的技巧。这里作者给出的解释是:holder 持有了待释放的对象,这些对象应该根据配置在不同线程进行释放(release)。此处 holder 被 block 持有,然后在另外的 queue 中释放。[holder count] 只是为了让 holder 被 block 捕获,保证编译器不会优化掉这个操作,所以随便调用了一个方法。
当block执行完毕,此时holder就会在block对应的queue上release了,这里确实很巧妙。作者在另一篇文章中说到:

对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。这里有个小 Tip:把对象捕获到 block 中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了。

代码中很多地方都体现出作者非常注重性能问题,不得不感叹作者写代码确实很讲究。
_trimToCost和_trimToAge方法的实现大致类似,就不展开谈了。

2.增删改查操作
简单谈谈- (void)setObject:(id)object forKey:(id)key方法的实现。其他的方法基本上都差不多,实际上就是对node、链表、和CF字典的操作。

- (void)setObject:(id)object forKey:(id)key实际上调用的是- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost方法,但cost参数传入的总是0,所以说cost这个维度实际上好像没什么卵用,而且作者也谈到,我们一般不需要太关注cost。
内部实现:
如果key为空,就直接返回;如果object为空,就把key对应的Object删除。
否则,就根据这个key找到对应的node,如果node找得到,就更新这个node的属性以及修改链表totalCost,然后把这个node移到链表头;如果node找不到,就创建一个node并插入到链表头。
最后进行totalCost和totalCount检查,如果缓存超标,就用LRU算法去移除结点并在对应线程中释放。

- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
    if (!key) return;
    if (!object) {//如果object为空,就代表把该key对应项清除
        [self removeObjectForKey:key];
        return;
    }
    pthread_mutex_lock(&_lock);
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    NSTimeInterval now = CACurrentMediaTime();//最新修改时间
    //先判断字典里有没有这个key-node值
    if (node) {//如果该key原有对应object//取出某结点,更新字段,把结点移到头部
        //更新链表cost
        _lru->_totalCost -= node->_cost;
        _lru->_totalCost += cost;
        node->_cost = cost;
        node->_time = now;//修改时间
        node->_value = object;
        [_lru bringNodeToHead:node];
    } else {//原key不含object 在链表头部插入新结点
        node = [_YYLinkedMapNode new];
        node->_cost = cost;
        node->_time = now;
        node->_key = key;
        node->_value = object;
        [_lru insertNodeAtHead:node];
    }
    //总开销超出上限,删除链表尾部结点
    if (_lru->_totalCost > _costLimit) {
        //异步串行
        dispatch_async(_queue, ^{
            [self trimToCost:_costLimit];
        });
    }
    //数目超出上限
    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增加使用的周期,如果没有在开的queue中调用node的方法,node就会在queue之前被释放掉
                //这样做是为了让node在开的子线程中释放而不是在主线程
                [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);
}

2.YYKVStorage

在YYCache中,YYDiskCache负责管理磁盘缓存,而他的核心功能类是YYKVStorage,通过文件+sqlite数据库的方式缓存数据。但YYKVStorage不是线程安全的,YYDiskCache线程安全,作者建议不要直接使用YYKVStorage。

三种数据缓存策略

typedef NS_ENUM(NSUInteger, YYKVStorageType) {
    //文件读写缓存
    YYKVStorageTypeFile = 0,
    
    //数据库缓存
    YYKVStorageTypeSQLite = 1,
    
    //混合方式。如果YYKVStorageItem.filename不为空就用文件缓存,否则就使用数据库缓存
    YYKVStorageTypeMixed = 2,
};

初始化方法:

指定数据缓存方式,创建了缓存文件夹、sqlite数据库,打开并初始化数据库。

- (instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type {
    if (path.length == 0 || path.length > kPathLengthMax) {
        NSLog(@"YYKVStorage init error: invalid path: [%@].", path);
        return nil;
    }
    if (type > YYKVStorageTypeMixed) {
        NSLog(@"YYKVStorage init error: invalid type: %lu.", (unsigned long)type);
        return nil;
    }
    
    self = [super init];
    _path = path.copy;
    _type = type;
    _dataPath = [path stringByAppendingPathComponent:kDataDirectoryName];//path/data 缓存数据的文件路径
    _trashPath = [path stringByAppendingPathComponent:kTrashDirectoryName];//path/trash 存放丢弃的数据的文件路径
    _trashQueue = dispatch_queue_create("com.ibireme.cache.disk.trash", DISPATCH_QUEUE_SERIAL);//串行队列
    _dbPath = [path stringByAppendingPathComponent:kDBFileName];//path/manifest.sqlite sqlite数据库路径
    _errorLogsEnabled = YES;
    NSError *error = nil;
    if (![[NSFileManager defaultManager] createDirectoryAtPath:path
                                   withIntermediateDirectories:YES
                                                    attributes:nil
                                                         error:&error] ||
        ![[NSFileManager defaultManager] createDirectoryAtPath:[path stringByAppendingPathComponent:kDataDirectoryName]
                                   withIntermediateDirectories:YES
                                                    attributes:nil
                                                         error:&error] ||
        ![[NSFileManager defaultManager] createDirectoryAtPath:[path stringByAppendingPathComponent:kTrashDirectoryName]
                                   withIntermediateDirectories:YES
                                                    attributes:nil
                                                         error:&error]) {
        NSLog(@"YYKVStorage init error:%@", error);
        return nil;
    }

    //创建、打开数据库
    if (![self _dbOpen] || ![self _dbInitialize]) {
        // db file may broken...
        //数据库初始化、打开失败
        [self _dbClose];//关闭数据库
        [self _reset]; // rebuild 移除相关文件夹、文件
        if (![self _dbOpen] || ![self _dbInitialize]) {
            [self _dbClose];
            NSLog(@"YYKVStorage init error: fail to open sqlite db.");
            return nil;
        }
    }
    [self _fileEmptyTrashInBackground]; // empty the trash if failed at last time
    return self;
}

dataPath是基于文件方式缓存数据的文件夹,当需要把数据清除时,数据文件先移动到trashPath,然后再在后台线程中把trashPath数据清空。dbPath数数据库文件路径。

在这儿里涉及到几个数据库操作方法,作者严谨、优雅的封装以及数据库读写性能的优化非常值得学习。
1._dbOpen方法

//打开数据库
- (BOOL)_dbOpen {
    if (_db) return YES;
    
    int result = sqlite3_open(_dbPath.UTF8String, &_db);
    if (result == SQLITE_OK) {
        CFDictionaryKeyCallBacks keyCallbacks = kCFCopyStringDictionaryKeyCallBacks;
        CFDictionaryValueCallBacks valueCallbacks = {0};
        _dbStmtCache = CFDictionaryCreateMutable(CFAllocatorGetDefault(), 0, &keyCallbacks, &valueCallbacks);//创建sql语句缓存字典
        _dbLastOpenErrorTime = 0;
        _dbOpenErrorCount = 0;
        return YES;
    } else {
        _db = NULL;
        if (_dbStmtCache) CFRelease(_dbStmtCache);//释放数组
        _dbStmtCache = NULL;
        _dbLastOpenErrorTime = CACurrentMediaTime();
        _dbOpenErrorCount++;
        
        if (_errorLogsEnabled) {//打印错误日志
            NSLog(@"%s line:%d sqlite open failed (%d).", __FUNCTION__, __LINE__, result);
        }
        return NO;
    }
}

主要完成打开数据库的功能,另外初始化了几个成员变量,其中_dbStmtCache是一个用来缓存sql prepared语句的字典。
在sqlite操作中,直接调用sqlite3_exce()函数,会隐式地开启一个事务,而且sqlite3_exce()sqlite3_perpare()sqlite3_step()sqlite3_finalize()的一个结合,每调用一次这个函数,就会重复执行这三条语句,事务会被反复地开启关闭,增大IO量;其中sqlite3_perpare相当于编译sql语句,如果sql语句相同,就会增加很多的重复操作,重复编译很多次。
在sqlite官方文档中已经指出,很多时候sqlite3_perpare_v2()的执行时间要多于sqlite3_step(),因此建议开发者尽量避免重复调用sqlite3_perpare_v2()。要想避免这样的开销,只需要将待插入的数据以变量的形式绑定到sql语句中,这样,sql语句就只需要调用sqlite3_perpare_v2()函数编译一次即可,其后操作只是替换不同的变量数值。关于绑定的内容之后会谈到。
言归正传,在YYKVStorage初始化方法中的_dbStmtCache字典,就是用来缓存经sqlite3_prepare_v2()函数编译后的sql语句的。在YYKVStorage中,作者基本上都是把插入的数据以变量的形式绑定到sql语句中。当下一次再次使用某sql语句,则先从_dbStmtCache字典找出编译过的sql语句,这样就能减少编译次数。因此就有了以下这个方法:

2._dbPrepareStmt方法

//准备 检查,编译优化
//与sqlite3_exec等价的一组函数是sqlite3_prepare_v2、sqlite3_step、sqlite3_finalize。sqlite3_exec将编译、执行进行了封装
//sqlite3_prepare_v2更高效,只需要编译一次就可以重复执行N次
- (sqlite3_stmt *)_dbPrepareStmt:(NSString *)sql {
    if (![self _dbCheck] || sql.length == 0 || !_dbStmtCache) return NULL;
    //从字典里取出之前编译过的sqlite3_stmt
    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;
        }
        //将新的sqlite3_stmt保存进字典
        CFDictionarySetValue(_dbStmtCache, (__bridge const void *)(sql), stmt);
    } else {
        //将已编译的SQL语句恢复到初始状态,保留语句相关资源(不会对绑定状态进行改变)
        sqlite3_reset(stmt);
    }
    return stmt;
}

如果能从缓存字典中能找到编译过的sql就调用sqlite3_reset()函数把sql语句恢复到sqlite3_prepare_v2()运行之后的状态(之前没有执行过sqlite3_step()或者执行后返回SQLITE_DONE\SQLITE_OK\SQLITE_ROW中的一个)。如果找不到,就编译一次。

3._dbInitialize方法创建一张表

- (BOOL)_dbInitialize {
    NSString *sql = @"pragma journal_mode = wal; pragma synchronous = normal; 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);";
    return [self _dbExecute:sql];
}

这个表中一共有七个字段:key、filename、size、inline_data、modification_time、last_access_time、extended_data
3.1pragma journal_mode = wal表示使用sqlite日志模式中的WAL模式。

SQLite中日志模式主要有DELETEWAL两种,其他几种比如TRUNCATEPERSISTMEMORY基本原理都与DELETE模式相同,不作详细展开。DELETE模式下,日志中记录的变更前数据页内容;WAL模式下,日志中记录的是变更后的数据页内容。事务提交时,DELETE模式将日志刷盘,将DB文件刷盘,成功后,再将日志文件清理;WAL模式则是将日志文件刷盘,即可完成提交过程。那么WAL模式下,数据文件何时更新呢?这里引入了检查点概念,检查点的作用就是定期将日志中的新页覆盖DB文件中的老页,并通过参数wal_autocheckpoint来控制检查点时机,达到权衡读写的目的。

WAL的优势在于,它支持读写并发,而且写入性能要比DELETE好。使用WAL模式,写事务将更新写到.wal文件中,暂时不更新数据库文件,当执行checkPoint方法时,把.wal文件的内容批量写到数据库中。checkPoint可以自动执行,也可以手动执行。
更多关于WAL模式请看
YY中封装的checkpoint方法:

- (void)_dbCheckpoint {
    if (![self _dbCheck]) return;
    // Cause a checkpoint to occur, merge `sqlite-wal` file to `sqlite` file.
    sqlite3_wal_checkpoint(_db, NULL);//手动执行checkpoint,把wal文件中的数据写入到数据库中
}

3.2pragma synchronous = normal获取或设置当前磁盘的同步模式。默认设置是FULL。
简要说来,full写入速度最慢,但保证数据是安全的,不受断电、系统崩溃等影响,而off可以加速数据库的一些操作,但如果系统崩溃或断电,则数据库可能会损毁。

而当synchronous设置为NORMAL, SQLite数据库引擎在大部分紧急时刻会暂停,但不像FULL模式下那么频繁。 NORMAL模式下有很小的几率(但不是不存在)发生电源故障导致数据库损坏的情况。但实际上,在这种情况 下很可能你的硬盘已经不能使用,或者发生了其他的不可恢复的硬件错误。

4._dbClose方法 关闭数据库

- (BOOL)_dbClose {
    if (!_db) return YES;
    
    int  result = 0;
    BOOL retry = NO;
    BOOL stmtFinalized = NO; //缓存语句是否已经全部释放完毕
    
    if (_dbStmtCache) CFRelease(_dbStmtCache);//释放字典
    _dbStmtCache = NULL;
    
    do {
        retry = NO;
        result = sqlite3_close(_db);//关闭数据库
        if (result == SQLITE_BUSY || result == SQLITE_LOCKED) {//数据库上锁或者表上锁 此时有读写操作
            if (!stmtFinalized) {
                stmtFinalized = YES;
                sqlite3_stmt *stmt;
                while ((stmt = sqlite3_next_stmt(_db, nil)) != 0) { //sqlite3_next_stmt查找下一个prepared statement(编译过的sql语句)
                    sqlite3_finalize(stmt);//释放 prepared statement
                    retry = YES;
                }
            }
        } else if (result != SQLITE_OK) {
            if (_errorLogsEnabled) {
                NSLog(@"%s line:%d sqlite close failed (%d).", __FUNCTION__, __LINE__, result);
            }
        }
    } while (retry);
    _db = NULL;
    return YES;
}

完成释放_dbStmtCache字典、关闭数据库的功能。如果有未释放的编译过的语句(sqlite3_close也会返回SQLITE_BUSY),就逐个把编译过的sql语句用sqlite3_finalize()函数释放掉。

缓存数据的增删查改

YYKVStorageItem:

@property (nonatomic, strong) NSString *key;                ///< key 键值
@property (nonatomic, strong) NSData *value;                ///< value 对象,对应数据库的inline_data字段
@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;

YYKVStorageItem用来保存k-v对和元数据,一一对应数据库表中的七个字段。

1.增-写入数据

- (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) {
        //把数据data写入path/data/filename文件
        if (![self _fileWriteWithName:filename data:value]) { //失败
            return NO;
        }
        //把key value filename extendedData写入数据库manifest: /path/manifest.sqlite
        if (![self _dbSaveWithKey:key value:value fileName:filename extendedData:extendedData]) {
            //数据库操作失败,就删除之前的缓存文件 path/data/filename文件
            [self _fileDeleteWithName:filename];
            return NO;
        }
        return YES;
    } else {
        //非数据库缓存(同时又没有传入文件名,所以是混合方法缓存..
        if (_type != YYKVStorageTypeSQLite) {
            //根据key从数据库manifest查找文件名
            NSString *filename = [self _dbGetFilenameWithKey:key];
            if (filename) {
                //删除文件缓存 path/data/filename文件 //因为不可能文件系统缓存(filename参数不存在)所以要把文件缓存的文件删除掉?
                [self _fileDeleteWithName:filename];
            }
        }
//        把数据写入数据库manifest
        return [self _dbSaveWithKey:key value:value fileName:nil extendedData:extendedData];
    }
}

如果初始化时设置的缓存策略是文件缓存,但此方法中传入的filename为空,就判错,缓存失败。
然后判断filename是否存在,如果存在,就把数据写入文件,并把该数据相关信息写入数据库(但不会把数据本身存到数据库)。
如果filename不存在,就把数据及相关信息直接写到数据库,另外如果是混合方式的缓存策略,还要检查以前是否在文件中缓存了相同key的数据。

文件写入操作:

//向文件写入数据
- (BOOL)_fileWriteWithName:(NSString *)filename data:(NSData *)data {
    NSString *path = [_dataPath stringByAppendingPathComponent:filename];
    return [data writeToFile:path atomically:NO];
}

数据库写入方法:

//写入数据库
- (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];//取出已编译的sql语句或者编译该语句
    if (!stmt) return NO;
    //绑定七个字段
    int timestamp = (int)time(NULL);
    sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL);
    sqlite3_bind_text(stmt, 2, fileName.UTF8String, -1, NULL);
    sqlite3_bind_int(stmt, 3, (int)value.length);
    if (fileName.length == 0) {
        sqlite3_bind_blob(stmt, 4, value.bytes, (int)value.length, 0);
    } else {
        sqlite3_bind_blob(stmt, 4, NULL, 0, 0);
    }
    sqlite3_bind_int(stmt, 5, timestamp);
    sqlite3_bind_int(stmt, 6, timestamp);
    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;
}

先创建了一条sql语句,将待插入的数据以变量的形式绑定到sql语句中,这样只需要将该语句编译一次就可以重复使用多次了。“?”表示参数需要通过变量绑定,“?”后的数字表示绑定变量对应的索引号。最后调用sqlite3_step ()函数执行sql语句。
留意到:

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存在,inline_data字段就不绑定数据,fileName字段绑定文件名。因为数据已经保存在文件中了,不必重复保存。如果filename不存在,那么代表是使用数据库缓存策略,inline_data字段绑定数据,同时fileName字段不绑定。

理解这段代码很重要,因为YYCache的磁盘缓存就是基于这样的方式进行设计的。比如要查找数据:如果使用的是文件缓存策略,要取出缓存数据,先根据key值,在数据库中找到文件名,然后根据拿到的文件名去对应的文件路径中去取数据。如果使用的是数据库缓存,那么根据key值直接在数据库中就能找到数据。

比如删除缓存就是如此。
2.删除数据

//根据key删除数据库缓存
- (BOOL)removeItemForKey:(NSString *)key {
    if (key.length == 0) return NO;
    switch (_type) {//缓存方式
        case YYKVStorageTypeSQLite: {
            return [self _dbDeleteItemWithKey:key];//根据key来删除数据库记录
        } break;
        case YYKVStorageTypeFile:
        case YYKVStorageTypeMixed: {
            NSString *filename = [self _dbGetFilenameWithKey:key];//根据key从数据库中找到对应的filename
            if (filename) {//如果filename存在,删除文件中的数据
                [self _fileDeleteWithName:filename];
            }
            return [self _dbDeleteItemWithKey:key];//删除数据库中的记录
        } break;
        default: return NO;
    }
}

查找数据的过程基本就如上所述,根据不同的缓存策略使用不同的方式来删除数据。如果是文件缓存或者是混合缓存的话,除了删除文件数据还要把数据库中对应的记录删除掉。

触类旁通,至于查找和修改数据的方法,大多都是类似的数据库、文件读写方法,把握好了一个思路,其实看起来都是差不多的,在这里就不展开了讲了。

3.YYDiskCache

YYDiskCache是YYKVStorage的线程安全封装,与YYMemoryCache类似,实现了LRU淘汰算法。

初始化

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

- (instancetype)initWithPath:(NSString *)path
             inlineThreshold:(NSUInteger)threshold {
    self = [super init];
    if (!self) return nil;
    //根据path找YYDiskCache对象
    YYDiskCache *globalCache = _YYDiskCacheGetGlobal(path);//线程安全地取得YYDiskCache对象(相当于单例)
    if (globalCache) return globalCache;
    //找不到,就新建一个
    YYKVStorageType type;
    if (threshold == 0) {
        type = YYKVStorageTypeFile;
    } else if (threshold == NSUIntegerMax) {
        type = YYKVStorageTypeSQLite;
    } else {//默认策略
        type = YYKVStorageTypeMixed;
    }
    
    YYKVStorage *kv = [[YYKVStorage alloc] initWithPath:path type:type];
    if (!kv) return nil;
    
    _kv = kv;
    _path = path;
    _lock = dispatch_semaphore_create(1);//信号量
    _queue = dispatch_queue_create("com.ibireme.cache.disk", DISPATCH_QUEUE_CONCURRENT);//并发队列
    _inlineThreshold = threshold;
    _countLimit = NSUIntegerMax;
    _costLimit = NSUIntegerMax;
    _ageLimit = DBL_MAX;
    _freeDiskSpaceLimit = 0;
    _autoTrimInterval = 60;
    
    [self _trimRecursively];
    _YYDiskCacheSetGlobal(self);
    
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appWillBeTerminated) name:UIApplicationWillTerminateNotification object:nil];
    return self;
}

方法需要传入缓存路径和缓存阈值threshold参数。在作者设计思路文章中分析到,超过20k数据使用文件缓存读写快,而低于20k数据使用数据库读写比较快,所以默认的阈值是20K,当然我们也可以自行设置阈值。初始化方法中根据阈值参数决定缓存策略,默认是YYKVStorageTypeMixed

一个路径path对应一个YYDiskCache,类似的,使用了NSMapTable来缓存两者的对应关系,并且使用dispatch_semaphore信号量上锁来保证字典读写安全。

static YYDiskCache *_YYDiskCacheGetGlobal(NSString *path) {
    if (path.length == 0) return nil;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);//如果信号量>0就继续执行下面的操作,并将信号量-1.否则会阻塞当前线程,等待timeout
    id cache = [_globalInstances objectForKey:path];
    dispatch_semaphore_signal(_globalInstancesLock);//信号量+1
    return cache;
}

static void _YYDiskCacheSetGlobal(YYDiskCache *cache) {
    if (cache.path.length == 0) return;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    [_globalInstances setObject:cache forKey:cache.path];
    dispatch_semaphore_signal(_globalInstancesLock);
}

//初始化字典和锁
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];
    });
}

LRU淘汰,基于 SQLite 存储的元数据。与YYMemoryCache中的实现类似,递归调用_trimRecursively方法:

//递归淘汰
- (void)_trimRecursively {
    __weak typeof(self) _self = self;
    //60s定时清理
    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];
    });
}

只不过清理的频率变成了60s一次。除了有根据缓存花销(cost)、缓存对象数目、最后使用时间这三个维度进行淘汰,还有根据磁盘剩余空间大小来进行淘汰。其中涉及到了数据读写,需要用锁来保证多线程访问的安全性,同样地,这里也使用了dispatch_semaphore信号量,下面是相关的两个宏:

#define Lock() dispatch_semaphore_wait(self->_lock, DISPATCH_TIME_FOREVER)
#define Unlock() dispatch_semaphore_signal(self->_lock)
- (void)_trimInBackground {
    __weak typeof(self) _self = self;
    dispatch_async(_queue, ^{
        __strong typeof(_self) self = _self;
        if (!self) return;
        Lock();
        //由于下面这些方法的实现不是线程安全的,所以在使用它们之前要先上锁
        [self _trimToCost:self.costLimit];
        [self _trimToCount:self.countLimit];
        [self _trimToAge:self.ageLimit];
        [self _trimToFreeDiskSpace:self.freeDiskSpaceLimit];
        Unlock();
    });
}

写缓存

//添加缓存
- (void)setObject:(id<NSCoding>)object forKey:(NSString *)key {
    if (!key) return;
    if (!object) {//如果object为空,就删除缓存中和key关联的item
        [self removeObjectForKey:key];
        return;
    }
    
    NSData *extendedData = [YYDiskCache getExtendedDataFromObject:object];
    NSData *value = nil;
    //归档数据
    if (_customArchiveBlock) {//是否有自定义归档block
        value = _customArchiveBlock(object);
    } else {
        @try {
            value = [NSKeyedArchiver archivedDataWithRootObject:object];
        }
        @catch (NSException *exception) {
            // nothing to do...
        }
    }
    if (!value) return;
    NSString *filename = nil;
    if (_kv.type != YYKVStorageTypeSQLite) {
        if (value.length > _inlineThreshold) {//数据超过阈值(要使用文件缓存),取出key关联的文件名
            filename = [self _filenameForKey:key];
        }
    }
    
    Lock();
    [_kv saveItemWithKey:key value:value filename:filename extendedData:extendedData];//添加缓存
    Unlock();
}

传入缓存对象和key。先对对象进行归档,转成二进制数据,如果有自定义的归档方法就用,否则就用系统默认的归档方法。
判断缓存策略type,如果是数据库缓存,就直接调用-saveItemWithKey: value: filename: extendedData:将数据写入数据库,filename=nil。如果是另外两种缓存策略,判断数据是否超过threshold阈值(如果是文件缓存策略,作者已经写死只有threshold=0才是文件缓存;默认是混合缓存,阈值20K),超阈值就将数据写入文件。

读缓存

- (id<NSCoding>)objectForKey:(NSString *)key,先使用YYKVStorage 的getItemForKey:方法得到key对应YYKVStorageItem对象,然后再解档item.value得到原来的缓存对象。

还有一些删除缓存、异步回调的方法,比较简单,这里也不多说了。

4.最后

用过阅读YY源码,学习到了很多,LRU算法的实现、SQLite封装、线程安全等等,特别是性能优化问题上,代码中更是处处有体现,像作者这样的大神,技术真是让我敬佩

参考文章:
作者设计思路
Sqlite3常用的插入方法及性能测试
sqlite3中绑定bind函数用法 (将变量插入到字段中)
sqlite3_reset作用
提升SQLite数据插入效率低、速度慢的方法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容