绝大多数人想不到的 MMKV 封装思路

31 阅读13分钟

前言

可能有些熟悉Kotlin的小伙伴看到了这个标题会认为:不就是用 Kotlin 属性委托封装一下嘛,谁想不到呢?比如:

var name by mmkvString()
name = user.name

通过属性委托,只要赋值就能保存 MMKV 数据,比直接用 MMKV 方便得多。

个人确实是要讲属性委托的封装,但并不是上面的用法,这些基础用法已经在个人的 MMKV 开源库 (MMKV-KTX) 里实现了出来,这次是要在原有的基础上再进一步扩展更多更好用的用法。

封装思路

支持 StateFlow 类型

这就要先讲下个人之前封装的 LiveData 用法的实现方式了,StateFlow 也是用类似的方案。

个人的库是可以用 asLiveData() 扩展函数来转成 LiveData 类型:

val user by mmkvParcelable<User>().asLiveData()

转成了 LiveData 后,在保存 MMKV 数据的时候就能直接通知 UI 更新。如果缓存的数据是和 UI 挂钩的,可以转成 LiveData 来使用,缓存变了,UI 也会跟着变,用起来方便很多。

这个转换功能实现起来并不难,因为是在原有的属性委托上再调了个扩展函数,并且返回值也是用 by 关键字来使用的,那么扩展函数也要返回一个属性的委托类,这其实只是两个属性委托类进行转换。

我们先来实现一个委托类返回的 LiveData 类型,该类做的事情很简单,只需在一开始给个从 MMKV 读出来的初始值,在 setValue() 加多一个保存到 MMKV 的操作:

class MMKVLiveData<V>(
  private val getMMKVValue: () -> V,
  private val setMMKVValue: (V) -> Unit
) : MutableLiveData<V>(getMMKVValue()) {
  override fun getValue() = getMMKVValue()

  override fun setValue(value: V) {
    super.setValue(value)
    setMMKVValue(value)
  }
}

这里有两个非常重要的点:

第一,保存 MMKV 的操作要在 LiveDatasetValue() 之后,避免出现 LiveData 报错了,MMKV 却把数据给存了的情况。比如 setValue() 在子线程调用了,又刚好用 try catch 包着没崩溃。此时 LiveData 没保存到数据,而本地却存了,这就不符合预期了。

第二,LiveDatagetValue() 要重写为读取 MMKV 的值,不能使用 LiveData 自带的缓存。这是一个很多人考虑不到的点,LiveData 本身都有缓存了,为什么再去读一次呢?

多数情况下肯定是有缓存就用缓存,但是这次的情况比较特殊,有缓存反而是不好的,因为我们不知道这个数据什么时候会清掉。如果用了缓存,在退出登录清理 MMKV 所有数据后,LiveData 居然还有值,那就可能出现预料之外的问题。

其次,MMKV已经在底层通过内存映射文件的方式实现了高效的缓存机制。数据一旦被加载到内存后,读取性能非常高。因此对大多数使用场景来说,MMKV 已经足够快速和高效,没必要做额外的缓存。

现在有了 MMKVLiveData 后, 我们就能实现属性的委托类了。

fun <V> MMKVProperty<V>.asLiveData() = MMKVLiveDataProperty(this)

class MMKVLiveDataProperty<V>(private val mmkvProperty: MMKVProperty<V>) : ReadOnlyProperty<IMMKVOwner, MutableLiveData<V>> {
  private var cache: MutableLiveData<V>? = null

  override fun getValue(thisRef: IMMKVOwner, property: KProperty<*>): MutableLiveData<V> =
    cache ?: MMKVLiveData(
      { mmkvProperty.getValue(thisRef, property) },
      { mmkvProperty.setValue(thisRef, property, it) }
    ).also { cache = it }
}

铺垫了这么多,终于要来讲 StateFlow 了,StateFlow 的作用和 LiveData 很类似,不过 StateFlowFlow ,支持很多操作符,玩法更多。

