
1. 项目背景一次技术升级引发的存储方案大考察1.1 存量项目的存储使用画像我之前一直在维护一个运行了三年多的Android项目日活不算低代码里SharedPreferences以下简称SP的调用可以说是到处可见。登录态、用户配置、功能开关、本地缓存、埋点去重位……前前后后几十个key。最初这么做没什么问题但业务量涨起来之后我遇到了两个非常具体的痛点。第一个是SP文件越来越大大到什么程度呢有个配置文件里存了一堆List序列化后的Json字符串单个文件接近1MB。这个文件在getSharedPreferences()被调用时会同步读取首访直接拖慢启动流程几百毫秒。第二个是团队正在全面迁移协程和Flow但SP的调用方式跟这套体系格格不入——监听变化还得手动注册OnSharedPreferenceChangeListener每次写完数据还要考虑apply()和commit()的差异。我知道这些痛点业内早就讨论过但一直没下决心处理直到有一次线上反馈“启动白屏”跟SP读取阻塞扯上关系我才决定把市面上主流的三个方案真正放在一起做一个系统性评估。1.2 这次选型关注的核心指标先说结论如果你在网上搜“SP MMKV DataStore 对比”能看到一堆性能benchmark和优缺点的罗列但大多数都停留在“MMKV最快、DataStore最现代、SP最老”这种层面。真到做技术选型的时候光看最快最现代是不够的。我这次把评估维度定为五个一是读写性能不要只看理论值要放在真实场景里看主线程卡顿的概率二是数据可靠性App进程被杀、系统崩溃、磁盘IO异常时数据会不会丢、会不会损坏三是多进程支持我们的App有推送进程、播放进程等SP在多进程下是出了名的不靠谱四是迁移成本存量几十个key已经在SP里了切过去要改多少代码五是团队可维护性新方案的学习成本、debug成本、排查问题的难易程度。带着这五个问题我分别把三个方案在真实工程里跑了一遍“压力测试”。下面的内容全部来自我这次实际操作中的记录和思考包括后来踩进去的几个坑。2. 底层机制拆解一个写入请求在三个方案里分别做了什么2.1 SharedPreferencesXML解析与全量写入的旧时代设计想理解SP为什么会卡先得看它把数据存成了什么形式。SP本质上是一份XML文件所有key-value都以固定格式写在一个文件里。读取的时候getSharedPreferences()会创建一个SharedPreferencesImpl实例然后同步加载这个XML文件到内存构建一个全量的Map。这就是第一个性能瓶颈的根源文件多大首访就要解析多大。文件1MB低端机上解析时间轻松上百毫秒而且是卡在主线程的。写入则分两种方式。commit()是同步写调用线程直接执行writeToFile()把整个Map重新序列化成XML再写入磁盘apply()会立即更新内存中的Map然后异步写盘。很多人以为apply()就是完全异步、完全无阻塞的其实不完全是。它确实不会阻塞调用线程但会把写盘任务挂到QueuedWork队列里。在Activity.onStop()这类生命周期回调中系统会调用QueuedWork.waitToFinish()等待这个队列排空如果磁盘写入慢依然会造成ANR。这就是SP“异步了但还是卡”的真相。更麻烦的是写入方式——只要改动一个keySP就会全量重写整个XML文件。数据越多单次写入越慢写入放大效应越明显。1MB的文件改一个布尔值照样序列化整个Map。这在数据量小的时候无所谓数据量上来之后性能和耗电都很难看。2.2 MMKVmmap映射与增量写入为什么能快出数量级MMKV是腾讯开源的一个轻量级存储框架底层机制跟SP完全不同。它使用**mmap内存映射文件**把文件直接映射到进程地址空间写入数据时本质上写的是内存地址由操作系统负责在合适的时机把脏页刷回磁盘。应用进程不需要等待真正的磁盘IO完成所以单次写入的速度非常快可以做到微秒到毫秒级别。这是MMKV快的最核心原因之一。但很多人忽略了一点MMKV在文件组织和序列化上也做了大量优化。它采用**追加写append**模式新数据写在文件尾部用length keylength value的结构做区分而不是像SP那样全量重写。文件尾部有一个总长度标记校验时通过CRC32来判断数据是否完整。当写入数据导致文件需要扩容时MMKV会对文件做一次重整把有效数据压缩排列清理掉中间废弃的旧记录。另外一个容易被忽略但很重要的点是它的进程锁。MMKV支持多进程访问通过文件锁来实现进程间互斥这也是SP做不到的。后面我会讲到这个特性在实际使用中并不是银弹照样有坑。2.3 DataStore协程、Flow与事务性设计的取舍DataStore是Google官方在2020年前后推出的SP替代方案分两种形态Preferences DataStore和Proto DataStore。前者跟SP一样按key-value存数据后者基于Protocol Buffers定义结构化数据。核心设计理念有三个全程基于协程、Flow驱动的响应式更新、事务性写入。全程协程意味着读写操作都在Dispatchers.IO上执行不会阻塞主线程。这在理论上解决了SP首访卡顿的问题——读取也是异步的你可以用flow.first()去拿数据。Flow响应式更新是指每次数据变更订阅方都能通过collect感知到这比SP的监听器机制要自然得多。事务性写入体现在DataStore的更新操作是在一个Transaction里执行的先执行edit块成功后统一写盘中途失败不会留下半写状态。但要注意DataStore底层依然是文件存储使用Protocol Buffers编码。Preferences DataStore内部有一个SharedPreferences迁移辅助但它本身的数据格式跟SP的XML完全不兼容。它也不是增量写入——整体刷新数据时底层仍然要写整个文件。这意味着它解决了“主线程卡顿”的问题但并没能解决“写入放大”的问题只是把代价从主线程转移到了IO线程。2.4 三种方案底层机制对比总表维度SharedPreferencesMMKVDataStore存储格式XML文件自研二进制文件Protocol Buffers编码写机制全量重写整个文件追加写 定期重整全量/局部刷新(IO线程)读机制首访同步全量加载mmap内存映射异步读取,无主线程阻塞主线程阻塞首访commit肯定阻塞,apply在生命周期回调可能等待基本不阻塞不阻塞多进程支持不可靠支持不支持(官方明确不建议)类型安全无,靠强转无,主要存基础类型Preferences和Proto均支持数据损坏防护无机制CRC32校验 自动恢复文件写入原子性替换观察数据变化监听器,需手动管理也有回调机制Flow,协程友好依赖体积系统自带AAR约100KB约几百KB (含协程)这个表基本把三者的定位说清楚了。SP胜在“零依赖、系统自带”但设计思想还停留在十年前MMKV胜在极致的读写性能和多进程支持DataStore胜在官方背书、协程友好和类型安全。它们并不是简单的“谁替代谁”的关系而是适用场景的分化。3. 实测性能数据与卡顿边界3.1 测试环境与测试口径为了拿到真实可对比的数据我在同一个测试工程里写了三个封装类分别使用SP、MMKV和DataStore用完全相同的key-value结构去写入和读取。这里说明一下测试数据是在我的开发机上跑的机型是某款中端安卓手机骁龙7系系统是Android 13。测试方法算不上严格的benchmark因为重复写入和读取还会受到内存缓存等因素影响但我觉得用来反映真实使用中的体感差异已经足够。测试场景我分成三组小数据量10个基础类型key每个都是短字符串中数据量100个key包含List序列化后的Json字符串大数据量单个key存100KB以上的字符串。每组场景分别循环写入50次统计平均耗时读取则直接在多轮冷启动过程中统计首访耗时。提示这类自测数据仅供参考不同机型、系统版本上数值会有浮动但各方案之间的相对趋势是一致的。3.2 小数据量写入三者的差距没有想象中大先看最简单的场景——10个短字符串key一次写入平均耗时方案平均写入耗时主线程阻塞情况SharedPreferences(apply)2ms上下基本不等SharedPreferences(commit)15-25ms明显阻塞MMKV(encode)0.3-0.5ms无DataStore(edit)3-5ms无看到这个结果你可能会觉得“SP也没那么差啊”。确实在小数据量、低频写入的场景下三者差距虽然存在但对用户来说完全无感知。这也是为什么很多老项目用SP用了好几年也没出什么大问题——它的问题从来不是“单次写入慢”而是“数据变大之后的所有表现都变慢”。这个场景的结论是如果你的App只是存几个登录态、开关位SP完全没必要换。把它换成MMKV或DataStore收益微乎其微但代码改动和风险是实打实的。3.3 大数据量与高频写入差距在这里拉开再来看中大数据量场景。我在SP里放了一个包含200个key、里面有多个Json数组配置项的文件整体文件体积大概500KB。循环修改其中1个key并测量写入耗时方案平均单次写入耗时体感表现SharedPreferences(commit)180-300ms主线程明显掉帧,配合滚动直接卡顿SharedPreferences(apply)调用立即返回,但生命周期回调有等待风险onStop偶尔出现几十ms停顿MMKV(encode)0.5-1ms完全无感知DataStore(edit)末次大概20-40ms,IO线程无界面卡顿,协程正常回调这次差距就非常明显了。SP的commit在500KB文件上单次写入要200ms上下这是因为它要把整个Map重新序列化成一整段XML再全量写盘。MMKV因为只append新记录写上几百字节也就微秒级到几个毫秒的耗时。DataStore虽然也要重写整个文件但它跑在IO线程界面不卡用户感知不到。还有一个值得注意的细节SP在getSharedPreferences()首访时如果文件保持在500KB-1MB低端机上解析耗时可以达到300ms以上。这个时间发生在Application启动早期会直接推高冷启动时间。我在这个项目里用systrace抓过启动阶段SP的ensureLoadedLocked()明显就是一段长任务很多线上卡顿和ANR的根因都在这。3.4 冷启动耗时用户感知最强的维度冷启动阶段的表现直接决定了用户在桌面点开App之后是“秒开”还是“白屏半天”。我在测试工程里模拟了三种方案初始化后读取数据的耗时数据规模500KB中端机方案初始化首访平均耗时对启动的影响SharedPreferences250-400ms如果启动链路早期使用了SP,会占了白屏期一大块MMKV5-15msmmap方式加载极快,基本无感DataStore50-80ms异步读取,不阻塞启动,但首帧时数据可能还没读到DataStore在冷启动阶段虽然不阻塞主线程但其异步特性带来一个新的问题如果你的App必须在启动后立即获取某个配置项来做初始化逻辑你用runBlocking或挂起等数据回来整个启动时间反而会比SP更慢。因为它绕开主线程阻塞的代价是“你得等它返回”。这一点非常反直觉我在踩坑部分会再展开。4. 迁移踩坑实录三个让我加班的真实事故4.1 事故一DataStore的IOException让应用启动直接闪退这是我迁DataStore后遇到的第一个线上事故。起因很简单我在Application里初始化DataStore然后调用.data.first()读取上次登录的账号信息用于请求广告或初始化推送。本地模拟一切正常灰度发出去之后TMDCrash监控上出现了一批崩溃堆栈指向DataStore的read方法异常类型是java.io.IOException: failed to read preference data。排查过程用了不少时间。我先在本地复现发现怎么操作都复现不出来后来从崩溃日志里的设备信息看出规律——崩溃集中在存储空间告急的低端机上。根因是DataStore在读取文件时如果遇到IO错误比如磁盘损坏、文件被系统清理、或刚好写入一半时进程被杀会向上抛出IOException。关键是这个异常发生在collect的Flow里默认情况下协程异常会直接导致应用崩溃不会像SP那样“读个空Map顶多返回默认值”。解决方案也很直接读取DataStore数据时不能裸调.first()要做异常兜底。我写了一个包装函数捕获IOException后返回默认的空值或预设值。如果业务上允许还可以考虑在捕获到异常时备份损坏文件给后续排查提供线索。suspend fun T readFromDataStoreSafely( flow: FlowT, defaultValue: T ): T { return try { flow.first() } catch (e: IOException) { // 返回默认值,避免协程异常导致崩溃 defaultValue } }注意DataStore官方建议把异常处理放在map操作里对单个数据类型做兜底但启动阶段的整体兜底也不能省两边都做才稳。4.2 事故二SP的apply异步写盘导致迁移数据不完整第二个坑出现在做SP到DataStore迁移的时候。我写了一个MigrationManager理论上应该是在首次启动时把SP里所有数据读出来写入DataStore然后打标记后续逻辑都走DataStore。测试里一切正常但灰度到一部分用户后发现有些用户登录态丢失、功能开关回到默认值。排查过程很曲折。一开始怀疑是DataStore数据没写成功日志打完发现很多用户根本没有执行过迁移代码。后来又怀疑是迁移代码本身有问题检查半天没找到逻辑错误。最后找了台开发机做极端模拟——写入SP之后立刻杀进程再重启App问题终于复现了。根因在SP的apply()机制上它在内存里立即更新但写盘是异步的。很多存量代码在写入登录态时用的都是apply()虽然调用完成了但如果紧接着进程被杀数据可能根本没落盘成功。我的迁移逻辑是在新版本启动后读取SP旧数据此时如果旧数据因为上次的apply没写完就已经丢了那读到的自然是“旧版本本来就没存上的状态”。这个锅严格来说不是迁移代码的锅而是历史代码用apply()埋的雷但迁移时把“读取旧数据”的时点提前暴露了这个问题。解决方案分两层。对迁移本身我改成“数据完整性校验多轮迁移”策略迁移完成后不是立即打标记而是把迁移标记和关键数据一起写入DataStore下次启动如果发现isMigrated不是true就再做一次迁移同时把那些关键的登录态、用户ID类数据在迁移落盘后用commit()强制同步写一次SP确保数据不丢。当然这个修法只针对我当时的场景——解决存量apply数据不落盘的问题你也得根据自己项目的数据重要性评估。fun migrateKeyFromSpToDataStore( key: String, dataStore: DataStorePreferences ) { // 1. 判断迁移标记是否已经完成 // 2. 从SP读旧值 // 3. 写入DataStore (挂起) // 4. 写迁移标记 // 5. 关键数据用commit()补一次描盘,防止杀进程丢数据 }4.3 事故三MMKV多进程场景下的文件损坏与自动恢复第三个坑跟MMKV相关确切说不是MMKV本身有性能或逻辑问题而是在多进程场景下的文件损坏。我们的App有一个独立的播放进程需要在后台持续播放音频播放进程和主进程都会写入一些媒体播放状态。早期方案选型时看中MMKV支持多进程于是几个进程共用一个MMKV实例。结果上线后偶发出现“播放设置丢失”的情况极端情况下还出现过一次崩溃堆栈指向读入数据时CRC校验失败。MMKV对文件损坏是有防护的——文件尾部有CRC32校验码每次读取时校验不一致就触发自动恢复机制从备份文件恢复或者清空损坏数据。但这个“自动恢复”在极端场景下是有代价的如果文件写了一半且备份也来不及更新恢复出来可能就是空数据。我的播放进程和主进程同时写同一个key时虽然没有死锁但进程间锁的粒度不够细存在竞争写导致的中间状态。排查到这里我反而庆幸选了MMKV——同样的情况如果换成SP数据损坏概率只会更高恢复机制则完全没有。但这也说明了**“支持多进程”不等于“多进程可以随便写”**。最终我的处理方式是把跨进程共享的数据单独拆出来放到一个独立的MMKV实例里并且限制写入频率避免高频写入放大文件损坏概率同时对关键数据做了冗余存储写一个key的同时写一份备份key读取时如果校验失败就尝试从备份恢复。4.4 三个事故的共性总结事故根因解决方案DataStore启动闪退未捕获IO异常,协程异常向上抛捕获IOException,返回默认值迁移数据丢失SP的apply写盘异步,杀进程丢数据多轮迁移标记关键数据commit强制写盘MMKV多进程损坏多进程高频竞争写,文件损坏恢复不完整拆独立实例限制写入频率备份key冗余这三个事故让我形成了一个非常强烈的印象任何存储方案的官方文档都不会告诉你它在真实使用中的边界条件。性能参数往往是“理想情况下的上限”而可靠性问题永远是“真实场景下的下限”。搞存储选型一定得先明确你的数据对“丢失、损坏、等待”这三个风险有多大的容忍度。5. 最终选型与迁移落地实践5.1 按业务场景选择而不是按流行度选择这次对比做完之后我的结论其实不是一个方案替代另一个方案而是按数据的使用特征分层。很多团队一听DataStore是官方的就全员迁移一听MMKV快就统统换掉这种“一刀切”的做法在存量项目里容易出事。我把App里的数据分成了三类。第一类是标准偏好设置用户开关、界面偏好、语言选择这类数据。特点是低频读写、单条数据量小、对实时性要求不高。这类我用DataStore的Preferences DataStore承接好处是协程友好、Flow可观察以后做功能开关实时生效之类的需求会顺手很多。第二类是高频/大对象数据播放进度、位置缓存、复杂的Json配置对象等。特点是写入频率高、数据量大。这类我切到MMKV单独建一个mmkv实例不进主进程的DataStore。第三类是少量但关键的数据登录态、用户ID、设备唯一标识。这类我仍然用SP的commit()写因为它在极小数据量下性能并不差而且同步写盘保证了进程被杀也不丢。这个分层方案可能跟你想象的“全量迁移”不一样但它的风险是最低的。先把最痛的部分处理掉其余部分逐步替换是存量项目改造的常态。5.2 从SP到DataStore的平滑迁移路径如果你决定把SP迁移到DataStore推荐的方式是用DataStore官方提供的SharedPreferencesMigration它能自动把SP里的数据搬到DataStore。但在Android 12API 31及以上有个容易踩的坑系统会自动备份SP文件迁移完成如果没处理好备份恢复可能会把旧SP数据带回来覆盖新DataStore的数据。我的做法分三步第一步在build.gradle里引入依赖并创建DataStore实例。val Context.dataStore by preferencesDataStore( name user_preferences, produceMigrations { context - listOf(SharedPreferencesMigration(context, old_sp_name)) } )第二步在首次读取时触发迁移。val userSettings: FlowSettings context.dataStore.data .map { preferences - Settings( isLogin preferences[Keys.IS_LOGIN] ?: false, userName preferences[Keys.USER_NAME] ?: ) } .catch { exception - if (exception is IOException) { emit(emptyPreferences()) } else { throw exception } }调用.first()或collect时迁移会自动进行。但要注意如果旧SP数据里有带SetString或自定义对象序列化的字段SharedPreferencesMigration只支持基本类型和StringSet复杂类型需要你自己先转成支持的格式再迁移。第三步迁移完成后验证并清理旧文件。读取到迁移完成的标记后在后续版本里可以逐步删除SP文件的读取逻辑最后彻底删掉旧的SP文件。不要一上来就删文件否则用户升级过程中如果数据还没迁移成功一删直接丢数据。5.3 一些更细节的经验建议最后分享几个我这次改造中沉淀下来的细节经验这些在官方文档里基本查不到给DataStore初始化加超时或降级。严格来说DataStore初始化本身很快但.data.first()在网络、磁盘状态异常时可能长时间挂起。对于启动链路里的关键数据我建议用withTimeoutOrNull包一层超时返回默认值待后台任务慢慢重试。MMKV的encode返回值一定要检查。很多写法是kv.encode(key, value)然后就不管了但它在写盘失败时会返回false。虽然概率很低但一旦发生数据的完整性完全没有保证。我在关键数据写入后至少打一条日志方便线上问题排查。避免在同一个DataStore里塞太多不同类型的数据。DataStore的每次更新都需要重写整个文件如果一个DataStore文件里既有高频的计数器又有低频的用户配置计数器的每次写入都会带累配置数据一起重写。合理的做法是把不同生命周期的数据拆到不同的DataStore文件里。Android 12以后的SP备份问题要提前处理。系统自动备份会对SP做云备份如果你迁移到DataStore后没有清除旧SP那么在新设备上恢复时旧SP的数据会被系统重新写回。多少用户在“换手机后登录态错乱”的问题根因就出在这。迁移完成后要显式地删除或清空旧SP里的关键数据。我个人在这轮改造里的体会是存储方案选型不是比参数表谁好看而是看你手头的业务特征更适合哪种设计。DataStore确实更“现代化”但它的异步和事务性要求你的代码从一开始就得用协程去组织MMKV确实快但多进程和高频写入的边界也需要你自己守住。共享优先级的核心就一句话——让合适的数据找到合适的家而不是看哪款框架最流行就一窝蜂切过去。这套思路你拿去评估自己的项目应该也能少踩不少坑。