iOS数据缓存的各个实现方式及YYCache源码分析

数据的缓存分为两类,一类内存缓存,一类磁盘缓存。每类缓存都有不同的实现方式。

内存缓存

NSMutableDictionary
NSMutableDictionary使用方便,读取速度O(1),看起来是内存缓存的最好实现方法。不过其实它有两大缺点:

  • 不能自动响应内存警告去清除缓存,需要自己来实现。
  • 存值的时候键值会copy,所以键的类必须实现NSCopying协议。

NSCache
NSCache一直处于NSMutableDictionary的阴影之下,很多人都忘记了它的存在,其实它弥补了NSMutableDictionary的两大缺点。
而且它的使用方法和NSMutableDictionary很像,setObject:forKey:cost:,多的那个cost参数,很多人直接存object所占内存的字节数。不过NSHipster说:

通常,精确的 cost 应该是对象占用的字节数。然而,移除流程并不会依据cost 大小来决定顺序。所以如果你期望通过控制 cost 的值来完成某些特殊行为的话,结果可能会对你的程序无益。那么就没必要费劲地去计算它,因为这么做的话会增加使用缓存的代价。如果你没有有效的值传入,那就传入 0,或者用 setObject:forKey: 方法,它不需要传入 cost 值。

SDWebImage的内存缓存就直接用的NSCache。
YYCache的内存缓存用的NSMutableDictionary,自己实现了响应内存警告和计时器来移除缓存。而且使用了双向链表保存键值对来实现LRU淘汰算法,所以性能会更加好。

磁盘缓存

NSFileManager文件系统
每个key对应它的fileName,value对应文件,这个实现很简单。

  • 统计文件数算法,文件修改时间等方法实现起来会复杂很多,而且性能会差很多。
  • 单条数据大于 20K 时,读取文件的速度会比SQLite快。

SQLite数据库
这个优缺点和NSFileManager相反。
所以可以把key值,文件大小,修改时间之类的数据存SQLite,这样统计算法等操作性能会很好。
value值大于 20K 可以存文件,小于 20K 存SQLite,这样整体性能都有提高。

mmap
将磁盘文件映射到内存,以前操作系统讲到过,简单点说就是磁盘文件被映射到了内存上的多个页中,如果缺页了,操作系统会有自己的一套策略来把缺失页调到内存中。
也就是说mmap实现了数据缓存整个功能,看起来很复杂,其实C里有方法把整个流程封装起来了,实现起来很容易。
void * mmap(void *, size_t, int, int, int, off_t)
int munmap(void *, size_t)

SDWebImage的磁盘缓存用的NSFileManager。
FastImageCache的磁盘缓存用的mmap。
YYCache的磁盘缓存用的SQLite结合NSFileManager。

YYCache

为什么分析YYCache呢,因为YYCache做了更多优化,性能更好。而且代码里有着很多小技巧,很值得学习。

YYMemoryCache

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

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

代码很好理解,比普通的CFMutableDictionaryRef多了:

  • _YYLinkedMapNode(双向链表),头结点和尾结点都有指针指向,刚访问过的结点会被移到头部。
  • _releaseOnMainThread和_releaseAsynchronously,清除缓存时是否要在主线程上操作。
  • 实现了响应内存警告释放内存,定时释放超过限度的内存两个方法。

对_YYLinkedMapNode的一系列操作就是数据结构最基本的链表处理了,用处就是清除缓存时,可以根据链表从tail开始清除,实现了LRU算法。

而清除缓存时有个小技巧:

NSMutableArray *holder = [NSMutableArray new];
    while (!finish) {
        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
        });
    }

会把要移除的node取出,存在局部变量holder数组里,holder在dispatch_async的block里调用count方法被block捕获,确保在相应的queue里释放。而queue默认不是主队列,避免release在主线程操作。

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

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

_autoTrimInterval默认为5,定时策略,释放超过限度的内存。

YYDiskCache

@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

- (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) {
        if (![self _fileWriteWithName:filename data:value]) {
            return NO;
        }
        if (![self _dbSaveWithKey:key value:value fileName:filename extendedData:extendedData]) {
            [self _fileDeleteWithName:filename];
            return NO;
        }
        return YES;
    } else {
        if (_type != YYKVStorageTypeSQLite) {
            NSString *filename = [self _dbGetFilenameWithKey:key];
            if (filename) {
                [self _fileDeleteWithName:filename];
            }
        }
        return [self _dbSaveWithKey:key value:value fileName:nil extendedData:extendedData];
    }
}

这就是磁盘存储的核心代码了,当文件大于 20K 时,value会在存在file里,这样存取速度快。
而别的值还是存在SQLite里,这样统计数据这类的操作会很简单。

感想

也就不多分析了,因为YYCache真的很好理解啊。

这些开源的框架,鲁棒性和性能固然都考虑到了,可以各显神通。不过易用的方法,清晰的代码这些差距就大了。

FastImageCache写的是最糟糕的,不仅方法难用,理解起来也超级吃力。
而YYCache从头看到尾,真是舒畅。所以不仅思路要学,代码的风格也要学。我一直觉得代码是需要维护的,所以要写出别人一看就很舒服的代码,要对自己的代码负责。

作者:levi
comments powered by Disqus