iOS高性能缓存框架

有关iOS缓存的框架挺多的,有系统自带的NSCache,或者一些三方的,比如YYCahce,以及SDWebImage里的SDImageCache。这些都是性能比较高的,代码质量也是比较高,所以今天就把它们拿出来做个比较。前面我对YYCache做了两篇分析笔记,在研读这篇文章之前大家先去阅读一下。
YYCache内存缓存是用字典进行的数据存储,然后以双向链表关联起来一个逻辑结构。YYCache的磁盘缓存是文件存储和sqlite存储的结合。有关YYCache内存缓存的细节分析,可以参考前面的几篇文章。那两篇文章主要分析了双向链表是如何操作数据的。 有关磁盘缓存,今天我想更深入的讲解一下。

YYCache的磁盘缓存是用到了sqlite和文件的结合,我们可以看下面的图。


image.png
image.png

当数据的大小大于20KB的时候,会给filename赋值,有了filename,数据就会以文件存储的方式存储,因为20KB是一个临界值,小于20KB以sqlite的方式存取数据比较快,大于20KB以文件的读取方式存取数据比较快。这个大家自己可以去测试一下。虽然作者没有说为什么是20KB,但是我还是在sqlite官网找到了答案。如下图:


关于文件存储的操作这里就不说了,比较简单。这里重点讲一下sqlite.

我们先看一段代码


image.png

image.png

数据库操作的主要流程可以这么看,这里有个查询sql语句字符串,为了能够执行,我们需要将这个sql字符串进行处理,第二张图就是对sql语句进行的处理。主要方法是sqlite3_prepare_v2(_db, sql.UTF8String, -1, &stmt, NULL),它是将字符串转化成可执行的的字节流然后存储在stmt这个对象里,stmt可以理解成一个sqlite3_stmt类型的结构体,也可以理解成是一个可执行sql语句。sqlite3_prepare_v2每次执行的性能消耗是巨大的。所以呢作者把sqlite3_stmt结构体对象缓存起来,每次先从_dbStmtCache缓存里取数据。如果取到了之后重置一下,然后直接返回,如果没有取到那么才会执行sqlite3_prepare_v2这个方法。_dbPrepareStmt方法已经处理好了sql语句,也就是说可以执行了。然后我们看源码里看,这是一个查询方法,所以需要先绑定key到stmt对象里,接着就是执行sqlite3_step,这个方法是真正执行sql语句的方法。


image.png

执行完后返回每一行的数据,我们如果需要取每个具体字段的数据,就需要调用sqlite3_column_xx等方法。然后依次赋值到YYKVStorageItem对象里,最后返回。完成了一个查询操作,其他的插入,删除,修改相关的操作也是类似。

除了sqlite常见的增删改查相关的源码,我还发现了作者也使用了sqlite的wal机制。这里也说一下吧。wal机制是sqlite3.7.0版本之后才有的,在sqlite3.7.0之前是rollback journal机制。

rollback journal机制的原理是:在修改数据库文件中的数据之前,先将修改所在分页中的数据备份在另外一个地方,然后才将修改写入到数据库文件中;如果事务失败,则将备份数据拷贝回来,撤销修改;如果事务成功,则删除备份数据,提交修改。( SQLite在3.7.0 之前)

WAL机制的原理是:修改并不直接写入到数据库文件中,而是写入到另外一个称为WAL的文件中;如果事务失败,WAL中的记录会被忽略,撤销修改;如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改。( SQLite在3.7.0 之后)。如下图:


同步WAL文件和数据库文件的行为被称为checkpoint(检查点),它由SQLite自动执行,默认是在WAL文件积累到1000页修改的时候;当然,在适当的时候,也可以手动执行checkpoint,SQLite提供了相关的接口。执行checkpoint之后,WAL文件会被清空。

image.png

我们可以看到作者是手动触发的checkpoint方法,具体原因我猜测是为了提高存储效率吧。

有关YYCache相关的内容都说的差不多了。

除了YYCache,还有一个性能比较高的缓存框架,那就是SDWebImage,这个大家都比较熟悉,但是很少有人去分析它的缓存吧。今天我们一起来看一下它是如何的与众不同吧。

SD的缓存也分为内存缓存和硬盘缓存,从类的声明上可以看出SDMemoryCache继承于NSCache,SDMemoryCache具有NSCache所有的缓存API的功能,我们点进去看看NSCache有哪些API,API都比较简单,有点像NSDictionary的API。存取都很方便。

