前言
可能有些熟悉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 的操作要在 LiveData 的 setValue() 之后,避免出现 LiveData 报错了,MMKV 却把数据给存了的情况。比如 setValue() 在子线程调用了,又刚好用 try catch 包着没崩溃。此时 LiveData 没保存到数据,而本地却存了,这就不符合预期了。
第二,LiveData 的 getValue() 要重写为读取 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 很类似,不过 StateFlow 是 Flow ,支持很多操作符,玩法更多。
两者的委托功能实现起来大差不差,但是也有个比较大的差异,就是 MutableLiveData 是一个类可以直接继承,而 MutableStateFlow 是一个接口。官方也有 MutableStateFlow 的实现类 StateFlowImpl ,但是这个实现类是私有的,没法直接继承。这就要用到另一个 Kotlin 委托的特性来处理了,就是接口委托,这里就不展开了,有兴趣自己去了解下。
我们需要重写 MutableStateFlow 的 读写操作,加上 MMKV 的缓存逻辑。MutableStateFlow 还有一个 compareAndSet() 函数也会更新值,这个也要重写。MutableStateFlow 要数据有变化了才会更新,这也和 LiveData 有所不同。
还有前面讲过的两个很重要的点,要先修改 flow 再去修改 MMKV,getValue() 一定要重写,不能用缓存。最终的代码如下:
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
现在把 LiveData 和 StateFlow 都支持了,具体用哪个看大家的习惯了。
支持 getAllKV()
由于 MMKV 是按字节进行存储的,写入文件会把类型擦除。这会导致了 MMKV 不支持用 getAll() 来获取已保存的键值对,从而很难迁移数据。
当然这也不是没有办法,程序员江同学给过一个方案,给 key 添加一个类型后缀,比如我们存个 name,实际的 key 是 name@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() 功能。
不过这样还不够,由于个人的库是还支持 LiveData 和 StateFlow 类型,从 getAllKV() 得到 LiveData 或 StateFlow 类型的数据会有点奇怪。用户更多关心的是缓存什么数据,应该要把缓存的值读出来,所以加了些判断:
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 了,写的代码没有报错,运行起来也符合预期,非常优雅地把 MMKV 的 getAll() 功能给实现出来了。
如果把这个封装思路讲出来,绝大多数人写到这里就结束了。但是这里其实还藏了一个非常隐蔽的坑,比如我们还在类里通过 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 增加后缀再存起来。 比如我们要存个 id 为 123456 的任务,实际我们存的 key 是 tasks$$123456,读也是读这个 key,这样存取功能就没问题了。
这个方案实现存取是很简单的,但是怎么获取到历史保存过的数据呢?个人有两种思路:
mmkv.getAllKeys()可以获取所有的 keys,我们去匹配符合的前缀,比如匹配tasks$$前缀得到所有保存过的任务相关的key,通过这些key可以把所有保存过的任务读取出来。- 把所有保存过
id用mmkv.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 ;
- 用属性名作为键名,无需声明大量的键名常量;
- 可以确保类型安全,避免类型或者键名不一致导致的异常;
- 支持转换成
LiveData和StateFlow来使用; - 支持转换成
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() 函数,用属性名作为键名。
支持以下类型:
| 类型 | 函数 | 默认值 |
|---|---|---|
Int | mmkvInt() | 0 |
Long | mmkvLong() | 0L |
Boolean | mmkvBool() | false |
Float | mmkvFloat() | 0f |
Double | mmkvDouble() | 0.0 |
String | mmkvString() | / |
Set<String> | mmkvStringSet() | / |
ByteArray | mmkvBytes() | / |
Parcelable | mmkvParcelable() | / |
| 类型 | 函数 | 默认值 |
|---|---|---|
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 属性委托再扩展出 LiveData、SateFlow、getAllKV()、Map 等用法。代码并不多,但是需要考虑的东西很多,很多小细节需要注意。
最后分享了个人封装好的开源库 —— MMKV-KTX,说是世界上最好用的 MMKV 库应该不过分。如果觉得好用,希望能给个 star 支持一下哟~
关于我
一个兴趣使然的程序“工匠” 。有代码洁癖,喜欢封装,对封装有一定的个人见解,有不少个人独特或原创的封装思路。GitHub 有分享一些帮助搭建开发框架的开源库,推荐大家用一用。有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈。
- 掘金:DylanCai
- GitHub:DylanCaiCoding
- 微信号:DylanCaiCoding