本地存储与持久化
源:Multiplatform Settings - GitHub
在 KMP 项目中,本地存储是最基础的平台差异化需求。各平台提供了不同的 Key-Value 持久化方案,本文将展示如何通过 expect/actual 机制优雅地统一这些差异。
平台差异对比
| 平台 | 原生 API | 底层实现 | 关键特性 | 注意事项 |
|---|---|---|---|---|
| Android | SharedPreferences | XML 文件存储于 /data/data/包名/shared_prefs/ | 支持多进程、commit/apply 异步 | 不适合大数据存储 |
| iOS/macOS | NSUserDefaults | plist 文件(~/Library/Preferences/) | 自动同步、支持 iCloud | 数据未加密 |
| Desktop (JVM) | Preferences | OS 依赖(Windows 注册表/Linux ini) | 跨平台但 API 复杂 | 路径差异大 |
| Desktop (Native) | 配置文件 | 自定义 JSON/INI | 完全可控 | 需手动实现 |
expect/actual 实现方案
API 核心签名说明
expect class Settingsfun Settings.putString(key: String, value: String)fun Settings.getString(key: String, defaultValue: String): Stringfun Settings.putInt(key: String, value: Int)fun Settings.getInt(key: String, defaultValue: Int): Intfun Settings.putBoolean(key: String, value: Boolean)fun Settings.getBoolean(key: String, defaultValue: Boolean): Booleanfun 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