Kotlin Multiplatform(KMP) 实现统一的跨平台数据存储方案

1,253 阅读4分钟

1. 前言

KMP已经发布了很长一段时间了,结合Compose Multiplatform的发布,我们现在已经能很轻易的在KMP上开发支持Android、iOS、Desktop和前端Wasm(Alpha)的App了。

我们可以通过官方提供的向导创建Compose Multiplatform项目。地址如下:

kmp.jetbrains.com/

本文主要介绍如何在多平台上,实现一个统一的数据持久化方案,

  • 假设大家已经了解过KPM,这里不再赘述KMP的相关知识。

2. DataStore Multiplatform

DataStore这个库Android开发者应该都比较熟悉,它Google Jetpack中的一个库,是一种数据存储解决方案。

  • DataStore主要是用来存储小型数据集的,如果需要要缓存大型或者复杂数据,需要使用其他方案(数据库等)。

Jetpack中大部分库都在支持KMP,DataStore也不例外。目前最新的版本已经能支持Android,iOS,和Desktop平台了,但是截止至今天(25年,2月)还不支持WASM平台。

3. 实现统一的持久化工具

现在/gradle/libs.versions.toml文件添加如下代码,定义依赖。

androidxDataStore = "1.1.2"

[libraries]
androidx-datastore-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidxDataStore" }

3.1. 在支持WASM平台的项目上依赖DataStore的问题

DataStore目前是没有提供WASM平台支持的,如果直接在同时支持4个平台的module下,在commonMain添加datastore依赖,会报错。如下:

依赖

commonMain.dependencies {
    implementation(libs.androidx.datastore.core)
}

报错

:composeApp:wasmJsMain: Could not resolve androidx.datastore:datastore-preferences-core:1.1.2.
Required by:
    project :composeApp

Possible solution

可以通过单独在特定的平台依赖DataStore,从而规避这个报错,例如:

androidMain.dependencies {
    implementation(libs.androidx.datastore.core)
}

commonMain.dependencies {
    implementation(libs.androidx.datastore.core)
}

iosMain.dependencies {
    implementation(libs.androidx.datastore.core)
}

这样可以避免报WASM平台上找不到对应的实现的错误,但是这样会带来一个新的问题:共享代码(commonMain)中没依赖DataStore库,导致我们所有的DataStore调用逻辑都需要分别在三个平台上单独实现,这样违背了我们使用DataStore的初衷。

3.2. 引入Native模块

在解决类似的问题时,我们可以通过引入多一个Module的方法解决,这个Module只支持特定平台,在实际

  • Native模块在这里是指,仅支持Android,iOS,Desktop等Native平台的一个模块(你可以根据你的喜好随便命名),如果在支持WASM的项目中依赖该模块的话,则WASM中的具体功能需要使用其它方式实现。

3.2.1.创建native-components modules

首先,创建支持Android,iOS,Desktop的modules,然后依依赖DataStore库

commonMain.dependencies {
    api ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
    api(libs.androidx.datastore.core)
}

3.2.2. 创建DataStoreCreator.kt文件

在commonMain/kotlin/包名/store创建DataStoreCreator.kt文件,代码如下:

/**
 *  不同平台下的DataStore创建方法
 **/
expect fun dataStorePreferences(
    path: String,
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    migrations: List<DataMigration<Preferences>> = emptyList(),
): DataStore<Preferences>

/**
 * 创建DataStore的最终调用
 **/
internal fun createDataStoreWithDefaults(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    migrations: List<DataMigration<Preferences>> = emptyList(),
    pathGetter: () -> String,
) = PreferenceDataStoreFactory
    .createWithPath(
        corruptionHandler = corruptionHandler,
        scope = coroutineScope,
        migrations = migrations,
        produceFile = {
            var path = pathGetter()
            if (!path.endsWith(".preferences_pb")){
                path += ".preferences_pb"
            }
            path.toPath()
        }
    )
  • 兼容不同的平台特性,这里使用的是KMP的expect/actual实现,这里不深入分享。在平时的开发中,要尽量避免使用,大部分代码我们都应该在commonMain中实现。

3.2.3. 不同平台下的创建DataStore调用

