Android 通用缓存存储设计实践
目录介绍
01.整体概述说明
1.1 项目背景介绍
1.2 遇到问题记录
1.3 基础概念介绍
1.4 设计目标
1.5 产生收益分析
02.市面存储方案
2.1 缓存存储有哪些
2.2 缓存策略有哪些
2.3 常见存储方案
2.4 市面存储方案说明
2.5 存储方案的不足
03.存储方案原理
3.1 Sp 存储原理分析
3.2 MMKV 存储原理分析
3.3 LruCache 考量分析
3.4 DiskLru 原理分析
3.5 DataStore 分析
3.6 HashMap 存储分析
3.7 Sqlite 存储分析
3.8 使用存储的注意点
3.9 各种数据存储文件
04.通用缓存方案思路
4.1 如何兼容不同缓存
4.2 打造通用缓存 Api
4.3 切换不同缓存方式
4.4 缓存的过期处理
4.5 缓存的阀值处理
4.6 缓存的线程安全性
4.7 缓存数据的迁移
4.8 缓存数据加密处理
4.9 缓存效率的对比
05.方案基础设计
5.1 整体架构图
5.2 UML 设计图
5.3 关键流程图
5.4 模块间依赖关系
06.其他设计说明
6.1 性能设计说明
6.2 稳定性设计
6.3 灰度设计
6.4 降级设计
6.5 异常设计说明
6.6 兼容性设计
6.7 自测性设计
07.通用 Api 设计
7.1 如何依赖该库
7.2 初始化缓存库
7.3 切换各种缓存方案
7.4 数据的存和取
7.5 线程安全考量
7.6 查看缓存文件数据
7.7 如何选择合适方案
08.其他说明介绍
8.1 遇到的坑分析
8.2 遗留的问题
8.3 未来的规划
8.4 参考链接记录
01.整体概述说明
1.1 项目背景介绍
项目中很多地方使用缓存方案
有的用
sp
,有的用mmkv
,有的用lru
,有的用DataStore
,有的用sqlite
,如何打造通用 api 切换操作不同存储方案?缓存方案众多,且各自使用场景有差异,如何选择合适的缓存方式?
针对不同场景选择什么缓存方式,同时思考如何替换之前老的存储方案,而不用花费很大的时间成本!
针对不同的业务场景,不同的缓存方案。打造一套通用的方案
屏蔽各种缓存方式的差异性,暴露给外部开发者统一的 API,外部开发者简化使用,提高开发效率和使用效率……
1.2 遇到问题记录
记录几个常见的问题
问题 1:各种缓存方案,分别是如何保证数据安全的,其内部使用到了哪些锁?由于引入锁,给效率上带来了什么影响?
问题 2:各种缓存方案,进程不安全是否会导致数据丢失,如何处理数据丢失情况?如何处理脏数据,其原理大概是什么?
问题 3:各种缓存方案使用场景是什么?有什么缺陷,为了解决缺陷做了些什么?比如 sp 存在缺陷的替代方案是 DataStore,为何这样?
问题 4:各种缓存方案,他们的缓存效率是怎样的?如何对比?接入该库后,如何做数据迁移,如何覆盖操作?
思考一个 K-V 框架的设计
问题 1-线程安全:使用 K-V 存储一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;
问题 2-内存缓存:由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;
问题 3-事务:由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;
问题 4-事务串行化:由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;
问题 5-异步或同步写回:由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;有时候又要求数据读写是同步的;
问题 6-增量更新:由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;
问题 7-变更回调:由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;
问题 8-多进程:由于程序可能有多进程需求,那么框架如何保证多进程数据同步?
问题 9-可用性:由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;
问题 10-高效性:性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;
问题 11-安全性:如果程序需要存储敏感数据,如何保证数据完整性和保密性;
问题 12-数据迁移:如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;
问题 13-研发体验:是否模板代码冗长,是否容易出错。各种 K—V 框架使用体验如何?
常见存储框架设计思考导图
1.3 基础概念介绍
最初缓存的概念
提及缓存,可能很容易想到 Http 的缓存机制,
LruCache
,其实缓存最初是针对于网络而言的,也是狭义上的缓存,广义的缓存是指对数据的复用。缓存容量,就是缓存的大小
每一种缓存,总会有一个最大的容量,到达这个限度以后,那么就须要进行缓存清理了框架。这个时候就需要删除一些旧的缓存并添加新的缓存。
1.4 设计目标
打造通用存储库:
设计一个缓存通用方案,其次,它的结构需要很简单,因为很多地方需要用到,再次,它得线程安全。灵活切换不同的缓存方式,使用简单。
内部开源该库:
作为技术沉淀,当作专项来推动进展。高复用低耦合,便于拓展,可快速移植,解决各个项目使用内存缓存,sp,mmkv,sql,lru,DataStore 的凌乱。抽象一套统一的 API 接口。
1.5 产生收益分析
统一缓存 API 兼容不同存储方案
打造通用 api,抹平了 sp,mmkv,sql,lru,dataStore 等各种方案的差异性。简化开发者使用,功能强大而使用简单!
02.市面存储方案
2.1 缓存存储有哪些
比较常见的是内存缓存以及磁盘缓存。
内存缓存:这里的内存主要指的存储器缓存;磁盘缓存:这里主要指的是外部存储器,手机的话指的就是存储卡。
内存缓存:
通过预先消耗应用的一点内存来存储数据,便可快速的为应用中的组件提供数据,是一种典型的以空间换时间的策略。
磁盘缓存:
读取磁盘文件要比直接从内存缓存中读取要慢一些,而且需要在一个 UI 主线程外的线程中进行,因为磁盘的读取速度是不能够保证的,磁盘文件缓存显然也是一种以空间换时间的策略。
二级缓存:
内存缓存和磁盘缓存结合。比如,
LruCache
将图片保存在内存,存取速度较快,退出 APP 后缓存会失效;而DiskLruCache
将图片保存在磁盘中,下次进入应用后缓存依旧存在,它的存取速度相比LruCache
会慢上一些。
2.2 缓存策略有哪些
缓存的核心思想主要是什么呢
一般来说,缓存核心步骤主要包含缓存的添加、获取和删除这三类操作。那么为什么还要删除缓存呢?不管是内存缓存还是硬盘缓存,它们的缓存大小都是有限的。
当缓存满了之后,再想其添加缓存,这个时候就需要删除一些旧的缓存并添加新的缓存。这个跟线程池满了以后的线程处理策略相似!
缓存的常见的策略有那些
FIFO(first in first out):先进先出策略,相似队列。
LFU(less frequently used):最少使用策略,
RecyclerView
的缓存采用了此策略。LRU(least recently used):最近最少使用策略,
Glide
在进行内存缓存的时候采用了此策略。
2.3 常见存储方案
内存缓存:存储在内存中,如果对象销毁则内存也会跟随销毁。如果是静态对象,那么进程杀死后内存会销毁。
Map,LruCache 等等
磁盘缓存:后台应用有可能会被杀死,那么相应的内存缓存对象也会被销毁。当你的应用重新回到前台显示时,你需要用到缓存数据时,这个时候可以用磁盘缓存。
SharedPreferences,MMKV,DiskLruCache,SqlLite,DataStore,Room,Realm,GreenDao 等等
2.4 市面存储方案说明
内存缓存
Map:内存缓存,一般用 HashMap 存储一些数据,主要存储一些临时的对象
LruCache:内存淘汰缓存,内部使用 LinkedHashMap,会淘汰最长时间未使用的对象
磁盘缓存
SharedPreferences:轻量级磁盘存储,一般存储配置属性,线程安全。建议不要存储大数据,不支持跨进程!
MMKV:腾讯开源存储库,内部采用 mmap。
DiskLruCache:磁盘淘汰缓存,写入数据到 file 文件
SqlLite:移动端轻量级数据库。主要是用来对象持久化存储。
DataStore:旨在替代原有的 SharedPreferences,支持 SharedPreferences 数据的迁移
Room/Realm/GreenDao:支持大型或复杂数据集
其他开源缓存库
ACache:一款高效二级存储库,采用内存缓存和磁盘缓存
2.5 存储方案的不足
存储方案 SharedPreferences 的不足
1.SP 用内存层用 HashMap 保存,磁盘层则是用的 XML 文件保存。每次更改,都需要将整个 HashMap 序列化为 XML 格式的报文然后整个写入文件。
2.SP 读写文件不是类型安全的,且没有发出错误信号的机制,缺少事务性 API
3.commit() / apply()操作可能会造成 ANR 问题
存储方案 MMKV 的不足
1.没有类型信息,不支持 getAll。由于没有记录类型信息,MMKV 无法自动反序列化,也就无法实现 getAll 接口。
2.需要引入 so,增加包体积:引入 MMKV 需要增加的体积还是不少的。
3.文件只增不减:MMKV 的扩容策略还是比较激进的,而且扩容之后不会主动 trim size。
存储方案 DataStore 的不足
1.只是提供异步 API,没有提供同步 API 方法。在进行大量同步存储的时候,使用 runBlocking 同步数据可能会卡顿。
2.对主线程执行同步 I/O 操作可能会导致 ANR 或界面卡顿。可以通过从 DataStore 异步预加载数据来减少这些问题。
03.存储方案原理
3.1 Sp 存储原理分析
SharedPreferences,它是一个轻量级的存储类,特别适合用于保存软件配置参数。
轻量级,以键值对的方式进行存储。采用的是 xml 文件形式存储在本地,程序卸载后会也会一并被清除,不会残留信息。线程安全的。
它有一些弊端如下所示
对文件 IO 读取,因此在 IO 上的瓶颈是个大问题,因为在每次进行 get 和 commit 时都要将数据从内存写入到文件中,或从文件中读取。
多线程场景下效率较低,在 get 操作时,会锁定 SharedPreferences 对象,互斥其他操作,而当 put,commit 时,则会锁定 Editor 对象,使用写入锁进行互斥,在这种情况下,效率会降低。
不支持跨进程通讯,由于每次都会把整个文件加载到内存中,不建议存储大的文件内容,比如大 json。
有一些使用上的建议如下
建议不要存储较大数据;频繁修改的数据修改后统一提交而不是修改过后马上提交;在跨进程通讯中不去使用;键值对不宜过多
读写操作性能分析
第一次通过
Context.getSharedPreferences()
进行初始化时,对xml
文件进行一次读取,并将文件内所有内容(即所有的键值对)缓到内存的一个Map
中,接下来所有的读操作,只需要从这个Map
中取就可以
3.2 MMKV 存储原理分析
早期微信的需求
微信聊天对话内容中的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的。
只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。
该需求对应的技术考量
考量 1:把聊天页面的显示文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式来查看。但这种方式涉及到 io 流读写,且消息多会有性能问题。
考量 2:App 程序都崩溃了,如何保证要存储的内容,都写入到磁盘中呢?
考量 3:保存聊天内容到磁盘的行为,这个做成同步还是异步呢?如果是异步,如何保证聊天消息的时序性?
考量 4:如何存储数据是同步行为,针对群里聊天这么多消息,如何才能避免卡顿呢?
考量 5:存储数据放到主线程中,用户在群聊天页面猛滑消息,如何爆发性集中式对磁盘写入数据?
MMKV 存储框架介绍
MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。
MMKV 设计的原理
内存准备:通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。
数据组织:数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。
写入优化:考虑到主要使用场景是频繁地进行写入更新,需要有增量更新的能力。考虑将增量 kv 对象序列化后,append 到内存末尾。
空间增长:使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。需要在性能和空间上做个折中。
MMKV 诞生的背景
针对该业务,高频率,同步,大量数据写入磁盘的需求。不管用 sp,还是 store,还是 disk,还是数据库,只要在主线程同步写入磁盘,会很卡。
解决方案就是:使用内存映射 mmap 的底层方法,相当于系统为指定文件开辟专用内存空间,内存数据的改动会自动同步到文件里。
用浅显的话说:MMKV 就是实现用「写入内存」的方式来实现「写入磁盘」的目标。内存的速度多快呀,耗时几乎可以忽略,这样就把写磁盘造成卡顿的问题解决了。
3.3 LruCache 考量分析
在 LruCache 的源码中,关于 LruCache 有这样的一段介绍:
cache 对象通过一个强引用来访问内容。每次当一个 item 被访问到的时候,这个 item 就会被移动到一个队列的队首。当一个 item 被添加到已经满了的队列时,这个队列的队尾的 item 就会被移除。
LruCache 核心思想
LRU 是近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用 LRU 算法的缓存有两种:LrhCache 和 DiskLruCache,分别用于实现内存缓存和硬盘缓存,其核心思想都是 LRU 缓存算法。
LruCache 使用是计数 or 计量
使用计数策略:1、Message 消息对象池:最多缓存 50 个对象;2、OkHttp 连接池:默认最多缓存 5 个空闲连接;3、数据库连接池
使用计量策略:1、图片内存缓存;2、位图池内存缓存
那么思考一下如何理解 计数 or 计量 ?针对计数策略使用 Lru 仅仅只统计缓存单元的个数,针对计量则要复杂一点。
LruCache 策略能否增加灵活性
在缓存容量满时淘汰,除了这个策略之外,能否再增加一些辅助策略,例如在 Java 堆内存达到某个阈值后,对 LruCache 使用更加激进的清理策略。
比如:Glide 除了采用 LRU 策略淘汰最早的数据外,还会根据系统的内存紧张等级 onTrimMemory(level) 及时减少甚至清空 LruCache。
3.4 DiskLru 原理分析
DiskLruCache 用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。
DiskLruCache 最大的特点就是持久化存储,所有的缓存以文件的形式存在。在用户进入 APP 时,它根据日志文件将 DiskLruCache 恢复到用户上次退出时的情况,日志文件 journal 保存每个文件的下载、访问和移除的信息,在恢复缓存时逐行读取日志并检查文件来恢复缓存。
DiskLruCache 缓存基础原理流程图
关于 DiskLruCache 更多的原理解读,可以看:AppLruDisk
3.5 DataStore 分析
为何会有 DataStore
DataStore 被创造出来的目标就是替代 Sp,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。
DataStore 优势是异步 Api
DataStore 的主要优势之一是异步 API,所以本身并未提供同步 API 调用,但实际上可能不一定始终能将周围的代码更改为异步代码。
提出一个问题和思考
如果使用现有代码库采用同步磁盘 I/O,或者您的依赖项不提供异步 API,那么如何将 DataStore 存储数据改成同步调用?
使用阻塞式协程消除异步差异
使用 runBlocking() 从 DataStore 同步读取数据。runBlocking()会运行一个新的协程并阻塞当前线程直到内部逻辑完成,所以尽量避免在 UI 线程调用。
频繁使用阻塞式协程会有问题吗
要注意的一点是,不用在初始读取时调用 runBlocking,会阻塞当前执行的线程,因为初始读取会有较多的 IO 操作,耗时较长。
更为推荐的做法则是先异步读取到内存后,后续有需要可直接从内存中拿,而非运行同步代码阻塞式获取。
3.6 HashMap 存储分析
内存缓存的场景
比如 SharedPreferences 存储中,就做了内存缓存的操作。
3.7 Sqlite 存储分析
注意:缓存的数据库是存放在/data/data/databases/目录下,是占用内存空间的,如果缓存累计,容易浪费内存,需要及时清理缓存。
3.8 使用缓存注意点
在使用内存缓存的时候须要注意防止内存泄露,使用磁盘缓存的时候注意确保缓存的时效性
针对 SharedPreferences 使用建议有:
因为 SharedPreferences 虽然是全量更新的模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。
它设计初衷是轻量级,建议当存储文件中 key-value 数据超过 30 个,如果超过 30 个(这个只是一个假设),则开辟一个新的文件进行存储。建议不同业务模块的数据分文件存储……
针对 MMKV 使用建议有:
如果项目中有高频率,同步存储数据,使用 MMKV 更加友好。
针对 DataStore 使用建议有:
建议在初始化的时候,使用全局上下文 Context 给 DataStore 设置存储路径。
针对 LruCache 缓存使用建议:
如果你使用“计量”淘汰策略,需要重写 SystemLruCache#sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量。
3.9 各种数据存储文件
SharedPreferences 存储文件格式如下所示
MMKV 存储文件格式如下所示
MMKV 的存储结构,分了两个文件,一个数据文件,一个校验文件 crc 结尾。大概如下所示:
这种设计最直接问题就是占用空间变大了很多,举一个例子,只存储了一个字段,但是为了方便 MMAP 映射,磁盘直接占用了 8k 的存储。
LruDiskCache 存储文件格式如下所示
DataStore 存储文件格式如下所示
04.通用缓存方案思路
4.1 如何兼容不同缓存
定义通用的存储接口
不同的存储方案,由于 api 不一样,所以难以切换操作。要是想兼容不同存储方案切换,就必须自己制定一个通用缓存接口。
定义接口,然后各个不同存储方案实现接口,重写抽象方法。调用的时候,获取接口对象调用 api,这样就可以统一 Api
定义一个接口,这个接口有什么呢?
主要是存和取各种基础类型数据,比如 saveInt/readInt;saveString/readString 等通用抽象方法
4.2 打造通用缓存 Api
通用缓存 Api 设计思路:
通用一套 api + 不同接口实现 + 代理类 + 工厂模型
定义缓存的通用 API 接口,这里省略部分代码
基于接口而非实现编程的设计思想
将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
4.3 切换不同缓存方式
传入不同类型方便创建不同存储方式
隐藏存储方案创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体存储方案的类名。需要符合开闭原则
那么具体该怎么实现呢?
看到下面代码是不是有种很熟悉的感觉,没错,正是使用了工厂模式,灵活切换不同的缓存方式。但针对应用层调用 api 却感知不到影响。
4.4 缓存的过期处理
说一个使用场景
比如你准备做 WebView 的资源拦截缓存,针对模版页面,为了提交加载速度。会缓存 css,js,图片等资源到本地。那么如何选择存储方案,如何处理过期问题?
思考一下该问题
比如 WebView 缓存方案是数据库存储,db 文件。针对缓存数据,猜想思路可能是 Lru 策略,或者标记时间清除过期文件。
那么缓存过期处理的策略有哪些
定时过期:每个设置过期时间的 key 都需要创建⼀个定时器,到过期时间就会立即清除。
惰性过期:只有当访问⼀个 key 时,才会判断该 key 是否已过期,过期则清除。
定期过期:每隔⼀定的时间,会扫描⼀定数量的数据库的 expires 字典中⼀定数量的 key(是随机的), 并 清除其中已过期的 key 。
分桶策略:定期过期的优化,将过期时间点相近的 key 放在⼀起,按时间扫描分桶。
4.5 缓存的阀值处理
淘汰一个最早的节点就足够吗?以 Lru 缓存为案例做分析……
标准的 LRU 策略中,每次添加数据时最多只会淘汰一个数据,但在 LRU 内存缓存中,只淘汰一个数据单元往往并不够。
例如在使用 “计量” 的内存图片缓存中,在加入一个大图片后,只淘汰一个图片数据有可能依然达不到最大缓存容量限制。
那么在 LRUCache 该如何做呢?
在复用 LinkedHashMap 实现 LRU 内存缓存时,前文提到的 LinkedHashMap#removeEldestEntry() 淘汰判断接口可能就不够看了,因为它每次最多只能淘汰一个数据单元。
LruCache 是如何解决这个问题
这个地方就需要重写 LruCache 中的 sizeOf()方法,然后拿到 key 和 value 对象计算其内存大小。
4.6 缓存的线程安全性
为何要强调缓存方案线程安全性
缓存虽好,用起来很快捷方便,但在使用过程中,大家一定要注意数据更新和线程安全,不要出现脏数据。
针对 LruCache 中使用 LinkedHashMap 读写不安全情况
保证 LruCache 的线程安全,在 put,get 等核心方法中,添加 synchronized 锁。这里主要是 synchronized (this){ put 操作 }
针对 DiskLruCache 读写不安全的情况
DiskLruCache 管理多个 Entry(key-values),因此锁粒度应该是 Entry 级别的。
get 和 edit 方法都是同步方法,保证内部的 Entry Map 的安全访问,是保证线程安全的第一步。
4.7 缓存数据的迁移
如何将 Sp 数据迁移到 DataStore
通过属性委托的方式创建 DataStore,基于已有的 SharedPreferences 文件进行创建 DataStore。将 sp 文件名,以参数的形式传入 preferencesDataStore,DataStore 会自动将该文件中的数据进行转换。
如何将 sp 数据迁移到 MMKV
MMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来。
MMKV 还额外实现了一遍 SharedPreferences、SharedPreferences.Editor 这两个 interface。
思考一下,MMKV 框架实现了 sp 的两个接口,即磨平了数据迁移差异性
那么使用这个方式,借鉴该思路,你能否尝试用该方法,去实现 LruDiskCache 方案的 sp 数据一键迁移。
4.8 缓存数据加密
思考一下,如果让你去设计数据的加密,你该怎么做?
具体可以参考 MMKV 的数据加密过程。
4.9 缓存效率的对比
测试数据
测试写入和读取。注意分别使用不同的方式,测试存储或获取相同的数据(数据为 int 类型数字,还有 String 类型长字符串)。然后查看耗时时间的长短……
比较对象
SharePreferences/DataStore/MMKV/LruDisk/Room。使用华为手机测试
测试数据案例 1
在主线程中测试数据,同步耗时时间(主线程还有其他的耗时)跟异步场景有较大差别。
测试数据案例 2
测试 1000 组长字符串数据,MMKV 就不具备优势了,反而成了耗时最久的;而这时候的冠军就成了 DataStore,并且是遥遥领先。
最后思考说明
从最终的数据来看,这几种方案都不是很慢。虽然这半秒左右的主线程耗时看起来很可怕,但是要知道这是 1000 次连续写入的耗时。
而在真正写程序的时候,怎么会一次性做 1000 次的长字符串的写入?所以真正在项目中的键值对写入的耗时,不管你选哪个方案,都会比这份测试结果的耗时少得多的,都少到了可以忽略的程度,这是关键。
05.方案基础设计
5.1 整体架构图
统一存储方案的架构图
5.2 UML 设计图
通用存储方案 UML 设计图
5.3 代码说明图
项目中代码相关说明图
5.4 关键流程图
mmap 的零拷贝流程图
5.5 模块间依赖关系
存储库依赖的关系
MMKV 需要依赖一些腾讯开源库的服务;DataStore 存储需要依赖 datastore 相关的库;LruDisk 存储需要依赖 disk 库
如果你要拓展其他的存储方案,则需要添加其依赖。需要注意,添加的库使用 compileOnly。
06.其他设计说明
6.1 性能设计
关于基础库性能如何考量
具体性能可以参考测试效率的对比。
6.2 稳定性设计
针对多进程初始化
遇到问题:对于多进程在 Application 的 onCreate 创建几次,导致缓存存储库初始化了多次。
问题分析:该场景不是该库的问题,建议判断是否是主进程,如果是则进行初始化。
如何解决:思路是获取当前进程名,并与主进程对比,来判断是否为主进程。具体可以参考:优雅判断是否是主进程
6.3 灰度设计
暂无灰度设计
6.4 降级设计
由于缓存方式众多,在该库中配置了降级,如何设置降级
降级后的逻辑处理是
如果是降级逻辑,则默认使用谷歌官方存储框架 SharedPreferences。默认是不会降级的!
6.5 异常设计说明
DataStore 初始化遇到的坑
遇到问题:不能将 DataStore 初始化代码写到 Activity 里面去,否则重复进入 Activity 并使用 Preferences DataStore 时,会尝试去创建一个同名的.preferences_pb 文件。
问题分析:SingleProcessDataStore#check(!activeFiles.contains(it)),该方法会检查如果判断到 activeFiles 里已经有该文件,直接抛异常,即不允许重复创建。
如何解决:在项目中只在顶层调用一次 preferencesDataStore 方法,这样可以更轻松地将 DataStore 保留为单例。
MMKV 遇到的坑说明
MMKV 是有数据损坏的概率的,MMKV 的 GitHub wiki 页面显示,微信的 iOS 版平均每天有 70 万次的数据校验不通过(即数据损坏)。
6.6 兼容性设计
MMKV 数据迁移比较难
MMKV 都是按字节进行存储的,实际写入文件把类型擦除了,这也是 MMKV 不支持 getAll 的原因,虽然说 getAll 用的不多问题不大,但是 MMKV 因此就不具备导出和迁移的能力。
比较好的方案是每次存储,多用一个字节来存储数据类型,这样占用的空间也不会大很多,但是具备了更好的可扩展性。
6.7 自测性设计
MMKV 不太方便查看数据和解析数据
官方目前支持了 5 个平台,Android、iOS、Win、MacOS、python,但是没有提供解析数据的工具,数据文件和 crc 都是字节码,除了中文能看出一些内容,直接查看还是存在大量乱码。
比如线上出了问题,把用户的存储文件捞上来,还得替换到系统目录里,通过代码断点去看,这也太不方便了。
Sp,FastSp,DiskCache,Store 等支持查看文件解析数据
傻瓜式的查看缓存文件,操作缓存文件。具体看该库:MonitorFileLib磁盘查看工具
07.通用 Api 设计
7.1 如何依赖该库
依赖该库如下所示
7.2 初始化缓存库
通用存储库初始化
7.3 切换各种缓存方案
如何调用 api 切换各种缓存方案
7.4 数据的存和取
存储数据和获取数据
也可以通过注解的方式存储数据
7.5 查看缓存文件数据
android 缓存路径查看方法有哪些呢?
将手机打开开发者模式并连接电脑,在 pc 控制台输入 cd /data/data/目录,使用 adb 主要是方便测试(删除,查看,导出都比较麻烦)。
如何简单快速,傻瓜式的查看缓存文件,操作缓存文件,那么该项目小工具就非常有必要呢!采用可视化界面读取缓存数据,方便操作,直观也简单。
一键接入该工具
FileExplorerActivity.startActivity(this);
开源项目地址:https://github.com/yangchong211/YCAndroidTool
查看缓存文件数据如下所示
7.6 如何选择合适方案
比如常见的缓存、浏览器缓存、图片缓存、线程池缓存、或者 WebView 资源缓存等等
那就可以选择 LRU+缓存淘汰算法。它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。
比如针对高频率,同步存储,或者跨进程等存储数据的场景
那就可以选择 MMKV 这种存储方案。它的核心思想就是高速存储数据,且不会阻塞主线程卡顿。
比如针对存储表结构,或者一对多这类的数据
那就可以选择 DataStore,Room,GreenDao 等存储库方案。
比如针对存储少量用户类数据
其实也可以将 json 转化为字符串,然后选择 sp,mmkv,lruDisk 等等都可以。
08.其他说明介绍
8.1 遇到的坑分析
Sp 存储数据 commit() / apply()操作可能会造成 ANR 问题
commit()是同步提交,会在 UI 主线程中直接执行 IO 操作,当写入操作耗时比较长时就会导致 UI 线程被阻塞,进而产生 ANR;
apply()虽然是异步提交,但异步写入磁盘时,如果执行了 Activity / Service 中的 onStop()方法,那么一样会同步等待 SP 写入完毕,等待时间过长时也会引起 ANR 问题。
首先分析一下 SharedPreferences 源码中 apply 方法
SharedPreferencesImpl#apply(),这个方法主要是将记录的数据同步写到 Map 集合中,然后在开启子线程将数据写入磁盘 SharedPreferencesImpl#enqueueDiskWrite(),这个会将 runnable 被写入了队列,然后在 run 方法中写数据到磁盘 QueuedWork#queue(),这个将 runnable 添加到 sWork(LinkedList 链表)中,然后通过 handler 发送处理队列消息 MSG_RUN
然后再看一下 ActivityThread 源码中的 handlePauseActivity()、handleStopActivity()方法。
ActivityThread#handlePauseActivity()/handleStopActivity(),Activity 在 pause 和 stop 的时候会调用该方法 ActivityThread#handlePauseActivity()#QueuedWork.waitToFinish(),这个是等待 QueuedWork 所有任务处理完的逻辑 QueuedWork#waitToFinish(),这个里面会通过 handler 查询 MSG_RUN 消息是否有,如果有则会 waiting 等待
那么最后得出的结论是
handlePauseActivity()的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。但普通存储的场景,这种可能性很小。
8.2 项目开发分享
通用缓存存储库开源代码
https://github.com/yangchong211/YCCommonLib/tree/master/AppBaseStore
版权声明: 本文为 InfoQ 作者【杨充】的原创文章。
原文链接:【http://xie.infoq.cn/article/3f5af86e8966b729b4600d765】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论