本文记录 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)。
valueget / 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(wrapper 钉版,与 2.2.21 线配套)
kotlinxcoroutines 1.10.2-1.0.0(社区制品线,自带双 ohos 变体)
multiplatform-settings源码内置模块(上游 1.3.0 原样 + oh-tpc 移植 actual)
DevEco product5.1.0(18) 兼容;实测 7.0.0(API 26)模拟器
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.remembersetting
1.3 源码依赖度盘点
文件依赖形态处置
rememberSetting.kt / asMutableState.kt / LocalComposeRememberSettingConfig.ktCompose 运行时(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 测试 ×11compose.uiTest / runComposeUiTest移入 composeTest,不参与编译
上游 flow 测试 ×2turbine + 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() 200msstateIn(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必填字段行为
initbundleName关闭旧会话;OhosPreferencesSettings(bundleName).makeObservable() 建新会话
puttype key value引擎异步写(last-write-wins)
putNulltype keyOrNull 语义 = 删 key
gettype key直读存储,found + value
statetype key def,可选 expect读引擎 StateFlow 当前值;带 expect 时阻塞等待值变化(监听链路证明)
externalWritetype key value绕过引擎直写 —— 随后 state 应追到新值
remove / clearkey / —删一个 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.tomlKotlin 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.ktssettings 全家桶:jvm + 双 ohos + 自包含 cinterop
compose-remember-setting/.../PersistentSetting.kt引擎(reified 工厂 + last-write-wins + lastError)
example/nativeApp/build.gradle.kts双 ABI sharedLib、-lohpreferences 链接、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、MiniJson、会话与引擎缓存、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面(两 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 写 / 当前值 / 写失败记录 / 关闭
settingsOhosPreferencesSettings(bundleName).makeObservable()OH_Preferences 会话 + 可监听包装(fork 加锁)
settingstoFlowSettings().getXOrNullFlow(key)键监听 → Flow(六类型)
NativeRememberSettingDispatch / RememberSettingVersionC ABI JSON 导出
N-APIrememberSettingDispatch(req) / rememberSettingVersion()向 ArkTS 暴露
ArkTSdispatch(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. 启动即完成版本握手 1.0.4-ohos.1(真实 Native 调用);
  2. init 携带自解析 bundleName,状态条就绪;
  3. 文本框挂载即触发 stringKey 写入,状态条"写入 stringKey = Hello world! 成功";
  4. uitest 注入点击 + 三次,计数器 0 → 3,日志滚动留痕;
  5. aa force-stop 杀进程 → 重新启动,计数器仍为 3 —— 落盘持久化成立;
  6. 「外部写入 +1」绕过引擎直写 reactiveKey,state 追到 1,状态条"引擎 StateFlow 已更新";
  7. 全程无崩溃(修复前三次不同形态的崩溃全部消除)。

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 踩坑复盘

  1. eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  2. settings 制品线所有版本只有 ohosArm64 —— 声明 ohosX64() 会在依赖解析期失败,双 ABI 的正解是源码内置;
  3. KN so 必须显式 linkerOpts -lohpreferences(sysroot 桩),否则 dlopen 后首次 cinterop 调用跳进未解析 PLT 段错误 —— 崩溃栈要拿未 strip 的 debug so 用 llvm-symbolizer 符号化才能定位到 withStore;
  4. OH_Preferences 并发 session 会写失败(15500011 PREFERENCES_ERROR_STORAGE),多线程访问必须串行化;此 Kotlin 线的 stdlib 锁是 internal,用 coroutines Mutex + runBlocking;
  5. 引擎异步 scope 里的未捕获异常会 SIGABRT 杀进程,写路径必须捕获并记录(lastError);
  6. 公开 inline 工厂访问 internal 构造器/internal inline 函数需要 @PublishedApi,这是引擎对上游文件仅有的注解级改动;
  7. kotest / turbine 无 ohos 制品:测试统一 JVM 跑 + 根构建禁用 Ohos*Test* 任务,上游测试文件零改动;
  8. HarmonyOS CAdapter 不会把业务 @CName 挂到 ArkTS 模块表,必须 NAPI 包装;KN 返回字符串必须 DisposeString 归还;
  9. ArkTS 严格模式拒绝 Record<string, string> 对象字面量(arkts-no-untyped-obj-literals),接口字面量才是正解;default 是字段名风险位,改名 def;
  10. init 需要 bundleName(bundleManager.getBundleInfoForSelfSync(0).name 自解析),OH_PreferencesOption_SetBundleName 必须与运行中 HAP 一致;
  11. 模拟器 ABI 是 x86_64:HAP 缺对应 so 安装即报 9568347,双 abiFilters 一个都不能少;
  12. stateIn(Eagerly) 订阅是异步启动的,测试要在写入前预热等待 —— Compose 场景天然无此竞态,这是把 UI 时序搬进测试时必须补的一环;
  13. signingConfigs 保持空数组由 DevEco 自动签名回填,证书绝不进 AtomGit。

8.2 已知问题

  • @Composable API 不为 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 三条经验

  1. 纯 Compose 库的适配对象是"组合函数背后的管线":把 rememberSetting 的初值/监听/写语义搬进非 Compose 的类,上游文件一行不改地保留证据,JVM 交叉验证就能证明等价 —— 这比在 ArkTS 里重写状态逻辑可信得多;
  2. 制品线盘点决定依赖策略:coroutines 双 ohos 变体齐全所以走制品,settings 全线只有 arm64 所以源码内置 —— 两条策略并存于一个构建,各自收敛到最小内置面,且克隆即可复现;
  3. 崩溃要用证据链消灭: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。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