需要在不同的平台下实现不同的dataStorePreferences方法是因为,不同平台的应用私有目录的获取方式不一样。

Android

在androidMain/kotlin/包名/store创建DataStoreCreator.kt文件,代码如下:

actual fun dataStorePreferences(
    path: String,
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    coroutineScope: CoroutineScope,
    migrations: List<DataMigration<Preferences>>,
): DataStore<Preferences>{
    return createDataStoreWithDefaults(
        corruptionHandler = corruptionHandler,
        migrations = migrations,
        coroutineScope = coroutineScope,
        pathGetter = {
            File(applicationContext.filesDir, "datastore/$path").path
        }
    )
}

iOS

在iosMain/kotlin/包名/store创建DataStoreCreator.kt文件,代码如下:

actual fun dataStorePreferences(
    path: String,
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    coroutineScope: CoroutineScope,
    migrations: List<DataMigration<Preferences>>,
): DataStore<Preferences> = createDataStoreWithDefaults(
    corruptionHandler = corruptionHandler,
    migrations = migrations,
    coroutineScope = coroutineScope,
    pathGetter = {
        val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )
        (requireNotNull(documentDirectory).path + "/$path")
    }
)

Desktop

在desktopMain/kotlin/包名/store创建DataStoreCreator.kt文件,代码如下:

actual fun dataStorePreferences(
    path: String,
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    coroutineScope: CoroutineScope,
    migrations: List<DataMigration<Preferences>>,
): DataStore<Preferences> = createDataStoreWithDefaults(
    corruptionHandler = corruptionHandler,
    migrations = migrations,
    coroutineScope = coroutineScope,
    pathGetter = {
        val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )
        (requireNotNull(documentDirectory).path + "/$path")
    }
)

3.2.4. 进一步封装对外提供的接口

现在我们已经完成了DataStore的创建方法了,为了外部使用更加简单,我们再封装一个工具类LocalDataStore。这个类主要功能如下:

  • 根据namespace获取对应的DataStore,如果不存在,则自动创建。
  • 对外提供一些常用Api调用,如:get,set等。
  • 根据泛型类型,自动创建DataStore专用Key,Preferences.Key。

代码如下:

class LocalDataStore private constructor(){

    companion object{
        const val DEFAULT_NAMESPACE = "default"
        val instance by lazy {
            LocalDataStore()
        }
    }

    private val dataStoreMap: MutableMap<String, DataStore<Preferences>> by lazy {
        mutableMapOf()
    }

    private val mutex by lazy { Mutex() }

    suspend inline fun <reified T> get(namespace: String = DEFAULT_NAMESPACE, key: String): T? {

        return when (T::class) {
            Int::class -> {
                val intKey = intPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences[intKey]
                }.first() as T
            }

            Long::class -> {
                val longKey = longPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences[longKey]
                }.first() as T
            }

