Skip to content

本地存储与持久化

源:Multiplatform Settings - GitHub

在 KMP 项目中,本地存储是最基础的平台差异化需求。各平台提供了不同的 Key-Value 持久化方案,本文将展示如何通过 expect/actual 机制优雅地统一这些差异。

平台差异对比

平台原生 API底层实现关键特性注意事项
AndroidSharedPreferencesXML 文件存储于 /data/data/包名/shared_prefs/支持多进程、commit/apply 异步不适合大数据存储
iOS/macOSNSUserDefaultsplist 文件(~/Library/Preferences/自动同步、支持 iCloud数据未加密
Desktop (JVM)PreferencesOS 依赖(Windows 注册表/Linux ini)跨平台但 API 复杂路径差异大
Desktop (Native)配置文件自定义 JSON/INI完全可控需手动实现

expect/actual 实现方案

API 核心签名说明

  • expect class Settings
  • fun Settings.putString(key: String, value: String)
  • fun Settings.getString(key: String, defaultValue: String): String
  • fun Settings.putInt(key: String, value: Int)
  • fun Settings.getInt(key: String, defaultValue: Int): Int
  • fun Settings.putBoolean(key: String, value: Boolean)
  • fun Settings.getBoolean(key: String, defaultValue: Boolean): Boolean
  • fun Settings.remove(key: String)
  • fun Settings.clear()

标准代码块

kotlin
expect class Settings {
    fun putString(key: String, value: String)
    fun getString(key: String, defaultValue: String = ""): String
    
    fun putInt(key: String, value: Int)
    fun getInt(key: String, defaultValue: Int = 0): Int
    
    fun putBoolean(key: String, value: Boolean)
    fun getBoolean(key: String, defaultValue: Boolean = false): Boolean
    
    fun putLong(key: String, value: Long)
    fun getLong(key: String, defaultValue: Long = 0L): Long
    
    fun putFloat(key: String, value: Float)
    fun getFloat(key: String, defaultValue: Float = 0f): Float
    
    fun remove(key: String)
    fun clear()
    fun hasKey(key: String): Boolean
}

// 业务层使用
class UserPreferences(private val settings: Settings) {
    
    var userToken: String
        get() = settings.getString("user_token", "")
        set(value) = settings.putString("user_token", value)
    
    var isFirstLaunch: Boolean
        get() = settings.getBoolean("is_first_launch", true)
        set(value) = settings.putBoolean("is_first_launch", value)
    
    var loginCount: Int
        get() = settings.getInt("login_count", 0)
        set(value) = settings.putInt("login_count", value)
    
    fun clearUserData() {
        settings.remove("user_token")
        settings.remove("login_count")
    }
}
kotlin
import android.content.Context
import android.content.SharedPreferences

actual class Settings(context: Context) {
    private val prefs: SharedPreferences = context.getSharedPreferences(
        "app_settings",
        Context.MODE_PRIVATE
    )
    
    actual fun putString(key: String, value: String) {
        prefs.edit().putString(key, value).apply()
    }
    
    actual fun getString(key: String, defaultValue: String): String {
        return prefs.getString(key, defaultValue) ?: defaultValue
    }
    
    actual fun putInt(key: String, value: Int) {
        prefs.edit().putInt(key, value).apply()
    }
    
    actual fun getInt(key: String, defaultValue: Int): Int {
        return prefs.getInt(key, defaultValue)
    }
    
    actual fun putBoolean(key: String, value: Boolean) {
        prefs.edit().putBoolean(key, value).apply()
    }
    
    actual fun getBoolean(key: String, defaultValue: Boolean): Boolean {
        return prefs.getBoolean(key, defaultValue)
    }
    
    actual fun putLong(key: String, value: Long) {
        prefs.edit().putLong(key, value).apply()
    }
    
    actual fun getLong(key: String, defaultValue: Long): Long {
        return prefs.getLong(key, defaultValue)
    }
    
    actual fun putFloat(key: String, value: Float) {
        prefs.edit().putFloat(key, value).apply()
    }
    
    actual fun getFloat(key: String, defaultValue: Float): Float {
        return prefs.getFloat(key, defaultValue)
    }
    
    actual fun remove(key: String) {
        prefs.edit().remove(key).apply()
    }
    
    actual fun clear() {
        prefs.edit().clear().apply()
    }
    
    actual fun hasKey(key: String): Boolean {
        return prefs.contains(key)
    }
}
kotlin
import platform.Foundation.NSUserDefaults

actual class Settings {
    private val userDefaults = NSUserDefaults.standardUserDefaults
    
    actual fun putString(key: String, value: String) {
        userDefaults.setObject(value, forKey = key)
    }
    
    actual fun getString(key: String, defaultValue: String): String {
        return userDefaults.stringForKey(key) ?: defaultValue
    }
    
    actual fun putInt(key: String, value: Int) {
        userDefaults.setInteger(value.toLong(), forKey = key)
    }
    
    actual fun getInt(key: String, defaultValue: Int): Int {
        return if (userDefaults.objectForKey(key) != null) {
            userDefaults.integerForKey(key).toInt()
        } else {
            defaultValue
        }
    }
    
    actual fun putBoolean(key: String, value: Boolean) {
        userDefaults.setBool(value, forKey = key)
    }
    
    actual fun getBoolean(key: String, defaultValue: Boolean): Boolean {
        return if (userDefaults.objectForKey(key) != null) {
            userDefaults.boolForKey(key)
        } else {
            defaultValue
        }
    }
    
    actual fun putLong(key: String, value: Long) {
        userDefaults.setObject(value, forKey = key)
    }
    
    actual fun getLong(key: String, defaultValue: Long): Long {
        return (userDefaults.objectForKey(key) as? Long) ?: defaultValue
    }
    
    actual fun putFloat(key: String, value: Float) {
        userDefaults.setFloat(value, forKey = key)
    }
    
    actual fun getFloat(key: String, defaultValue: Float): Float {
        return if (userDefaults.objectForKey(key) != null) {
            userDefaults.floatForKey(key)
        } else {
            defaultValue
        }
    }
    
    actual fun remove(key: String) {
        userDefaults.removeObjectForKey(key)
    }
    
    actual fun clear() {
        val domain = NSBundle.mainBundle.bundleIdentifier ?: return
        userDefaults.removePersistentDomainForName(domain)
    }
    
    actual fun hasKey(key: String): Boolean {
        return userDefaults.objectForKey(key) != null
    }
}
kotlin
import java.util.prefs.Preferences

actual class Settings(private val nodeName: String = "app_settings") {
    private val prefs: Preferences = Preferences.userRoot().node(nodeName)
    
    actual fun putString(key: String, value: String) {
        prefs.put(key, value)
        prefs.flush()
    }
    
    actual fun getString(key: String, defaultValue: String): String {
        return prefs.get(key, defaultValue)
    }
    
    actual fun putInt(key: String, value: Int) {
        prefs.putInt(key, value)
        prefs.flush()
    }
    
    actual fun getInt(key: String, defaultValue: Int): Int {
        return prefs.getInt(key, defaultValue)
    }
    
    actual fun putBoolean(key: String, value: Boolean) {
        prefs.putBoolean(key, value)
        prefs.flush()
    }
    
    actual fun getBoolean(key: String, defaultValue: Boolean): Boolean {
        return prefs.getBoolean(key, defaultValue)
    }
    
    actual fun putLong(key: String, value: Long) {
        prefs.putLong(key, value)
        prefs.flush()
    }
    
    actual fun getLong(key: String, defaultValue: Long): Long {
        return prefs.getLong(key, defaultValue)
    }
    
    actual fun putFloat(key: String, value: Float) {
        prefs.putFloat(key, value)
        prefs.flush()
    }
    
    actual fun getFloat(key: String, defaultValue: Float): Float {
        return prefs.getFloat(key, defaultValue)
    }
    
    actual fun remove(key: String) {
        prefs.remove(key)
        prefs.flush()
    }
    
    actual fun clear() {
        prefs.clear()
        prefs.flush()
    }
    
    actual fun hasKey(key: String): Boolean {
        return prefs.get(key, null) != null
    }
}

代码封装示例

以下是生产可用的完整封装,支持类型安全和默认值:

kotlin
// commonMain
interface SettingsRepository {
    var userToken: String
    var userId: String
    var isLoggedIn: Boolean
    var themeMode: ThemeMode
    
    fun clearUserSession()
}

enum class ThemeMode {
    LIGHT, DARK, SYSTEM
}

class SettingsRepositoryImpl(
    private val settings: Settings
) : SettingsRepository {
    
    override var userToken: String
        get() = settings.getString("user_token", "")
        set(value) = settings.putString("user_token", value)
    
    override var userId: String
        get() = settings.getString("user_id", "")
        set(value) = settings.putString("user_id", value)
    
    override var isLoggedIn: Boolean
        get() = settings.getBoolean("is_logged_in", false)
        set(value) = settings.putBoolean("is_logged_in", value)
    
    override var themeMode: ThemeMode
        get() {
            val mode = settings.getString("theme_mode", ThemeMode.SYSTEM.name)
            return ThemeMode.valueOf(mode)
        }
        set(value) = settings.putString("theme_mode", value.name)
    
    override fun clearUserSession() {
        settings.remove("user_token")
        settings.remove("user_id")
        settings.putBoolean("is_logged_in", false)
    }
}

第三方库推荐

Multiplatform Settings 推荐

官方维护的跨平台 KV 存储库,API 设计优雅且功能完善。

优势:

  • ✅ 成熟稳定,社区广泛使用
  • ✅ 支持所有主流平台(Android/iOS/Desktop/JS)
  • ✅ 提供协程扩展(Flow 监听)
  • ✅ 支持加密存储(EncryptedSettings)
  • ✅ 零依赖,包体积小

对比自实现:

  • 自实现:完全可控,适合简单场景
  • Multiplatform Settings:功能更丰富,适合复杂项目

使用示例:

kotlin
// commonMain
val settings: Settings = Settings()

// 使用委托属性
var userName by settings.string("user_name", "Guest")
var age by settings.int("age", 0)

// Flow 监听变化(需要 kotlinx-coroutines)
settings.getStringFlow("user_name", "").collect { name ->
    println("Name changed: $name")
}

依赖补充

使用 Multiplatform Settings

kotlin
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("com.russhwolf:multiplatform-settings:1.1.1")
            // 可选:协程扩展
            implementation("com.russhwolf:multiplatform-settings-coroutines:1.1.1")
        }
    }
}
groovy
kotlin {
    sourceSets {
        commonMain {
            dependencies {
                implementation "com.russhwolf:multiplatform-settings:1.1.1"
                implementation "com.russhwolf:multiplatform-settings-coroutines:1.1.1"
            }
        }
    }
}
toml
[versions]
multiplatform-settings = "1.1.1"

