正文
这是网上找到的一份Android键值对存储方法的性能测试对比(数越小越好):
可以看出,DataStore的性能比MMKV差了一大截。MMKV是腾讯在2018年推出的,而DataStore是Android官方在2020年推出的,并且它的正式版在2021年8月才发布。一个官方发布的、更新新的库,性能竟然比不过SharedPreferences。Android官方当初之所以推出DataStore,就是要替代掉SharePreference,并且主要原因之一就是SharedPreferences有性能问题,可是测试结果却是它的性能不如SharedPreferences.
所以,这到底是为什么?
SharePreference:不知不觉被嫌弃
键值对的存储在移动开发里非常常见。比如深色模式的开关、软件语言、字体大小,这些用户偏好设置,很适合对键值对来存储。而键值对的存储方案,最传统也最广为人知的就是Android自带的SharedPreferences。它里面的-Preferences,就是偏好设置的意思,从名字也能看出它最初的定位。
SharedPreferences使用起来很简单,也没什么问题,大家就这么用了很多年。——但!渐渐有人发现它有一个问题:卡顿,甚至有时候会出现ANR。
MMKV:好快!
怎么办?换!2018年9月,腾讯开源了一个叫做MMKV的项目。它和SharedPreferences一样,都是做键值对存储,可是它的性能比SharedPreferences强很多。真的是强,很,多。在MMKV推出之后,很多团队都把键值对存储方案从SharedPreferences换到了MMKV。
DataStore:官方造垃圾
再然后,就是又过了两年,Google自己也表示受不了SharedPreferences了,Android团队公布了Jetpack的新库:DataStore,目标直指SharedPreferences,声称它就是Android官方给出的SharedPreferences的替代品。
替代的理由,Android团队列了好几条,但不出大家意料地,「性能」是其中之一:
也就是说,Android团队直接抛弃了SharedPreferences,换了个新东西来提供更优的性能。
但是,问题随之就出现了:大家已测试,发现这个DataStore的性能并不强啊?跟MMKV比起来差远了啊?要知道,MMKV的发布是比DataStore早两年的。DataStore比人家晚两年发布,可是性能却比人家差一大截?甚至,从测试数据来看,它连要被替代掉的SharedPreferences都比不过。这么弱?那它搞个毛啊!
Android团队吭哧吭哧搞个新东西迟来,竟然还没有市场上两年前就出现的东西强?这是为啥?
首先,肯定得排除「DataStore是垃圾」这个可能性。
那如果不是的话,那又是为什么?——因为你被骗了。
MMKV的一二三四
被谁骗了?不是被MMKV骗了,也不是具体的某各人。事情其实是这样的: 大家知道MMKV当初为什么会被创造出来吗?其实不是为了取代SharedPreferences.
最早是因为微信的一个需求:
微信作为一个全民的聊天App,对话内容的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的,只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。
那么这个「时光倒流」应该怎么做,就成了问题的关键。我们要知道,程序中的所有变量都是存活在内存里的,一旦程序崩溃,所有变量全都灰飞烟灭。
所以要想实现「时光倒流」,就需要把想回溯的时光预先记录下来。说人话就是,我们需要把界面里显示的文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式查看。
更麻烦的是,这种记录的目标是用来回溯查找「导致程序崩溃的那段文字」,而同时,正是因为没有人知道哪段文字会导致崩溃采取做的记录,这就要求每一段文字都需要先写入磁盘、然后再去显示,这样才能保证程序崩溃的时候那段导致崩溃的文字一定已经被记录到了磁盘。
对吧? 这就有点难了。
我们来想象一下实际场景:
- 如果用户的微信现在处于一个对话界面中,这时候来了一条新的消息,这条消息里可能会包含微信处理不了的字符,导致微信的崩溃。
- 而微信为了及时地找出导致崩溃的字符串或者字符,所以给程序加了逻辑:所有的对话内容在显示之前,先保存到磁盘再显示:
val bubble: WxTextView = ...
recordTextToDisk(text)// 显示之前,先保存到磁盘
bubble.setText(text)
- 那么你想一下,这个「保存到磁盘」的行为,我应该做成同步的还是异步的?
- 为了不卡住主线程,我显然应该做成异步的;
- 但这是马上就要显示的文字,如果做成异步的,就极有可能在程序崩溃的时候,后台线程还没来得及把文字存到磁盘。这样的话,就无法进行回溯,从而这种记录也就失去了价值。
- 所以从可能性的角度来看,我只能选择放弃性能,把它做成同步的,也就是在主线程进行磁盘的写操作。
- 一次磁盘的写操作,花个一两毫秒是很正常的,三五毫秒甚至超过10毫秒也都是有可能得。具体的方案可以选择
SharedPreferences,也可以选择数据库,但不管选哪个,只要在主线程去完成这个写操作,这种耗时就绝对无法避免。一帧的时间也就是16毫秒而已——那时候还没有高刷,我们就先不谈高刷了,一帧就是16毫秒里来个写磁盘的的操作,用户很可能就会感受到一次卡顿。 - 这还是相对比较好的情况。我们再想一下,如果用户点开了一个活跃的群,这个群里有几百条没看过的消息:
- 那么在他点开的一瞬间,是不是界面中会显示出好几条消息气泡?这几条消息的内容,哪些需要记录到磁盘?全都要记录的,因为谁也不知道哪一条会导致微信的崩溃,任何一条都是可能得。
- 而如果把这几条消息都记录下来,是不是每条消息的记录都会涉及一次写磁盘的操作?这几次写磁盘行为,是发生在同一帧里的,所以在这一帧里因为记录文字而导致的主线程耗时,也会相比起刚才的例子翻上好几倍,卡顿时间就同样也会翻上好几倍。
- 还有更差的情况。如果用户按完这一页之后,决定翻翻聊天记录,看看大家之前都聊了什么:
- 这时候,是不是上方每一个新的聊天气泡的出现,都会涉及一次主线程上的写磁盘行为?
- 而如果用户把手猛地往下一滑,让上面的几十条消息依次滑动显示出来,这是不是就会导致一次爆发性的、集中式的对磁盘的写入?
- 用户的手机,一定会卡爆。
所以这种「高频、同步写入磁盘」的需求,让所有的现有方案都变得不可行了:不管你是用
SharedPreferences还是用数据库还是别的什么,只要你在主线程同步写入磁盘,就一定会卡,而且是很卡。
但是微信还是有高手,还是有能想办的人,最终微信找到了解决方案。他们没有用任何的线程方案,而是使用了一种叫做内存映射(mmap())的底层方法。
它可以让系统为你指定的文件开辟一块专用的内存,这块内存和文件之间是自动映射、自动同步地关系,你对文件的改动会自动写到这块内存里,对这块内存的改动也会自动写到文件里。
更多更深的原理,说实话我也不是看得很懂,就不跟大家装了。但关键是,有了这一层内存作为中间人,我们就可以用「写入内存」的方式来实现「写入磁盘」的目标嘞。内存的速度多快呀,耗时几乎可以忽略,这样一下子就把鞋磁盘造成卡顿的问题解决了。
并且这个内存映射还有一点很方便的是,虽然这块映射的内存不是实时向对应的文件写入新数据,但是它在程序崩溃的时候,并不会随着进程一起被销毁掉,而是会继续有条不紊地把它里面还没同步完的内容同步到它所映射的文件里面去。
至于更下层原理,我也说了,没看懂,你也别问我。
总之,有了这些特性,内存映射就可以让程序用往内存里写数据的速度实现往磁盘里写数据的实际效果,这样的话,「高频、同步写入磁盘」的需求就完美满足了。不管是用户打开新的聊天页面,还是滑动聊天记录来查看聊天历史,用内存映射的方式都可以既实时写入所有即将渲染的文字,又不会造成界面的卡顿。这种性能,是SharedPreferences和数据库都做不到的——顺便提一句,虽然我总在提SharedPreferences,但其实这种做法本来是现在iOS版的微信里应用的,后来才移植到了Android版微信。这也是我刚才说的,MMKV的诞生并不是为了取代SharedPreferences.
再后来,就是2018年,微信把这个叫做MMKV的项目开源了。它的名字,我才九十直白的「Memory-Map based Key-Value(方案)」,基于内存映射的键值对。
在MMKV开源之后,很多团队就把键值对存储方案从SharedPreferences迁移到了MMKV。为什么呢?因为它快呀。
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又是:写速度极快
我们来看一个MMKV官方给出的数据对比图:
从这张图看来,SharedPreferences的耗时是MKV的接近60倍。很明显,如果SharedPreferences用异步的API也就是apply()来存活的话,是不可能有这么差的性能的,这个一定是使用同步的commit()的性能来做的对比。那么为什么MMKV官方会这样做对比呢?这个又要说到它的诞生场景了:MMKV最初的功能是在文字显示之前把它记录到磁盘,然后如果接下来这个文字显示失败导致程序崩溃,稍后就可以从磁盘里把这段文字恢复出来,进行分析。而刚才我也说过,这种场景的特殊性在于,导致程序崩溃的文字往往是刚刚被记录下来,程序就崩溃了,所以如果采用异步处理的方案,就很有可能在文字还没来得及真正存储到磁盘的时候程序就发生了崩溃,那就没办法把它恢复出来进行分析了。因此这样的场景,是不能接受异步处理的方案的,只能同步进行。所以MMKV在意的,就是同步处理机制下的耗时,它不在意异步,因为它不接受异步。
而在同步处理的急之下,MMKV的性能优势就太明显了。原因上面说过了,它写入内存就几乎等于写入了磁盘,所以速度巨快无敌。这就是MMKV的优势之一:极高的同步写入磁盘的性能。
另外MMKV还有个特点是,它的更新并不像SharedPreference那样全量重新写入磁盘,而是只要把要更新的键值对写入,也就是所谓的增量式更新。这也会给它带来一些性能优势,不过这个又是并不算太核心,因为SharedPreferences虽然是全量更新模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。所以这个性能优势虽然有,但并不是关键。
还有刚才提到的,对于大字符串的场景,MMKV的写入性能并不算快,甚至在我们的测试结果里是最慢的,对吧?这一点算是劣势。但是实事求是地说,我们在开发里不太可能连续不断地去写入大字符串吧?所以这个性能劣势虽然有,但也并不是关键。
整体来说,MMKV比起SharedPreferences和DataStore来说,在写入小数据的情况下,具有很高的写入性能,这就让高频写入的场景非常适合使用MMKV来处理。因此如果你的项目里也有像微信的崩溃回溯的这种高频写入的需求,MMKV就很可能是你的最佳方案。而如果你除了「高频写入」,还和微信一样要求「同步写入」,那MMKV就可能是你的唯一选择了。不过,如果你真的主要是存储大字符串——例如你写的是一个文本编辑器软件,需要保存的总是大块的文本——那么用MMKV不一定会更快了,甚至可能会比较慢。
MMKV优势:支持多进程
另外,MMKV还有一个巨大的优势:它支持多进程。
行业内也有很多公司选用MMKV并不是因为它快,而是因为它支持多进程。SharedPreferences是不支持多进程的,DataStore也不支持——从DataStore提交的代码来看,它已经在加入多进程的支持了,但目前还没有实现。所以如果你们公司的APp是需要在多进程里访问键值对数据,那么MMKV是你唯一的选择。
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的劣势:卡顿
但更进一步地说:如果你选择了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。因为它是唯一完全不会卡顿的。
SharedPreferences的劣势:回调
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 的键值对存储有没有最优解?
微信公众号:扔物线