            Boolean::class -> {
                val booleanKey = booleanPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences[booleanKey]
                }.first() as T
            }

            String::class -> {
                val stringKey = stringPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences[stringKey]
                }.first() as T
            }

            Float::class -> {
                val floatKey = floatPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences[floatKey]
                }.first() as T
            }

            else -> {
                val stringKey = stringPreferencesKey(key)
                val json =  getDataStore(namespace).data.map { preferences ->
                    preferences[stringKey]
                }.first()
                json?.decodeFromString<T>()
            }

        }
    }

    suspend inline fun <reified T> set(namespace: String = DEFAULT_NAMESPACE, key: String, value: T) {
        when (value) {
            is Int -> {
                val intKey = intPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences[intKey] = value
                }
            }

            is Long -> {
                val longKey = longPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences[longKey] = value
                }
            }

            is Boolean -> {
                val booleanKey = booleanPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences[booleanKey] = value
                }
            }

            is String -> {
                val stringKey = stringPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences[stringKey] = value
                }
            }

            is Float -> {
                val floatKey = floatPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences[floatKey] = value
                }
            }

            else -> {
                val stringKey = stringPreferencesKey(key)
                val json = value.encodeToString()
                getDataStore(namespace).edit { preferences ->
                    preferences[stringKey] = json
                }
            }
        }
    }

    suspend inline fun <reified T> remove(namespace: String = DEFAULT_NAMESPACE, key: String) {
        when (T::class) {
            Int::class -> {
                val intKey = intPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences.remove(intKey)
                }
            }

            Long::class -> {
                val longKey = longPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences.remove(longKey)
                }
            }

            Boolean::class -> {
                val booleanKey = booleanPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences.remove(booleanKey)
                }
            }

            String::class -> {
                val stringKey = stringPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences.remove(stringKey)
                }
            }

            Float::class -> {
                val floatKey = floatPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences.remove(floatKey)
                }
            }

            else -> {
                val stringKey = stringPreferencesKey(key)
                getDataStore(namespace).edit { preferences ->
                    preferences.remove(stringKey)
                }
            }
        }
    }

    suspend fun clear(namespace: String = DEFAULT_NAMESPACE) {
        getDataStore(namespace).edit { preferences ->
            preferences.clear()
        }
    }

    suspend inline fun <reified T> contains(namespace: String = DEFAULT_NAMESPACE, key: String): Boolean {
        return when (T::class) {
            Int::class -> {
                val intKey = intPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences.contains(intKey)
                }.first()
            }

            Long::class -> {
                val longKey = longPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences.contains(longKey)
                }.first()
            }

            Boolean::class -> {
                val booleanKey = booleanPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences.contains(booleanKey)
                }.first()
            }

            String::class -> {
                val stringKey = stringPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences.contains(stringKey)
                }.first()
            }

            Float::class -> {
                val floatKey = floatPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences.contains(floatKey)
                }.first()
            }

            else -> {
                val stringKey = stringPreferencesKey(key)
                getDataStore(namespace).data.map { preferences ->
                    preferences.contains(stringKey)
                }.first()
            }
        }
    }

    suspend fun getDataStore(namespace: String = DEFAULT_NAMESPACE): DataStore<Preferences> {
        return mutex.withLock {
            if (dataStoreMap[namespace] == null) {
                val  dataStore = dataStorePreferences(
                    path = namespace,
                )
                dataStoreMap[namespace] = dataStore
                dataStore
            } else {
                dataStoreMap[namespace]!!
            }
        }
    }
}

3.3. 在App模块(或其他base模块)中,提供统一的持久化工具

3.3.1. 依赖Native模块

我们已经开发完native模块了,如上所述,因为不支持WASM,所以不能在commonMain中直接依赖的。依赖方式如下:

androidMain.dependencies {
    implementation(project(":native-components"))
}

desktopMain.dependencies {
    implementation(project(":native-components"))
}
iosMain.dependencies {
    implementation(project(":native-components"))
}

3.3.2. 定义统一持久化工具expect类KVLocalDataStore

const val DEFAULT_NAMESPACE = "default"

expect class KVLocalDataStore() {

    suspend inline fun <reified T> get(key: String, namespace: String = DEFAULT_NAMESPACE): T?

    suspend inline fun <reified T> set(key: String, value: T, namespace: String = DEFAULT_NAMESPACE)

    suspend inline fun <reified T> remove(key: String, namespace: String = DEFAULT_NAMESPACE)

    suspend inline fun <reified T> clear(namespace: String = DEFAULT_NAMESPACE)

    suspend inline fun <reified T> contains(key: String, namespace: String = DEFAULT_NAMESPACE): Boolean

}

object KVLocalDataStoreProvider {
    val instance: KVLocalDataStore by lazy { KVLocalDataStore() }
}
  • 把namespace放最后一个参数属于个人习惯,这样设置到default时,调用起来更方便。如果你的习惯不太一样,自行调整。

3.3.3. 在Android,iOS,Desktop实现KVLocalDataStore

在Android,iOS和Desktop平台中,我们直接使用native模块中实现的LocalDataStore来实现KVLocalDataStore。

Android, iOS,和Desktop中的实现都是一样的:

actual class KVLocalDataStore {

    val localDataStore = LocalDataStore.instance

    actual suspend inline fun <reified T> get(key: String, namespace: String): T? {
        return localDataStore.get(namespace, key)
    }

    actual suspend inline fun <reified T> set(key: String, value: T, namespace: String) {
        localDataStore.set(namespace, key, value)
    }

    actual suspend inline fun <reified T> remove(key: String, namespace: String) {
        localDataStore.remove<T>(namespace, key)
    }

    actual suspend inline fun <reified T> clear(namespace: String) {
        localDataStore.clear(namespace)
    }

    actual suspend inline fun <reified T> contains(key: String, namespace: String): Boolean {
        return localDataStore.contains<T>(namespace, key)
    }

}

