背景
对于 Android 轻量级存储方案,有大多数人都很熟悉的 SharedPreferences;也有基于 mmap 的高性能组件 MMKV,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强;还有 Jetpack DataStore 是一种数据存储解决方案,允许您使用协议缓冲区存储键值对或类型化对象。DataStore 使用 Kotlin 协程和流程(Flow)以异步、一致的事务方式存储数据。本文将一一分析这三个方案的来龙去脉,并深入源码进行分析。(本文基于Android 29 源码)
SharedPreferences
SharedPreferences 是 Android 中简单易用的轻量级存储方案,用来保存 App 的相关信息,其本质是一个键值对(key-value)的方式保存数据的 xml 文件,文件路径为 /data/data/应用程序包名/shared_prefs
,文件内容如下:
每次读取数据时,通过解析 xml 文件,得到指定 key 对应的 value。
SharedPreferences 的设计初衷是轻量级存储,如果我们存储了大量的数据,那会对内存造成什么影响?
源码初步分析
我们先来看看 SharedPreferences 的源码设计,首先从我们的常规调用 Context.getSharedPreferences(name, mode)
开始,最终都会调用到
再来看看方法 getSharedPreferences 方法
SharedPreferences 是个接口,其真正实现类是 SharedPreferencesImpl
每当调用 SharedPreferencesImpl 的构造器的时候,都会开始调用 startLoadFromDisk 方法,然后在该方法中开启一个子线程加载 xml 文件中的内容,最后将 xml 中的内容全部加载到 mMap中
内存占用
从上面的分析可以看出当 xml 中数据过大时,肯定会导致内存占用过高,虽然 Context.getSharedPreferences(name, mode)
调用时会将 xml 中的数据一股脑加载到 mMap 中导致内存占用过大,也就是空间换时间,同时 ContextImpl.getSharedPreferencesCacheLocked
可以看到这个静态的 sSharedPrefsCache 保存了所有的 sp,然后 sSharedPrefsCache 的 value 值保存了所有键值对,也就是说用过的 sp 永远存在于内存中。
同时开发者也需要注意对每个 sp(xml)大小进行控制,毕竟对读写操作也会有一定的影响,具体的区分可以根据相应的业务进行区分。
但是 SharedPreferences 的设计初衷就是面向轻量级的数据存储,所以该设计没毛病,设计者应该自己注意使用场景,毕竟再好的设计也不能面对所有场景。
首次调用可能阻塞主线程
我们再来看看 SharedPreferencesImpl.getString()
方法
可以看到,当 sp 还没加载完毕主线程会一直阻塞在那里,直到加载 sp 的子线程加载完成。对于上面的问题,我们可以提前调用 getSharedPreferences 方法让子线程提前加载 sp 的内容。
防止连续多次edit/commit/apply
每次调用 edit 方法都会创建一个 Editor 对象,造成额外的内存占用。很多设计者会对 SharedPreferences 进行封装,隐藏掉 edit()
和commit/apply()
调用流程,但往往同时也忽略了Editor.commit/apply()
的设计理念和使用场景。如果是复杂的场景,用户可以在多次 putXxx 方法之后再统一进行 commit/apply()
,也就是一次更新多个键值对,只进行一次 IO 操作。
commit/apply 引起的 ANR 问题
commit 是同步地提交到硬件磁盘,有返回值表明修改是否成功,如果在主线程中提交会阻塞线程,影响后续的操作,可能导致 ANR;而 apply 是将修改数据提交到内存,而后异步真正提交到硬件磁盘,没有返回值。我们着重研究一下 apply 为什么会导致 ANR 问题,先来看看 apply 的源码:
首先把带有 await 的 runnable 添加到 QueuedWork 队列,然后把这个写入任务 postWriteRunnable 通过 enqueueDiskWrite 交给 HandlerThread(Handler + Thread) 进行执行,待处理的任务排队进行执行。然后我们进入 ActivityThread 的 handleStopActivity 方法中,可以看到如下代码
我们再来看看 waitToFinish 中的一段源码
还记得之前的 QueuedWork.addFinisher(awaitCommit)
吗,里面的 awaitCommit 在等待写入线程,如果用户使用了太多的 apply,也就是说写入队列中会有很多写入任务。而只有一个线程在写入,一旦涉及到大量的读写很容易造成ANR(android 8.0 之前,android 8.0 之前的实现 QueuedWork.waitToFinish 是有缺陷的。在多个生命周期方法中,在主线程等待任务队列去执行完毕,而由于cpu调度的关系任务队列所在的线程并不一定是处于执行状态的,而且当apply提交的任务比较多时,等待全部任务执行完成,会消耗不少时间,这就有可能出现 ANR),因为本文的源码时基于 android 29 的,所以该版本或者说是 android 8.0之后并不存在 ANR 问题,因为 8.0之后做了很大的优化,会主动触发processPendingWork取出写任务列表中依次执行,而不是只在在等待。还有一个更重要的优化:
我们知道在调用 apply 方法时,会将改动同步提交到内存中 map 中,然后将写入磁盘的任务加入的队列中,在工作线程中从队列中取出写入任务,依次执行写入。注意,不管是内存的写入还是磁盘的写入,对于一个 xml 格式的 sp 文件来说,都是全量写入的。 这里就存在优化的空间,比如对于同一个 sp 文件,连续调用 n 次apply,就会有 n 次写入磁盘任务执行,实际上只需要最后执行最后那次就可以了,最后那次提交对应内存的 map 是持有最新的数据,所以就可以省掉前面 n-1 次的执行,这个就是android 8.0中做的优化,是使用版本来进行控制的。
解决方案
解决方案可以参考今日头条的解决方案,通过反射 ActivityThread 中的 H(Handler) 变量,给 Handler 设置一个 callback,Handler 的 dispatchMessage 中先处理 callback。队列清理需要反射调用 QueuedWork。Google 之所以在Activity/Service 的 onStop 之前调用该方法是为了尽量保证 sp 的数据持久化,该文章中也对比了清理队列和未清理情况下的失败率(相差不大)。
还有一个解决方案,因为 SharedPreferences 是个接口,所以可以自己实现 apply (异步调用系统 commit,这样并不会导致类似系统 apply 那样的阻塞),同时重写 Activity 和 Application 的 getSharedPreference 方法,直接返回自己的实现。但是这个方案带来的副作用比清理等待锁更加明显:系统apply是先同步更新缓存再异步写文件,调用方在同一线程内读写缓存是同步的,无需关心上下文数据读写同步问题;commit 异步化之后直接在子线程中更新缓存再写文件,调用方需要关注上下文线程切换,异步有可能引发读写数据不一致问题。所以还是推荐使用第一种方案。
安全机制
安全机制我们可以分为线程安全,进程安全,文件备份机制。
SharedPreferences 通过锁来保证线程安全,这里就不赘述了。而如何保证进程安全呢,我们再来看看 SharedPreferences 类的注释,可以看到不支持进程安全。
SharedPreferences 提供了 MODE_MULTI_PROCESS 这个 Flag 来支持跨进程,保证了在 API 11 以前的系统上,如果 sp 已经被读取进内存,再次获取这个 sp 的时候,如果有这个 flag,会重新读一遍文件,仅此而已!
所以说 SharedPreferences 的跨进程通信压根就不可靠!对于如何保证进程安全,可以使用 ContentProvider 进行统一访问,或者使用文件锁的方式。
最后我们再来看看文件备份机制,我们在运行程序的时候,可能会遇到手机死机或者断电等突发状况,这个时候如何保证文件的正常和安全就至关重要了。Android 系统本身的文件系统虽然有保护机制,但还会有数据丢失或者文件损坏的情况,所以对文件的备份就至关重要了。从 SharedPreferencesImpl 的 commit() -> enqueueDiskWrite() -> writeToFile()
,
备份的时候是直接将原有的文件重命名为备份文件,写入成功后再删除备份文件。再来看看前面的 loadFromDisk 方法
如果因为异常情况(比如进程被 kill)导致写入失败,下次启动的时候若发现存在备份文件,则将备份文件重新命名为源文件,原本未完成写入的文件就直接丢弃。
小结
到此我们先做个小结,我们提到了 SharedPreferences 的内存占用问题以及可能阻塞主线程,正确的应用场景和合适的代码调用方式,还提到了可能导致的 ANR 问题,最后我们分析了它的安全机制,线程安全,进程安全(无),文件备份机制。
在正确使用 SharedPreferences 的情况下,可以大概总结一下 SharedPreferences 的问题,可能导致内存占用高,ANR,无法保证进程安全。
MMKV
MMKV 腾讯开发的基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强,支持多进程。从 2015 年中至今在微信上使用,其性能和稳定性经过了时间的验证。后续也已移植到 Android / macOS / Win32 / POSIX 平台,一并开源。
MMKV 原本是要解决微信上特殊文字引起系统的 crash,解决过程中有一些计数器需要保存(因为闪退随时可能发生),这时就需要一个性能非常高的通用 key-value组件,SharedPreferences、NSUserDefaults、SQLite 等常见组件这些都不满足,考虑到这个防 crash 方案最主要的诉求还是实时写入,而 mmap 内存映射文件刚好满足这种需求。
使用方式
首先导入依赖
MMKV 的使用非常简单,所有变更立马生效,无需调用 sync
、apply
。 在 App 启动时初始化 MMKV,设定 MMKV 的根目录(files/mmkv/),例如在 Application
里:
如果不同的业务需要区别存储,也可以单独创建自己的实例
如果业务需要多进程访问,那么在初始化的时候加上标志位 MMKV.MULTI_PROCESS_MODE
:
MMKV 提供一个全局的实例,可以直接使用:
支持的数据类型
- 支持以下 Java 语言基础类型:
-
boolean、int、long、float、double、byte[]
- 支持以下 Java 类和容器:
-
String、Set<String>
- 任何实现了
Parcelable
的类型
SharedPreferences 迁移
- MMKV 提供了
importFromSharedPreferences()
函数,可以比较方便地迁移数据过来。 - MMKV 还额外实现了一遍
SharedPreferences
、SharedPreferences.Editor
这两个 interface,在迁移的时候只需两三行代码即可,其他 CRUD 操作代码都不用改。
可以看到使用preferences.edit();
可以让迁移后的用法和之前一样,MMKV 已经为我们考虑的很周到了,迁移的成本非常低,不迁移过来还等什么呢?
mmap 原理
mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
关于虚拟(地址)空间和虚拟内存:请放弃虚拟内存这个概念,那个是广告性的概念,在开发中没有意义。开发中只有虚拟空间的概念,进程看到的所有地址组成的空间,就是虚拟空间。虚拟空间是某个进程对分配给它的所有物理地址(已经分配的和将会分配的)的重新映射。 mmap的作用,在应用这一层,是让你把文件的某一段,当作内存一样来访问。
通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。
为什么选择 Protobuf
数据序列化方面我们选用 Protobuf 协议,pb 在性能和空间占用上都有不错的表现。Protocol buffers 通常称为 Protobuf,是 Google 开发的一种协议,允许对结构化数据进行序列化和反序列化,不仅仅是一种消息格式,它还是一组用于定义和交换这些消息的规则和工具。 谷歌开发它的目的是提供一种比 XML更好的方式来进行系统间通信。该协议甚至超越了JSON,具有更好的性能,更好的可维护性和更小的尺寸。
但是它也有一些缺点,二进制格式可读性差,维护成本高等。关于序列化选型,可以参考这篇文章。
增量更新机制
标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。
使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 重排,尝试序列化保存重排结果;重排后空间还是不够用的话,将文件扩大一倍,直到空间足够。
多进程设计与实现
我们先来看MMKV的设计初衷是要解决什么问题,最主要的诉求还是实时写入,而且要求速度够快,性能高。当要求跨进程通信的时候,我们先看看我们有什么,C/S 架构中有 ContentProvider,但是问题很明显,启动慢访问也慢,这个可以说是 Android 下基于Binder 的 C/S 架构组件的痛点,socket、PIPE、message queue,因为要至少 2 次的内存拷贝,就更加慢了。
MMKV 追求的是极致的访问速度,我们要尽可能的避免进程间通信,C/S架构是不可取的。再考虑到 MMKV 底层使用 mmap 实现,采用去中心化的架构是很自然的选择。我们只需要将文件 mmap 到每个访问进程的内存空间,加上合适的进程锁,再处理好数据的同步,就能够实现多进程并发访问。
性能对比
- 单进程性能,可以看到 MMKV 在写入性能上远远超过 SharedPreferences 和 SQlite,在读取性能上也有相近或超越的表现。
- 多进程性能,MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite, MMKV 在 Android 多进程 key-value 存储组件上是不二之选。
(测试机器是 华为 Mate 20 Pro 128G,Android 10,每组操作重复 1k 次,时间单位是 ms。)
小结
MMKV 可以解决 SharedPreferences 不能直接跨进程通信的问题,但 SharedPreferences 也可以通过 ContentProvider 或者文件锁等方式解决该问题,个人感觉 MMKV 的主要优势有两点,SharedPreferences 可能导致 Activity/Service 等生命周期去做 waitToFinish() 导致ANR 问题,而 MMKV 不存在这个问题,另一个优势是实时写入,性能高,速度快(设计初衷)。
虽然 SharedPreferences 的跨进程、ANR 问题也可以用技术方案进行解决,但是 MMKV 天然不存在这两个问题,而且该组件也支持从 SharedPreferences 迁移到 MMKV,迁移也及其简单,成本很小。所以 MMKV 的确是一个更好的轻量级存储方案。
DataStore
DataStore 是 Android Jetpack 的一部分。Jetpack DataStore 是一种数据存储解决方案,允许您使用协议缓冲区存储键值对或类型化对象。DataStore 使用 Kotlin 协程和流程(Flow)以异步、一致的事务方式存储数据。官方建议如果当前在使用 SharedPreferences 存储数据,请考虑迁移到 DataStore。
DataStore 提供两种不同的实现:Preferences DataStore 和 Proto DataStore。
- Preferences DataStore 以键值对的形式存储在本地和 SharedPreferences 类似,此实现不需要预定义的架构,也不确保类型安全。
- Proto DataStore 将数据作为自定义数据类型的实例进行存储。此实现要求您使用协议缓冲区来定义架构,但可以确保类型安全。
Preferences DataStore 使用方式
先导入依赖
Preferences DataStore 的使用方式如下
需要注意的是读取、写入数据都要在协程中进行,因为 DataStore 是基于 Flow 实现的。也可以看到没有 commit/apply() 方法,同时可以监听到操作成功或者失败结果。
Preferences DataStore 只支持 Int
, Long
, Boolean
, Float
, String
键值对数据,适合存储简单、小型的数据,并且不支持局部更新,如果修改了其中一个值,整个文件内容将会被重新序列化。
SharedPreferences 迁移到 Preferences DataStore
接下来我们来看看 SharedPreferences 迁移到 DataStore,在构建 DataStore 的时候传入 SharedPreferencesMigration,当 DataStore 构建完了之后,需要执行一次读取或者写入操作,即可完成迁移,迁移成功后,会自动删除 SharedPreferences 文件
我们原本的 SharedPreferences 数据如下
原本文件目录如下:
迁移后的文件目录如下:
可以看到迁移后原本的 SharedPreferences 被删除了,同时也可以看到 DataStore 的文件更小一些,在迁移的过程中发现一个有趣的情况,如果我直接迁移后并不进行任意值的读取,在对应的目录上找不到迁移后的文件,只有当我进行任意值的读取后,才会在对应的目录上找到文件,不知道是不是 bug,还是说设计如此。完整代码如下:
下面我们继续来看 Proto DataStore,Proto DataStore 比 Preference DataStore 更加灵活,支持更多的类型
- Preference DataStore 只支持
Int
、 Long
、 Boolean
、 Float
、 String
,而 protocol buffers 支持的类型,Proto DataStore 都支持 - Proto DataStore 使用了二进制编码压缩,体积更小,速度比 XML 更快
Proto DataStore 使用方式
依赖方式上面已经讲了,同时还要使用一些插件,这里引用 HiDhl 大佬的文章,文中有具体操作。
- Protobuf | 安装 Gradle 插件编译 proto 文件
- Protobuf | 如何在 ubuntu 上安装 Protobuf 编译 proto 文件
- Protobuf | 如何在 MAC 上安装 Protobuf 编译 proto 文件
因为 Proto DataStore 是存储类的对象(typed objects ),通过 protocol buffers 将对象序列化存储在本地。序列化是将一个对象转换成可存储或可传输的状态,可分为对象序列化和数据序列化。Android 中可以通过 Serializable 和 Parcelable 两种方式实现对象序列化。但是 Serializable 序列化和反序列化过程用到大量反射和临时变量,会频繁触发GC,序列化性能差,但是实现方式简单;而 Parcelable 虽然比 Serializable 快很多,因为读取和写入都是采用自定义序列化存储的方式,但是使用要复杂很多(不过已经有插件解决了这个问题了)。
数据序列化常用的方式有 JSON、Protocol Buffers、FlatBuffers。Protocol Buffers 简称 Protobuf,共两个版本 proto2 和 proto3,大多数项目使用的 proto2,两者语法不一致,proto3 简化了 proto2 的语法,提高了开发效率。Proto DataStore 对着两者都支持,我们这里使用 proto 3。
新建Person.proto文件,添加一下内容:
syntax
:指定 protobuf 的版本,如果没有指定默认使用 proto2,必须是.proto文件的除空行和注释内容之外的第一行
option
:表示一个可选字段
message
中包含了一个 string 类型的字段(name)。注意 :=
号后面都跟着一个字段编号
每个字段由三部分组成:字段类型 + 字段名称 + 字段编号,在 Java 中每个字段会被编译成 Java 对象。
这些是简单的语法介绍,然后进行 Build 就可以看到生成的文件,具体参考前面的文章。
然后我们再来看具体的使用方式
PersonSerializer 类实现如下:
读取和写入也是都在协程当中,创建的文件在该目录下:
SharedPreferences 迁移到 Proto DataStore
接下来我们来看看 SharedPreferences 如何迁移到 Proto DataStore 当中
可以看到迁移首先需要创建映射关系,然后构建 Protos DataStore 并传入 sharedPrefsMigration,最后迁移完的 SharedPreferences 会被删除,就算你只迁移了一个数据,整个SharedPreferences 也会被删除,所以迁移是一定要把所有需要的数据都搬过去。最后是迁移后的目录
SharedPreferences vs DataStore 功能对比
SharedPreferences vs DataStore 性能对比
下面我们来看看 Preferences DataStore 数据的存储速率如何,测试机器为HUAWEI YAL-AL10 Android 10, 8G 内存 8核 CPU 型号为 Kirin 980,下面是我的代码,存储1000次
然后在该手机上测试 SharedPreferences,下面是我的代码
分别统计三次,耗时如下:
可以看到 Preferences DataStore 耗时差不多是 SharedPreferences 的 两倍多一些,在另一个小米手机(Xiaomi Note 3)上测试发现,也是两倍多一些。
但是在两组虚拟机上的测试结果都是一点几倍,和之前测试相差倍数总体上差距不会太大。
经过上面的一系列测试,我们可以大致估计 Preferences DataStore 耗时差不多是 SharedPreferences 的两倍左右,注意测试的时候要指定在 Dispatchers.IO,需要跑在相同类型的协程中测试,否则结果也会相差很大。
而 Proto DataStore 和 SharedPreferences 的对比则没有意义,因为前者存的往往是一个对象,没有什么可比性。
应用场景
- Preferences DataStore 以键值对的形式存储在本地和 SharedPreferences 类似,存取一些简单的字段等。
- Proto DataStore 将数据作为自定义数据类型的实例进行存储。可以存取一些复杂的对象,适合保存一些重要对象的保存。
总结
SharedPreferences 的 Api 使用很友好,数据改变时可以进行监听。但是它在 8.0 之前可能造成ANR(8.0之后优化了),而且不能跨进程。而 DataStore 存在 Preferences DataStore 和 Proto DataStore 这两种方式,前者适合存储键值对的数据但是效率并不如 SharedPreferences(耗时是两倍左右),后者适合存储一些自定义的数据类型,DataStore 也可以在当数据改变可以进行监听,使用 Flow 以异步一致性方式存储数据,功能强大很多,但还是不能跨进程。
Proto DataStore 感觉在复杂数据的存储上可能会很有优势,当本地需要一些缓存数据对象,如果使用 Proto DataStore 能够快速获取整个对象(比如首页的缓存数据),然后进行数据加载这是很有优势的。但是其速度我也还没和其他方式进行对比,有兴趣的读者可以自己尝试一波。
而 MMKV 虽然不是官方出品的,但是在性能,速率,跨进程上面秒杀官方的两个数据存储方式。如果只是很简单的数据存储而且需要跨进程,MMKV 是首选。
参考
github.com/Tencent/MMK…
weishu.me/2016/10/13/…
github.com/Tencent/MMK…
zhuanlan.zhihu.com/p/53339153
tech.meituan.com/2015/02/26/…