两者的委托功能实现起来大差不差,但是也有个比较大的差异,就是 MutableLiveData 是一个类可以直接继承,而 MutableStateFlow 是一个接口。官方也有 MutableStateFlow 的实现类 StateFlowImpl ,但是这个实现类是私有的,没法直接继承。这就要用到另一个 Kotlin 委托的特性来处理了,就是接口委托,这里就不展开了,有兴趣自己去了解下。

我们需要重写 MutableStateFlow 的 读写操作,加上 MMKV 的缓存逻辑。MutableStateFlow 还有一个 compareAndSet() 函数也会更新值,这个也要重写。MutableStateFlow 要数据有变化了才会更新,这也和 LiveData 有所不同。

还有前面讲过的两个很重要的点,要先修改 flow 再去修改 MMKVgetValue() 一定要重写,不能用缓存。最终的代码如下:

class MMKVFlow<V>(
  private val getMMKVValue: () -> V,
  private val setMMKVValue: (V) -> Unit,
  private val flow: MutableStateFlow<V> = MutableStateFlow(getMMKVValue())
) : MutableStateFlow<V> by flow {
  override var value: V
    get() = getMMKVValue()
    set(value) {
      val origin = flow.value
      flow.value = value
      if (origin != value) {
        setMMKVValue(value)
      }
    }

  override fun compareAndSet(expect: V, update: V): Boolean =
    flow.compareAndSet(expect, update).also { setSuccess ->
      if (setSuccess) setMMKVValue(value)
    }
}

属性的委托类和 LiveData 的基本一样,就不带着大家写了。最终我们可以用 asStateFlow() 扩展来得到一个 MutableStateFlow 对象。

val user by mmkvParcelable<User>().asStateFlow()
user.onEach {
  // 更新昵称和头像
}.launch(scope)
user.value = user

现在把 LiveDataStateFlow 都支持了,具体用哪个看大家的习惯了。

支持 getAllKV()

由于 MMKV 是按字节进行存储的,写入文件会把类型擦除。这会导致了 MMKV 不支持用 getAll() 来获取已保存的键值对,从而很难迁移数据。

当然这也不是没有办法,程序员江同学给过一个方案,给 key 添加一个类型后缀,比如我们存个 name,实际的 keyname@String。这样就能从 key 中获取到是什么类型,直接强转。

这确实是个可行的方案,可以写个 MMKV 工具类,或者用属性委托来实现。

但是该方案有个弊端,就是只能在新项目实施,不能在已有的项目进行改造。比如现在的项目用了 name 作为 key 保存了个数据,我们突然把保存的 key 改成了 name@String,但是用户升级 app 后不一定会触发保存操作。如果没有用新的 key 来保存,当前数据的 key 还是 name,就会因为不清楚具体的类型而丢弃掉。这就不符合预期了,getAll() 不应该会丢弃掉之前已经保存的数据。

所以这个方案也不适用于个人已发布的库,因为该库是用属性名来保存,而且很多人已经在使用了,只能另辟蹊径。有个小伙伴提供了另一个更好地思路,我们在用属性委托的时候,会声明出属性的类型,比如:

object SettingsRepository : MMKVOwner(mmapID = "settings") {
  var isNightMode by mmkvBool()
  var isEnableNotification by mmkvBool(default = true)
  var language by mmkvString(default = "zh")
}

由于是用属性名作为 key,属性的类型也知道了,其实这就已经有 key 和对应类型的信息了,那实现个 getAll() 不是轻轻松松吗?反射类里的所有用了属性委托的成员变量,去调用属性对象的 get() 函数获取当前的值:

fun IMMKVOwner.getAllKV(): Map<String, Any?> = buildMap {
  this@getAllKV::class.memberProperties
    .filterIsInstance<KProperty1<IMMKVOwner, *>>()
    .forEach { property ->
      property.isAccessible = true
      this[property.name] = property.get(this@getAllKV)
      property.isAccessible = false
    }
}

代码应该已经很清晰了就不过多解释了。虽然代码不多,但是思路非常巧妙,能让已有的库不用改动就扩展出了 getAllKV() 功能。