[libraries]
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatform-settings" }

最新稳定版本查看链接:https://github.com/russhwolf/multiplatform-settings/releases

自实现依赖(仅需平台库)

自实现方案无需额外依赖,仅依赖各平台标准库。

实战坑点

数据类型兼容性

类型映射问题

iOS 的 NSUserDefaults 不直接支持 Int,内部会转为 NSInteger(即 Long)。
读取时如果值超出 Int 范围会截断,需要注意边界情况。

解决方案:

kotlin
// iosMain
actual fun getInt(key: String, defaultValue: Int): Int {
    return if (userDefaults.objectForKey(key) != null) {
        userDefaults.integerForKey(key).toInt() // 显式转换
    } else {
        defaultValue
    }
}

默认值陷阱

键不存在 vs 值为默认值

Android SharedPreferences 和 NSUserDefaults 都无法区分"键不存在"和"值恰好等于默认值"。

解决方案:
使用 hasKey() 先检查键是否存在:

kotlin
fun getIntOrNull(key: String): Int? {
    return if (settings.hasKey(key)) {
        settings.getInt(key, 0)
    } else {
        null
    }
}

多进程安全

Android 多进程问题

SharedPreferences 的多进程模式已废弃,多进程写入可能导致数据丢失。

解决方案:
使用 ContentProvider 或数据库(如 SQLDelight)实现进程间通信。

性能问题

::: caution 首次启动性能 首次访问 SharedPreferences 会触发整个文件的解析,文件过大会阻塞主线程。 :::

解决方案:

kotlin
// 在后台线程初始化
suspend fun initSettings(context: Context): Settings = withContext(Dispatchers.IO) {
    Settings(context)
}

Desktop 平台路径差异

跨平台路径问题

JVM Preferences 在不同操作系统的存储位置差异巨大,调试时需注意。

查看数据位置:

  • Windows: 注册表编辑器 → HKEY_CURRENT_USER\Software\JavaSoft\Prefs
  • Linux: ~/.java/.userPrefs/节点名称/prefs.xml
  • macOS: ~/Library/Preferences/com.apple.java.util.prefs.plist