3.3.4.在WASM平台中,使用LocalStorage实现KVLocalDataStore

在WASM平台中,我们可以使用LocalStorage实现缓存功能,代码如下:

actual class KVLocalDataStore {

    actual suspend inline fun <reified T> get(key: String, namespace: String): T? {
        val fullKey = "$namespace:$key"
        val jsonValue = localStorage.getItem(fullKey) ?: return null
        return Json.decodeFromString(jsonValue)
    }

    actual suspend inline fun <reified T> set(key: String, value: T, namespace: String) {
        val fullKey = "$namespace:$key"
        val jsonValue = Json.encodeToString(value)
        localStorage.setItem(fullKey, jsonValue)
    }

    actual suspend inline fun <reified T> remove(key: String, namespace: String) {
        val fullKey = "$namespace:$key"
        localStorage.removeItem(fullKey)
    }

    actual suspend inline fun <reified T> clear(namespace: String) {
        val keysToRemove = mutableListOf<String>()
        for (i in 0 until localStorage.length) {
            val key = localStorage.key(i) ?: continue
            if (key.startsWith("$namespace:")) {
                keysToRemove.add(key)
            }
        }
        keysToRemove.forEach { localStorage.removeItem(it) }
    }

    actual suspend inline fun <reified T> contains(key: String, namespace: String): Boolean {
        val fullKey = "$namespace:$key"
        return localStorage.getItem(fullKey) != null
    }
}

4.如何使用

保存数据

KVLocalDataStoreProvider.instance.set("testKey", text)

读取数据

val savedText = KVLocalDataStoreProvider.instance.get<String>("testKey")

简单写个demo试试:

@Composable
@Preview
fun App() {
    MaterialTheme {
        var showContent by remember { mutableStateOf(false) }
        val snackbarHostState = remember { SnackbarHostState() }
        val coroutineScope = rememberCoroutineScope()

        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Button(onClick = { showContent = !showContent }) {
                Text("Click me!")
            }
            AnimatedVisibility(showContent) {
                val greeting = remember { Greeting().greet() }
                var text by remember { mutableStateOf("") }
                Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
                    Image(painterResource(Res.drawable.compose_multiplatform), null)
                    Text("Compose: $greeting")
                    OutlinedTextField(value = text, onValueChange = {
                        text = it
                    })
                    Button(onClick = {
                        coroutineScope.launch {
                            KVLocalDataStoreProvider.instance.set("testKey", text)
                        }
                    }) {
                        Text("Save")
                    }

                    Button(onClick = {
                        coroutineScope.launch {
                            val savedText = KVLocalDataStoreProvider.instance.get<String>("testKey")
                            snackbarHostState.showSnackbar("Saved text: $savedText")
                        }
                    }) {
                        Text("Load Cache")
                    }

                    SnackbarHost(hostState = snackbarHostState)
                }
            }
        }
    }
}
  1. 输入框输入5555
  2. 点击save
  3. 点击load cache

Screenshot_20250224_204618.png

image.png

可以看到,KVLocalDataStoreProvider.instance.get("testKey")读取的值就是我们写进去的值。

跑一下其它平台的应用,也能实现一样的效果!

5. 小结

  • 本文主要介绍了,如何使用DataStore在KMP上实现跨平台的统一持久化方案。
  • 当我们的KPM库只支持部分平台时,我们可以通过建一个针对特定的平台的Modules,在这个Modules上实现跨平台逻辑。
  • DataStore还有其它的特性本文没有进一步介绍,如果需要了解,可以参考官方文档。

6. 小推广

需要获取demo地址的话,请关注公众号:代码之外的程序员

在对话框输入:kmp datasotre

7. 参考文档

DataStore(Kotlin 多平台): developer.android.com/kotlin/mult…

Jetpack Preferences DataStore in Kotlin Multiplatform (KMP): funkymuse.dev/posts/creat…