不过这样还不够,由于个人的库是还支持 LiveDataStateFlow 类型,从 getAllKV() 得到 LiveDataStateFlow 类型的数据会有点奇怪。用户更多关心的是缓存什么数据,应该要把缓存的值读出来,所以加了些判断:

fun IMMKVOwner.getAllKV(): Map<String, Any?> = buildMap {
  this@getAllKV::class.memberProperties
    .filterIsInstance<KProperty1<IMMKVOwner, *>>()
    .forEach { property ->
      property.isAccessible = true
      this[property.name] = when (val value = property.get(this@getAllKV)) {
        is LiveData<*> -> value.value
        is StateFlow<*> -> value.value
        else -> value
      }
      property.isAccessible = false
    }
}

这样就 ok 了,写的代码没有报错,运行起来也符合预期,非常优雅地把 MMKVgetAll() 功能给实现出来了。

如果把这个封装思路讲出来,绝大多数人写到这里就结束了。但是这里其实还藏了一个非常隐蔽的坑,比如我们还在类里通过 by lazy {} 实现了个懒加载的属性:

object SettingsRepository : MMKVOwner(mmapID = "settings") {
  private val api by lazy { retrofit.create<Api>() }
  var isNightMode by mmkvBool()
  // ...
}

上面的 api 对象也会被添加到 getAllKV() 返回的 map 里,这就非常奇怪了。为什么会出现这样的情况?getAllKV() 函数的代码明明看起来没有什么问题呀。

其实这和 MMKV 不支持 getAll() 有着同样的原因 —— 类型擦除。在上面 getAllKV() 的实现方式里,用到类型的地方其实只有一处,就是 filterIsInstance<KProperty1<IMMKVOwner, *>>()。虽然我们在泛型里填的是 KProperty1<IMMKVOwner, *>,后面遍历得到的对象也推断出了同样的类型,但是实际过滤出来的是 KProperty1<*, *> ,类型被擦除了,这会把类里所有用了 Kotin 委托的属性给反射出来。

要解决也很简单,再多判断是不是自己所实现的委托类,把其它的属性委托给排除掉。

fun IMMKVOwner.getAllKV(): Map<String, Any?> = buildMap {
  val types = arrayOf(MMKVProperty::class, MMKVLiveDataProperty::class, MMKVStateFlowProperty::class)
  this@getAllKV::class.memberProperties
    .filterIsInstance<KProperty1<IMMKVOwner, *>>()
    .forEach { property ->
      property.isAccessible = true
      val delegate = property.getDelegate(this@getAllKV)
      if (types.any { it.isInstance(delegate) }) {
        this[property.name] = when (val value = property.get(this@getAllKV)) {
          is LiveData<*> -> value.value
          is StateFlow<*> -> value.value
          else -> value
        }
      }
      property.isAccessible = false
    }
}

至此就真的把 getAllKV() 功能完美地实现出来了。

现在功能是没问题,但是这个方案还是有点点瑕疵的,就是要把属性声明出来,才能在 getAllKV() 的时候读取到对应数据。而有些属性是不好声明出来的,比如不同的 id 保存不同的配置,这是一个很常见的需求。可是 id 是通过后台动态获取的,没法提前知道要申明什么属性名。

那该怎么办呢?把这种情况也覆盖了不就行了。

支持 Map 用法

终于要讲这次的重头戏了,这是一个超级爽的用法,就是用操作 map 的方式来保存或读取 MMKV 数据。比如:

val tasks by mmkvParcelable<Task>().asMap()
tasks[info.id] = info.task

要实现这个用法,最关键是怎么把 map 的数据存起来和读出来,这里有两种思路。

首先是大多数人会想到的方案,存 json 字符串。这个方案实现起来非常简单,只需要在增删改之后,把 map 转成 json 保存一下,可以抽出一个函数统一调用。然后读数据的时候把 json 转成 map 再去调用 map[key]

