开源鸿蒙平台 CMP 三方库「compose-remember-setting」适配全流程
本文记录
compose-remember-setting接入 OpenHarmony 的完整过程,覆盖纯 Compose 库的引擎提取、ohosArm64/ohosX64双目标、settings 全家桶的源码内置、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 验收页面、HAP 构建,以及在 DevEco x86_64 模拟器上的真运行验证(含三次崩溃复盘与修复)。
compose-remember-setting是 Compose Multiplatform 的持久化状态库:把remember { mutableStateOf(x) }变成落盘版本 ——rememberIntSetting/rememberStringSetting/rememberBooleanSetting(另有 Long / Float / Double 与各OrNull变体),底层基于 russhwolf 的 multiplatform-settings。本次适配与社区早期路线(qrose / haze 的「零依赖内核提取」)方向相同但对象不同:上游的全部公共 API 都是@Composable,没有可直接复用的非 Compose 内核,因此本 fork 把这些组合函数背后的持久化状态引擎(一次性初值读取、FlowSettings监听驱动的饥饿共享StateFlow、last-write-wins 异步写入)逐条提取为纯 Kotlin 的PersistentSetting,与上游语义一一对齐后编译为 OpenHarmony KLIB,ArkUI 页面作为验收宿主。
项目地址: AtomGit/oh-tpc/compose-remember-setting.git
上游仓库: burnoo/compose-remember-setting
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
compose-remember-setting 为 Compose Multiplatform 应用提供"记忆即持久化"这一件事的完整答案:一行 rememberIntSetting("intKey", 0) 得到的 MutableState<Int>,赋值即落盘、进程重启即恢复;OrNull 变体把 null 语义映射为删 key;多個 Composable 读同一个 key 时通过 ObservableSettings 监听互相可见。上游把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套持久化状态语义,但上游构建(Kotlin 2.1.10 / Compose 1.7.3)不会生成 OpenHarmony 变体。
难点比非 UI 库多一层:这个库的 API 表面全部是 @Composable。只编译它的 Gradle 模块不等于适配成功 —— 必须回答"鸿蒙侧跑什么"。如果只把示例页面改成 ArkTS 而不移植引擎,页面上的控件就只是本地变量,杀进程即丢。适配需要同时验证:引擎语义(初值/监听/异步写)、multiplatform-settings 存储层在 OH_Preferences 上的落地、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| API 全是 Compose | 上游 9 个源文件里 6 个 remember*Setting.kt + LocalComposeRememberSettingConfig.kt + rememberSetting.kt / asMutableState.kt 都依赖 Compose 运行时;鸿蒙路线不编译 Compose,需要提取等价纯 Kotlin 引擎。 |
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.1.10 / AGP 8.7.3 / dokka / sonatype 发布链与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 依赖矩阵 | 引擎依赖 kotlinx-coroutines 与 multiplatform-settings 全家桶(core / coroutines / make-observable / no-arg / test 五件套),社区 Maven 制品线只发布 ohosArm64,没有 ohosX64。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:引擎语义的 ohos 编译、根构建替换(cmp_qrose 模式)、settings 全家桶源码内置(解锁双 ABI)和 C ABI/N-API 桥接层。上游组合函数原样留在仓内不删,存储语义由 multiplatform-settings 上游实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。
1.2 库提供的能力
Demo 完整对应上游示例 App.kt 的三个持久化控件,另加引擎验证区,覆盖桥接的全部入口:
- 文本持久化(stringKey):
rememberStringSetting("stringKey", "Hello world!")的等价物 —— 输入框每次变更即落盘,重启恢复; - 开关持久化(booleanKey):
rememberBooleanSetting的等价物; - 计数器持久化(intKey):
rememberIntSetting的等价物,−/+ 按钮写、中间数字读; - 响应式证明(reactiveKey):
externalWrite绕过引擎直写存储,state仍能追上新值 —— 这正是remember*Setting赖以工作的 ObservableSettings 监听 → FlowSettings → StateFlow 链路; - 重开会话(reopen):销毁全部引擎与存储会话后重建,值仍在 —— 落盘而非内存缓存;
- 版本握手:
rememberSettingVersion()返回 fork 版本号,来自真实 Native 调用而非静态文字。
响应统一为:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op / key / type | 回显本次操作、目标键与类型(int/long/float/double/boolean/string)。 |
value | get / state 返回当前值(数字/布尔原生嵌入,字符串转义);写操作为 null。 |
引擎侧完整能力(六类型读写、OrNull 置 null 删 key、多引擎同 key 互相观察、异常记录不中止进程)全部随编译进入 .so,JVM 侧保留 16 项交叉验证用例,证明 ohos .so 里执行的引擎逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 引擎语义逐条来自上游 rememberSetting / asMutableState / getStateFlow / getValueOrNullFlow;后两个文件原样保留在 commonMain(仅补 @PublishedApi),库内唯一新增源码是 PersistentSetting.kt;上游 9 个 @Composable 文件原样移入 src/composeMain(非源码集,不参与编译)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 example/composeApp、iOS/wasm 目标、dokka、sonatype 发布全部留盘不进构建。 |
| 依赖纪律 | kotlinx-coroutines 用社区制品线 1.10.2-1.0.0(自带双 ohos 变体);multiplatform-settings 全家桶因制品线缺 ohosX64 而以源码内置(上游 1.3.0 原样 + 已审核 oh-tpc 移植 actual)。 |
| 桥接稳定 | 2 个 C ABI、UTF-8 JSON、手写 MiniJson,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| UI 完整 | 两 Tab 覆盖全部桥接入口:三个持久化控件、响应式验证、重开会话、操作日志、存储快照与关于页。 |
| 仓库规范 | 项目说明、适配文档(中英双份)和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。适配的是
remember*Setting背后的引擎与存储链路;ArkTS Demo 把这条链路画出来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 API 表面与依赖边界、确认 settings 制品线只有 ohosArm64、确定交付物
第 2 阶段:引擎提取 ── @Composable 隔离到 composeMain、PersistentSetting 纯 Kotlin 化、语义对照表
第 3 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、JVM 测试保留
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、-lohpreferences 显式链接、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── bundleName 自解析、三个持久化控件、引擎验证区、操作日志和版本握手
第 6 阶段:模拟器攻坚 ── settings 源码内置解锁 ohosX64、三次崩溃复盘、双 ABI HAP、模拟器全功能验收
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
compose-remember-setting/ 引擎与配置(进 ohos 构建,jvm() + ohosArm64 + ohosX64)
src/commonMain/ PersistentSetting.kt(新增引擎)、ComposeRememberSettingConfig.kt、
internal/getStateFlow.kt、internal/getValueOrNullFlow.kt(上游原样)
src/composeMain/ 上游 9 个 @Composable 文件原样移入,不参与编译
src/commonTest/ 上游 2 个 flow 测试零改动 + 新增 PersistentSettingTest 七项
src/composeTest/ 上游 Compose UI 测试移入,不参与编译
multiplatform-settings-ohos/ settings 全家桶源码内置(v2 新增,见第 6 阶段)
example/nativeApp/ Kotlin/Native 动态库(Bridge.kt:@CName ×2 + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
example/composeApp、iosApp 等 上游示例留盘不进 settings.gradle.kts
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :multiplatform-settings-ohos、:compose-remember-setting 与 :example:nativeApp,rootProject.name = "ohos_compose-remember-setting"。上游的 dokka / maven-publish / signing / sonatype 发布段、AGP classpath、example/composeApp 全部随根构建替换剥离 —— 本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Temurin 21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 钉版,与 2.2.21 线配套) |
| kotlinx | coroutines 1.10.2-1.0.0(社区制品线,自带双 ohos 变体) |
| multiplatform-settings | 源码内置模块(上游 1.3.0 原样 + oh-tpc 移植 actual) |
| DevEco product | 5.1.0(18) 兼容;实测 7.0.0(API 26)模拟器 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.remembersetting |
1.3 源码依赖度盘点
| 文件 | 依赖形态 | 处置 |
|---|---|---|
rememberSetting.kt / asMutableState.kt / LocalComposeRememberSettingConfig.kt | Compose 运行时(remember / MutableState / CompositionLocal / LocalInspectionMode) | 移入 composeMain,语义由引擎接棒 |
remember*Setting.kt ×6 | @Composable 薄包装 | 移入 composeMain |
ComposeRememberSettingConfig.kt | 纯 Kotlin + settings | 保留 commonMain(零改动) |
internal/getStateFlow.kt / getValueOrNullFlow.kt | 纯协程 + FlowSettings | 保留 commonMain,仅补 @PublishedApi 注解,逻辑零改动 |
| 上游 Compose UI 测试 ×11 | compose.uiTest / runComposeUiTest | 移入 composeTest,不参与编译 |
| 上游 flow 测试 ×2 | turbine + kotest(JVM 可解析) | 保留 commonTest,零改动跑通 |
关键结论:这个库的"内核"不是某段算法,而是 rememberSetting 组合出的一条固定管线 —— ObservableSettings.toFlowSettings().getStateFlow(scope, key, 初值) 得到饥饿共享的 StateFlow,再由 asMutableState 把写路径包成"取消上一个写任务、起新任务"的 last-write-wins 语义。引擎提取就是把这条管线搬进非 Compose 的类。
1.4 页面边界
标题区 COMPOSE-REMEMBER-SETTING · OPENHARMONY / 版本握手行(持久化 remember 引擎 · Kotlin/Native · 版本号)
Tab 切换 数据存取 / 快照与关于
持久化区 stringKey 输入框、booleanKey 开关、intKey 减/计数/加三件套(对应上游 App.kt)
引擎验证区 reactiveKey 状态行 + 外部写入 +1 / 重开会话 / remove intKey / clear 全部
日志区 带时间戳的请求/响应日志(→ 与 ← 交替,最新在前,上限 100 条)
快照与关于 全量键值表(类型徽标)、分层说明、AtomGit 仓库与社区链接
页面启动时先 init(携带 ArkTS 自解析的 bundleName)建会话,再 rememberSettingVersion() 握手,然后逐个 state 拉初值。
第 2 阶段:引擎提取
2.1 语义对照
引擎 PersistentSetting 与上游逐条对齐:
| 上游(@Composable 内) | 引擎(纯 Kotlin) |
|---|---|
remember(key) { ... } 首次组合计算一次 | PersistentSetting.create() 构造一次,缓存于桥的会话表 |
observableSettings[key] ?: defaultValue 一次性初值 | 同款 reified 读,工厂内完成 |
toFlowSettings().getStateFlow(scope, key, initial)(stateIn(Eagerly) + filterNotNull) | 复用 commonMain 里原样的 internal/getStateFlow.kt |
asMutableState setter:setValueJob?.cancel(); setValueJob = scope.launch { observableSettings[key] = it } | var value setter 逐字复刻(last-write-wins) |
| 组合作用域随 Composition 取消 | close() 显式关闭(桥的 reopen / clear 调用) |
// compose-remember-setting/src/commonMain/kotlin/PersistentSetting.kt(节选)
inline fun <reified T> create(
observableSettings: ObservableSettings,
key: String,
defaultValue: T,
flowSettingsDispatcher: CoroutineDispatcher? = null,
): PersistentSetting<T> {
val initialValue: T = observableSettings[key] ?: defaultValue
val flowSettings = if (flowSettingsDispatcher == null) {
observableSettings.toFlowSettings()
} else {
observableSettings.toFlowSettings(flowSettingsDispatcher)
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
return PersistentSetting(
scope = scope,
stateFlow = flowSettings.getStateFlow(scope, key, initialValue),
setValue = { observableSettings[key] = it },
)
}
reified 工厂是关键:Settings 的 reified get/set 操作符与库内 reified getValueOrNullFlow(按 T::class 分派六种类型)都必须在 inline reified 上下文里特化,所以 create 是 inline、构造器 @PublishedApi internal,getStateFlow / getValueOrNullFlow 相应补 @PublishedApi(全 fork 对上游文件仅有的注解级改动)。
2.2 两处 fork 级加固
| 加固 | 原因 |
|---|---|
写入 job 捕获异常记入 lastError 而非放任未捕获 | 上游失败会随 Composition 崩掉应用;鸿蒙 Demo 里引擎 scope 活在主进程,Kotlin/Native 未捕获异常直接 SIGABRT 杀进程。 |
测试侧 warmUp() 200ms | stateIn(Eagerly) 订阅异步启动;Compose 里 composition 永远先于用户输入,测试若订阅完成前立刻写入会错过监听事件 —— 预热后与真实 UI 时序对齐。 |
2.3 JVM 交叉验证
上游 GetStateFlowTest / GetValueOrNullFlowTest 零改动通过;新增 PersistentSettingTest 七项:int / string / boolean 往返、long(含超 double 精度的 9007199254740993L)/ float / double 往返、外部直写驱动 StateFlow、双引擎同 key 互相观察、OrNull 置 null 删 key。共 16 项全绿 —— "引擎没有改坏上游接线"的直接证据。
第 3 阶段:目标与依赖打通
3.1 制品线盘点(决定依赖策略的成败手)
逐个拉取社区 Maven(https://maven.eazytec-cloud.com)的 .module 元数据核验变体:
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2-1.0.0
variants: ohosArm64ApiElements-published / ohosX64ApiElements-published / jvm... ← 双 ohos 变体齐
com.russhwolf:multiplatform-settings(-coroutines/-make-observable/-no-arg/-test)
1.3.0-0.5.0-12、1.3.0-1.0.0、1.4.0-0.1.0-rc1-05 全部版本线
variants: 仅 ohosArm64ApiElements-published,ohos_x64 为 0 ← 只有 arm64
三个关键结论:其一,coroutines 双 ohos 变体齐全,版本主体 1.10.2 与上游一致(换线不换版),可以直接消费;其二,settings 制品线所有版本都只有 ohosArm64,强行声明 ohosX64() 在依赖解析期直接失败 —— v1 因此只出 arm64;其三,解锁 x86_64 模拟器的唯一独立可复现路线是 settings 全家桶源码内置(第 6 阶段)。
3.2 工具链钉版(为什么是 2.2.21-1.0.0)
社区已批准的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / multiplatform-settings 同款),Gradle 配套钉 8.14.1,JDK 21。settings.gradle.kts 把 eazytec 双仓写在最前:
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
google()
mavenCentral()
}
}
// dependencyResolutionManagement 同序置前;dependencyResolutionManagement 需要
// repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
rootProject.name = "ohos_compose-remember-setting"
include(":multiplatform-settings-ohos")
include(":compose-remember-setting")
include(":example:nativeApp")
gradle.properties 全局禁用 ohos 测试任务(kotest / turbine 无 ohos 制品,测试统一 JVM 跑):
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
3.3 编译真实引擎
库模块收敛为纯 KMP:
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm()
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(project(":multiplatform-settings-ohos"))
}
commonTest.dependencies { /* kotlin-test / coroutines-test / kotest / turbine */ }
}
}
实测:
:multiplatform-settings-ohos:compileKotlinJvm / OhosArm64 / OhosX64: BUILD SUCCESSFUL
:compose-remember-setting:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:compose-remember-setting:jvmTest: 16 项全部通过
第 4 阶段:桥协议与原生桥接
4.1 桥协议
dev.burnoo.compose.remembersetting/**(引擎 + settings 内置模块)
↓ PersistentSetting<T> / StateFlow / last-write-wins 写
Bridge.kt(@CName × 2,扁平 JSON 进出)
↓ UTF-8 JSON
N-API rememberSettingDispatch() / rememberSettingVersion()
↓
ArkUI Index.ets(两 Tab 滚动)
| op | 必填字段 | 行为 |
|---|---|---|
init | bundleName | 关闭旧会话;OhosPreferencesSettings(bundleName).makeObservable() 建新会话 |
put | type key value | 引擎异步写(last-write-wins) |
putNull | type key | OrNull 语义 = 删 key |
get | type key | 直读存储,found + value |
state | type key def,可选 expect | 读引擎 StateFlow 当前值;带 expect 时阻塞等待值变化(监听链路证明) |
externalWrite | type key value | 绕过引擎直写 —— 随后 state 应追到新值 |
remove / clear | key / — | 删一个 key / 清空 |
snapshot | — | 全量键值 + 类型推断 |
reopen | — | 重建会话,值必须仍在 —— 落盘证明 |
version | — | fork 版本握手 |
init 必须先执行且必须携带 bundleName:OH_PreferencesOption_SetBundleName 要求与运行中 HAP 的 bundleName 一致,Kotlin 侧无法自取,ArkTS 用 bundleManager.getBundleInfoForSelfSync(0).name 自解析后传入。错误统一走 {"ok":false,"error":"..."} 通道,Kotlin 异常永远不跨 FFI。
4.2 Kotlin/Native 导出与 CMake
// example/nativeApp:双 ABI sharedLib + -lohpreferences 显式链接(关键!)
ohosArm64 {
binaries.sharedLib {
baseName = "ohosremembersetting"
linkerOpts("-L$ohosSysroot/usr/lib/aarch64-linux-ohos", "-lohpreferences")
}
}
ohosX64 { /* 同构,triple 换 x86_64-linux-ohos */ }
sysroot 从 DevEco SDK 常见安装路径自动探测,可用 -Pohos.native.sysroot 覆盖。linkDebugSharedOhosArm64/X64 经 finalizedBy 把 libohosremembersetting.so + konan 的 libc++_shared.so 拷入 entry/libs/<abi>/。
add_library(ohosremembersetting SHARED IMPORTED)
set_target_properties(ohosremembersetting PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
target_link_libraries(remembersetting_napi PUBLIC
libace_napi.z.so libhilog_ndk.z.so ohosremembersetting)
OHOS_ARCH 为空或 KN so 缺失直接 FATAL_ERROR;$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 同目录。每个 N-API 回调统一三步:napi_typeof 校验 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回;KN 返回字符串必须经 libohosremembersetting_symbols()->DisposeString 归还运行时,绝不 free()。
第 5 阶段:ArkUI 宿主
5.1 初始化与触发
private resolveBundleName(): string {
try {
return bundleManager.getBundleInfoForSelfSync(0).name;
} catch (e) {
return 'org.terminator.ohos.remembersetting'; // 兜底
}
}
private dispatch(req: DispatchRequest): BridgeResponse {
const raw: string = rememberSettingDispatch(JSON.stringify(req));
this.log(`→ ${req.op} ...`, 'req');
this.log(`← ${raw}`, 'res');
return JSON.parse(raw) as BridgeResponse;
}
计数器按钮的完整闭环:put(int, intKey, 旧值±1) → state(type:int, key, def:"0", expect:旧值) 阻塞等引擎 StateFlow 回填 → 显示新值。state 的 expect 参数让"写后读"确定性成立。
5.2 ArkTS 严格模式的一课
DevEco 的 ArkTS 严格编译拒绝 Record<string, string> 对象字面量(arkts-no-untyped-obj-literals,13 处全中),只认显式接口类型上下文。解法:定义 interface DispatchRequest { op: string; bundleName?: string; type?: string; ... },请求一律接口字面量 + JSON.stringify 序列化;顺带把 default 字段改名 def(关键字风险位),桥侧同步。
第 6 阶段:模拟器攻坚(v2,三次崩溃复盘)
用户要求 Demo 必须跑在 DevEco x86_64 模拟器上,触发 v2 改造。这一阶段的价值在于三次"现象 → 符号化 → 根因 → 修复"的完整闭环。
6.1 源码内置 multiplatform-settings-ohos
| 内容 | 来源 | 处置 |
|---|---|---|
Settings/Operators/Delegates/Experimental.kt | 上游 core commonMain | 原样内置 |
MapSettings.kt | 上游 test 模块 commonMain | 原样内置(上游本就把它放 test 制品,这正是上游 commonMain 依赖 test 制品的原因) |
coroutines 6 个 common 文件、MakeObservableSettings.kt(纯 common)、NoArg.kt expect | 上游 | 原样内置 |
jvm 侧 PreferencesSettings/PropertiesSettings/NoArg/ConverterDefaultDispatcher | 上游 jvmMain | 原样内置 |
OhosPreferencesSettings.kt + cinterop/{ohpreferences.def, prefs_min.h} | 已审核 oh-tpc/multiplatform-settings 移植版 | 原样内置,cinterop 自包含不吃 SDK 头 |
ohos NoArg.kt(actual + bundleName holder)、ohos ConverterDefaultDispatcher(Dispatchers.Default) | 本 fork 新增 | 仅有的新增源码 |
6.2 三次崩溃复盘
① 安装失败 9568347(ABI 不匹配)。hdc shell param get const.product.cpu.abilist 显示目标是 x86_64 模拟器,而 HAP 只含 arm64 so。修复:ohosX64() target + abiFilters ["arm64-v8a","x86_64"] + copyKnSoX64,HAP 携带双 ABI 六件套。
② 首次 init SIGSEGV。崩溃栈经 llvm-symbolizer 对未 strip 的 debug so 符号化,落在 OhosPreferencesSettings.withStore → memScoped 的 cinterop 调用;再对比 oh-tpc 参照工程的 nativeApp 构建,发现它有 linkerOpts("-L<sysroot>/usr/lib/<triple>", "-lohpreferences") 而我们漏了 —— OH_Preferences 符号未绑定,首次调用跳进未解析 PLT。加上显式链接后段错误消失。
③ SetString(index) failed rc=15500011(PREFERENCES_ERROR_STORAGE)杀进程。参照 Demo 同代码在同一模拟器上运行正常,差异只剩线程模型:参照 Demo 全部在 JS 线程同步调用,我们的引擎在 Dispatchers.Default 工作线程并发开关 session —— 移植版没有任何锁,并发 session 触发存储层互斥失败,且异步 scope 里的异常以 SIGABRT 杀掉进程。修复:OhosPreferencesSettings 增加 per-instance Mutex 串行化 open→op→flush→close(此 Kotlin 线 stdlib 锁是 internal,用 coroutines 的 Mutex + runBlocking);引擎写入 job 捕获异常记入 lastError。
6.3 模拟器实测(HarmonyOS 7.0.0 / API 26,x86_64)
hvigorw assembleHap: BUILD SUCCESSFUL(双 ABI 六件套 so)
hdc install entry-default-unsigned.hap: install bundle successfully
aa start: start ability successfully,进程存活无崩溃
uitest 注入点击 ×3:计数器 0 → 3
aa force-stop → 重新 aa start:计数器仍为 3(持久化闭环)
externalWrite +1 → state:reactiveKey 0 → 1(响应式链路)
四、完整代码对照
4.1 整体架构
dev.burnoo.compose.remembersetting/**(引擎,上游语义逐条复刻)
│ PersistentSetting<T> / StateFlow / last-write-wins
com.russhwolf.settings/**(内置模块,上游 1.3.0 原样)
│ MakeObservableSettings / FlowSettings / MapSettings
│ OhosPreferencesSettings(oh-tpc 移植 + fork 并发锁)
Bridge.kt + MiniJson
│ RememberSettingDispatch / RememberSettingVersion
libremembersetting_napi.so
│ rememberSettingDispatch / rememberSettingVersion
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 模拟器/真机页面(两 Tab 滚动)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_compose-remember-setting |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 + coroutines 1.10.2-1.0.0 钉版(settings 走内置模块) |
gradle.properties | 版本 1.0.4-ohos.1、JDK21、全局禁用 Ohos*Test* |
build.gradle.kts(根) | group dev.burnoo、版本从 gradleProperty 读 |
multiplatform-settings-ohos/build.gradle.kts | settings 全家桶:jvm + 双 ohos + 自包含 cinterop |
compose-remember-setting/.../PersistentSetting.kt | 引擎(reified 工厂 + last-write-wins + lastError) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、-lohpreferences 链接、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、MiniJson、会话与引擎缓存、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面(两 Tab) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(16 节,含 v2 更新) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | PersistentSetting.create(settings, key, default, dispatcher?) | reified 工厂:初值读取 + StateFlow 装配 |
| 引擎 | value / stateFlow / lastError / close() | last-write-wins 写 / 当前值 / 写失败记录 / 关闭 |
| settings | OhosPreferencesSettings(bundleName).makeObservable() | OH_Preferences 会话 + 可监听包装(fork 加锁) |
| settings | toFlowSettings().getXOrNullFlow(key) | 键监听 → Flow(六类型) |
| Native | RememberSettingDispatch / RememberSettingVersion | C ABI JSON 导出 |
| N-API | rememberSettingDispatch(req) / rememberSettingVersion() | 向 ArkTS 暴露 |
| ArkTS | dispatch(req) | 触发操作并刷新 UI / 日志 |
4.4 ArkTS 与 Kotlin 的边界
// 计数器 +1:写穿引擎、读回 StateFlow
const before: string = `${this.counter}`;
this.putTyped('int', KEY_INT, `${this.counter + 1}`);
this.counter = Number(this.readState(before, 'int', KEY_INT, '0'));
// 桥侧:state 带 expect 时阻塞等待 StateFlow 变化
runBlocking {
withTimeoutOrNull(json.long("timeoutMs", 2000)) {
engine.stateFlow.first { plain(it) != expect }
} ?: engine.value
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:提取引擎而不是重写 remember*Setting
上游 API 全是 @Composable,但它们只是"初值读取 + FlowSettings StateFlow + last-write-wins 写"这条管线的薄包装。把管线原样搬进 PersistentSetting(连 getStateFlow / getValueOrNullFlow 两个上游文件都不动),引擎与上游语义天然对齐,JVM 上 16 项测试直接证明等价性;反之在 ArkTS 里重写状态逻辑等于把库手抄一遍,必然漂移。
决策 2:settings 全家桶源码内置(换源不换版)
社区制品线全部版本只有 ohosArm64,x86_64 模拟器因此无解。内置模块把上游 1.3.0 的 common 源码原样收进仓库(20 个文件,其中仅 2 个 ohos actual 是 fork 新增),既解锁双 ABI,又保证任何人从 AtomGit 克隆后无需本地姐妹仓库即可复现构建。coroutines 因自带双 ohos 变体,继续走制品线 —— 内置面收敛到最小。
决策 3:-lohpreferences 显式链接
cinterop 的 prefs_min.h 是自包含手写头,编译期无感;但最终 so 的 OH_Preferences_* 未定义符号若不链接 sysroot 里的 libohpreferences.so 桩,dlopen 后首次调用跳进未解析 PLT 直接段错误。链接后符号进入 DT_NEEDED,运行期由设备 sysroot 提供真身 —— 该 so 永远不打包进 HAP。
决策 4:存储会话加 per-instance 锁
oh-tpc 移植版每次操作独立开关 OH_Preferences 会话且无锁,单线程 Demo 没问题;引擎的多线程访问(Default 工作线程 + ArkTS 线程并发 state/snapshot/写任务)会触发 PREFERENCES_ERROR_STORAGE。用 coroutines Mutex 串行化整个 open→op→flush→close 序列(此 Kotlin 线 stdlib 锁 internal 不可用),配合引擎写入异常记录化,Demo 从"必崩"到稳定。
决策 5:交叉验证是"引擎没漂"的唯一可信证明
引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;上游 2 个 flow 测试零改动 + 新增 7 项引擎测试构成闸门。测试里发现的订阅竞态(写早于 stateIn(Eagerly) 订阅完成)反过来解释了 Compose 场景为何天然安全 —— composition 永远先于用户输入。
六、测试与验证
6.1 测试环境
Windows(x86_64)、JDK 21(Temurin)、DevEco Studio(E:\DE)、DevEco x86_64 模拟器(HarmonyOS 7.0.0 / API 26)、HarmonyOS Kotlin 2.2.21-1.0.0 工具链、hdc / hvigor 命令行。
6.2 JVM 交叉验证
:compose-remember-setting:jvmTest —— 16 项全部通过
GetStateFlowTest(2) 上游零改动
GetValueOrNullFlowTest(7) 上游零改动
PersistentSettingTest(7) int/string/boolean 往返、long/float/double 往返、
外部写入驱动 StateFlow、双引擎互相观察、OrNull 删 key
6.3 HAP 验证
:multiplatform-settings-ohos:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:compose-remember-setting:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / OhosX64: BUILD SUCCESSFUL(copyKnSo* 回填三件套 ×2 ABI)
llvm-nm -D: RememberSettingDispatch / RememberSettingVersion 均为全局 T 符号(双 ABI)
ELF 头核验: arm64 so = b700 (AArch64),x86_64 so = 3e00
hvigorw assembleHap: BUILD SUCCESSFUL(HAP 内 libs/arm64-v8a + libs/x86_64 各三件套)
hdc install: install bundle successfully
libohpreferences.so 不打包 —— 来自设备 sysroot,构建期由 sysroot 桩链接。
6.4 功能用例(模拟器实测)
- 启动即完成版本握手
1.0.4-ohos.1(真实 Native 调用); init携带自解析 bundleName,状态条就绪;- 文本框挂载即触发
stringKey写入,状态条"写入 stringKey = Hello world! 成功"; uitest注入点击+三次,计数器 0 → 3,日志滚动留痕;aa force-stop杀进程 → 重新启动,计数器仍为 3 —— 落盘持久化成立;- 「外部写入 +1」绕过引擎直写 reactiveKey,
state追到 1,状态条"引擎 StateFlow 已更新"; - 全程无崩溃(修复前三次不同形态的崩溃全部消除)。
6.5 验证结论
引擎语义、settings 存储层、kotlinx-coroutines、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。Demo 在 DevEco x86_64 模拟器上安装并进入前台,页面展示了真实的持久化会话:版本握手、六类型写读、杀进程重启持久化闭环与响应式链路(见下节效果图)。arm64 真机安装同一 HAP 即为最终验收。
七、运行效果
7.1 真机运行截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-remember-setting.git
cd compose-remember-setting
export JAVA_HOME="/path/to/jdk-21"
./gradlew :compose-remember-setting:jvmTest # 16 项交叉验证
./gradlew :multiplatform-settings-ohos:compileKotlinOhosArm64 :multiplatform-settings-ohos:compileKotlinOhosX64
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-unsigned.hap # 模拟器接受未签名
hdc shell aa start -a EntryAbility -b org.terminator.ohos.remembersetting
hdc shell hilog | grep RememberSetting
八、遗留问题与改进方向
8.1 踩坑复盘
- eazytec 双仓必须排在
pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64; - settings 制品线所有版本只有 ohosArm64 —— 声明
ohosX64()会在依赖解析期失败,双 ABI 的正解是源码内置; - KN so 必须显式
linkerOpts -lohpreferences(sysroot 桩),否则 dlopen 后首次 cinterop 调用跳进未解析 PLT 段错误 —— 崩溃栈要拿未 strip 的 debug so 用llvm-symbolizer符号化才能定位到withStore; OH_Preferences并发 session 会写失败(15500011PREFERENCES_ERROR_STORAGE),多线程访问必须串行化;此 Kotlin 线的 stdlib 锁是 internal,用 coroutinesMutex+runBlocking;- 引擎异步 scope 里的未捕获异常会 SIGABRT 杀进程,写路径必须捕获并记录(
lastError); - 公开 inline 工厂访问 internal 构造器/internal inline 函数需要
@PublishedApi,这是引擎对上游文件仅有的注解级改动; - kotest / turbine 无 ohos 制品:测试统一 JVM 跑 + 根构建禁用
Ohos*Test*任务,上游测试文件零改动; - HarmonyOS CAdapter 不会把业务
@CName挂到 ArkTS 模块表,必须 NAPI 包装;KN 返回字符串必须DisposeString归还; - ArkTS 严格模式拒绝
Record<string, string>对象字面量(arkts-no-untyped-obj-literals),接口字面量才是正解;default是字段名风险位,改名def; init需要bundleName(bundleManager.getBundleInfoForSelfSync(0).name自解析),OH_PreferencesOption_SetBundleName必须与运行中 HAP 一致;- 模拟器 ABI 是 x86_64:HAP 缺对应 so 安装即报
9568347,双abiFilters一个都不能少; stateIn(Eagerly)订阅是异步启动的,测试要在写入前预热等待 —— Compose 场景天然无此竞态,这是把 UI 时序搬进测试时必须补的一环;signingConfigs保持空数组由 DevEco 自动签名回填,证书绝不进 AtomGit。
8.2 已知问题
@ComposableAPI 不为 OHOS 编译(留在composeMain,接回 commonMain 即恢复上游形态);PersistentSetting可作为非 Compose 场景(ArkTS 混合工程、纯逻辑模块)的长期入口保留;- 桥是同步一次一调用,
state(expect)会阻塞 JS 线程至多timeoutMs(默认 2000ms); - 因上游
getStateFlow的filterNotNull,OrNull 置 null 后 StateFlow 保留上一个非空值(上游同款行为); snapshot的类型推断是 best-effort(int → boolean → double → string 顺序试探);- android / js / wasmJs / apple 目标剥离(源文件留盘),发布、dokka、kover 一并剥离,本 fork 不发 Maven Central;
- arm64 真机的最终签名验收待执行(与模拟器同一个 HAP)。
8.3 未来优化方向
- 用 N-API 线程安全回表把 StateFlow 变化推给 ArkTS,替代
state(expect)轮询等待; - 内置模块回迁:社区制品线补齐 ohosX64 后切回制品依赖、删除内置源码;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入双 ohos 编译、ELF 符号检查、HAP 构建与模拟器冒烟;
- 真机(arm64)验收矩阵与自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 remember*Setting(全部 @Composable)
→ 提取 PersistentSetting 纯 Kotlin 引擎(语义逐条对齐 + JVM 16 项闸门)
→ 根构建替换(cmp_qrose 模式,Kotlin 2.2.21-1.0.0 / Gradle 8.14.1)
→ settings 制品线只有 ohosArm64 → 源码内置全家桶解锁双 ABI
→ @CName ×2 + MiniJson + -lohpreferences 显式链接
→ 存储会话加锁(15500011 复盘)+ 写异常记录化
→ ArkTS 严格模式接口化改造 + bundleName 自解析 init
→ DevEco x86_64 模拟器全功能验收(持久化 / 响应式 / 版本握手)
9.2 封装层次
- 引擎层:
PersistentSetting<T>,上游 rememberSetting 语义的纯 Kotlin 形态; - 存储层:内置 multiplatform-settings(上游原样 + oh-tpc OH_Preferences actual + fork 并发锁);
- Native 层:双 ABI 动态库,2 个 C ABI 输出 JSON;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:页面状态、JSON 校验、操作分发、日志与错误文本;
- DevEco 层:CMake、HAP、签名、安装和模拟器运行。
9.3 三条经验
- 纯 Compose 库的适配对象是"组合函数背后的管线":把
rememberSetting的初值/监听/写语义搬进非 Compose 的类,上游文件一行不改地保留证据,JVM 交叉验证就能证明等价 —— 这比在 ArkTS 里重写状态逻辑可信得多; - 制品线盘点决定依赖策略:coroutines 双 ohos 变体齐全所以走制品,settings 全线只有 arm64 所以源码内置 —— 两条策略并存于一个构建,各自收敛到最小内置面,且克隆即可复现;
- 崩溃要用证据链消灭:
hdc拉崩溃日志 → 未 strip so 符号化 → 与已验证参照工程逐行对比构建配置,三次崩溃(9568347 / 段错误 / 15500011)各自由证据定位到 ABI、链接、并发三个不同根因,没有一次靠猜。
9.4 适配成果
当前适配已完成:
PersistentSetting引擎的双 ABI Kotlin/Native 编译(上游语义逐条复刻,JVM 16 项交叉验证全绿);- multiplatform-settings 全家桶源码内置(上游 1.3.0 原样 + 已审核 OH_Preferences actual,jvm + ohosArm64 + ohosX64);
- 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证双 ABI 全局 T 符号); - DevEco Stage ArkUI 两 Tab 验收页面(六类型写读、杀进程重启持久化、响应式链路、版本握手、操作日志、存储快照);
- 双 ABI 六件套 so 的 HAP 构建、模拟器安装与全功能运行验证;
- AtomGit 项目文档、OpenHarmony README(中英双份,16 节含 v2 更新)和本地效果图。
适配过程 FAQ
以下问题来自
ohosArm64目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。
Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?
- 确认 DevEco Studio 版本与工程
build-profile.json5的compatibleSdkVersion一致; - 在
File > Settings > Build, Execution, Deployment > Ohpm配置可用的 ohpm 仓库镜像或代理; - 删除
oh_modules、oh-package-lock.json5与.hvigor缓存后重新 Sync; - 公司网络需要代理时,同时给 ohpm 与 hvigor 配置
http.proxy。
Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?
- 先在
example目录执行./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用linkDebugSharedOhosX64); - 确认
libxxx.so与libc++_shared.so已复制到ohos/entry/libs/<abi>/; - 检查
entry/build-profile.json5的abiFilters与设备 ABI 是否一致(真机arm64-v8a,模拟器x86_64); - 清理
entry/.cxx与entry/build后重试。
Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?
- 确认 CMake 的
add_library(<target> SHARED ...)名称与import ... from 'libxxx_napi.so'完全一致; - 解包 HAP,确认
libs/<abi>/libxxx_napi.so已打包; - 删除
entry/build、entry/.cxx与oh_modules后重新构建; - 检查
module.json5的srcEntry与页面路径是否正确。
Q4:真机安装报 signature verification failed 或 install failed?
- 在 DevEco Studio 的
Signing Configs配置证书、Profile 与密码; bundleName必须与签名 profile 中的 bundle name 一致;- 设备需加入 profile 的
deviceIds,调试证书有效期需覆盖当前时间; - 用
hdc list targets确认设备在线,再执行hdc install -r xxx-signed.hap。
Q5:应用启动白屏、闪退或 NAPI 调用崩溃?
- 用
hdc hilog | grep -i "napi\|kotlin"查看原生日志; - 确认
libc++_shared.so与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致; - 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用
XxxFree释放; - 在 DevEco 的 Native 调试配置中附加
lldb,定位空指针与越界访问。
Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?
- 使用鸿蒙发行版 Kotlin(如
2.2.21-1.0.0),并在settings.gradle.kts配置 eazytec 仓库; - Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
- 确认
kotlin { ohosArm64() }已应用,且local.properties的 SDK 路径有效; - 清理
~/.gradle/caches中对应版本的 Kotlin/Native 缓存后重试。
Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?
- 对齐 DevEco Studio、HarmonyOS SDK 与
build-profile.json5中的compatibleSdkVersion; - 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免
modelVersion与 IDE 不匹配; - 命令行构建时设置
DEVECO_SDK_HOME指向 DevEco 的sdk目录。
Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?
- 使用 DevEco 自带的 node 与 hvigor:
"$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...; - 设置
JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME; - 加上
--no-daemon排除守护进程缓存影响,并清理entry/build与.hvigor。
参考文档
- AtomGit/oh-tpc/compose-remember-setting
- KMP&CMP 鸿蒙社区
- 上游 burnoo/compose-remember-setting
- 上游 russhwolf/multiplatform-settings
- AtomGit/oh-tpc/multiplatform-settings(OH_Preferences 移植)
- AtomGit/oh-tpc/cmp_qrose(审核路线参照)
- Kotlin Multiplatform
- OpenHarmony OH_Preferences C API
- OpenHarmony N-API
- DevEco Studio
- OpenHarmony ArkUI
更多推荐




所有评论(0)