MMKV和SP、DataStore对比

1、细数SharedPreference的几宗罪

2018年末,输入法开始全面拥抱MMKV,SharedPreference在同一时间被标注为@Deprecated。4年过去了,SharedPreference仍然活跃在我们面前。

SharedPreference(下文简称SP)是Android平台第一个键值对存储方案,使用非常方便。底层使用xml的结构进行数据存储,所有的数据都是明文的。

也许Google在设计之初对SP的定位就是一个轻量级的数据存储,所以没有考虑到数据量增大时可能带来的一系列问题。

细数SP在使用过程中的问题,包括以下几项:

  • 卡顿问题,通常发生在初始化及写入文件的场景。即使使用了异步写入(apply)的API,上述问题仍可能发生。在 8.0 之前和 8.0 之后实现各不相同
  • SharedPreference 不能保证类型安全
  • SharedPreference 加载的数据会一直留在内存中,浪费内存
  • SharedPreference的多进程是不安全的
  • 无法监听写入的结果
  • ......

下面着重介绍一下SP的卡顿问题,也是大家诟病最多的一个问题。其他几个缺点都比较好理解。

1.1 SP的卡顿问题

SP导致卡顿通常在以下两个场景中发生:

  • SP初始化时,会把文件加载到内存中。在未完成加载之前,尝试调用get来获取value
  • Google为确保SP跨进程通讯的安全性,会在组件的相关生命周期中,把SP中的缓存写入文件。

1.1.1 初始化导致的卡顿

在SharedPreferencesImpl类的构造函数中,会调用startLoadFromDisk方法,尝试把数据加载到内存中。方法如下:

而在SP的任意一个get方法中,都可以看到,在获取数据之前调用了:

await方法中会等待数据加载完成。如果SP的数据量太大,卡顿就不可避免了。

1.1.2 apply同样可能导致卡顿

每次调用SharedPreference的apply方法,都会创建一个runnable加入到QueueWork中。

以ActivityThread中handlePauseActivity为例,其中就有等待提交的commit完成的代码:

这样的代码在Service的创建、stop,和Activity的pause、stop中都有涉及。

上述问题并非无解,我们也可以通过HOOK的方式去解决。大致的思路是通过反射找到pending的队列,为其设置代理,使他永远返回一个空的列表。这样主线程就不会等待了。

2、MMKV的横空出世

MMKV的全称是什么?根据微信公众号的这篇文章,我猜应该是Memory Mapped Key Value。

2.1 MMKV的缘起

至少从一开始,MMKV的设计初衷就不是为了取代SP。根据官方的说法,微信客户端在日常运营中,时不时就会爆发特殊文字引起系统的 crash。为了发现这些引起闪退的异常文字,微信需要记录发生崩溃前的那一瞬间的环境,以便事后重新复现。

然而没有人知道崩溃什么时候会发生,这就要求实时写入信息。或者说,即使在程序崩溃后仍然能够有条不紊把信息记录下来。当时市面上的SharedPreferences、NSUserDefaults、SQLite 等常见组件都无法满足如此苛刻的性能要求,于是,微信团队自己实现了一套方案,也就是今天我们看到的mmkv。

mmkv最初是在ios上实现的,后来才扩展到了Android平台。可见,MMKV从一开始的设计初衷,就不是对标SharedPreference。

2.2 MMKV的实现原理

MMKV的实现原理在官方文档中都有说明,这里挑选一些重点简单介绍一下,有兴趣的同学可以进一步看官方的github

2.2.1 内存映射

通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。

2.2.2 数据组织

MMKV采用 protobuf 协议对数据进行序列化。但是标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。于是mmkv将增量 kv 对象序列化后,直接 append 到内存末尾。这样同一个 key 会有新旧若干份数据,最新的数据在最后。

使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。于是,mmkv又以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

2.2.3 数据有效性

考虑到文件系统、操作系统都有一定的不稳定性,mmkv增加了 crc 校验,对无效数据进行甄别。

2.2.4 支持多进程

MMKV使用了一系列复杂的架构来支持Android平台的多进程,包括去中心化架构的IPC,支持递归和锁升级/降级的文件锁等。

根据我的测试结果,MMKV在支持多进程的前提下,双进程密集写入,写入的耗时约为单进程的5~10倍。

2.3 MMKV存在的问题

如果我们使用SP同步写入文件,不可避免地会有IO操作。而IO本身的不稳定性使得耗时通常在ms级别,严重时甚至会导致用户掉帧。而MMKV通过 mmap 内存映射文件,只需要把数据写入内存,就几乎达到了同步写入文件的效果,即使进程崩溃也不会收到影响。从这一点上说,MMKV几乎是碾压SP的存在。但是,MMKV真的可以全方面替代SP吗?

2.3.1 与SP的apply()方法对比,MMKV并不占性能优势

