Q1:SharedPreferences 为什么会导致 ANR?MMKV 如何从根本上解决?
高频考察点:Android 主线程阻塞原理、SP 同步 / 异步机制缺陷、MMKV 内存映射技术
- SP 导致 ANR 的三大元凶:
- 同步提交(commit ()):直接在主线程执行磁盘 I/O,写入 XML 文件耗时可达 100ms+(尤其首次写入或文件较大时)。
- 异步陷阱(apply ()):虽异步写入,但依赖
QueuedWork
机制,在Activity.onStop()
/onDestroy()
时会强制等待所有未完成的 apply 任务,若任务堆积则阻塞主线程。 - 首次加载阻塞:首次调用
getXXX()
时,若 SP 尚未加载完数据(异步加载未完成),主线程会陷入wait()
无限等待,典型场景:启动页读取配置。
- MMKV 的解决方案:
- 内存映射(mmap):将文件直接映射到内存地址空间,写入时修改内存即可,由操作系统异步刷盘(非阻塞),省去传统 I/O 的用户态 / 内核态数据拷贝(4 次→0 次)。
- 无队列化异步:取消
QueuedWork
依赖,写入任务提交到独立线程池(默认线程数与 CPU 核心数相关),不阻塞 Activity 生命周期。 - 预加载机制:初始化时直接映射文件,首次读取无需等待加载,数据立即可见。
- 面试应答模板:
“SP 的 ANR 根源在于同步 I/O 和生命周期阻塞,而 MMKV 通过 mmap 实现内存直接操作,异步刷盘且不依赖 Activity 队列,从底层消除了主线程阻塞风险。某大厂曾统计,迁移 MMKV 后 ANR 率从 18% 降至 0.3%,核心就是解决了这两个痛点。”
Q2:MMKV 如何实现多进程数据一致性?相比 SP 的 ContentProvider 方案有何优势?
高频考察点:跨进程通信、文件锁机制、Android 多进程坑点
- SP 多进程的致命缺陷:
- 文件锁(
FileLock
)非原子操作,跨进程读写时可能出现 “写一半” 的情况(如进程 A 写入时被进程 B 打断,导致 XML 文件损坏),某电商 APP 曾因此出现 37% 的数据错乱率。 - 依赖
ContentProvider
通知变更时,需手动处理序列化和回调,实现复杂且易漏更新。
- 文件锁(
- MMKV 的多进程方案:
- 原子锁(flock):基于 Linux 系统级
flock()
锁,保证跨进程读写时的原子性(写时加独占锁,读时加共享锁),避免数据竞争。 - Ashmem 匿名内存:跨进程传输数据时,通过 Android 提供的
Ashmem
(匿名共享内存)传递内存地址,避免敏感数据落地到文件,提升安全性和传输效率。 - 变更广播:内置
ContentProvider
实现跨进程变更通知,自动触发其他进程的缓存刷新,无需开发者手动处理。
- 原子锁(flock):基于 Linux 系统级
- 对比优势:
方案 数据一致性 性能 实现复杂度 安全性 SP+ContentProvider 弱(需手动) 低(序列化开销) 高 数据落盘传输 MMKV 原生多进程 强(原子锁) 高(内存映射) 低(一行代码) Ashmem 防泄漏 - 面试应答模板:
“MMKV 通过系统级 flock 锁保证原子操作,结合 Ashmem 避免数据落盘传输,相比 SP 的文件锁和 ContentProvider 方案,既解决了数据错乱问题,又简化了多进程开发(只需传入MULTI_PROCESS_MODE
参数)。微信支付场景中,多进程并发读写的成功率从 SP 的 63% 提升至 MMKV 的 99.99%。”
Q3:Protobuf 序列化相比 XML 有哪些技术优势?为什么 MMKV 选择它而非 JSON?
高频考察点:数据格式对比、性能优化原理、二进制协议特性
- Protobuf 核心优势(对比 XML/JSON):
- 体积更小:二进制编码(非文本),去除冗余格式(如标签名),数据体积比 XML 缩小 45%(例:100KB XML → 55KB Protobuf),减少磁盘 I/O 和内存占用。
- 解析更快:无需解析复杂标签结构,通过字段编号(如
int32 age = 1
中的1
)直接定位数据,序列化 / 反序列化速度比 XML 快 5 倍(万次操作:Protobuf 20ms vs XML 100ms)。 - 类型安全:基于 IDL(接口定义语言)生成代码,编译期检查数据类型,避免运行时解析错误(如 XML 中字符串误读为数字)。
- 为何不选 JSON?
- JSON 是文本格式,需频繁进行字符解析(如
{}
、""
),性能低于二进制协议。 - 动态类型导致反序列化时需额外类型判断,内存占用更高(需解析成 Map/List 结构)。
- JSON 是文本格式,需频繁进行字符解析(如
- 面试应答模板:
“Protobuf 的二进制编码和字段编号机制,使其在性能和体积上碾压 XML/JSON。MMKV 作为高频读写的存储方案,选择 Protobuf 能大幅减少 I/O 耗时和内存占用,这也是为什么在 1000 次写入测试中,MMKV 仅需 7ms,而 SP 需 1200ms—— 其中 30% 的性能提升来自 Protobuf 编码优化。”
Q4:MMKV 如何避免 mmap 导致的内存泄漏?LRU 缓存机制如何实现?
高频考察点:内存管理、Android 资源回收、WeakReference 应用
- mmap 潜在风险:
- 若长期保持文件映射,且实例未被释放,可能导致内存占用过高(尤其多实例场景)。
- MMKV 的解决方案:
- LRU 缓存淘汰:内部维护一个
LRUCache
,根据最近使用时间自动释放长时间未访问的 MMKV 实例的 mmap 内存,默认最大缓存数量为 64(可通过MMKVConfig
配置)。 - 弱引用关联 Context:实例持有对 Context 的
WeakReference
,避免因 Context 被 MMKV 强引用导致的 Activity 泄漏(如非静态内部类持有 Activity 引用)。 - 进程退出自动释放:mmap 映射的内存由操作系统管理,进程结束后自动回收,无需手动释放。
- LRU 缓存淘汰:内部维护一个
- 面试应答模板:
“MMKV 通过 LRU 缓存和弱引用机制双重保障内存安全。LRU 会淘汰最少使用的实例的 mmap 映射,而弱引用避免 Context 泄漏。实际开发中,即使创建大量 MMKV 实例,内存占用也会稳定在合理范围 —— 某金融 APP 实测,同时维护 200 个实例时,内存波动不超过 5MB。”
Q5:迁移 MMKV 时需要注意哪些兼容性问题?如何验证迁移是否成功?
高频考察点:数据迁移实战、异常处理、灰度测试
- 迁移关键细节:
- 数据格式转换:
- SP 支持的
Set<String>
类型,MMKV 需通过encodeStringSet()
/decodeStringSet()
处理(底层用 Protobuf 重复字段存储)。 - 布尔 / 数值类型可无缝迁移,但需注意 SP 的
getXXX(key, default)
中默认值的处理(MMKV 无默认值概念,需显式判断containsKey()
)。
- SP 支持的
- 多进程模式适配:
- 若原 SP 依赖
Context.MODE_MULTI_PROCESS
(已废弃),需手动切换为 MMKV 的MULTI_PROCESS_MODE
,否则可能出现数据不同步。
- 若原 SP 依赖
- 迁移验证步骤:
- 灰度阶段对比新旧存储的读写耗时、内存占用。
- 编写自动化测试:随机写入 10 万 + 条数据,验证迁移前后
key-value
一致性(推荐用 MD5 校验整体数据)。 - 监控 ANR 率和 Crash 率,重点关注首次初始化和多进程场景。
- 数据格式转换:
- 面试应答模板:
“迁移时需注意数据格式(如 Set 类型)和多进程模式的适配,建议通过MMKV.importFromSharedPreferences(oldSp)
一键迁移,同时保留旧 SP 一段时间(如 7 天)用于数据回滚。某社交 APP 迁移时,通过灰度测试发现getBoolean(key, true)
在 MMKV 中需先判断containsKey(key)
,避免了默认值差异导致的逻辑错误。”
MMKV 实战操作代码详解(带核心注释)
1. 基础初始化与全局配置(必考点:初始化原理)
// MMKV 初始化(建议在 Application.onCreate() 中执行)
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// 基础初始化(默认存储路径:/data/data/包名/files/mmkv/)
MMKV.initialize(this)
// 高级配置(面试常问:如何优化内存/性能)
val mmkvConfig = MMKVConfig.defaultMMKVConfig(this).apply {
// 配置 LRU 缓存大小(默认 64 个实例,防止内存泄漏)
maxCacheSize = 100
// 启用文件加密(AES-CFB-128 模式,需提供 16 字节密钥)
cryptoKey = "1234567890123456".toByteArray()
// 多进程模式(核心:flock 原子锁)
multiProcessEnable = true
}
// 使用自定义配置初始化(适用于复杂场景)
MMKV.initialize(mmkvConfig, this)
}
}
// 获取默认实例(全局唯一,共享同一存储文件)
val defaultKV = MMKV.defaultMMKV()
// 获取指定 ID 的实例(支持多文件隔离,如不同业务模块独立存储)
val userKV = MMKV.mmkvWithID("user_config")
注释解析:
- 初始化支持 默认配置 和 自定义配置,
mmkvWithID
实现多文件隔离,避免不同模块数据混杂(如用户配置 vs 业务缓存)。 cryptoKey
配置加密时,必须是 16/24/32 字节长度(对应 AES-128/192/256),面试可追问:“为何选择 CFB 模式?”(支持流式加密,适配 MMKV 的增量写入)。
2. 数据读写实战(全类型覆盖 + 性能优化点)
// 写入基础数据类型(原子操作,无锁设计)
fun writeBaseData() {
val kv = MMKV.defaultMMKV()
// 字符串(自动处理空值,SP 需手动判空)
kv.encode("user_name", "John")
// 数值类型(支持 Long/Double 等大字段,SP 性能随字段大小下降明显)
kv.encode("user_age", 25)
kv.encode("user_weight", 65.5f)
// 布尔值(底层 Protobuf 用 varint 编码,比 XML 的 <bool> 标签更紧凑)
kv.encode("is_vip", true)
// 数组/集合(SP 的 Set<String> 需手动处理,MMKV 原生支持)
kv.encode("favorite_fruits", setOf("Apple", "Banana"))
}
// 读取数据(无首次加载阻塞,直接返回内存映射数据)
fun readBaseData(): String {
val kv = MMKV.defaultMMKV()
// 安全读取(提供默认值,与 SP 行为一致)
val name = kv.decodeString("user_name", "Guest")
// 集合读取(直接返回 Set,无需手动解析 XML)
val fruits = kv.decodeStringSet("favorite_fruits", emptySet())
// 存在性检查(SP 需通过 getAll() 间接判断,MMKV 直接支持)
val hasAge = kv.containsKey("user_age")
return name
}
// 批量读写(核心优化:避免多次 I/O,SP 需多次 commit/apply)
fun batchWrite() {
val kv = MMKV.defaultMMKV()
// 开启事务(保证批量操作原子性,失败自动回滚)
kv.withTransaction {
it.encode("key1", "value1")
it.encode("key2", "value2")
}
}
注释解析:
withTransaction
实现批量操作原子性,避免部分写入失败(SP 无此机制,需手动保证一致性)。- 数值类型写入时,MMKV 通过 Protobuf 的 Varint 编码(小数值用 1-2 字节存储),比 XML 的文本存储(如
25
需 2 字节字符)更高效。
3. 多进程场景实战(面试重灾区:跨进程一致性)
// 主进程初始化多进程实例(关键参数:MULTI_PROCESS_MODE)
val crossProcessKV = MMKV.mmkvWithID(
"cross_process_config",
MMKV.MULTI_PROCESS_MODE // 启用系统级 flock 锁
)
// 子进程读取数据(自动感知主进程变更)
fun childProcessRead() {
val kv = MMKV.mmkvWithID("cross_process_config", MMKV.MULTI_PROCESS_MODE)
// 注册变更监听(跨进程时自动触发,替代 SP 的 ContentObserver)
kv.addOnContentChangedListener { key, oldValue, newValue ->
Log.d("MMKV", "Key $key changed from $oldValue to $newValue")
}
// 读取数据(底层通过 Ashmem 共享内存,避免数据落盘传输)
val value = kv.decodeString("cross_key")
}
// 主进程写入(自动通知子进程)
fun mainProcessWrite() {
crossProcessKV.encode("cross_key", "main_process_value")
// 强制刷盘(可选,默认异步刷盘,如需立即持久化调用)
crossProcessKV.flush()
}
注释解析:
MULTI_PROCESS_MODE
底层使用flock()
锁,保证跨进程读写原子性(SP 的MODE_MULTI_PROCESS
已废弃,且实现不可靠)。addOnContentChangedListener
内置跨进程广播(基于 ContentProvider),无需手动实现 IPC 通信(SP 需自定义 BroadcastReceiver)。
4. 数据加密实战(金融 / 支付场景必备)
// 初始化加密实例(关键:设置 cryptoKey,面试问:如何保护密钥?)
val encryptedKV = MMKV.mmkvWithID(
"encrypted_config",
MMKV.MULTI_PROCESS_MODE,
"16字节长度的密钥".toByteArray() // AES-CFB-128 密钥(必选)
)
// 写入加密数据(底层自动加密,不影响上层 API)
encryptedKV.encode("secret_key", "敏感信息如支付密码")
// 读取加密数据(自动解密,性能损耗仅 15%,远优于 SP 手动加密)
val secretValue = encryptedKV.decodeString("secret_key")
// 密钥保护方案(面试延伸:MMKV 如何防反编译?):
// 1. 密钥存储在 Native 层(通过 JNI 从 SO 库获取,非 Java 层硬编码)
// 2. 使用白盒加密(White-Box Cryptography),防止密钥被逆向工程提取
注释解析:
- 加密对上层 API 透明,写入即加密、读取即解密,无需开发者处理加解密逻辑(SP 需手动实现,易出错)。
- 密钥建议通过 动态生成 + 安全通道传输(如从服务器下发),避免硬编码在 APK 中。
5. 数据迁移实战(从 SP 无缝切换,面试必问迁移步骤)
Step 1:初始化 MMKV(关键配置)
// 在 Application 中初始化 MMKV(建议与 SP 并行运行一段时间)
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// 初始化 MMKV(默认配置,如需加密/多进程在此配置)
MMKV.initialize(this)
// 保留旧 SP 实例(用于对比验证和回滚)
spMigrationHelper = getSharedPreferences("old_sp", Context.MODE_PRIVATE)
}
companion object {
lateinit var spMigrationHelper: SharedPreferences // 全局旧 SP 引用
}
}
核心注意:
- 初始化 MMKV 时 不建议立即删除 SP,需先并行运行,确保迁移后功能正常
- 若旧 SP 使用多进程模式(
MODE_MULTI_PROCESS
),MMKV 需显式配置MULTI_PROCESS_MODE
Step 2:一键导入 SP 数据(核心迁移接口)
// 核心迁移方法(一行代码完成基础数据迁移)
fun migrateFromSP() {
val oldSP = MyApplication.spMigrationHelper // 旧 SP 实例
val mmkv = MMKV.defaultMMKV() // MMKV 目标实例
// 1. 调用官方迁移工具(内部处理 XML 解析与 Protobuf 转换)
MMKV.importFromSharedPreferences(oldSP)
// 2. 特殊类型处理(SP 的 Set<String> 需手动迁移,MMKV 不支持自动转换)
oldSP.getStringSet("old_set_key", emptySet())?.let {
mmkv.encodeStringSet("new_set_key", it) // 显式调用 StringSet 接口
}
// 3. 处理默认值差异(SP 的 getXxx(key, default) 在 MMKV 中需先判断 key 是否存在)
if (!mmkv.containsKey("old_key_with_default")) {
mmkv.encode("old_key_with_default", "default_value") // 手动写入默认值
}
}
源码级解析:
importFromSharedPreferences
内部逻辑:- 读取 SP 的 XML 文件字节流
- 解析 XML 节点,提取
key-value
对 - 按 MMKV 的 Protobuf 格式重新编码并写入
- Set<String> 迁移陷阱:SP 的
Set<String>
在 XML 中存储为多个<string-array>
节点,MMKV 需通过encodeStringSet()
显式处理,否则会丢失
Step 3:数据一致性验证(生产环境必做)
// 验证迁移后所有数据与旧 SP 完全一致
fun verifyMigrationIntegrity() {
val oldSP = MyApplication.spMigrationHelper
val mmkv = MMKV.defaultMMKV()
// 1. 对比 key 集合(排除 MMKV 自动生成的元数据 key)
val mmkvKeys = mmkv.allKeys().toSet()
val spKeys = oldSP.all.keys.toSet()
check(mmkvKeys.containsAll(spKeys)) { "Key missing: ${spKeys - mmkvKeys}" }
// 2. 逐类型对比 value(覆盖所有数据类型)
oldSP.all.forEach { (key, spValue) ->
when (spValue) {
is String -> {
val mmkvValue = mmkv.decodeString(key)
check(mmkvValue == spValue) { "String mismatch: $key" }
}
is Int -> {
val mmkvValue = mmkv.decodeInt(key, -1) // 提供默认值避免 NPE
check(mmkvValue == spValue) { "Int mismatch: $key" }
}
is Boolean -> {
val mmkvValue = mmkv.decodeBool(key, false)
check(mmkvValue == spValue) { "Boolean mismatch: $key" }
}
// 处理 Set<String>(SP 独有,需单独验证)
is Set<*> -> {
@Suppress("UNCHECKED_CAST")
val spStringSet = spValue as Set<String>
val mmkvStringSet = mmkv.decodeStringSet(key, emptySet())
check(mmkvStringSet == spStringSet) { "Set mismatch: $key" }
}
}
}
// 3. 性能对比验证(可选,记录迁移前后读写耗时变化)
val spReadTime = measureTimeMillis { oldSP.all }
val mmkvReadTime = measureTimeMillis { mmkv.allKeys() }
Log.d("Migration", "SP Read Time: $spReadTime ms, MMKV Read Time: $mmkvReadTime ms")
}
验证关键点:
- 默认值处理:MMKV 的
decodeXxx(key)
若 key 不存在返回null
(除基础类型可指定默认值),而 SP 的getXxx(key, default)
自动返回默认值,需在迁移后补全默认值逻辑 - 大文件迁移:若 SP 文件超过 1MB,
importFromSharedPreferences
可能耗时较长,建议在子线程执行,避免 ANR
Step 4:逐步废弃旧 SP(灰度与回滚策略)
// 生产环境推荐的灰度迁移流程
fun migrateInGrayMode() {
// 阶段 1:双写阶段(同时写入 SP 和 MMKV,持续 1-2 个版本)
fun writeData(key: String, value: String) {
// 旧逻辑:写入 SP
oldSP.edit().putString(key, value).apply()
// 新逻辑:写入 MMKV
MMKV.defaultMMKV().encode(key, value)
}
// 阶段 2:单读 MMKV + 对比验证(确认无误后关闭 SP 写入)
fun readData(key: String): String {
val mmkvValue = MMKV.defaultMMKV().decodeString(key)
val spValue = oldSP.getString(key, "")
// 对比双写数据一致性(用于监控报警)
if (mmkvValue != spValue) {
上报迁移不一致异常(key, mmkvValue, spValue)
}
return mmkvValue ?: spValue // 过渡期回退到 SP(防止 MMKV 数据缺失)
}
// 阶段 3:完全废弃 SP(确认 MMKV 稳定后清理旧数据)
if (迁移验证通过 && 灰度周期结束) {
oldSP.edit().clear().commit() // 清空旧 SP 数据
删除旧 SP 文件(context, "old_sp.xml") // 物理删除文件(需适配 Android 10+ 存储策略)
}
}
// 辅助函数:删除旧 SP 文件(注意文件路径)
fun 删除旧 SP 文件(context: Context, spFileName: String) {
val spDir = context.filesDir.resolve("shared_prefs")
val spFile = spDir.resolve("$spFileName.xml")
spFile.delete()
}
生产级最佳实践:
- 双写阶段:避免因迁移工具不完善导致的数据丢失,确保新老存储数据实时一致
- 灰度监控:通过 APM 工具(如 Firebase、Bugly)监控
mmkvValue != spValue
的异常比例,设定阈值(如 >0.1% 触发回滚) - 文件清理:Android 10+ 需通过
Context.deleteFile()
或直接操作文件路径,避免残留旧 XML 文件占用存储
6. 性能压测实战(面试加分项:如何复现 300 倍性能差)
// 写入性能测试(对比 SP/MMKV)
fun testWritePerformance() {
val sp = getSharedPreferences("sp_test", Context.MODE_PRIVATE)
val mmkv = MMKV.mmkvWithID("mmkv_test")
val iterations = 1000
// SP 同步写入
val spStart = System.currentTimeMillis()
repeat(iterations) {
sp.edit().putString("key_$it", "value_$it").commit() // 同步提交,阻塞主线程
}
val spTime = System.currentTimeMillis() - spStart
// MMKV 异步写入(默认异步刷盘,非阻塞)
val mmkvStart = System.currentTimeMillis()
repeat(iterations) {
mmkv.encode("key_$it", "value_$it") // 直接写入内存映射,无需等待磁盘
}
val mmkvTime = System.currentTimeMillis() - mmkvStart
Log.d("Performance", "SP Time: $spTime ms, MMKV Time: $mmkvTime ms")
// 典型输出:SP 1200ms vs MMKV 7ms(300 倍差距,取决于设备性能)
}
注释解析:
- SP 的
commit()
同步写入在多次调用时累积阻塞时间,而 MMKV 的encode()
基于 mmap 直接操作内存,耗时几乎固定(仅内存操作 + 极短的异步刷盘调度)。 - 压测时建议在子线程执行,避免影响主线程,结果更接近真实场景。
代码级面试扩展
Q:MMKV 写入时为什么不需要显式调用 commit/apply?
答:
MMKV 使用 内存映射(mmap) 技术,encode()
直接修改内存中的映射区域,数据会由操作系统通过 msync()
异步刷盘(默认策略:延迟 500ms 或内存不足时触发)。
对比 SP:commit()
同步刷盘、apply()
异步但依赖队列阻塞,而 MMKV 的异步是真正的非阻塞,写入性能不受磁盘 I/O 影响(见 testWritePerformance
代码)。
Q:多进程模式下,如何保证多个进程同时写入不冲突?
答:
MMKV 在创建实例时通过 MMKV.MULTI_PROCESS_MODE
启用 flock 系统级文件锁:
- 写操作时加 独占锁(LOCK_EX),确保同一时间只有一个进程写入;
- 读操作时加 共享锁(LOCK_SH),允许多个进程同时读取。
该机制比 SP 的FileLock
更可靠(原子性由内核保证),代码示例见crossProcessKV
初始化。
Q:加密功能是否会影响性能?如何平衡安全与效率?
答:
MMKV 采用 AES-CFB-128 流式加密,加密过程与数据写入同时进行(无需等待完整数据块),性能损耗约 15%(实测 1000 次加密写入耗时约 10ms,非加密 7ms)。
对比手动加密 SP:需先序列化数据、加密、写入,耗时可能增加 50% 以上,且易因加密逻辑错误导致数据损坏(见 encryptedKV
代码示例)。
Q:MMKV.importFromSharedPreferences 内部是如何实现的?会有性能问题吗?
答:
- 内部实现:
- 读取 SP 的 XML 文件(位于
data/data/包名/shared_prefs/
目录) - 使用 Android 内置的
XmlPullParser
解析 XML 节点,提取key-value
对 - 按 MMKV 的 Protobuf 格式重新编码,通过 mmap 写入内存映射区域
- 读取 SP 的 XML 文件(位于
- 性能影响:
- 单文件迁移耗时与 SP 文件大小正相关(1MB 文件约 5ms,10MB 约 50ms)
- 建议在子线程执行迁移(避免阻塞主线程),可通过
runOnBackgroundThread { MMKV.importFromSharedPreferences(oldSP) }
实现
Q:迁移后发现部分数据丢失,可能的原因有哪些?
答:
- Set<String> 未手动迁移:MMKV 的
importFromSharedPreferences
不处理Set<String>
类型,需额外调用encodeStringSet()
- SP 文件损坏:若 SP 的 XML 文件因磁盘错误损坏(如突然断电),迁移工具会跳过损坏节点
- 数据类型不匹配:SP 中存储的非标量类型(如通过
putString()
存储 JSON)需手动解析后再写入 MMKV
Q:生产环境如何保证迁移过程的高可用性?
答:
- 双写双读:在迁移期间同时读写 SP 和 MMKV,通过一致性校验确保数据实时同步
- 灰度发布:分批次上线迁移功能(如先开放 1% 用户,逐步扩大到 100%)
- 回滚策略:保留旧 SP 数据一段时间(如 7 天),若出现大面积异常,可通过
MMKV.exportToSharedPreferences()
(需自定义工具)回退数据