Compose Multiplatform KV 存储库 multiplatform-settings 的 OpenHarmony 鸿蒙化适配实战
Compose Multiplatform KV 存储库 multiplatform-settings 的 OpenHarmony 鸿蒙化适配实战(OH_Preferences cinterop + 孤儿源集排查 + 模拟器实测)
库版本:com.russhwolf:multiplatform-settings 1.3.0-ohos.1(本地重建鸿蒙切片)|验证环境:Kotlin 2.2.21-0.4.0(鸿蒙定制版)|Compose Multiplatform 1.9.2-0.4.0|DevEco Studio 26.0.0|DevEco 模拟器|HarmonyOS 7.0.0(API 26)
multiplatform-settings 是 KMP 生态做键值(KV)存储的事实标准——Android 用 SharedPreferences,iOS 用 NSUserDefaults,鸿蒙侧对接系统 OH_Preferences。和上一篇的 reorderable 不同,这个库不是纯 Compose 库,它的 ohos 实现要走 cinterop 绑定鸿蒙系统 C API(libohpreferences.so),是真正的「平台原生能力适配」。本文记录我把它完整跑上鸿蒙的全过程:从本地重建对齐工具链的 klib、写一个 KV 读写 demo,到排查一个比 IR 崩溃更隐蔽的「孤儿源集」问题——编译全程不报错,但 ohos 代码压根没进 klib。

先睹为快:DevEco 模拟器实测。KV 读写 demo——Put/Get/Has/Remove/Clear/Reopen 全功能,状态卡实时显示 store/size/get/has,底部快照列出当前所有键值对

一、先看清楚:multiplatform-settings 的鸿蒙实现是怎么落地的
照例先翻源码分布。这个库比 reorderable 重——它强依赖平台存储 API,每个平台都有 actual 实现:
| 上游源集 | 内容 | 对平台的依赖 |
|---|---|---|
commonMain | Settings 接口(putString/getIntOrNull/hasKey/clear/keys/size)+ SettingsListener | 无 |
androidMain | SharedPreferencesSettings | Android SharedPreferences |
appleMain | NSUserDefaultsSettings | iOS/macOS NSUserDefaults |
ohosMain | OhosPreferencesSettings + cinterop/ohpreferences.def | 鸿蒙 OH_Preferences_* C API |
jvmMain | PreferencesSettings | Java Preferences API |
关键观察:鸿蒙侧靠 cinterop 把 libohpreferences.so 的 C 函数包成 Kotlin 可调用的 OhosPreferencesSettings。适配核心是三件事:
- cinterop 的
.def文件要能找到鸿蒙系统头文件(这个 fork 用一个裁剪过的prefs_min.h最小头,而非全量 SDK 头); - 链接时要把
-lohpreferences加进 linkerOpts,否则 native 符号解析失败; bundleName必须与鸿蒙工程的app.json5一致,否则OH_Preferences_Open返回错误。

二、工程结构
cmp-settings-demo/
├── composeApp/ # CMP 共享模块
│ ├── src/commonMain/kotlin/ohos/cmp/settings/
│ │ ├── App.kt # KV 读写 demo UI(Put/Get/Has/Remove/Clear/Reopen)
│ │ └── Platform.kt # expect fun createSettings(): Settings
│ ├── src/ohosMain/kotlin/ohos/cmp/settings/
│ │ └── Platform.ohos.kt # actual: OhosPreferencesSettings.Factory(...)
│ └── build.gradle.kts # ohosArm64/ohosX64 双目标 + -lohpreferences
├── harmonyApp/ # DevEco 鸿蒙应用工程
│ └── entry/src/main/ets/pages/ # ArkTS 页面,加载 libkn.so
├── gradle/libs.versions.toml # 版本目录(Kotlin 2.2.21-0.4.0 等)
└── _ref/multiplatform-settings/ # 本地重建的 settings 源码(对齐 0.4.0 工具链)
composeApp:标准 CMP 模块,commonMain写 UI 和expect,ohosMain写actual;harmonyApp:DevEco 工程,通过 NAPI 加载libkn.so,把 Compose 渲染结果上屏到 ArkUI;_ref/multiplatform-settings:从 oh-tpc 克隆的鸿蒙 fork 源码,修改版本后publishToMavenLocal。
三、适配过程:六个关键步骤

