11 月 19 - 20 日 Apache Pulsar 社区年度盛会来啦,立即报名! 了解详情
写点什么

iOS 缓存设计之 YYCache

  • 2019-09-26
  • 本文字数:7890 字

    阅读完需:约 26 分钟

iOS缓存设计之YYCache

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 Person12//归档要实现的协议方法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写入文件,文件名为FileName2  [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  }1314  // 建表完成之后, 插入记录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 磁盘+内存组合优化

利用内存和磁盘特性,融合各自优点,整合如下:



  • APP 会优先请求内存缓冲中的资源;

  • 如果内存缓冲中有,则直接返回资源文件, 如果没有的话,则会请求资源文件,这时资源文件默认资源为本地磁盘存储,需要操作文件系统或数据库来获取;

  • 获取到的资源文件,先缓存到内存缓存,方便以后不再重复获取,节省时间。

  • 然后就是从缓存中取到数据然后给 app 使用。


这样就充分结合两者特性,利用内存读取快特性减少读取数据时间。


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@end15@implementation _YYLinkedMapNode16@end17/**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    @package24    CFMutableDictionaryRef _dic; // do not set object directly25    NSUInteger _totalCost;26    NSUInteger _totalCount;27    _YYLinkedMapNode *_head; // MRU, do not change it directly28    _YYLinkedMapNode *_tail; // LRU, do not change it directly29    BOOL _releaseOnMainThread;30    BOOL _releaseAsynchronously;31}3233/// Insert a node at head and update the total cost.34/// Node and node.key should not be nil.35- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;3637/// Bring a inner node to header.38/// Node should already inside the dic.39- (void)bringNodeToHead:(_YYLinkedMapNode *)node;4041/// Remove a inner node and update the total cost.42/// Node should already inside the dic.43- (void)removeNode:(_YYLinkedMapNode *)node;4445/// Remove tail node if exist.46- (_YYLinkedMapNode *)removeTailNode;4748/// Remove all node in background queue.49- (void)removeAll;5051@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; // 链头的头节点这里设置为nil15    _head->_prev = node; // 之前的_head将为第二个节点16    _head = node; // 当前node成为新的_head17}
复制代码


其他方法就不挨个举例了,具体可翻看源码,这些代码结构清晰,类和函数遵循单一职责,接口高内聚,低耦合,是个不错的学习示例!


3.3 磁盘优化 — 数据分类存储

YYDiskCache 是一个线程安全的磁盘缓存,基于 sqlite 和 file 来做的磁盘缓存,我们的缓存对象可以自由的选择存储类型。


下面简单对比一下:


  • sqlite: 对于小数据(例如 NSNumber)的存取效率明显高于 file。

  • file: 对于较大数据(例如高质量图片)的存取效率优于 sqlite。


所以 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 timestamp10@property (nullable, nonatomic, strong) NSData *extendedData; ///< extended data (nil if no extended data)11@end121314/**15 YYKVStorage 是基于 sqlite 和文件系统的键值存储。16 通常情况下,我们不应该直接使用这个类。1718 @warning 19  这个类的实例是 *非* 线程安全的,你需要确保20  只有一个线程可以同时访问该实例。如果你真的21  需要在多线程中处理大量的数据,应该分割数据22  到多个 KVStorage 实例(分片)。23 */24@interface YYKVStorage : NSObject2526#pragma mark - Attribute27@property (nonatomic, readonly) NSString *path;        /// storage 路径28@property (nonatomic, readonly) YYKVStorageType type;  /// storage 类型29@property (nonatomic) BOOL errorLogsEnabled;           /// 是否开启错误日志3031#pragma mark - Initializer32- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;3334#pragma mark - Save Items35- (BOOL)saveItem:(YYKVStorageItem *)item;36...3738#pragma mark - Remove Items39- (BOOL)removeItemForKey:(NSString *)key;40...4142#pragma mark - Get Items43- (nullable YYKVStorageItem *)getItemForKey:(NSString *)key;44...4546#pragma mark - Get Storage Status47- (BOOL)itemExistsForKey:(NSString *)key;48- (int)getItemsCount;49- (int)getItemsSize;5051@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 以二进制形式存储于 sqlite12    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;3132        default:33            break;34    }35}
复制代码