如上文所述,通过MMKV把key-value写入内存,几乎就等于写入了文件。因此,MMKV对标的是SP的commit方法。但是,如果和apply相比呢?

我们使用下述的代码进行测试:

kotlin 复制代码
val times = 10_000

val testString = "sp_val_with_"
val spCostString = measureTimeMillis {
    val edit = sp.edit()
    for (i in 1..times) {
        edit.putString("sp_key_$i", "$testString $i")
    }
    edit.apply()
}

val mvCostString = measureTimeMillis {
    for (i in 1..times) {
        mmkv.encode("sp_key_$i", "$testString $i")
    }
}

Log.d("denny", "spCost: $spCostString ms, mmkvCost: $mvCostString ms")

在全新安装APP(无本地缓存)的前提下进行测试,测试机型为OPPO Reno10,Android版本11。最终得到的数据是:

D/denny: spCost: 36 ms, mmkvCost: 75 ms

也就是说,在异步写入的情况下,MMKV的耗时比SP还多。

2.3.2 MMKV在写入大数据时,性能下降严重

我们仅仅把上面的测试代码中的

val testString = "sp_val_with_"

修改为:

val testString = "sp_val_with_".repeat (10).repeat (10).repeat(10)

再次重新运行,得到的结果就变成了:

D/denny: spCost: 269 ms, mmkvCost: 1969 ms

在写入长字符串等大数据的情况下,MMKV的性能劣化比SP要严重得多。

猜测导致上述问题的原因是:MMKV通过mmap开辟的内存,是在native中完成的。每一次的写入操作,都需要一次从JNI到Native的内存拷贝。当写入数据变大时,这一次的内存拷贝的损耗就变得不可忽略了。

2.3.3 MMKV丢数据的问题

尽管MMKV的机制能够在进程退出后,继续完成把数据持久化的任务。这一点让MMKV看起来安心不少。但是需要注意的是,MMKV数据损坏的概率其实比SP要高。

如果设备在数据写入mmap开辟的内存中的过程中、对文件进行整理的过程中断电重启,那么MMKV将有很大概率出现数据损坏的情况。

而SP的机制则是在写入前,对原始文件进行备份。写入成功后删除备份文件。这种机制使得即使设备出现了意外,那么大概率也是丢失部分数据,而不是整个文件被写坏了。

在 iOS 微信现网环境上,平均有约 70万日次的数据校验不通过。需要注意,这只是2018年的数据。

3、SP的正统继任者:DataStore

如果说SP最大的问题是卡顿,那么MMKV和DataStore(下面简称DS)分别从两个不同的方向来解决这个问题。DS的解决方案,就是通过协程把同步的操作,变成异步的。

DS的API会返回一个Flow对象,通过这个Flow对象,你可以轻松地切换线程完成异步化操作,也可以监听数据的读写操作。

DataStore 提供了两种不同的实现:Preferences DataStore 和 Proto DataStore。前者对标的就是SP,而后者底层使用proto协议,可以保存写入的数据的类型(string/int/float等),这样就确保了类型安全。

不过需要注意的是,DS的API比SP和MMKV都要复杂得多。因此,在同样异步写入轻量级数据的过程中,DS的性能反而是最差的那一个。

4、总结

名称 SharedPreference MMKV DataStore
是否阻塞主线程 否。只需写入内存即可实现数据的持久化,耗时极短 否。通过协程实现异步化
是否支持跨进程
是否是类型安全的
是否能监听数据变化

5.附录:参考资料

  1. juejin.cn/post/688144...
  2. github.com/Tencent/MMK...
  3. blog.csdn.net/m0_66264910...
  4. www.jianshu.com/p/a2c1c92a2...
相关推荐
太空漫步111 小时前
android社畜模拟器
android
小钊(求职中)3 小时前
Java开发实习面试笔试题(含答案)
java·开发语言·spring boot·spring·面试·tomcat·maven
小小码农(找工作版)3 小时前
JavaScript 前端面试 4(作用域链、this)
前端·javascript·面试
海绵宝宝_4 小时前
【HarmonyOS NEXT】获取正式应用签名证书的签名信息
android·前端·华为·harmonyos·鸿蒙·鸿蒙应用开发
凯文的内存6 小时前
android 定制mtp连接外设的设备名称
android·media·mtp·mtpserver
uhakadotcom6 小时前
约束求解领域的最新研究进展
人工智能·面试·架构
天若子6 小时前
Android今日头条的屏幕适配方案
android
超爱吃士力架7 小时前
MySQL 三层 B+ 树能存多少数据?
java·后端·面试
超爱吃士力架7 小时前
MySQL 索引的最左前缀匹配原则是什么?
java·后端·面试
林的快手7 小时前
伪类选择器
android·前端·css·chrome·ajax·html·json