这是我在网上找到的一份 Android 键值对存储方案的性能测试对比(数越小越好):
可以看出,DataStore 的性能比 MMKV 差了一大截。MMKV 是腾讯在 2018 年推出的,而 DataStore 是 Android 官方在 2020 年推出的,并且它的正式版在 2021 年 8 月才发布。一个官方发布的、更(gèng)新的库,性能竟然比不过比它早两年发布的、第三方的库。而且我们能看到,更离谱的是,它甚至还比不过 SharedPreferences 。Android 官方当初之所以推出 DataStore,就是要替代掉 SharedPreferences,并且主要原因之一就是 SharedPreferences 有性能问题,可是测试结果却是它的性能不如 SharedPreferences。
所以,这到底是为什么?
啊,我知道了——因为 Google 是傻逼!
大家好,我是扔物线朱凯。
键值对的存储在移动开发里非常常见。比如深色模式的开关、软件语言、字体大小,这些用户偏好设置,很适合用键值对来存。而键值对的存储方案,最传统也最广为人知的就是 Android 自带的 SharedPreferences
。它里面的 -Preferences,就是偏好设置的意思,从名字也能看出它最初的定位。
SharedPreferences 使用起来很简单,也没什么问题,大家就这么用了很多年。——但!渐渐地,有人发现它有一个问题:卡顿,甚至有时候会出现 ANR。
怎么办?换!2018 年 9 月,腾讯开源了一个叫做 MMKV 的项目。它和 SharedPreferences 一样,都是做键值对存储的,可是它的性能比 SharedPreferences 强很多。真的是强,很,多。在 MMKV 推出之后,很多团队就把键值对存储方案从 SharedPreferences 换到了 MMKV。
再然后,就是又过了两年,Google 自己也表示受不了 SharedPreferences 了,Android 团队公布了 Jetpack 的新库:DataStore,目标直指 SharedPreferences,声称它就是 Android 官方给出的 SharedPreferences 的替代品。
替代的理由,Android 团队列了好几条,但不出大家意料地,「性能」是其中之一:
也就是说,Android 团队直接抛弃了 SharedPreferences,换了个新东西来提供更优的性能。
但是,问题随之就出现了:大家一测试,发现这 DataStore 的性能并不强啊?跟 MMKV 比起来差远了啊?要知道,MMKV 的发布是比 DataStore 早两年的。DataStore 比人家晚两年发布,可是性能却比人家差一大截?甚至,从测试数据来看,它连要被它替代掉的 SharedPreferences 都比不过。这么弱?那它搞个毛啊!
Android 团队吭哧吭哧搞个新东西出来,竟然还没有市场上两年前就出现的东西强?这是为啥?
首先,肯定得排除「DataStore 是垃圾」这个可能性。虽然这猛一看、粗一想,明显就是 DataStore 垃圾、Google 傻逼,但是你仔细想想,这可能吗?
那如果不是的话,又是因为什么?——因为你被骗了。
被谁骗了?不是被 MMKV 骗了,也不是具体的某个人。事情其实是这样的:
大家知道 MMKV 当初为什么会被创造出来吗?其实不是为了取代 SharedPreferences。
最早是因为微信的一个需求(来源:MMKV 组件现在开源了):
微信作为一个全民的聊天 App,对话内容中的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的,只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。
那么这个「时光倒流」应该怎么做,就成了问题的关键。我们要知道,程序中的所有变量都是存活在内存里的,一旦程序崩溃,所有变量全都灰飞烟灭。
所以要想实现「时光倒流」,就需要把想回溯的时光预先记录下来。说人话就是,我们需要把界面里显示的文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式来查看。
更麻烦的是,这种记录的目标是用来回溯查找「导致程序崩溃的那段文字」,而同时,正是因为没有人知道哪段文字会导致程序崩溃才去做的记录,这就要求每一段文字都需要先写入磁盘、然后再去显示,这样才能保证程序崩溃的时候那段导致崩溃的文字一定已经被记录到了磁盘。
对吧?
这就有点难了。
我们来想象一下实际场景:
val bubble: WxTextView = ...
recordTextToDisk(text) // 显示之前,先保存到磁盘
bubble.setText(text)
所以这种「高频、同步写入磁盘」的需求,让所有的现有方案都变得不可行了:不管你是用 SharedPreferences
还是用数据库还是别的什么,只要你在主线程同步写入磁盘,就一定会卡,而且是很卡。
但是微信还是有高手,还是有能想办法的人,最终微信找到了解决方案。他们没有用任何的现成方案,而是使用了一种叫做内存映射(mmap()
)的底层方法。
它可以让系统为你指定的文件开辟一块专用的内存,这块内存和文件之间是自动映射、自动同步的关系,你对文件的改动会自动写到这块内存里,对这块内存的改动也会自动写到文件里。
更多更深的原理,说实话我也不是看得很懂,就不跟大家装了。但关键是,有了这一层内存作为中间人,我们就可以用「写入内存」的方式来实现「写入磁盘」的目标了。内存的速度多快呀,耗时几乎可以忽略,这样一下子就把写磁盘造成卡顿的问题解决了。
而且这个内存映射还有一点很方便的是,虽然这块映射的内存不是实时向对应的文件写入新数据,但是它在程序崩溃的时候,并不会随着进程一起被销毁掉,而是会继续有条不紊地把它里面还没同步完的内容同步到它所映射的文件里面去。
至于更下层的原理,我也说了,没看懂,你也别问我。
总之,有了这些特性,内存映射就可以让程序用往内存里写数据的速度实现往磁盘里写数据的实际效果,这样的话,「高频、同步写入磁盘」的需求就完美满足了。不管是用户打开新的聊天页面,还是滑动聊天记录来查看聊天历史,用内存映射的方式都可以既实时写入所有即将被渲染的文字,又不会造成界面的卡顿。这种性能,是 SharedPreferences
和数据库都做不到的——顺便提一句,虽然我总在提 SharedPreferences
,但其实这种做法本来是先在 iOS 版的微信里应用的,后来才移植到了 Android 版微信。这也是我刚才说的,MMKV 的诞生并不是为了取代 SharedPreferences。
再后来,就是 2018 年,微信把这个叫做 MMKV 的项目开源了。它的名字,我猜就是直白的「Memory-Map based Key-Value(方案)」,基于内存映射的键值对。不过没有找作者求证,如果说错了欢迎指正。
在 MMKV 开源之后,很多团队就把键值对存储方案从 SharedPreferences
迁移到了 MMKV。为什么?因为它快呀。
不过……事情其实没那么简单。MMKV 虽然大的定位方向和 SharedPreferences
一样,都是对于键值对的存储,但它并不是一个全方位更优的方案。
比如性能。我前面一直在说 MMKV 的性能更强,对吧?但事实上,它并不是任何时候都更强。由于内存映射这种方案是自行管理一块独立的内存,所以它在尺寸的伸缩上面就比较受限,这就导致它在写大一点的数据的时候,速度会慢,而且可能会很慢。我做了一份测试:
在连续 1000 次写入 Int
值的场景中,SharedPreferences
的耗时是 1034 毫秒,也就是 1 秒多一点;而 MMKV 只有 2 毫秒,简直快得离谱;而且最离谱的是,Android 官方最新推出的 DataStore
是 1215 毫秒,竟然比 SharedPreferences
还慢。这个前面我也提过,别人的测试也是这样的结果。
可是,SharedPreferences
是有异步 API 的,而 DataStore 是基于协程的。这就意味着,它们实际占用主线程的时间是可以低于这份测试出的时间的,而界面的流畅在意的正是主线程的时间消耗。所以如果我统计的不是全部的耗时,而是主线程的耗时,那么统计出的 SharedPreferences
和 DataStore
的耗时将会大幅缩减:
还是比 MMKV 慢很多,是吧?但是这是对于 Int
类型的高频写入,Int 数据是很小的。而如果我把写入的内容换成长字符串,再做一次测试:
MMKV 就不具备优势了,反而成了耗时最久的;而这时候的冠军就成了 DataStore,并且是遥遥领先。这也就是我在开头说的:你可能被骗了。被谁骗了?被「耗时」这个词:我们关注性能,考量的当然是耗时,但要明确:是主线程的耗时。所以视频开头的那张图,是不具备任何参考意义的。
不过在换成了这种只看主线程的耗时的对比方案之后,我们会发现谁是冠军其实并不是很重要,因为从最终的数据来看,三种方案都不是很慢。虽然这半秒左右的主线程耗时看起来很可怕,但是要知道这是 1000 次连续写入的耗时,而我们在真正写程序的时候,怎么会一次性做 1000 次的长字符串的写入?所以真正在项目中的键值对写入的耗时,不管你选哪个方案,都会比这份测试结果的耗时少得多的,都少到了可以忽略的程度,这是关键。
那……既然它们的耗时都少到了可以忽略,不就是选谁都行?那倒不是。
我们来看一个 MMKV 官方给出的数据对比图:
从这张图看来,SharedPreferences
的耗时是 MMKV 的接近 60 倍。很明显,如果 SharedPreferences 用异步的 API 也就是 apply()
来保存的话,是不可能有这么差的性能的,这个一定是使用同步的 commit()
的性能来做的对比。那么为什么 MMKV 官方会这样做对比呢?这个又要说到它的诞生场景了:MMKV 最初的功能是在文字显示之前先把它记录到磁盘,然后如果接下来这个文字显示失败导致程序崩溃,稍后就可以从磁盘里把这段文字恢复出来,进行分析。而刚才我也说过,这种场景的特殊性在于,导致程序崩溃的文字往往是刚刚被记录下来,程序就崩溃了,所以如果采用异步处理的方案,就很有可能在文字还没来得及真正存储到磁盘的时候程序就发生了崩溃,那就没办法把它恢复出来进行分析了。因此这样的场景,是不能接受异步处理的方案的,只能同步进行。所以 MMKV 在意的,就是同步处理机制下的耗时,它不在意异步,因为它不接受异步。
而在同步处理的机制下,MMKV 的性能优势就太明显了。原因上面说过了,它写入内存就几乎等于写入了磁盘,所以速度巨快无比。这就是 MMKV 的优势之一:极高的同步写入磁盘的性能。
另外 MMKV 还有个特点是,它的更新并不像 SharedPreferences
那样全量重新写入磁盘,而是只把要更新的键值对写入,也就是所谓的增量式更新。这也会给它带来一些性能优势,不过这个优势并不算太核心,因为 SharedPreferences
虽然是全量更新的模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。所以这个性能优势虽然有,但并不是关键。
还有刚才提到的,对于大字符串的场景,MMKV 的写入性能并不算快,甚至在我们的测试结果里是最慢的,对吧?这一点算是劣势。但是实事求是地说,我们在开发里不太可能连续不断地去写入大字符串吧?所以这个性能劣势虽然有,但也并不是关键。
整体来说,MMKV 比起 SharedPreferences 和 DataStore 来说,在写入小数据的情况下,具有很高的写入性能,这就让高频写入的场景非常适合使用 MMKV 来处理。因此如果你的项目里也有像微信的崩溃回溯的这种高频写入的需求,MMKV 就很可能是你的最佳方案。而如果你除了「高频写入」,还和微信一样要求「同步写入」,那 MMKV 就可能是你的唯一选择了。不过,如果你真的主要是存储大字符串的——例如你写的是一个文本编辑软件,需要保存的总是大块的文本——那么用 MMKV 不一定会更快了,甚至可能会比较慢。
另外,MMKV 还有一个巨大的优势:它支持多进程。
行业内也有很多公司选用 MMKV 并不是因为它快,而是因为它支持多进程。SharedPreferences 是不支持多进程的,DataStore 也不支持——从 DataStore 提交的代码来看,它已经在加入多进程的支持了,但目前还没有实现。所以如果你们公司的 App 是需要在多个进程里访问键值对数据,那么 MMKV 是你唯一的选择。
除了速度快和支持多进程这两个优势之外,MMKV 也有一个弱点:它会丢数据。
任何的操作系统、任何的软件,在往磁盘写数据的过程中如果发生了意外——例如程序崩溃,或者断电关机——磁盘里的文件就会以这种写了一半的、不完整的形式被保留。写了一半的数据怎么用啊?没法用,这就是文件的损坏。这种问题是不可能避免的,MMKV 虽然由于底层机制的原因,在程序崩溃的时候不会影响数据往磁盘的写入,但断电关机之类的操作系统级别的崩溃,MMKV 就没办法了,文件照样会损坏。对于这种文件损坏,SharedPreferences 和 DataStore 的应对方式是在每次写入新数据之前都对现有文件做一次自动备份,这样在发生了意外出现了文件损坏之后,它们就会把备份的数据恢复过来;而 MMKV,没有这种自动的备份和恢复,那么当文件发生了损坏,数据就丢了,之前保存的各种信息只能被重置。也就是说,MMKV 是唯一会丢数据的方案。
可能会有人好奇,为什么 MMKV 不做全自动的备份和恢复。我的猜测是这样的:MMKV 底层的原理是内存映射,而内存映射这种方式,它从内存往磁盘里同步写入的过程并不是实时的,也就是说并不是每次我们写入到映射的内存里就会立即从这块内存写入到磁盘,而是会有一些滞后。而如果我们要做全自动的备份,那就需要每次往内存里写入之后,立即手动把内存里最新的数据同步到磁盘。但这就和 MMKV 的定位不符了:因为这种「同步」本质上就是一次从内存到磁盘的写入,并且是同步的写入;而 MMKV 是要高频写入的,如果在高频写入内存的同时,还要实时地把数据从内存同步到磁盘,就会一下子把写入速度从内存级别下降到磁盘级别,MMKV 的性能优势也就荡然无存了。所以从原理上,自动备份是个很难实现的需求,因为它和 MMKV 的定位是矛盾的。不过正好 MMKV 所要记录的这些要显示的文字,也并不是不能丢失的内容——真要是丢了就丢了呗,反正是崩溃日志,丢了就不要了,我下次启动程序之后继续记录就是了——所以既然要求必须高频写入而导致很难实现自动备份,并且也确实能接受因为不做自动备份而导致的数据损坏,那就干脆不做自动备份了。不过这也是我猜的啊,大家如果有不同意见欢迎留言评论指正。
所以如果你要用 MMKV,一定要记得只能用它来存可以接受丢失、不那么重要的数据。或者你也可以选择对数据进行定期的手动备份——全自动的实时备份应该是会严重影响性能的,不过我没试过,你如果有兴趣可以试试。另外据我所知,国内在使用 MMKV 的团队里,几乎没有对 MMKV 数据做了备份和恢复的处理的。
那么说到这里,很容易引出一个问题:微信自己就不怕丢数据吗?(大字:微信就不怕丢数据?)关于这一点,我相信,微信绝对不会把用户登录状态相关的信息用 MMKV 保存并且不做任何的备份,因为这一定会导致每天都会有一些用户在新一次打开微信的时候发现自己登出了。这会是非常差的用户体验,所以微信一定不会让这种事发生。至于一些简单的用户设置,那我就不清楚了。比如深色主题重要吗?这是个不好说的事情:某个用户在打开软件的时候,发现自己之前设置的深色主题失效了,软件突然变回了亮色方案,这肯定是不舒服的事;但我们要知道,MMKV 的文件损坏终归是个概率极低的事件,所以偶尔地发生一次这样的事件在产品的角度是否可以接受,那可能是需要产品团队自身做一个综合考量的事了。对于不同的产品和团队,也许不可接受,也许无伤大雅。而对于你所开发的产品应该是怎样的判断,就得各位自己和团队去商量了。所以像深色主题这种「可以重要也可以不重要」的信息,用不用 MMKV 保存、用的时候做不做备份,大家需要自己去判断。
总之,大家要知道这件事:MMKV 是有数据损坏的概率的,这个在 MMKV 的官方文档就有说明:MMKV 的 GitHub wiki 页面显示,微信的 iOS 版平均每天有 70 万次的数据校验不通过(即数据损坏)。这还是 2020 年的数据,现在可能会更多。
所以我们在使用 MMKV 的时候,一定要考虑到这个问题,你要知道这件事。至于具体的应对,是接受它、坏就坏了,还是要认真应对、做好备份和恢复,这就是大家自己的决策了。
SharedPreferences
的优势:不丢数据好,那么说完了 MMKV,我来说一下 SharedPreferences,这个最传统的方案。
它有什么优势呢?——它没有优势。跟 MMKV 比起来,它不会丢数据,这个倒是它比 MMKV 强的地方,但是我觉得更应该归为 MMKV 的劣势,而不是 SharedPreferences 的优势,因为只有 MMKV 会丢数据嘛,是吧?
不过不管是这个的优势还是那个的劣势,如果你不希望丢数据,并且也不想花时间去做手动的备份和恢复,同时对于 MMKV 的超高写入性能以及多进程支持都没有需求,那你其实更应该选择 SharedPreferences,而不是 MMKV。对吧?
但更进一步地说:如果你选择了 SharedPreferences,那么你更应该考虑 DataStore。因为 DataStore 是一个完全超越了 SharedPreferences 的存在。你看 SharedPreferences 和 MMKV 它俩是各有优劣对吧?虽然 MMKV 几乎完胜,但是毕竟 SharedPreferences 不会丢数据呀,所以它俩是各有优劣的。但当 DataStore 和 SharedPreferences 比起来,那就是 DataStore 完胜了。这其实也很合理,因为 DataStore 被创造出来,就是用于替代掉 SharedPreferences 的;而 MMKV 不一样,它的诞生有它独特的使命,它是为了「高频同步写入」而诞生的,所以不能全角度胜过 SharedPreferences 也很正常。
我们还说回 DataStore。DataStore 被创造出来的目标就是替代 SharedPreferences,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。
先说性能问题:SharedPreferences 虽然可以用异步的方式来保存更改,以此来避免 I/O 操作所导致的主线程的耗时;但在 Activity 启动和关闭的时候,Activity 会等待这些异步提交完成保存之后再继续,这就相当于把异步操作转换成同步操作了,从而会导致卡顿甚至 ANR(程序未响应)。这是为了保证数据的一致性而不得不做的决定,但它也确实成为了 SharedPreferences 的一个弱点。而 MMKV 和 DataStore 用不同的方式各自都解决了这个问题——事实上,当初 MMKV 被公布的时候之所以在业界有相当大的反应,就是因为它解决了 SharedPreferences 的卡顿和 ANR 的问题。
不过有一点我的观点可能和一些人不同:SharedPreferences 所导致的卡顿和 ANR,其实并不是个很大的问题。它和 MMKV 的数据损坏一样,都是非常低概率的事件。它俩最大的区别在于其实是政治上的:SharedPreferences 的卡顿很容易被大公司的性能分析后台监测到,所以不解决的话会扣绩效,而解决掉它会提升绩效;而 MMKV 的数据损坏是无法被监测到的,所以……哈?事实上,大家想一下:卡顿和数据损坏,哪个更严重?当然是数据损坏了,对吧。
其实除了写数据时的卡顿,SharedPreferences 在读取数据的时候也会卡顿。虽然它的文件加载过程是在后台进行的,但如果代码在它加载完成之前就去尝试读取键值对,线程就会被卡住,直到文件加载完成,而如果这个读取的过程发生在主线程,就会造成界面卡顿,并且数据文件越大就会越卡。这种卡顿,不是 SharedPreferences 独有的,MMKV 也是存在的,因为它初始化的过程同样也是从磁盘里读取文件,而且是一股脑把整个文件读完,所以耗时并不会比 SharedPreferences 少。而 DataStore,就没有这种问题。DataStore 不管是读文件还是写文件,都是用的协程在后台进行读写,所有的 I/O 操作都是在后台线程发生的,所以不论读还是写,都不会卡主线程。
简单来说,SharedPreferences 会有卡顿的问题,这个问题 MMKV 解决了一部分(写时的卡顿),而 DataStore 完全解决了。所以如果你的目标在于全方位的性能,那么你应该考虑的是 DataStore,因为它是唯一完全不会卡顿的。
DataStore 解决的 SharedPreferences 的另一个问题就是回调。SharedPreferences 如果使用同步方式来保存更改(commit()
),会导致主线程的耗时;但如果使用异步的方式,给它加回调又很不方便,也就是如果你想做一些「等这个异步提交完成之后再怎么怎么样」的工作,会很麻烦。
而 DataStore 由于是用协程来做的,线程的切换是非常简单的,你就把「保存完成之后做什么」直接写在保存代码的下方就可以了,很直观、很简单。
对比来说,MMKV 虽然没有使用协程,但是它太快了,所以大多数时候并不需要切线程也不会卡顿。总之,在这件事上,只有 SharedPreferences 最弱。
区别大概就是这么些区别了,大致总结一下就是:
如果你有多进程支持的需求,MMKV 是你唯一的选择;如果你有高频写入的需求,你也应该优先考虑 MMKV。但如果你使用 MMKV,一定要知道它是可能丢失数据的,不过概率很低就是了,所以你要在权衡之后做好决定:是自行实现数据的备份和恢复方案,还是直接接受丢数据的事实,在每次丢失数据之后帮用户把相应的数据进行初始化。当然了,一个最鸡贼的做法是:反正数据监测不会监测到 MMKV 的数据丢失,又不影响绩效,那就不管它呗!不过我个人是不太赞同这种策略的,有点不负责哈。
另外,如果你没有多进程的需求,也没有高频写入的需求,DataStore 作为性能最完美的方案,应该优先被考虑。因为它在任何时候都不会卡顿,而 MMKV 在写大字符串和初次加载文件的时候是可能会卡顿的,而且初次加载文件的卡顿不是概率性的,只要文件大到了引起卡顿的程度,就是 100% 的卡顿。不过如果你的团队没有在用协程,甚至没有在用 Kotlin,那 DataStore 也暂时不适合你们,因为它是完全依赖 Kotlin 协程来实现和使用的。
哦对了,其实我今天说的 DataStore 只是面向简单键值对存储的 DataStore 方案,它的全称叫 Preferences DataStore,而 DataStore 还有用于保存结构化数据的方案,叫做 Proto DataStore,它内部用的是 Protocol Buffer 作为数据结构的支持。但是这个有点跑题,我就不展开了。
至于 SharedPreferences 嘛,在这个时代,它真的可以被放弃了。除非——像我刚说的——如果你们还没在用协程,那 SharedPreferences 可能还能苟延残喘一下。
今天的内容就是这些了。如果你喜欢我的内容,还请多多点赞转发收藏分享,以后我会讲更多和 Android 开发相关的有趣、有用的分享。关注我,不错过我的任何新内容。我是扔物线,我不和你比高低,我只助你成长。我们下期见。