1 前言
来公司一段时间业务有缓存需求,翻看代码没找到适合的,于是结合 YYCache 和业务需求,做了缓存层(内存 &磁盘)+ 网络层的方案尝试,目前已在贝壳装修业务中实践。
由于 YYCache 采用了内存缓存和磁盘缓存组合方式,性能优良,这里拿它的原理来说下如何设计一套缓存的思路,并结合网络整理一套完整流程!
2 初步认识缓存
2.1 什么是缓存?
我们做一个缓存前,先了解它是什么,缓存是本地数据存储,存储方式主要包含两种:磁盘储存和内存存储。
2.1.1 磁盘存储
磁盘缓存,磁盘也就是硬盘缓存,磁盘是程序的存储空间,磁盘缓存容量大速度慢,磁盘是永久存储东西的,iOS 为不同数据管理对存储路径做了规范如下:
1)每一个应用程序都会拥有一个应用程序沙盒。
2)应用程序沙盒就是一个文件系统目录。
沙盒根目录结构:Documents、Library、temp。
磁盘存储方式主要有文件管理和数据库,其特性:
2.1.2 内存存储
内存缓存,内存缓存是指当前程序运行空间,内存缓存速度快容量小,它是供 cpu 直接读取,比如我们打开一个程序,他是运行在内存中的,关闭程序后内存又会释放。
iOS 内存分为 5 个区:栈区,堆区,全局区,常量区,代码区
栈区 stack:这一块区域系统会自己管理,我们不用干预,主要存一些局部变量,以及函数跳转时的现场保护。因此大量的局部变量,深递归,函数循环调用都可能导致内存耗尽而运行崩溃;
堆区 heap:与栈区相对,这一块一般由我们自己管理,比如 alloc,free 的操作,存储一些自己创建的对象;
全局区(静态区 static):全局变量和静态变量都存储在这里,已经初始化的和没有初始化的会分开存储在相邻的区域,程序结束后系统会释放;
常量区:存储常量字符串和 const 常量;
代码区:存储代码
在程序中声明的容器(数组 、字典)都可看做内存中存储,特性如下:
2.2 缓存做什么?
我们使用场景比如:离线加载,预加载,本地通讯录…等,对非网络数据,使用本地数据管理的一种,具体使用场景有很多。
2.3 怎么做缓存?
简单缓存可以仅使用磁盘存储,iOS 主要提供四种磁盘存储方式:
1)NSKeyedArchiver:采用归档的形式来保存数据,该数据对象需要遵守 NSCoding 协议,并且该对象对应的类必须提供 encodeWithCoder:和 initWithCoder:方法。
1//自定义Person实现归档解档
2//.h文件
3#import <Foundation/Foundation.h>
4@interface Person : NSObject<NSCoding>
5@property(nonatomic,copy) NSString * name;
6
7@end
8
9//.m文件
10#import "Person.h"
11@implementation Person
12//归档要实现的协议方法
13- (void)encodeWithCoder:(NSCoder *)aCoder {
14 [aCoder encodeObject:_name forKey:@"name"];
15}
16//解档要实现的协议方法
17- (instancetype)initWithCoder:(NSCoder *)aDecoder {
18 if (self = [super init]) {
19 _name = [aDecoder decodeObjectForKey:@"name"];
20 }
21 return self;
22}
23@end
复制代码
使用归档解档
1 // 将数据存储在path路径下归档文件
2 [NSKeyedArchiver archiveRootObject:p toFile:path];
3 // 根据path路径查找解档文件
4 Person *p = [NSKeyedUnarchiver unarchiveObjectWithFile:path];
复制代码
缺点:归档的形式来保存数据,只能一次性归档保存以及一次性解压。所以只能针对小量数据,如果想改动数据的某一小部分,需要解压整个数据或者归档整个数据。
2)NSUserDefaults:用来保存应用程序设置和属性、用户保存的数据。用户再次打开程序或开机后这些数据仍然存在。
NSUserDefaults 可以存储的数据类型包括:NSData、NSString、NSNumber、NSDate、NSArray、 NSDictionary。
1// 以键值方式存储
2 [[NSUserDefaults standardUserDefaults] setObject:@"value" forKey:@"key"];
3// 以键值方式读取
4 [[NSUserDefaults standardUserDefaults] objectForKey:@"key"];
复制代码
3)Write 写入方式:永久保存在磁盘中。具体方法为:
1 //将NSData类型对象data写入文件,文件名为FileName
2 [data writeToFile:FileName atomically:YES];
3 //从FileName中读取出数据
4 NSData *data=[NSData dataWithContentsOfFile:FileName options:0 error:NULL];
复制代码
4)SQLite:采用 SQLite 数据库来存储数据。SQLite 作为一个中小型数据库,应用 ios 中跟其他三种保存方式相比,相对复杂一些。
1 //打开数据库
2 if (sqlite3_open([databaseFilePath UTF8String], &database)==SQLITE_OK) {
3 NSLog(@"sqlite dadabase is opened.");
4 } else { return;}//打开不成功就返回
5
6 //在打开了数据库的前提下,如果数据库没有表,那就开始建表了哦!
7 char *error;
8 const char *createSql="create table(id integer primary key autoincrement, name text)"; if (sqlite3_exec(database, createSql, NULL, NULL, &error)==SQLITE_OK) {
9 NSLog(@"create table is ok.");
10 } else {
11 sqlite3_free(error);//每次使用完毕清空error字符串,提供给下⼀一次使用
12 }
13
14 // 建表完成之后, 插入记录
15 const char *insertSql="insert into a person (name) values(‘gg’)";
16 if (sqlite3_exec(database, insertSql, NULL, NULL, &error)==SQLITE_OK) {
17 NSLog(@"insert operation is ok.");
18 } else {
19 sqlite3_free(error);//每次使用完毕清空error字符串,提供给下一次使用
20 }
复制代码
上面提到的磁盘存储特性,具备空间大、可持久、但是读取慢,面对大量数据频繁读取时更加明显,以往测试中磁盘读取比内存读取保守测量低于几十倍,那我们怎么解决磁盘读取慢的缺点呢?又如何利用内存的优势呢?
3 如何优化缓存
YYCache 背景知识:
源码中由两个主要类构成:
1)YYMemoryCache (内存缓存)
操作 YYLinkedMap 中数据, 为实现内存优化,采用双向链表数据结构实现 LRU 算法,YYLinkedMapItem 为每个子节点。
2)YYDiskCache (磁盘缓存)
不会直接操作缓存对象(sqlite/file),而是通过 YYKVStorage 来间接的操作缓存对象。
容量管理:
ageLimit :时间周期限制,比如每天或每星期开始清理;
costLimit: 容量限制,比如超出 10M 后开始清理内存;
countLimit : 数量限制, 比如超出 1000 个数据就清理。
这里借用 YYCache 设计, 来讲述缓存优化。
3.1 磁盘+内存组合优化
利用内存和磁盘特性,融合各自优点,整合如下:
这样就充分结合两者特性,利用内存读取快特性减少读取数据时间。
YYCache 源码解析:
1- (id<NSCoding>)objectForKey:(NSString *)key {
2 // 1.如果内存缓存中存在则返回数据
3 id<NSCoding> object = [_memoryCache objectForKey:key];
4 if (!object) {
5 // 2.若不存在则查取磁盘缓存数据
6 object = [_diskCache objectForKey:key];
7 if (object) {
8 // 3.并将数据保存到内存中
9 [_memoryCache setObject:object forKey:key];
10 }
11 }
12 return object;
13}
复制代码
3.2 内存优化 — 提高内存命中率
但是我们想在基础上再做优化,比如想让经常访问的数据保留在内存中,提高内存的命中率,减少磁盘的读取,那怎么做处理呢? — LRU 算法。
LRU 算法:我们可以将链表看成一串数据链,每个数据是这个串上的一个节点,经常访问的数据移动到头部,等数据超出容量后从链表后面的一些节点销毁,这样经常访问数据在头部位置,还保留在内存中。
链表实现结构图:
YYCache 源码解析:
1/**
2 A node in linked map.
3 Typically, you should not use this class directly.
4 */
5@interface _YYLinkedMapNode : NSObject {
6 @package
7 __unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic
8 __unsafe_unretained _YYLinkedMapNode *_next; // retained by dic
9 id _key;
10 id _value;
11 NSUInteger _cost;
12 NSTimeInterval _time;
13}
14@end
15@implementation _YYLinkedMapNode
16@end
17/**
18 A linked map used by YYMemoryCache.
19 It's not thread-safe and does not validate the parameters.
20 Typically, you should not use this class directly.
21 */
22@interface _YYLinkedMap : NSObject {
23 @package
24 CFMutableDictionaryRef _dic; // do not set object directly
25 NSUInteger _totalCost;
26 NSUInteger _totalCount;
27 _YYLinkedMapNode *_head; // MRU, do not change it directly
28 _YYLinkedMapNode *_tail; // LRU, do not change it directly
29 BOOL _releaseOnMainThread;
30 BOOL _releaseAsynchronously;
31}
32
33/// Insert a node at head and update the total cost.
34/// Node and node.key should not be nil.
35- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;
36
37/// Bring a inner node to header.
38/// Node should already inside the dic.
39- (void)bringNodeToHead:(_YYLinkedMapNode *)node;
40
41/// Remove a inner node and update the total cost.
42/// Node should already inside the dic.
43- (void)removeNode:(_YYLinkedMapNode *)node;
44
45/// Remove tail node if exist.
46- (_YYLinkedMapNode *)removeTailNode;
47
48/// Remove all node in background queue.
49- (void)removeAll;
50
51@end
复制代码
_YYLinkedMapNode *_prev 为该节点的头指针,指向前一个节点;
_YYLinkedMapNode *_next 为该节点的尾指针,指向下一个节点。
头指针和尾指针将一个个子节点串连起来,形成双向链表。
来看下 bringNodeToHead:的源码实现,它是实现 LRU 算法主要方法,移动 node 子结点到链头。(详细已注释在代码中)
1- (void)bringNodeToHead:(_YYLinkedMapNode *)node {
2 if (_head == node) return; // 如果当前节点是链头,则不需要移动
3
4 // 链表中存了两个指向链头(_head)和链尾(_tail)的指针,便于链表访问
5 if (_tail == node) {
6 _tail = node->_prev; // 若当前节点为链尾,则更新链尾指针
7 _tail->_next = nil; // 链尾的尾节点这里设置为nil
8 } else {
9 // 比如:A B C 链表, 将 B拿走,将A C重新联系起来
10 node->_next->_prev = node->_prev; // 将node的下一个节点的头指针指向node的上一个节点,
11 node->_prev->_next = node->_next; // 将node的上一个节点的尾指针指向node的下一个节点
12 }
13 node->_next = _head; // 将当前node节点的尾指针指向之前的链头,因为此时node为最新的第一个节点
14 node->_prev = nil; // 链头的头节点这里设置为nil
15 _head->_prev = node; // 之前的_head将为第二个节点
16 _head = node; // 当前node成为新的_head
17}
复制代码
其他方法就不挨个举例了,具体可翻看源码,这些代码结构清晰,类和函数遵循单一职责,接口高内聚,低耦合,是个不错的学习示例!
3.3 磁盘优化 — 数据分类存储
YYDiskCache 是一个线程安全的磁盘缓存,基于 sqlite 和 file 来做的磁盘缓存,我们的缓存对象可以自由的选择存储类型。
下面简单对比一下:
所以 YYDiskCache 使用两者配合,灵活的存储以提高性能。
另外,YYDiskCache 具有以下功能:
它使用 LRU(least-recently-used) 来删除对象。
支持按 cost,count 和 age 进行控制。
它可以被配置为当没有可用的磁盘空间时自动驱逐缓存对象。
它可以自动抉择每个缓存对象的存储类型(sqlite/file)以便提供更好的性能表现。
YYCache 源码解析:
1// YYKVStorageItem 是 YYKVStorage 中用来存储键值对和元数据的类
2// 通常情况下,我们不应该直接使用这个类
3@interface YYKVStorageItem : NSObject
4@property (nonatomic, strong) NSString *key; ///< key
5@property (nonatomic, strong) NSData *value; ///< value
6@property (nullable, nonatomic, strong) NSString *filename; ///< filename (nil if inline)
7@property (nonatomic) int size; ///< value's size in bytes
8@property (nonatomic) int modTime; ///< modification unix timestamp
9@property (nonatomic) int accessTime; ///< last access unix timestamp
10@property (nullable, nonatomic, strong) NSData *extendedData; ///< extended data (nil if no extended data)
11@end
12
13
14/**
15 YYKVStorage 是基于 sqlite 和文件系统的键值存储。
16 通常情况下,我们不应该直接使用这个类。
17
18 @warning
19 这个类的实例是 *非* 线程安全的,你需要确保
20 只有一个线程可以同时访问该实例。如果你真的
21 需要在多线程中处理大量的数据,应该分割数据
22 到多个 KVStorage 实例(分片)。
23 */
24@interface YYKVStorage : NSObject
25
26#pragma mark - Attribute
27@property (nonatomic, readonly) NSString *path; /// storage 路径
28@property (nonatomic, readonly) YYKVStorageType type; /// storage 类型
29@property (nonatomic) BOOL errorLogsEnabled; /// 是否开启错误日志
30
31#pragma mark - Initializer
32- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;
33
34#pragma mark - Save Items
35- (BOOL)saveItem:(YYKVStorageItem *)item;
36...
37
38#pragma mark - Remove Items
39- (BOOL)removeItemForKey:(NSString *)key;
40...
41
42#pragma mark - Get Items
43- (nullable YYKVStorageItem *)getItemForKey:(NSString *)key;
44...
45
46#pragma mark - Get Storage Status
47- (BOOL)itemExistsForKey:(NSString *)key;
48- (int)getItemsCount;
49- (int)getItemsSize;
50
51@end
复制代码
我们只需要看一下 YYKVStorageType 这个枚举,它决定着 YYKVStorage 的存储类型。
YYKVStorageType:
1/**
2 存储类型,指示“YYKVStorageItem.value”存储在哪里。
3
4 @discussion
5 通常,将数据写入 sqlite 比外部文件更快,但是
6 读取性能取决于数据大小。在测试环境 iPhone 6s 64G,
7 当数据较大(超过 20KB)时从外部文件读取数据比 sqlite 更快。
8 */
9typedef NS_ENUM(NSUInteger, YYKVStorageType) {
10 YYKVStorageTypeFile = 0, // value 以文件的形式存储于文件系统
11 YYKVStorageTypeSQLite = 1, // value 以二进制形式存储于 sqlite
12 YYKVStorageTypeMixed = 2, // value 将根据你的选择基于上面两种形式混合存储
13};
复制代码
3.4 小结
这里说了 YYCache 几个主要设计优化之处,其实细节上也有很多不错的处理,比如:
1)线程安全
如果说 YYCache 这个类是一个纯逻辑层的缓存类(指 YYCache 的接口实现全部是调用其他类完成),那么 YYMemoryCache 与 YYDiskCache 还是做了一些事情的(并没有 YYCache 当甩手掌柜那么轻松),其中最显而易见的就是 YYMemoryCache 与 YYDiskCache 为 YYCache 保证了线程安全。
YYMemoryCache 使用了 pthread_mutex 线程锁来确保线程安全,而 YYDiskCache 则选择了更适合它的 dispatch_semaphore,上文已经给出了作者选择这些锁的原因。
2)性能
YYCache 中对于性能提升的实现细节:
异步释放缓存对象
锁的选择
使用 NSMapTable 单例管理的 YYDiskCache
YYKVStorage 中的 _dbStmtCache
甚至使用 CoreFoundation 来换取微乎其微的性能提升
4 网络和缓存同步流程
结合网络层和缓存层,设计了一套接口缓存方式,比较灵活且速度得到提升,目前已应用在贝壳装修业务中。比如首页界面可能由多个接口提供数据,没有采用整块存储而是将存储细分到每个接口中,有 API 接口控制,基本结构如下:
主要分为:
层级图:
服务端每套数据对应一个 version (或时间戳),若后台数据发生变更,则 version 发生变化,在返回客户端数据时并将 version 一并返回;
当客户端请求网络时,将本地上一次数据对应 version 上传;
服务端获取客户端传来得 version 后,与最新的 version 进行对比,若 version 不一致,则返回最新数据,若未发生变化,服务端不需要返回全部数据只需返回 304(No Modify) 状态值;
客户端接到服务端返回数据,若返回全部数据非 304,客户端则将最新数据同步到本地缓存中;客户端若接到 304 状态值后,表示服务端数据和本地数据一致,直接从缓存中获取显示。
以上也是 ETag 的大致流程,详细可以查看 https://baike.baidu.com/item/ETag/4419019?fr=aladdin
源码示例:
1- (void)getDataWithPage:(NSNumber *)page pageSize:(NSNumber *)pageSize option:(DataSourceOption)option completion:(void (^)(HomePageListCardModel * _Nullable, NSError * _Nullable))completionBlock {
2 NSString *cacheKey = CacheKey(currentUser.userId, PlatIndexRecommendation);// 全局静态常量 (userid + apiName)
3 // 根据需求而定是否需要缓存方式,网络方式走304逻辑
4 switch (option) {
5 case DataSourceCache:
6 {
7 if ([_cache containsObjectForKey:cacheKey]) {
8 completionBlock((HomePageListCardModel *)[self->_cache objectForKey:cacheKey], nil);
9 } else {
10 completionBlock(nil, LJDError(400, @"缓存中不存在"));
11 }
12 }
13 break;
14 case DataSourceNetwork:
15 {
16 [NetWorkServer requestDataWithPage:page pageSize:pageSize completion:^(id _Nullable responseObject, NSError * _Nullable error) {
17 if (responseObject && !error) {
18 HomePageListCardModel *model = [HomePageListCardModel yy_modelWithJSON:responseObject];
19 if (model.errnonumber == 304) { //取缓存数据
20 completionBlock((HomePageListCardModel *)[self->_cache objectForKey:cacheKey], nil);
21 } else {
22 completionBlock(model, error);
23 [self->_cache setObject:model forKey:cacheKey]; //保存到缓存中
24 }
25 } else {
26 completionBlock(nil, error);
27 }
28 }];
29 }
30 break;
31
32 default:
33 break;
34 }
35}
复制代码
这样做好处:
5 总结
项目中并不一定完全这样做,有时候过渡设计也是一种浪费,多了解其他设计思路后,针对项目找到适合的才是最好的!
作者介绍:
方丈山(企业代号名),目前负责贝壳找房装修平台移动端 iOS 研发工作。
本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。
原文链接:
https://mp.weixin.qq.com/s/tiKinRmiXhRuV1ej9BVteQ
评论