这样做好处:


  • 对于不频繁更新数据的接口,不需要 loading 等待

  • 返回 304 状态值,节省了大量 JSON 数据的编解码时间

  • 节约流量,节省加载时长

  • 用户界面显示加快


5 总结

项目中并不一定完全这样做,有时候过渡设计也是一种浪费,多了解其他设计思路后,针对项目找到适合的才是最好的!


作者介绍:


方丈山(企业代号名),目前负责贝壳找房装修平台移动端 iOS 研发工作。


本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。


原文链接:


https://mp.weixin.qq.com/s/tiKinRmiXhRuV1ej9BVteQ


2019-09-26 10:501979

评论

发布
暂无评论
发现更多内容

String源码解析-String的不变性分析

zarmnosaj

5月月更

网络协议之:memcached text protocol详解

程序那些事

Java Netty 网络协议 程序那些事 5月月更

2022开源之夏 | 龙蜥社区@你,快来报名

OpenAnolis小助手

开源软件 龙蜥社区 开源之夏 供应链点亮计划 学生开发者

旧活新整-数据埋点

analysis-lion

埋点定义 埋点治理 埋点框架 埋点重构

node爬虫爬取小说章节

空城机

爬虫 Node 5月月更

国内外最顶级的12大看板工具

PingCode

融云参编,业界首个办公即时通信软件安全标准重磅发布!

融云 RongCloud

企评家,几大功能协助企业并购融资

企评家

主流开源分布式图计算框架 Benchmark

NebulaGraph

图计算 分布式图数据库

加速虚拟与现实交互,2022视觉计算私享会邀请你一起沉浸体验

阿里云弹性计算

vr 虚拟现实 元宇宙 视觉计算

一名优秀的算法工程师,日常都做些什么呢?

非凸科技

招聘 社招 校园招聘

易周金融观点 | 支付机构入局数字藏品;Q1手机银行交易超150万亿

易观分析

金融

百问百答第37期:如何一次性贯穿前后请求,调用链告诉你答案

博睿数据

调用链 博睿数据 IT运维

企评家|上海申通地铁股份有限公司成长性评价报告摘要

企评家

OpenHarmony兼容性平台更新上线

OpenHarmony开发者社区

OpenHarmony 兼容性平台

豆瓣评分8.5,详细讲解Python基础知识和应用的百科全书来了!

图灵教育

Python 程序员 计算机

墨天轮访谈 | 百度云邱学达:GaiaDB如何解决云上场景的业务需求?

墨天轮

数据库 百度云 国产数据库

手把手,带你用数据做好迭代复盘改进 | 敏捷开发落地指南

阿里云云效

阿里云 敏捷开发 研发 迭代管理 项目协作

Nginx 日志采集与分析

观测云

运维 可观测性 可观测

企评家|上海贵酒股份有限公司成长性报告简述

企评家

如何写出GC更优的代码,以达到提升代码性能的目的

非凸科技

性能 编程语言 垃圾回收 GC 吞吐率

连续3年实力登榜!EMQ映云科技再度跻身“2022中国边缘计算企业20强”

EMQ映云科技

物联网 IoT 边缘计算 emq 5月月更

WordPress 固定链接设置

海拥(haiyong.site)

5月月更

一个无经验的大学毕业生,可以转行做软件测试吗?我的真实案例

伤心的辣条

Python 程序人生 软件测试 自动化测试 测试 单元测试

架构实战营 第 6 期 模块五课后作业

火钳刘明

架构实战营 「架构实战营」

ScaleFlux加入阿里云PolarDB开源数据库社区

阿里云数据库开源

数据库 阿里巴巴 阿里云 国产数据库 PolarDB-X

做本让客户念念不忘的产品手册

小炮

产品手册

iOS缓存设计之YYCache_文化 & 方法_方丈山_InfoQ精选文章