这个方案有个非常大的弊端,就是要频繁的序列化对象。而且对于增删改操作,不仅是序列化一次,而是要序列化两次。还记不记得前面说的缓存问题,我们是不知道何时会被删的。所以每次使用 map 对象的时候都应该先获取 json 序列化出最新的数据,在增删改数据后还要把修改后的 map 序列化成 json 给存起来,如此一来就需要序列化两次。

因此个人直接 pass 了 json 方案,下面讲下第二个方案,就是给 key 增加后缀再存起来。 比如我们要存个 id123456 的任务,实际我们存的 keytasks$$123456,读也是读这个 key,这样存取功能就没问题了。

这个方案实现存取是很简单的,但是怎么获取到历史保存过的数据呢?个人有两种思路:

  1. mmkv.getAllKeys() 可以获取所有的 keys,我们去匹配符合的前缀,比如匹配 tasks$$ 前缀得到所有保存过的任务相关的 key,通过这些 key 可以把所有保存过的任务读取出来。
  2. 把所有保存过 idmmkv.encodeStringSet() 保存起来,比如用 task$key 保存所有任务 id,再用这些 id 作为后缀拼出 key, 去读取保存过的任务。

这两种方式各有优劣。 调用 getAllKeys() 都会遍历所有存储的键,还要结合 filter {} 进行筛选,执行时间随键的数量增多而增加。不过不需要额外维护一个专门的 keys 集合,数据只存储一次。

而用 encodeStringSet() 保存 keys 的性能更好,相对于遍历所有键效率更高。缺点是需要维护一个单独的集合存储所有的 keys,后续增删查改都要对这个集合进行管理,以此减少每次筛选的开销。

个人最终是选择用 Set<String> 来保存 keys,还是前面所说过的缓存问题,我们是不知道数据会不会已经没了,所以每次用这个 map 的时候,都应该先同步一下最新的数据,频繁调用 getAllKeys() 遍历所有存储的键并不是很好。

之后就是要处理所有的增删查改函数了,注意要根据函数的用法来更新逻辑,比如 putIfAbsent() 是没有这个键的时候才会保存数据。其中有几个比较难处理的地方,就是和迭代器相关的操作。比如用迭代器删数据:

val iterator = tasks.entries.iterator()
while (iterator.hasNext()) {
    val entry = iterator.next()
    if (entry.key == "123456") {
        iterator.remove()
    }
}

或者用迭代器改数据:

val iterator = tasks.entries.iterator()
while (iterator.hasNext()) {
    val entry = iterator.next()
    entry.setValue(entry.value.copy(isExecuted = true))
}

这里 iterator.remove()entry.setValue() 如果也要同步更新 MMKV 数据,就得把前面 StateFlow 的封装方式用三次了,做三层的装饰。

最后可能还有一个很多人会忽略的小细节要处理一下,如果是用了接口委托的方式实现了 MutableMap 接口,要重写 equals() 函数,否则不会判断两个 map 的内容是否相等。

把这些都实现了之后,我们就能用操作 map 的方式来保存或读取 MMKV 数据了,甚至很多 map 的扩展函数我们都能使用。

最终方案

在这里正式介绍一下世界上最好用的 MMKV 库 —— MMKV-KTX

有以下特性:

  • 自动初始化 MMKV ;
  • 用属性名作为键名,无需声明大量的键名常量;
  • 可以确保类型安全,避免类型或者键名不一致导致的异常;
  • 支持转换成 LiveDataStateFlow 来使用;
  • 支持转换成 Map,可以根据不同的 id 来保存数据;
  • 支持 getAllKV(),为数据迁移提供了可能性;

前段时间,MMKV 发布了 2.0 的大版本,所以个人也跟上官方的脚步来个 2.0 大版本更新,支持更多的用法。特别是 asMap() 的用法,用起来真的非常爽。

在 settings.gradle 文件的 repositories 结尾处添加:

dependencyResolutionManagement {
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
  repositories {
    mavenCentral()
    maven { url 'https://www.jitpack.io' }
  }
}

