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 |
---|---|---|---|
是否阻塞主线程 | 是 | 否。只需写入内存即可实现数据的持久化,耗时极短 | 否。通过协程实现异步化 |
是否支持跨进程 | 否 | 是 | 否 |
是否是类型安全的 | 否 | 否 | 是 |
是否能监听数据变化 | 否 | 否 | 是 |