键值存储对比
Android开发涉及到的KV存储是一个比较重要的问题。
从最开始官方提供的SharePreferences,到18年微信团队开源的MMKV库,官方20年推出的基于kotlin的DataStore,最新的还有一个FastKV组件。
本文将梳理以上四种存储方式并进行简单的存储效率对比,同时测试数据和环境较为单一,若有不同甚至是错误的结论,也希望大家指正。
存储方式与对比
对于四种不同的存储方法,我进行了对比测试。
第一类测试我针对不同长度的字符串进行了读写测试。测试方案如下:
- 方案一 单个文件大小为5,文件数量为100,重复次数为10
- 方案二 单个文件大小为50,文件数量为100,重复次数为10
- 方案三 单个文件大小为500,文件数量为100,重复次数为10
因为DataStore在测试demo当中呈现的读写时间过于庞大,在图标当中不会展示该部分数据。
以下为写入时间柱状图
以下为读取时间柱状图
以下表格将展示以上读写测试的数据(时间单位为毫秒)
写入数据 | SP | MMKV | FastKV | DataStore |
---|---|---|---|---|
方案一 | 1.654 | 0.263 | 0.091 | 24.042 |
方案二 | 1.646 | 0.757 | 0.158 | 30.896 |
方案三 | 1.650 | 2.038 | 0.476 | 79.090 |
读取数据 | SP | MMKV | FastKV | DataStore |
---|---|---|---|---|
方案一 | 0.071 | 0.230 | 0.081 | 0.270 |
方案二 | 0.080 | 0.236 | 0.084 | 0.257 |
方案三 | 0.085 | 0.460 | 0.117 | 0.427 |
第二类测试我才用了网上提供的测试方案,从设备当中获取SharePreferences本身存储在设备上的数据并写入list,然后通过四种方式重复读取写入,每次测试写入和读取重复十次。
测试数据如下(时间单位为毫秒)
写入 | 读取 | |
---|---|---|
SP | 967 | 7 |
MMKV | 32 | 14 |
FastKV | 24 | 3 |
DataStore | 130281 | 13 |
根据以上两种测试的数据来看,在单一文本重复存储读写的情况下,SharePreferences的读写是最为稳定的,MMKV和FastKV的读写耗时都会随着文本大小的提升而提升,但是综合来看,FastKV的表现仍然是最好的。
DataStore的写入表现是让人意外的,他的耗时是最长的。为什么会有这样的情况?
最开始我也是不理解的,但是在后续的学习当中,我对几种方式深入了解之后,我发现SharePreferences在多次input一次apply的方式下,读写耗时还能进一步降低。然后就理解了,我现行做的读写耗时比较都是在同步方式进行,而DataStore最主要针对的就是维护主线程的流畅,datastore通过协程来实现,所以如果要去计算比较读写耗时,我们应当统一标准,针对主线程的响应时间来比较各种存储方式的耗时。受限于水平和时间,在主线程进行读写对比的数据我从网上获取了,通过数据比较
可以发现datastore的效率是有很大提升的。所以网上大部分的对比都是从同步的角度来进行的。
那我们该如何在项目当中选择存储方式呢?这就需要对几种存储方式有一个简单的了解。
SharedPreferences
SP是大家最熟悉的方式了,适合用于存储少量键值对数据的持久化存储方案,结构简单,使用方便,很多应用都会使用到。但并不支持跨进程使用。
他最主要的缺点就是占用内存和引起卡顿甚至导致ANR。
内存占用
每个 SP 都对应一个本地磁盘中的 xmlFile,fileName 则是由开发者来显式指定的,每个 xmlFile 都对应一个 SharedPreferencesImpl。
所以 ContextImpl 的逻辑是先根据 fileName 拿到 xmlFile,再根据 xmlFile 拿到 SharedPreferencesImpl,最终应用内所有的 SharedPreferencesImpl 就都会被缓存在 sSharedPrefsCache 这个静态变量中。
此外,由于 SharedPreferencesImpl 在初始化后就会自动去加载 xmlFile 中的所有键值对数据,而 ContextImpl 内部并没有看到有清理 sSharedPrefsCache 缓存的逻辑,所以 sSharedPrefsCache 会被一直保留在内存中直到进程终结,其内存大小会随着我们引用到的 SP 增多而加大,这就可能会持续占用很大一块内存空间。
卡顿
apply源码
//SharedPreferencesImpl.EditorImpl.java
public void apply() {
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
// 线程等待
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
// 将awaitCommit加入QueuedWork
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
// 放入写队列
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
// 通知监听
notifyListeners(mcr);
}
可以看到在apply的过程当中,创建了一个名为awaitCommit的包含等待锁的Runnable对象,然后通过另一个Runnable对象加入到QueuedWork当中。
apply最关键的是通过SharedPreferencesImpl.this.enqueueDiskWrite创建了任务来完成文件的持久化。在commit()当中也是通过同样的方式来执行,只是参数不一样,第二个参数为null。
另一方面,我们可以看一下getString()的源码
//SharedPreferencesImpl.java
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
//阻塞等待sp将xml读取到内存后再get
awaitLoadedLocked();
String v = (String)mMap.get(key);
//如果value为空返回默认值
return v != null ? v : defValue;
}
}
private void awaitLoadedLocked() {
...
// sp读取完成后会把mLoaded设置为true
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
}
虽然通过apply用异步的方式来保存更改,以此来避免 I/O 操作所导致的主线程的耗时,但是Activity 启动和关闭的时候,Activity 会等待这些异步提交完成保存之后再继续,这就相当于把异步操作转换成同步操作,从而导致卡顿和ANR的产生。
另外在读取文件的时候,awaitLoadedLocked()这个操作会阻塞主线程,如果文件过大阻塞时间就会变长,甚至导致ANR,因为将xml文件读取完成后才会释放锁mLock.notifyAll()(SharedPreferencesImpl
当中释放锁的操作)
总的来说SharePreferences 的使用场景已经被DataStore和其他组件替代了,只有部分Android低版本仍然在使用SharePreferences。
MMKV
MMKV是微信在2018年开源的一个存储方案,大家很大程度上对他的看法就是一个字,快。
MMKV原本是腾讯基于mmap 内存映射文件用来iOS端记录日志使用的 K-V组件,后来延伸到Android端并拓展了多进程使用的场景,并开源的一个项目。
其源码结构也很复杂,短时间内我也没有能力去分析它。
基于网上对MMKV的介绍,我对其大致印象就是一个实现了操作内存的速度+读写硬盘的效果
的存储方式。它可以让系统为你指定的文件开辟一块专用的内存,这块内存和文件之间是自动映射、自动同步的关系,你对文件的改动会自动写到这块内存里,对这块内存的改动也会自动写到文件里。
同步处理的机制下,MMKV 的性能优势就太明显了。原因上面说过了,它写入内存就几乎等于写入了磁盘,所以速度巨快无比。这就是 MMKV 的优势之一:极高的同步写入磁盘的性能。
另外来说,MMKV支持多进程的这一点,也是极大的优势。
缺点
丢数据
MMKV 虽然由于底层机制的原因,在程序崩溃的时候不会影响数据往磁盘的写入,但断电关机之类的操作系统级别的崩溃,MMKV就会发生数据丢失的问题。
在使用的时候要注意这个问题。
Preferences DataStore
DataStore 是一种数据存储解决方案,使用协议缓冲区存储键值对或类型化对象。DataStore使用 Kotlin 协程和 Flow 以异步、一致的事务方式存储数据。
Google官方提供的 DataStore 其实有两种,我们现在说的是Preferences DataStore。
DataStore 被创造出来的目标就是替代 SharedPreferences,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。
在性能方面,DataStore不管是读文件还是写文件,都是用的协程在后台进行读写,所有的 I/O 操作都是在后台线程发生的,所以不论读还是写,都不会卡主线程。
回调方面,由于DataStore是用协程来做的,线程的切换是非常简单的,你就把「保存完成之后做什么」直接写在保存代码的下方就可以了,很直观、很简单。
网上很多的关于 DataStore 的读写比较其实都是把它放到同步操作环境下了,我们应当在意的是主线程耗时响应。在这方面DataStore无疑是一个比较好的选择。
FastKV
FastKV是github上个人开源的一个组件,主要作用就是为了更好的实现键值存储。
其存储采用二进制编码,默认通过mmap方式写入。这一方面其实和MMKV类似。但是又有不同的一点。
MMKV其实是强制要求了必须在同步的环境下保证文件的写入,所以在系统级别的崩溃时,会导致数据丢失。
但是FastKV不同,在用mmap的方式打开时,FastKV采用double-write的方式:数据依次写入A/B两个文件(如果写入A过程中崩溃,B仍是完整的,如果A完整写入了,则B写入时崩溃也不要紧);
加载数据时,通过checksum、标记、数据合法性检验等方法验证文件是否完整,若其中一个文件是损坏的,则用完整的文件覆盖之。
同时FastKV提供了支持多进程的存储类(MPFastKV)。支持监听文件内容变化,其中一个进程修改文件,所有进程皆可感知。
FileLock实现进程互斥,FileObserver实现文件变更监听。
A文件mmap写入,内存共享;B文件FileChannel写入,触发FileObserver回调(写入mmap不会出触发FileObserver回调)。
对比来说,解决了SP在存储时的性能问题,避免了其引起的卡顿。针对MMKV对数据进行了保证,避免了关键数据丢失。在与DataStore进行比较时,在异步操作方面我不能保证我的比较方法较为准确,最终的结果是和DataStore相差不大。
貌似看来FastKV是几种存储方式当中较为优秀的,但是我在进行超大字符串存储的时候,会发现通过FastKV存储时间是SP的数十倍以上。
写入时单个字符串长度为4002,写入个数为100,重复次数为10次
SP | FastKV | |
---|---|---|
写入 | 1.971 | 34.274 |
从这种角度来看,FastKV在大字符写入时还是有问题存在的。
写入很长的字符串时,FastKV会将这个字符串单独写到另外一个文件,然后在主文件中记录改文件的文件名。这样做是因为不想这样的大字符串占用mmap空间,一来节约内存,二来加速其他小key-value的读写。 而这个“写到另外一个文件”是同步写入的,所以会相对费时
其实这也是FastKV目前来说的一个劣势。
其他三种方式背后都有极其庞大的团队来稳定维护其功能,而FastKV只是作为一个个人开源项目,在目前看来,其性能是较为优异的,也没有暴露出很大的bug,但是个人开发的维护稳定性肯定不如其他组件,虽然也经历过了一年多的验证和使用,没有出现问题,但是在一些要求稳定性很高的项目当中,还是尽量选择其他组件。
总结
通过以上几种存储方式的基本了解,几种存储方式各有优劣。
在我看来,SP 这种方式基本上可以被放弃了,除开一些低版本 Android 项目当中仍然可以使用 SP,其他的场景下都可以被替代。
FastKV 在对比当中的性能表现可以说是比较好的,但是我觉得其个人开源项目不是很适合在一些要求稳定的大型项目中使用,他的安全可靠性还需要时间的检验。
一些小型的,或者说对性能有高要求的项目,可以使用FastKv这种方式,并推荐自己去理解FastKV的源码实现。
在有多进程支持的需求的项目,MMKV 是唯一的选择;如果你有高频写入的需求,你也应该优先考虑 MMKV 。当然在使用 MMKV 时也要做好数据丢失的准备。
其他场景下,一些大型项目中应当优先考虑 DataStore。因为它在任何时候都不会卡顿,而 MMKV 在写大字符串和初次加载文件的时候是可能会卡顿的,而且初次加载文件的卡顿不是概率性的,只要文件大到了引起卡顿的程度,就是 100% 的卡顿。
当然,由于 DataStore 是基于Kotlin和协程的,如果项目当中没有在用协程,甚至没有在用 Kotlin,该方式也不适合。这个时候推荐 MMKV,也可以尝试考虑引用FastKV(前提是要理解他的源码实现,保证在停止更新或出现问题之后能自主修复)
SP作为大家最为熟悉的方式,其实在现有场景下已经可以被取代了,部分低版本Android项目中或者你想要使用SP,那么要注意:
- SP文件不宜过大,如果SP文件需要存储的内容过多,可以根据不同的功能划分成多个文件;
- 如果可以的话尽可能早的调用getSharedPreferences,这样在调用put和get操作时,文件已经被读取到内存中了;
- 不要多次调用commit()或apply(),如果多次存入值,应该在最后一次调用。
- 少用commit()