前言
可能有些熟悉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