3.1 没有 0.4.0 工具链的现成切片,本地重建 klib
第一个坎:eazytec-cloud Nexus 上 com.russhwolf:multiplatform-settings 的最新鸿蒙切片是 1.4.0-0.1.0-rc1-05,但它的 klib 是用 Kotlin 2.2.21-OH.0.1.0-01 工具链编的——和 demo 的 2.2.21-0.4.0 不一致,直接链接会触发和 reorderable 一样的 IrFakeOverrideSymbol 崩溃。
解法和 reorderable 一样:把 oh-tpc 的 multiplatform-settings fork 克隆到 _ref/,把版本对齐到 -0.4.0,然后本地重建。这个 fork 有额外两处要改——它原本钉的是 1.0.0 工具链 + JDK 21:
# _ref/multiplatform-settings/gradle/libs.versions.toml(修改后)
[versions]
kotlin = "2.2.21-0.4.0" # 原来是 2.2.21-1.0.0
composeMultiplatform = "1.9.2-0.4.0" # 原来是 1.9.2-1.0.0
// _ref/multiplatform-settings/multiplatform-settings/build.gradle.kts
kotlin {
jvmToolchain(17) // 原来是 21(本机只有 JDK 17,降级)
}
# _ref/multiplatform-settings/gradle.properties(修改后)
org.gradle.java.home=C:\\Users\\nwu\\.jdks-portable\\jdk-17.0.13+11
发布到 mavenLocal:
cd _ref/multiplatform-settings
.\gradlew.bat --no-daemon :multiplatform-settings:publishToMavenLocal
产物坐标:com.russhwolf:multiplatform-settings:1.3.0-ohos.1,含 ohosArm64/ohosX64 两个 klib。
经验:鸿蒙生态的 KMP 库大多是「定制切片」,版本号形如
x.y.z-ohos.N。所有参与链接的 klib 必须用同一个 Kotlin 编译器版本,本地重建是绕不开的常态操作。
3.2 cinterop 配置:用最小头文件绑鸿蒙系统 C API
这个 fork 没有用全量 SDK 头,而是在 src/ohosMain/cinterop/ 放了一个裁剪过的最小头 prefs_min.h,只声明 demo 用到的 OH_Preferences_* 函数签名:
// prefs_min.h(节选)
#pragma once
#include <stdbool.h>
#include <stdint.h>
typedef struct OH_Preferences OH_Preferences;
typedef struct OH_PreferencesOption OH_PreferencesOption;
OH_PreferencesOption *OH_PreferencesOption_Create(void);
int OH_PreferencesOption_SetFileName(OH_PreferencesOption *option, const char *fileName);
int OH_PreferencesOption_SetBundleName(OH_PreferencesOption *option, const char *bundleName);
OH_Preferences *OH_Preferences_Open(OH_PreferencesOption *option, int *errCode);
int OH_Preferences_GetInt(OH_Preferences *preference, const char *key, int *value);
int OH_Preferences_SetInt(OH_Preferences *preference, const char *key, int value);
// ... GetBool/SetBool/GetString/SetString/Flush/Close 等
.def 文件极简单——直接指向这个本地头,包名定为 ohos.preferences:
# ohpreferences.def
headers = prefs_min.h
package = ohos.preferences
compilerOpts = -I.
build.gradle.kts 里为两个鸿蒙 target 注册 cinterop:
listOf(ohosArm64(), ohosX64()).forEach { target ->
target.compilations.getByName("main") {
cinterops.create("ohpreferences") {
defFile(cinteropInclude.resolve("ohpreferences.def"))
includeDirs(cinteropInclude)
compilerOpts("-I$cinteropInclude")
}
}
}
编译时 Kotlin/Native 会生成 ohos.preferences 包下的 Kotlin 绑定,OhosPreferencesSettings 就是基于这些绑定封装的。
为什么用最小头而不是 SDK 全量头:鸿蒙 SDK 的
preferences.h头依赖大量其它内部头,cinterop 解析时容易牵出一串无关符号。裁剪出一个只含 demo 所需函数的最小头,能让 cinterop 干净快速地产出绑定,是鸿蒙 KMP 库适配的常见技巧。
3.3 排查:比 IR 崩溃更隐蔽的「孤儿源集」
接好依赖、改完版本后第一次编译,报 Unresolved reference: OhosPreferencesSettings——但 commonMain 的 Settings 接口能正常解析。这说明 klib 能下载到、commonMain API 可见,唯独 ohos 特有的 OhosPreferencesSettings 类不见了。
把依赖从 commonMain.dependencies 挪到 ohosMain.dependencies 也没用。最后用 klib contents 解包重建出的 klib,发现里面只有 com.russhwolf.settings 的 commonMain 代码,根本没有 OhosPreferencesSettings。
真相:0.4.0 版定制 Kotlin 插件的 applyDefaultHierarchyTemplate()(默认源集层级)不包含 ohos 组合(1.0.0 版才内置)。于是 src/ohosMain/kotlin/ 成了一个「孤儿目录」——ohosArm64Main/ohosX64Main 各自存在,但没有公共的 ohosMain 中间源集把这段 Kotlin 代码喂给两个 target,编译器全程不报错,只是这段代码「消失」了。
修复:在 _ref/multiplatform-settings/multiplatform-settings/build.gradle.kts 的 sourceSets 块里显式补出中间源集:
sourceSets {
commonMain.dependencies { }
commonTest.dependencies { implementation(kotlin("test")) }
jvmTest.dependencies { implementation(kotlin("test")) }
// 0.4.0 工具链的 default hierarchy 不含 ohos 组合,显式声明中间源集,
// 否则 src/ohosMain/kotlin 是孤儿目录,编出的 klib 缺 OhosPreferencesSettings
val ohosMain = sourceSets.maybeCreate("ohosMain")
ohosMain.dependsOn(sourceSets.getByName("commonMain"))
sourceSets.getByName("ohosArm64Main").dependsOn(ohosMain)
sourceSets.getByName("ohosX64Main").dependsOn(ohosMain)
}
重新 publishToMavenLocal 后,再用 klib contents 验证,OhosPreferencesSettings 出现在 klib 里,Unresolved reference 消失。
这是本批适配里最容易被忽略的一类坑:不是编译错,而是「静默缺代码」。当某个平台特有的类「应该存在却 unresolved」时,先
klib contents看产物里到底有没有,再回到源集层级查是不是孤儿目录。
3.4 demo 侧接入:expect/actual + linkerOpts
demo 的 commonMain 只认 Settings 接口,平台差异收口在一个 expect 工厂:
// composeApp/src/commonMain/kotlin/ohos/cmp/settings/Platform.kt
package ohos.cmp.settings
import com.russhwolf.settings.Settings
/** 平台差异收口点:commonMain 只认 [Settings] 接口。 */
expect fun createSettings(): Settings
ohosMain 提供 actual,用 OhosPreferencesSettings.Factory 创建实例。bundleName 必须与 harmonyApp/AppScope/app.json5 的 bundleName 完全一致:
// composeApp/src/ohosMain/kotlin/ohos/cmp/settings/Platform.ohos.kt
package ohos.cmp.settings
import com.russhwolf.settings.OhosPreferencesSettings
import com.russhwolf.settings.Settings
actual fun createSettings(): Settings =
OhosPreferencesSettings.Factory("ohos.cmp.settings").create("demo")
链接配置(composeApp/build.gradle.kts)——关键就是补上 -lohpreferences:
ohosTarget.binaries.sharedLib {
baseName = "kn"
export(libs.compose.multiplatform.export)
linkerOpts("-lz")
// multiplatform-settings 的 ohos actual 依赖系统 Preferences 库
linkerOpts("-lohpreferences")
// CPF 统一渲染需要的系统库(-lnative_drawing / -lace_napi.z / -lhilog_ndk.z 等,同 reorderable)
}
依赖声明(commonMain):
commonMain.dependencies {
// ... compose 全家桶 ...
implementation("com.russhwolf:multiplatform-settings:1.3.0-ohos.1") // ← mavenLocal 鸿蒙切片
}
别忘了 settings.gradle.kts 里启用 mavenLocal()(优先于远程仓库)。
3.5 写一个能验证持久化的 KV 读写 demo
UI 不是随便摆几个按钮——它要能证明数据真的落盘了。核心逻辑:
@Composable
internal fun App() {
val settings = remember { createSettings() }
var kvCount by remember { mutableIntStateOf(0) }
var entries by remember { mutableStateOf(listOf<String>()) }
// ...
fun refresh() {
kvCount = settings.size
entries = settings.keys.map { k -> "$k = ${readForDisplay(settings, k)}" }
}
fun put() {
when (type) {
ValueType.STRING -> settings.putString(key, value)
ValueType.INT -> settings.putInt(key, value.toIntOrNull() ?: 0)
ValueType.BOOLEAN -> settings.putBoolean(key, boolValue)
}
refresh()
}
fun reopen() {
val reopened = createSettings() // ← 重新打开同名 store
kvCount = reopened.size
entries = reopened.keys.map { k -> "$k = ${readForDisplay(reopened, k)}" }
// Put 后 Reopen 的 size 不变 = Flush 已落盘
}
}
设计要点:
- 支持三种类型(String/Int/Boolean),用
FilterChip切换,覆盖OH_Preferences的三类基本读写; - 状态卡实时显示
store/size/get/has四个值,一眼看到每次操作结果; - 快照区列出当前所有键值对(
settings.keys+ 类型标注); Reopen按钮是验证持久化的关键:重新createSettings()打开同名 store,若 size 不变说明OH_Preferences_Flush已把数据写盘,否则只是内存态。
3.6 编译、打包、签名、安装
和 reorderable 同一套流程,不再赘述坑点(详见上一篇 FAQ):
.\gradlew.bat :composeApp:publishDebugBinariesToHarmonyApp # 产出双 ABI libkn.so 到 harmonyApp/entry/libs
cd harmonyApp
ohpm install
hvigor assembleHap # 打包 entry-default-unsigned.hap
java -jar hap-sign-tool.jar sign-app ... # 离线签名 → entry-signed.hap
hdc install entry-signed.hap
hdc shell aa start -b ohos.cmp.settings -a EntryAbility
bundleName 用 ohos.cmp.settings,和 Platform.ohos.kt 里的 Factory("ohos.cmp.settings") 对齐。
四、验证效果
4.1 构建并安装
publishDebugBinariesToHarmonyApp 产出 harmonyApp/entry/libs/{arm64-v8a,x86_64}/libkn.so,hvigor 打包 + hap-sign-tool 三级证书链离线签名得到 entry-signed.hap,hdc install 安装到模拟器,aa start 拉起。
4.2 模拟器运行效果
图 0 DevEco Studio 开发环境:Pura X 模拟器(HarmonyOS 7.0.0 / API 26)运行 CMP Settings demo,右侧 DevEco 面板可见

