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。

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

一、先看清楚:multiplatform-settings 的鸿蒙实现是怎么落地的

照例先翻源码分布。这个库比 reorderable 重——它强依赖平台存储 API,每个平台都有 actual 实现:

上游源集内容对平台的依赖
commonMainSettings 接口(putString/getIntOrNull/hasKey/clear/keys/size)+ SettingsListener无
androidMainSharedPreferencesSettingsAndroid SharedPreferences
appleMainNSUserDefaultsSettingsiOS/macOS NSUserDefaults
ohosMainOhosPreferencesSettings + cinterop/ohpreferences.def鸿蒙 OH_Preferences_* C API
jvmMainPreferencesSettingsJava Preferences API

关键观察:鸿蒙侧靠 cinterop 把 libohpreferences.so 的 C 函数包成 Kotlin 可调用的 OhosPreferencesSettings。适配核心是三件事:

  1. cinterop 的 .def 文件要能找到鸿蒙系统头文件(这个 fork 用一个裁剪过的 prefs_min.h 最小头,而非全量 SDK 头);
  2. 链接时要把 -lohpreferences 加进 linkerOpts,否则 native 符号解析失败;
  3. 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。

三、适配过程:六个关键步骤

Clear 后空态

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 主题

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

Reopen 持久化验证
图 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/false
  • remove → 快照区条目消失,size 减 1
  • clear → 快照区清空,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 鸿蒙社区:

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