SD为什么选择使用 NSCache 来做内存缓存
在 App 运行过程中,我们通常会缓存一些短时间需要用到并且创建非常昂贵的对象;重用这些对象可以优化性能,并且可以为用户更快的展示数据。

一般是利用键值对这种数据格式来缓存所需的对象,在 Objective-C 中,我们最常用的就是利用 NSDictionary & NSMutableDictionary 来保存键值对,那为什么内存缓存不用 Dictionary 来实现呢?我们可以思考下这些场景:

当内存缓存的对象越来越多,如何避免内存暴增?如何有效的管理这些对象呢?
遇到内存警告的时候,如何清除缓存对象,以便腾出空间给当前需要内存的应用呢?是将缓存对象全部清除呢?还是只清除一部分缓存对象呢?
在多线程环境下,调用 NSDictionary 的存取方法会不会出现问题呢?
NSDictionary 对于 Key-Value 有什么限制?
面对以上这些情况,官方推荐我们使用 NSCache 来实现内存缓存。现在我们来看下 NSCache 的一些特性:

NSCache 具有自动删减缓存策略(确保缓存不会占用太多内存),所以我们不需要再去实现复杂的缓存淘汰算法
NSCache 的方法是线程安全的,不用我们再去考虑多线程安全问题(PS:NSDictionary 里面的存取方法不是线程安全的)
NSCache 对于 Key-Value 中的 Key 没有要求(PS:NSDictionary 中要求 Key 必须实现 NSCoping 协议)
NSCache 可以设置缓存中对象总个数和总成本,这些尺度定义了缓存删减其中对象的时机
综上所诉,我们通常会使用 NSCache。

我们接着看源码,发现SDMemoryCache还有一个NSMapTable类型的缓存空间,这个缓存其实是个辅助的作用,可以称为辅助缓存。我们一起来看下为什么会出现这种设计。

Create a subclass of NSCache using a weak cache. Only remove the cache when memory warning and sync back the alive instance from weak cache into cache.  

这个是 SDWebImage 作者的提交日志

这个设计是针对下面这种场景进行了优化,不得不说很细节:

首先 NSCache 会强引用缓存对象,然后我们的 NSCache 监听了内存警告的通知,当发生内存警告的时候,NSCache 会 RemoveAllObjects,移除所有缓存对象,以便腾出内存空间处理当下重要的任务。
前面说过,NSCache 会强引用缓存对象,在 NSCache 调用 SetObject 方法之后,会将对象的引用计数加 1,在 NSCache 调用了 RemoveAllObjects 方法之后,就会将对象的引用计数减 1。
调用完 RemoveAllObjects 方法之后,这里就会有以下两种情况:
1、该对象已经没有被其他对象所强引用了,此时,这个对象的引用计数会为 0,对象会被完全的销毁
2、该对象还被其他对象所强引用,在 NSCache 调用 RemoveAllObjects 方法将对象的引用计数减1之后,它的引用计数还是会大于0,此时,这个对象并不会被销毁,但是这个对象却被移除了缓存,实际上这个对象还是在内存中
在面对第三点的第二种情况,NSCache虽然移除了缓存对象,但是这个对象依然被其他对象强引用了,所以它并不会销毁,还是存在在内存中;换句话说,就是下次我们可以重用该对象,并不需要重新创建。
所以面对这种情况,SDWebImage 会重新将该对象放回缓存中,就没有必要再去磁盘中查找照片。

SD内存缓存相关的内容都说完了,下面说说SD磁盘缓存相关的内容。SD因为是存储的图片,图片基本上都是大于20KB,所以毋庸置疑,作者直接使用了文件存储的方式进行了存储。下面就是存储的代码。

image.png

有关文件存储的相关操作这里就不说了,这里想说一下SD磁盘缓存的清理策略是怎样的。还是先看代码吧。

