深入理解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的作者开源了这么好的东西

posted @ 2017-06-27 18:49  马在路上  阅读(4485)  评论(2编辑  收藏