图 1 demo 首屏:标题 + 状态卡(store=demo / size / status / get / has)+ key/value 输入 + 类型切换 Chip + Put/Get/Has/Remove/Clear/Reopen 按钮 + 底部快照区。Material 3 主题

图 2 写入:填入 key=nickname、value=OpenHarmony,选 String 类型点 Put,状态卡显示 put nickname ok,快照区出现 nickname = OpenHarmony (string),size 变为 1

图 3 持久化(模拟器特写):点 Reopen 重新打开同名 store,状态卡显示 reopen: size=1 (persisted),快照区仍是 nickname = OpenHarmony (string)——证明 OH_Preferences_Flush 已落盘,非内存态
图 4 清空:点 Clear 后状态卡 store=demo size=0、get → null (missing)、has → false,快照区 (empty)——证明 Clear 生效且未命中路径返回正确的失败态。底部验证点提示也随 UI 滚动露出
功能验证:
putString/putInt/putBoolean→ 状态卡put ok,快照区新增对应类型条目getStringOrNull/getIntOrNull/getBooleanOrNull→ 命中返回值+类型,未命中返回null (missing)hasKey→has → true/falseremove→ 快照区条目消失,size减 1clear→ 快照区清空,size归 0,get/has返回失败态(见图 4)reopen→ 重新打开 store 后size与内容不变,证明数据已持久化到鸿蒙 Preferences(见图 3)
五、FAQ:适配过程与使用问题
Q1:Unresolved reference: OhosPreferencesSettings,但 Settings 接口能解析
这是「孤儿源集」问题。0.4.0 工具链的 applyDefaultHierarchyTemplate() 不含 ohos 组合,src/ohosMain/kotlin/ 没被编译进 klib。修复见 3.3:在库的 sourceSets 里显式 maybeCreate("ohosMain") 并让 ohosArm64Main/ohosX64Main 都 dependsOn 它。先用 klib contents 确认产物里是否真的没有目标类,再排查源集层级。
Q2:链接报 undefined reference to OH_Preferences_*
没在 sharedLib 的 linkerOpts 里加 -lohpreferences。cinterop 只负责生成 Kotlin 绑定,最终的 native 符号仍要在链接时解析到系统库。
Q3:OH_Preferences_Open 返回错误 / 打开失败
OhosPreferencesSettings.Factory(bundleName) 的 bundleName 与 harmonyApp/AppScope/app.json5 的 bundleName 不一致。两者必须完全相等,鸿蒙按 bundleName 隔离 Preferences 存储目录。
Q4:Put 之后杀进程重进数据没了
OH_Preferences 的写是异步缓冲的,库内部会在合适时机 Flush。若担心丢数据,确认走的是 OhosPreferencesSettings(它封装了 flush 逻辑),而非裸调 C API。demo 里的 Reopen 按钮就是验证这一点的。
Q5:cinterop 解析 SDK 全量头报一堆无关符号错
别用全量 SDK 头,裁剪一个只含所需函数的最小头(本 fork 的 prefs_min.h),.def 里 headers = prefs_min.h + compilerOpts = -I.。这是鸿蒙 KMP 库适配的常见技巧。
Q6:换工具链版本后又要重编?
是。鸿蒙定制 Kotlin 的 klib 没有跨编译器版本的 ABI 兼容保证。每次 demo 换 Kotlin 版本(如 0.4.0 → 1.0.0),所有本地重建的库都要跟着对齐重编并重新 publishToMavenLocal。
六、相关链接
欢迎加入 CPF-KMP-CMP 鸿蒙社区:
- CPF-KMP-CMP 鸿蒙社区:https://atomgit.com/CPF-KMP-CMP
- CMP 鸿蒙开发环境搭建指南:https://atomgit.com/CPF-KMP-CMP/cmp-docs
- 华为云码道:https://developer.huaweicloud.com/codeartsco.html
- 本项目适配地址:https://atomgit.com/oh-tpc/multiplatform
更多推荐

所有评论(0)