- (void)deleteOldFilesWithCompletionBlock:(nullable SDWebImageNoParamsBlock)completionBlock {
    dispatch_async(self.ioQueue, ^{
        NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];

        // Compute content date key to be used for tests
        NSURLResourceKey cacheContentDateKey = NSURLContentModificationDateKey;
        switch (self.config.diskCacheExpireType) {
            case SDImageCacheConfigExpireTypeAccessDate:
                cacheContentDateKey = NSURLContentAccessDateKey;
                break;

            case SDImageCacheConfigExpireTypeModificationDate:
                cacheContentDateKey = NSURLContentModificationDateKey;
                break;

            default:
                break;
        }
        
        NSArray *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey];

        // This enumerator prefetches useful properties for our cache files.
        NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL
                                                   includingPropertiesForKeys:resourceKeys
                                                                      options:NSDirectoryEnumerationSkipsHiddenFiles
                                                                 errorHandler:NULL];

        NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.config.maxCacheAge];
        NSMutableDictionary *> *cacheFiles = [NSMutableDictionary dictionary];
        NSUInteger currentCacheSize = 0;

        // Enumerate all of the files in the cache directory.  This loop has two purposes:
        //
        //  1. Removing files that are older than the expiration date.
        //  2. Storing file attributes for the size-based cleanup pass.
        NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
        for (NSURL *fileURL in fileEnumerator) {
            NSError *error;
            NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];

            // Skip directories and errors.
            if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
                continue;
            }

            // Remove files that are older than the expiration date;
            NSDate *modifiedDate = resourceValues[cacheContentDateKey];
            if ([[modifiedDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
                [urlsToDelete addObject:fileURL];
                continue;
            }
            
            // Store a reference to this file and account for its total size.
            NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
            currentCacheSize += totalAllocatedSize.unsignedIntegerValue;
            cacheFiles[fileURL] = resourceValues;
        }
        
        for (NSURL *fileURL in urlsToDelete) {
            [self.fileManager removeItemAtURL:fileURL error:nil];
        }

        // If our remaining disk cache exceeds a configured maximum size, perform a second
        // size-based cleanup pass.  We delete the oldest files first.
        if (self.config.maxCacheSize > 0 && currentCacheSize > self.config.maxCacheSize) {
            // Target half of our maximum cache size for this cleanup pass.
            const NSUInteger desiredCacheSize = self.config.maxCacheSize / 2;

            // Sort the remaining cache files by their last modification time or last access time (oldest first).
            NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
                                                                     usingComparator:^NSComparisonResult(id obj1, id obj2) {
                                                                         return [obj1[cacheContentDateKey] compare:obj2[cacheContentDateKey]];
                                                                     }];

            // Delete files until we fall below our desired cache size.
            for (NSURL *fileURL in sortedFiles) {
                if ([self.fileManager removeItemAtURL:fileURL error:nil]) {
                    NSDictionary *resourceValues = cacheFiles[fileURL];
                    NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
                    currentCacheSize -= totalAllocatedSize.unsignedIntegerValue;

                    if (currentCacheSize < desiredCacheSize) {
                        break;
                    }
                }
            }
        }
        if (completionBlock) {
            dispatch_async(dispatch_get_main_queue(), ^{
                completionBlock();
            });
        }
    });
}

磁盘存储方面SD是完全使用的是文件存储。不过SD对文件存储的操作也是非常6的。文件的存取都比较简单,但是SD的文件清理方式也挺好。首先,通过磁盘文件路径字符串生成一个路径URL.根据配置的文件清理时间维度是访问时间还是修改时间去文件路径下进行遍历,这样我们就拿到了所有的文件,文件有很多属性,生成的文件数组我们也可以自己去定制,比如这里设置了是否是文件目录的key,时间维度是访问时间还是修改时间,第三个是文件大小。遍历所有文件,取出每个文件的最后访问时间,然后跟配置好的过期时间进行对比,将过期的文件存放在一个即将要被删除的数组里面,后面集中进行删除操作。没有被删除的文件也重新进行文件总大小计算,目的是为了后面判断再将过期文件都删除之后,文件的总大小是否还是符合设定。如果文件大小依然大于我们之前的设定,那么再将剩余没被删除的数组进行排序之后,依次删除比较老被访问的文件,并且删除之后进行文件大小比对,直到最后文件总大小符合最先的设定。这样文件的删除操作就完成了。

总结一下:SD磁盘自动清理首先是删除掉过期的所有文件,然后判断总文件大小是否符合预期,如果删除之后总文件大小还是超出预定的最大大小,那么就对剩下的所有文件排序,排序之后进行for循环,逐个删除文件,直到达到预期大小,完成清理工作。

现在我们比较一下YYCache、SDImageCache、NSCache吧。


image.png
image.png

有关iOS高性能缓存源码解析就分析到这里了,有什么疑问可以和我交流,微博账号:梅嘉庆

你可能感兴趣的:(iOS高性能缓存框架)