1. 前言
KMP已经发布了很长一段时间了,结合Compose Multiplatform的发布,我们现在已经能很轻易的在KMP上开发支持Android、iOS、Desktop和前端Wasm(Alpha)的App了。
我们可以通过官方提供的向导创建Compose Multiplatform项目。地址如下:
本文主要介绍如何在多平台上,实现一个统一的数据持久化方案,
- 假设大家已经了解过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)
}
}
}
}
}
- 输入框输入5555
- 点击save
- 点击load cache
可以看到,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…