写点什么

Android 通用缓存存储设计实践

作者:杨充
  • 2023-03-07
    北京
  • 本文字数:13654 字

    阅读完需:约 45 分钟

目录介绍

  • 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。


    /**     * 这里是参考glide中的lru缓存策略,低内存的时候清除     * @param level             level级别     */    public void trimMemory(int level) {        if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {            clearMemory();        } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {            trimToSize(maxSize() / 2);        }    }
复制代码


- 关于Lru更多的原理解读,可以看:[AppLruCache](https://github.com/yangchong211/YCCommonLib/tree/master/AppLruCache)
复制代码

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 存储文件格式如下所示


    <?xml version='1.0' encoding='utf-8' standalone='yes' ?>    <map>        <string name="name">杨充</string>        <int name="age" value="28" />        <boolean name="married" value="true" />    </map>
复制代码


  • MMKV 存储文件格式如下所示

  • MMKV 的存储结构,分了两个文件,一个数据文件,一个校验文件 crc 结尾。大概如下所示:

  • 这种设计最直接问题就是占用空间变大了很多,举一个例子,只存储了一个字段,但是为了方便 MMAP 映射,磁盘直接占用了 8k 的存储。

  • LruDiskCache 存储文件格式如下所示

  • DataStore 存储文件格式如下所示

04.通用缓存方案思路

4.1 如何兼容不同缓存

  • 定义通用的存储接口

  • 不同的存储方案,由于 api 不一样,所以难以切换操作。要是想兼容不同存储方案切换,就必须自己制定一个通用缓存接口。

  • 定义接口,然后各个不同存储方案实现接口,重写抽象方法。调用的时候,获取接口对象调用 api,这样就可以统一 Api

  • 定义一个接口,这个接口有什么呢?

  • 主要是存和取各种基础类型数据,比如 saveInt/readInt;saveString/readString 等通用抽象方法

4.2 打造通用缓存 Api

  • 通用缓存 Api 设计思路:

  • 通用一套 api + 不同接口实现 + 代理类 + 工厂模型

  • 定义缓存的通用 API 接口,这里省略部分代码


    interface ICacheable {        fun saveXxx(key: String, value: Int)        fun readXxx(key: String, default: Int = 0): Int        fun removeKey(key: String)        fun totalSize(): Long        fun clearData()    }
复制代码


  • 基于接口而非实现编程的设计思想

  • 将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。

4.3 切换不同缓存方式

  • 传入不同类型方便创建不同存储方式

  • 隐藏存储方案创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体存储方案的类名。需要符合开闭原则

  • 那么具体该怎么实现呢?

  • 看到下面代码是不是有种很熟悉的感觉,没错,正是使用了工厂模式,灵活切换不同的缓存方式。但针对应用层调用 api 却感知不到影响。


    public static ICacheable getCacheImpl(Context context, @CacheConstants.CacheType int type) {        if (type == CacheConstants.CacheType.TYPE_DISK) {            return DiskFactory.create().createCache(context);        } else if (type == CacheConstants.CacheType.TYPE_LRU) {            return LruCacheFactory.create().createCache(context);        } else if (type == CacheConstants.CacheType.TYPE_MEMORY) {            return MemoryFactory.create().createCache(context);        } else if (type == CacheConstants.CacheType.TYPE_MMKV) {            return MmkvFactory.create().createCache(context);        } else if (type == CacheConstants.CacheType.TYPE_SP) {            return SpFactory.create().createCache(context);        } else if (type == CacheConstants.CacheType.TYPE_STORE) {            return StoreFactory.create().createCache(context);        } else {            return MmkvFactory.create().createCache(context);        }    }
复制代码

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 会自动将该文件中的数据进行转换。


    val Context.dataStore: DataStore<Preferences> by preferencesDataStore(        name = "user_info",        produceMigrations = { context ->            listOf(SharedPreferencesMigration(context, "sp_file_name"))        })
复制代码


  • 如何将 sp 数据迁移到 MMKV

  • MMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来。

  • MMKV 还额外实现了一遍 SharedPreferences、SharedPreferences.Editor 这两个 interface。


    MMKV preferences = MMKV.mmkvWithID("myData");    // 迁移旧数据    {        SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);        preferences.importFromSharedPreferences(old_man);        old_man.edit().clear().commit();    }
复制代码


  • 思考一下,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 降级设计

  • 由于缓存方式众多,在该库中配置了降级,如何设置降级


    //设置是否是debug模式    CacheConfig cacheConfig = builder.monitorToggle(new IMonitorToggle() {            @Override            public boolean isOpen() {                //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关                return true;            }        })        //创建        .build();    CacheInitHelper.INSTANCE.init(this,cacheConfig);
复制代码


  • 降级后的逻辑处理是

  • 如果是降级逻辑,则默认使用谷歌官方存储框架 SharedPreferences。默认是不会降级的!


    if (CacheInitHelper.INSTANCE.isToggleOpen()){        //如果是降级,则默认使用sp        return SpFactory.create().createCache();    }
复制代码

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 如何依赖该库

  • 依赖该库如下所示


    //通用缓存存储库,支持sp,fastsp,mmkv,lruCache,DiskLruCache等    implementation 'com.github.yangchong211.YCCommonLib:AppBaseStore:1.4.8'
复制代码

7.2 初始化缓存库

  • 通用存储库初始化


    CacheConfig.Builder builder = CacheConfig.Companion.newBuilder();    //设置是否是debug模式    CacheConfig cacheConfig = builder.debuggable(BuildConfig.DEBUG)            //设置外部存储根目录            .extraLogDir(null)            //设置lru缓存最大值            .maxCacheSize(100)            //内部存储根目录            .logDir(null)            //创建            .build();    CacheInitHelper.INSTANCE.init(MainApplication.getInstance(),cacheConfig);    //最简单的初始化    //CacheInitHelper.INSTANCE.init(CacheConfig.Companion.newBuilder().build());
复制代码

7.3 切换各种缓存方案

  • 如何调用 api 切换各种缓存方案


    //这里可以填写不同的type    val cacheImpl = CacheFactoryUtils.getCacheImpl(CacheConstants.CacheType.TYPE_SP)
复制代码

7.4 数据的存和取

  • 存储数据和获取数据


    //存储数据    dataCache.saveBoolean("cacheKey1",true);    dataCache.saveFloat("cacheKey2",2.0f);    dataCache.saveInt("cacheKey3",3);    dataCache.saveLong("cacheKey4",4);    dataCache.saveString("cacheKey5","doubi5");    dataCache.saveDouble("cacheKey6",5.20);        //获取数据    boolean data1 = dataCache.readBoolean("cacheKey1", false);    float data2 = dataCache.readFloat("cacheKey2", 0);    int data3 = dataCache.readInt("cacheKey3", 0);    long data4 = dataCache.readLong("cacheKey4", 0);    String data5 = dataCache.readString("cacheKey5", "");    double data6 = dataCache.readDouble("cacheKey5", 0.0);
复制代码


  • 也可以通过注解的方式存储数据


    class NormalCache : DataCache() {        @BoolCache(KeyConstant.HAS_ACCEPTED_PARENT_AGREEMENT, false)        var hasAcceptParentAgree: Boolean by this    }        //如何使用    object CacheHelper {        //常规缓存数据,记录一些重要的信息,慎重清除数据        private val normal: NormalCache by lazy {            NormalCache().apply {                setCacheImpl(                    DiskCache.Builder()                        .setFileId("NormalCache")                        .build()                )            }        }        fun normal() = normal    }        //存数据    CacheHelper.normal().hasAcceptParentAgree = true    //取数据    val hasAccept = CacheHelper.normal().hasAcceptParentAgree
复制代码

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

发布于: 刚刚阅读数: 4
用户头像

杨充

关注

还未添加个人签名 2018-07-30 加入

还未添加个人简介

评论

发布
暂无评论
Android通用缓存存储设计实践_杨充_InfoQ写作社区