添加依赖:

dependencies {
  implementation("com.github.DylanCaiCoding:MMKV-KTX:2.0.1")
}

让一个类继承 MMKVOwner 类,即可在该类使用 by mmkvXXXX() 函数将属性委托给 MMKV,例如:

object SettingsRepository : MMKVOwner(mmapID = "settings") {
  var isNightMode by mmkvBool()
  var language by mmkvString(default = "zh")
}

如果已经有了父类继承不了,那就实现 IMMKVOwner by MMKVOwner(mmapID),比如:

object SettingsRepository : BaseRepository(), IMMKVOwner by MMKVOwner(mmapID = "settings") {
  // ...
}

不管哪种都要确保每个 mmapID 不重复,只有这样才能 100% 确保类型安全!!!

设置或获取属性的值会调用对应的 encode() 或 decode() 函数,用属性名作为键名。

支持以下类型:

类型函数默认值
IntmmkvInt()0
LongmmkvLong()0L
BooleanmmkvBool()false
FloatmmkvFloat()0f
DoublemmkvDouble()0.0
StringmmkvString()/
Set<String>mmkvStringSet()/
ByteArraymmkvBytes()/
ParcelablemmkvParcelable()/
类型函数默认值
MutableLiveData<Int>mmkvInt().asLiveData()0
MutableLiveData<Long>mmkvLong().asLiveData()0L
MutableLiveData<Boolean>mmkvBool().asLiveData()false
MutableLiveData<Float>mmkvFloat().asLiveData()0f
MutableLiveData<Double>mmkvDouble.asLiveData()0.0
MutableLiveData<String>mmkvString().asLiveData()/
MutableLiveData<Set<String>>mmkvStringSet().asLiveData()/
MutableLiveData<ByteArray>mmkvBytes().asLiveData()/
MutableLiveData<Parcelable>mmkvParcelable().asLiveData()/
类型函数默认值
MutableStateFlow<Int>mmkvInt().asStateFlow()0
MutableStateFlow<Long>mmkvLong().asStateFlow()0L
MutableStateFlow<Boolean>mmkvBool().asStateFlow()false
MutableStateFlow<Float>mmkvFloat().asStateFlow()0f
MutableStateFlow<Double>mmkvDouble().asStateFlow()0.0
MutableStateFlow<String>mmkvString().asStateFlow()/
MutableStateFlow<Set<String>>mmkvStringSet().asStateFlow()/
MutableStateFlow<ByteArray>mmkvBytes().asStateFlow()/
MutableStateFlow<Parcelable>mmkvParcelable().asStateFlow()/
类型函数默认值
MutableMap<String, Int>mmkvInt().asMap()0
MutableMap<String, Long>mmkvLong().asMap()0L
MutableMap<String, Boolean>mmkvBool().asMap()false
MutableMap<String, Float>mmkvFloat().asMap()0f
MutableMap<String, Double>mmkvDouble().asMap()0.0
MutableMap<String, String>mmkvString().asMap()/
MutableMap<String, Set<String>>mmkvStringSet().asMap()/
MutableMap<String, ByteArray>mmkvBytes().asMap()/
MutableMap<String, Parcelable>mmkvParcelable().asMap()/

支持共计 36 种类型的属性,而这仅仅使用了 4 个属性的委托类,感兴趣的可以读读源码。

总结

本文带着大家给常见的 MMKV 属性委托再扩展出 LiveDataSateFlowgetAllKV()Map 等用法。代码并不多,但是需要考虑的东西很多,很多小细节需要注意。

最后分享了个人封装好的开源库 —— MMKV-KTX,说是世界上最好用的 MMKV 库应该不过分。如果觉得好用,希望能给个 star 支持一下哟~

关于我

一个兴趣使然的程序“工匠” 。有代码洁癖,喜欢封装,对封装有一定的个人见解,有不少个人独特或原创的封装思路。GitHub 有分享一些帮助搭建开发框架的开源库,推荐大家用一用。有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈。