本文记录 multiplatform-settings 接入 OpenHarmony 的完整过程,覆盖键值存储库盘点、系统 C API(OH_Preferences)的 cinterop 绑定、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 页面、HAP 构建和真机验收。

multiplatform-settings 是 Kotlin 社区事实标准的跨平台键值存储库:一套 Settings 接口覆盖 Int / Long / Float / Double / Boolean / String 六种类型的 put / get / getOrNull,加上 hasKey / remove / clear / keys / size 与 Settings.Factory,业务代码写在 commonMain,Android / iOS / 桌面各自出平台 actual。本次适配与社区早期路线(qrose / haze 的"零依赖内核提取"、KStore 的"kotlinx 制品线真编译")又不一样:这个库 commonMain 零第三方依赖,适配难点不在依赖矩阵,而在 actual 必须直连系统库——OhosPreferencesSettings 通过手写精简 C 头把 OH_Preferences_* 公开 C API 绑进 cinterop,链接期 -lohpreferences、运行期从设备 /system/lib64 动态解析,一条"带系统能力"的 KMP 适配链路完整跑通,ArkUI 页面作为验收宿主。

项目地址: AtomGit/oh-tpc/multiplatform-settings.git

上游仓库: russhwolf/multiplatform-settings

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP 适配

multiplatform-settings 为 Kotlin Multiplatform 应用提供"键值 ↔ 持久化"这一件事的标准答案:Settings 接口是全部门面,putInt / getStringOrNull / hasKey / remove / clear 覆盖键值生命周期,Settings.Factory 按平台注入构造器,Delegates.kt 还提供属性委托语法糖。Android 走 SharedPreferences,Apple 走 NSUserDefaults,JVM 走 java.util.Properties / Preferences。OpenHarmony 应用同样需要这套能力,但上游构建(Kotlin 2.1.20,一长串 Android / Apple / JS / wasm / mingw target)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明公共键值 API 在 OpenHarmony ARM64 设备上完成编译、链接和落盘。适配需要同时验证 Settings 接口语义、C API 绑定、类型映射、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.1.20 / 多 target 构建与鸿蒙工具链无法共存于一个 Gradle 构建,cinterop commonization 会把 ohos 和 linux 搅在一起。
C API 绑定难SDK 的 oh_preferences.h 带 __availability__ 属性并 #include <napi/native_api.h>,直接喂 cinterop 会撑爆 clang 前端。
类型不齐OH_Preferences 只有 SetInt / SetBool / SetString 三个 setter,Long / Float / Double 没有原生落点。
键枚举缺失C 侧 GetAll 走 PreferencesValue union,绑定成本高,keys / size 需要替代方案。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:Settings 接口的 ohos 编译、根构建替换(cmp_qrose 模式)、OH_Preferences 的 cinterop 绑定与 C ABI/N-API 桥接层。键值语义完全由上游接口约定,ArkTS 只负责调用 Native、解析 JSON 和展示结果。

1.2 库提供的能力

Demo 提供一个真实的键值存储会话,对应桥接的全部入口:

  • 初始化(init):按 bundleName + 文件名建 Factory 并打开 store,页面启动时自动完成;
  • 写入(put):六种类型全走 Settings.putX,值以字符串过桥、Native 侧再 parse;
  • 读取(get):getXOrNull 语义,缺 key 时 found=false、value=null,类型不匹配容忍为 null;
  • 查询(has):OH_Preferences_HasKey,Clear 后再查返回 false;
  • 删除(remove)与清空(clear):remove 删单键并更新索引,clear 按索引逐个删除;
  • 快照(snapshot):返回全部键值对及推断类型,覆盖 keys / size;
  • 持久化闭环(reopen):重新 Factory.create() 打开同一文件,Seed 后数据仍在——证明是 Flush 落盘而不是内存缓存;
  • 一键验收(seed):一次写入六种类型六条数据(Demo 快捷方式,非上游 API)。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
op / size回显本次操作与当前键数。
found / valueget 的读取结果;缺 key 时 value=null。
hasKeyhas 的查询结果。
keys / itemssnapshot 的键列表与带类型的键值明细。

接口侧完整能力(六类型读写、默认值、getOrNull null 语义、属性委托)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 jvmTest 交叉验证用例(上游 PropertiesSettings 一个字未改),证明 ohos .so 里执行的键值逻辑没有漂移。

1.3 实现适配

维度要求
代码复用commonMain 的 Settings.kt / Delegates.kt / Operators.kt 原样编译,不提取、不重写;新增的只有 ohosMain 一个 actual 与两份 cinterop 描述文件。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);coroutines / serialization / no-arg / datastore / make-observable 五个扩展 artifact 留盘不进构建。
桥接稳定使用 1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律本库 commonMain 零第三方依赖,无需 kotlinx 制品线,工具链直接用社区标准线 Kotlin 2.2.21-1.0.0。
UI 完整单页覆盖全部桥接入口:store 输入、键值输入、六类型选择、九个操作按钮、size 状态行与键值列表。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。multiplatform-settings 本身不碰 UI,适配面天然最小;ArkTS Demo 只是把桥暴露的键值闭环画出来。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认零依赖、确定交付物
第 2 阶段:目标与工具链打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版
第 3 阶段:C API 绑定与 actual ── prefs_min.h、cinterop、类型映射、sidecar 索引、短事务
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── bundleName 注入、六类型选择、九操作按钮、键值列表
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
multiplatform-settings/                 上游核心库(进 ohos 构建,jvm + ohosArm64 + ohosX64)
multiplatform-settings/src/ohosMain/    新增:cinterop(prefs_min.h / ohpreferences.def)+ OhosPreferencesSettings
multiplatform-settings/src/jvmMain/     上游 PropertiesSettings / PreferencesSettings(交叉验证宿主)
multiplatform-settings/src/jvmTest/     新增:JvmSmokeTest(round-trip 冒烟)
coroutines/ serialization/ no-arg/      上游扩展 artifact,留盘不进 settings
datastore/ make-observable/ test/
example/nativeApp/                      Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                     DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md          鸿蒙适配说明(中英双份)
README.upstream.md                      上游原文保存
sample/ tests/ convention-plugins/      上游示例与共享测试,留盘不进构建

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :multiplatform-settings 与 :example:nativeApp,rootProject.name = "ohos_multiplatform-settings"。上游的 convention-plugins(standard-configuration 约约插件)、55 行版本目录、多 target 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Eclipse Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 随根构建替换重新生成)
sysrootDevEco SDK native/sysroot(ohos.native.sysroot 属性注入,链接 -lohpreferences)
DevEco product5.1.0(18) 兼容 / API 26 实测
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.settings
export JAVA_HOME="/path/to/jdk-21"
java -version

注意:gradle.properties 里的 org.gradle.java.home 要等 Gradle 起来才生效,wrapper 自己启动 JVM 仍然需要先设 JAVA_HOME,否则 gradlew 直接报 JAVA_HOME is not set。

1.3 源码依赖度盘点
模块形态依赖处置
multiplatform-settingsSettings 接口 + 各平台 actual纯 Kotlin,零第三方依赖,零 UI整体编译(jvm + 双 ohos + 新增 ohos actual)
coroutines / serialization / no-arg / datastore / make-observable独立 artifactcoroutines / serialization / AndroidX DataStore留盘不进构建(P1 先跑核心)
sample / tests / convention-plugins上游示例与共享测试自带独立 Gradle 构建留盘不进构建

盘点结论是本次路线的起点:commonMain 一个第三方依赖都没有,Settings.kt 里没有 expect 声明(平台差异走接口 + 工厂,不走 expect/actual),所以既不用像 qrose 那样抽内核,也不用像 KStore 那样验证 kotlinx 制品线——ohosArm64() / ohosX64() 加上就能编。真正的工作量全部在平台 actual:把 OH_Preferences 的公开 C API 绑进 Kotlin/Native。

1.4 页面边界
标题区          multiplatform-settings · OpenHarmony / 副标题(KMP Settings + OH_Preferences C API)
状态行          最近一次操作结果(如 "seed size=6 store=demo")
store 区        文件名输入框(默认 demo)+ Init / Reopen 两按钮
键值区          key 输入框(默认 nickname)+ value 输入框(默认 OpenHarmony)
类型区          string / int / long / float / double / boolean 六个选择 chip
操作区          Put / Get / Has / Remove + Snapshot / Seed 6 types / Clear 七按钮
统计行          size=N  has=...(缺 key 时 found=false)
列表区          键值明细列表(键名 + 推断类型 + 值,滚动)

页面启动时先 bundleManager.getBundleInfoForSelfSync(0).name 取运行中包名,再自动发起 init,避免 bundleName 手写分叉。

第 2 阶段:目标与工具链打通

2.1 工具链钉版(为什么是 2.2.21-1.0.0)

社区此前批准的标准钉法就是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze / qrcode-kotlin 用的同一条线)。KStore 后来升到 2.4.20-0.1.0-07,是因为它的 coroutines 制品线基于 stdlib 2.4.20 构建,旧线读不了新元数据——工具链版本跟着依赖走。本库 commonMain 零依赖、jvmTest 只依赖 kotlin("test"),没有任何制品把版本顶上去,那就直接落回社区标准线 2.2.21-1.0.0: Gradle 相应钉 8.14.1(2.2.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 {
    repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_multiplatform-settings"

include(":multiplatform-settings")
include(":example:nativeApp")

ohosArm64() 这个函数只存在于 HarmonyOS 定制 Kotlin Gradle 插件,官方 JetBrains Kotlin 没有它。插件解析失败时,先看仓库顺序,再看版本号,不要去 JetBrains 文档里找 ohos target。

2.2 根构建替换与库模块

根 build.gradle.kts 收敛为版本声明 + ohos 测试任务禁用:

plugins {
    alias(libs.plugins.kotlin.multiplatform) apply false
}

allprojects {
    group = "com.russhwolf"
    version = providers.gradleProperty("version").orElse("1.3.0-ohos.1").get()
}

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }   // ohos 侧测试由 ArkTS Demo 当 L4 验收
}

库模块 multiplatform-settings/build.gradle.kts 声明三个目标与 cinterop:

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()                                   // 交叉验证用

    val sysroot = (project.findProperty("ohos.native.sysroot") as String).replace("\\", "/")
    val cinteropInclude = file("src/ohosMain/cinterop")

    listOf(ohosArm64(), ohosX64()).forEach { target ->
        target.compilations.getByName("main") {
            cinterops.create("ohpreferences") {
                defFile(cinteropInclude.resolve("ohpreferences.def"))
                includeDirs(cinteropInclude)
                compilerOpts("-I$cinteropInclude")
            }
        }
    }
}

实测:

./gradlew :multiplatform-settings:compileKotlinOhosArm64 :multiplatform-settings:compileKotlinOhosX64   # BUILD SUCCESSFUL

Settings 接口一个字没动,新增的只有 ohosMain 下的 actual 与 cinterop 文件。

第 3 阶段:C API 绑定与 actual(技术难点)

3.1 为什么不能直接喂 SDK 头

第一反应是把 SDK 的 oh_preferences.h 丢给 cinterop,编不过,原因很具体:其一,头文件用了 __attribute__((__availability__(ohos, introduced=13))) 这一套,cinterop 的 clang 前端认不全;其二,它 #include <napi/native_api.h>、oh_preferences_value.h,会把 NAPI 和 PreferencesValue 整棵树拉进来,绑定表面积突然变成"半个 ArkTS 运行时";其三,真正要用的只有十几个函数。处置是手抄一份精简头:

// src/ohosMain/cinterop/prefs_min.h(节选)
struct OH_Preferences;
struct OH_PreferencesOption;

struct OH_PreferencesOption* OH_PreferencesOption_Create(void);
void OH_PreferencesOption_SetBundleName(struct OH_PreferencesOption*, const char*);
void OH_PreferencesOption_SetFileName(struct OH_PreferencesOption*, const char*);
void OH_PreferencesOption_SetDataGroupId(struct OH_PreferencesOption*, const char*);
void OH_PreferencesOption_Destroy(struct OH_PreferencesOption*);

struct OH_Preferences* OH_Preferences_Open(const struct OH_PreferencesOption*);
void OH_Preferences_Close(struct OH_Preferences*);
int OH_Preferences_GetInt(const struct OH_Preferences*, const char*, int*);
int OH_Preferences_SetInt(const struct OH_Preferences*, const char*, int);
int OH_Preferences_HasKey(const struct OH_Preferences*, const char*, bool*);
int OH_Preferences_Flush(const struct OH_Preferences*);
/* GetBool / GetString / SetBool / SetString / Delete / FreeString 同款手抄 */
# src/ohosMain/cinterop/ohpreferences.def
headers = prefs_min.h
package = ohos.preferences
compilerOpts = -I.
3.2 cinterop 的 Kotlin 视图不是头文件的镜像

绑定生成后,Kotlin 侧看到的不是 ohos.preferences.OH_Preferences 这个类:函数在包 ohos.preferences,不透明结构体在 cnames.structs.OH_Preferences,bool * 映射成 kotlinx.cinterop.BooleanVar 而不是 platform.posix.boolVar。第一版 actual 按头文件里的名字原样 import,编译器直接报 Unresolved reference: OH_Preferences 和 Unresolved reference: boolVar。修法是用 klib dump-metadata-signatures 看一眼生成物再改 import——cinterop 的 Kotlin 视图和 C 头不是一一对应,不要猜。

3.3 类型映射决策

OH_Preferences 的 setter 只有三个,类型映射是本适配唯一需要拍板的语义问题:

Settings 调用落盘方式原因
putInt / getIntSetInt / GetIntC API 原生支持
putBoolean / getBooleanSetBool / GetBoolC API 原生支持
putString / getStringSetString / GetStringGetString 返回的堆内存必须 OH_Preferences_FreeString
putLong / putFloat / putDoubleSetString 存十进制文本C API 没有 int64 / float / double setter

读侧 getLongOrNull 先按字符串 parse,失败再 getIntOrNull 再 widen——"以前用 int 存的计数器,后来改成 long 读"不会直接崩。还有一个只有运行时才会暴露的坑:GetString 撞上 count 这种 Int 键,返回码不是 KEY_NOT_FOUND 而是类型不匹配,第一版把"非 OK 且非 NOT_FOUND"直接 error(),结果 Snapshot 遍历到第一颗 Int 键就抛异常,列表只剩 2 个键。修法:类型对不上就当 null,让 Snapshot 继续用 GetInt / GetBool 认类型。

3.4 keys / size 的 sidecar 索引

C 侧 OH_Preferences_GetAll 走 PreferencesValue union 数组,cinterop 成本高,这轮不绑。折中是写一个 sidecar 键 __ohos_settings_index,值为换行分隔的 key 列表:put* 时入集,remove / clear 时同步更新,对调用方不可见。限制写进文档:绕过本类直接改文件的写入会让索引漂移——Demo 与常规 KMP 业务都走同一个 Settings 实例,不会踩。

3.5 每调用一次短事务

actual 的每次读写都走同一条短事务:

// OhosPreferencesSettings.kt(骨架)
private inline fun <T> withStore(block: (CPointer<OH_Preferences>) -> T): T {
    val option = checkNotNull(OH_PreferencesOption_Create()) { "option create failed" }
    OH_PreferencesOption_SetBundleName(option, bundleName)
    OH_PreferencesOption_SetFileName(option, fileName)
    OH_PreferencesOption_SetDataGroupId(option, "")
    val store = checkNotNull(OH_Preferences_Open(option)) { "open failed bundle=$bundleName file=$fileName" }
    try {
        return block(store)
    } finally {
        checkOk(OH_Preferences_Flush(store), "flush")
        OH_Preferences_Close(store)
        OH_PreferencesOption_Destroy(option)
    }
}

把 Open/Close 放在每次调用里而不是进程级单例,是为了 Demo 的 Reopen 按钮有意义:点 Reopen 等于重新 Factory.create(),如果上一次没 Flush,新句柄读到的就是空。不 Flush,Reopen 后 size=0;Seed 后点 Reopen 仍是 6,说明真的落盘了。bundleName 必须与 HAP 的 app.json5 一致,写错时 Open 返回空指针——Demo 启动时运行时取自身包名,避免手写分叉。

链接期,nativeApp 的 sharedLib 必须加 sysroot 与系统库(不加的话 OH_Preferences_Open 以 UND 留在 so 里,运行时 dlopen 找不到符号):

ohosArm64 {
    binaries.sharedLib {
        baseName = "ohossettings"
        linkerOpts("-L$sysroot/usr/lib/aarch64-linux-ohos", "-lohpreferences")
    }
}

libohpreferences.so 在设备 /system/lib64,不打进 HAP;llvm-readelf -d 里能看到这些符号是 UND FUNC,运行时由系统库提供。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libsettings_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohossettings.so)
  │ SettingsCall
  ▼
OH_Preferences C API(系统库 libohpreferences.so)

HarmonyOS Kotlin 的 CAdapter 会给 sharedLib 生成 libohossettings_api.h 与 libohossettings_symbols(),但不会把业务 @CName 挂到 ArkTS 的 NAPI 模块表——ELF 里看得到 SettingsCall,ArkTS 运行时 import 不到(qrose / qrcode-kotlin 同款结论)。所以仍然要 CMake 再包一层 NAPI。

4.2 Kotlin/Native 导出函数
@OptIn(ExperimentalNativeApi::class)
@CName("SettingsCall")
fun settingsCall(request: String): String   // init/put/get/has/remove/clear/snapshot/reopen/seed 一口进
C ABI 符号ArkTS 方法覆盖内核
SettingsCallSettingsCall(req)Factory 构造 + 六类型 put/get + hasKey / remove / clear / keys / size / reopen / seed

JSON 解析是手写的极简扫描(只认字符串字段),故意不拉 kotlinx.serialization——KN 侧多一个依赖会把 ohos 目标的依赖图重新打乱,这个库的卖点就是零依赖。

4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"SettingsCall", nullptr, NapiSettingsCall, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohossettings_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。

4.4 CMake 和类型声明
add_library(ohossettings SHARED IMPORTED)
set_target_properties(ohossettings PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(settings_napi SHARED napi_init.cpp)
target_link_libraries(settings_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohossettings)

OHOS_ARCH 为空或 libohossettings.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++_shared.so 三件套同目录,dlopen 时互相可见。KN so 的 DT_NEEDED 同时含 libc++_shared.so(要打进 HAP)和 libohpreferences.so(设备 sysroot 提供)。

export const SettingsCall: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 注入 bundleName 和触发操作
aboutToAppear(): void {
  this.bundleName = bundleManager.getBundleInfoForSelfSync(0).name;   // 运行时取真实包名
  this.call({ op: 'init', bundleName: this.bundleName, name: this.storeName });
}

private call(payload: CallReq): void {
  if (this.busy) { return; }
  this.busy = true;
  try {
    const raw = SettingsCall(JSON.stringify(payload));   // 进 Kotlin/Native
    const parsed = JSON.parse(raw) as CallOk;
    if (!parsed.ok) {
      this.status = `失败: ${parsed.error ?? raw}`;      // 错误不吞,直接上屏
      return;
    }
    this.kvCount = parsed.size ?? this.kvCount;
    if (parsed.items) { this.items = parsed.items; }
    this.status = `${parsed.op ?? 'ok'}  size=${this.kvCount}  store=${this.storeName}`;
  } finally {
    this.busy = false;
  }
}
5.2 页面交互

单页自上而下:store 输入框 + Init / Reopen、key / value 输入框、六个类型选择 chip(选中反色)、Put / Get / Has / Remove 与 Snapshot / Seed 6 types / Clear 两排按钮、size=N has=... 统计行、键值明细列表(键名加粗、类型 = 值 副行)。seed 一键写入 nickname / count / big / ratio / pi / on 六条六类型,截图验收不依赖输入法。

5.3 对比度和错误边界

主操作 Put 蓝、Get 蓝、Has 紫、Remove 棕、Snapshot 青、Seed 深蓝、Clear 红,破坏性语义与颜色对齐;ok:false 时错误原文进状态行。@State 变量不能叫 size——和 CustomComponent.size 冲突,ArkTS 严格模式编译期直接红,改名 kvCount。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohossettings)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName("SettingsCall") + MiniJson(继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.settings、versionName 1.3.0-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/libohossettings_api.h
        ├── src/main/cpp/types/libsettings_napi/{Index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :multiplatform-settings:jvmTest                                   # 交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填)。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.settings -m entry

四、完整代码对照

4.1 整体架构

com.russhwolf.settings/**(上游接口原样编译)
    │ Settings / Settings.Factory / Delegates / Operators
OhosPreferencesSettings(ohosMain actual)
    │ OH_Preferences_* via cinterop(prefs_min.h)
    │ 类型映射 + sidecar 索引 + Open→Flush→Close 短事务
Bridge.kt + MiniJson
    │ SettingsCall
libsettings_napi.so
    │ SettingsCall
Index.ets
    │ JSON parse + @State 更新
    ▼
ArkUI 真机页面(单页滚动)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec 双仓置前、ohos_multiplatform-settings、扩展 artifact 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 钉版(上游 55 行目录收敛为 8 行)
build.gradle.kts(根)版本 1.3.0-ohos.1、全局禁用 Ohos*Test* 任务
multiplatform-settings/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64 + ohpreferences cinterop
src/ohosMain/cinterop/prefs_min.h / ohpreferences.def手写精简 C 头与绑定描述
src/ohosMain/.../OhosPreferencesSettings.kt核心 actual:类型映射、索引、短事务
src/jvmTest/.../JvmSmokeTest.ktJVM 交叉验证(上游 PropertiesSettings 宿主)
example/nativeApp/build.gradle.kts双 ABI sharedLib、-lohpreferences、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、九个 op 分发、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(28 节)

4.3 关键 API 对照

层次API作用
接口Settings.putInt / getIntOrNull六类型读写门面(commonMain,未改)
接口Settings.hasKey / remove / clear键生命周期
接口Settings.keys / size键枚举(ohos 走 sidecar 索引)
接口Settings.Factory.create(name)按文件名开 store
actualOH_Preferences_Open / Flush / Close短事务三件套
actualOH_Preferences_HasKeyAPI 23+ 的存在性查询
actual__ohos_settings_indexkeys / size 的 sidecar 索引
NativeSettingsCallC ABI JSON 导出
N-APISettingsCall(request)向 ArkTS 暴露能力
ArkTScall(payload)触发操作并刷新 UI

4.4 ArkTS 与 Kotlin 的边界

const raw = SettingsCall(JSON.stringify({
  op: 'put', type: 'string', key: 'nickname', value: 'OpenHarmony'
}));
// {"ok":true,"op":"put","key":"nickname","type":"string","size":1,...}

const got = SettingsCall(JSON.stringify({ op: 'get', type: 'string', key: 'nickname' }));
// {"ok":true,"found":true,"value":"OpenHarmony",...};缺 key 时 found=false、value=null
val settings = OhosPreferencesSettings.Factory(bundleName).create("demo")
settings.putString("nickname", "OpenHarmony")
settings.putInt("count", 7)
settings.hasKey("nickname")     // true
settings.remove("nickname")
settings.clear()

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;init 必须先做,否则所有 op 返回 call init first。

五、关键决策说明

决策 1:走公开 C API OH_Preferences,不绕 ArkTS Preferences

ArkTS @ohos.data.preferences 是给 ArkTS 业务用的:上下文是 Context,API 是 Promise/async。KMP 业务运行在 Kotlin/Native 线程,没有 JS runtime,也不能 await——把 KN 的 putInt 回调到 ArkTS 再写 Preferences,每条配置都要跨一次 NAPI 还要传 Ability 生命周期。OH_Preferences 是给 Native 用的同步 API,正好对上 KN 的调用习惯。边界在 C ABI,不在 UI 层。

决策 2:工具链留社区标准线 2.2.21-1.0.0

KStore 升 2.4.20-0.1.0-07 是被 coroutines 制品的 stdlib 元数据顶上去的;本库零依赖,没有任何制品施压,直接落回社区标准线(qrose / haze / qrcode-kotlin 同款),Gradle 相应钉 8.14.1。工具链版本跟着依赖走——没有依赖,就跟社区已审线走,两条经验不要互相套用。

决策 3:Long / Float / Double 按十进制文本存储

C API 没有 int64 / float / double setter,硬造 union 或拆高低位都是给未来埋雷。文本 round-trip 无损,读侧 getLongOrNull 先 parse 字符串、失败再 fallback getIntOrNull widen,"int 升 long"的旧数据兼容。代价是不能和原生 Int 列混做范围查询——对 Settings 这种 API 够用,限制写进文档。

决策 4:sidecar 索引而不是绑定 GetAll

OH_Preferences_GetAll 走 PreferencesValue union,cinterop 成本高、释放路径复杂。一个换行分隔的索引键就能让 keys / size 工作,put / remove / clear 顺带维护。漂移风险(绕过本类直接改文件)是明确边界而不是隐患——Demo 与常规业务都走同一个 Settings 实例。

决策 5:jvmTest 交叉验证 + 每调用短事务

上游 commonTest 随多 target 裁剪没法保留,换成 jvmTest 冒烟(宿主是上游 PropertiesSettings,一个字未改)——它绿,说明 Settings 接口的行为约定(默认值、null 语义、remove/clear 后的 keys/size)没被适配动过。每调用 Open→op→Flush→Close 的短事务则让 Demo 的 Reopen 天然成为落盘验收:语义最稳,代价是不是热路径——高频场景留待 transaction { } 批量接口。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x86_64)、JDK 21(Eclipse Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / ohpm / hdc)、DevEco 模拟器(x86_64)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :multiplatform-settings:jvmTest

上游 PropertiesSettings(JVM actual,零改动)作宿主:

测试类用例钉住什么
JvmSmokeTest.putGetRoundTrip1六类型 put/get 往返、默认值语义
JvmSmokeTest.missingAndRemove1getOrNull 的 null 语义、remove 后 hasKey=false

用例数不多,但钉的是接口契约本身:ohos actual 实现的就是这份契约,PropertiesSettings 全绿即"公共 API 没有漂"。ohos 落盘语义(Flush、类型映射、索引)由设备端验收覆盖,两层互补。

6.3 HAP 验证

:multiplatform-settings:jvmTest: BUILD SUCCESSFUL
compileKotlinOhosArm64 / compileKotlinOhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: SettingsCall / libohossettings_symbols 均为全局 T 符号
llvm-readelf -d: NEEDED 全是短 soname(libohpreferences.so / libc++_shared.so 等,无绝对路径泄漏)
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}
BuildNativeWithCmake: finished(双 ABI 编出 libsettings_napi.so)
CompileArkTS / PackageHap: finished
install bundle successfully / start ability successfully

链接产物实测:KN so libohossettings.so arm64 约 3.7MB、x86_64 约 3.4MB(debugShared,含 Kotlin/Native 运行时),libc++_shared.so 分别约 1.8MB / 1.7MB;libohpreferences.so 不进 HAP,由设备 sysroot 提供。

6.4 功能用例

  1. 启动首屏状态行出现 init size=0 store=demo(aboutToAppear 自动 init,bundleName 运行时获取);
  2. 点 Seed 6 types,size=6,列表按序显示 big / count / nickname / on / pi / ratio 六条六类型;
  3. 点 Get(string / nickname),状态行 Get nickname (string) = OpenHarmony;
  4. 点 Has,统计行 has=nickname=true;
  5. 点 Reopen,size 仍为 6——重新 Factory.create() 后数据仍在,Flush 落盘成立;
  6. 点 Remove,size=5,列表不再有 nickname;
  7. 点 Clear,size=0,列表清空;
  8. Clear 后再 Get,value=null、found=false——缺 key 失败态;
  9. Snapshot 扫到 Int 键 count 不抛异常(类型不匹配容忍为 null,用 GetInt 认类型)。

6.5 验证结论

Settings 接口语义、OH_Preferences C API 绑定、类型映射、sidecar 索引、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的键值会话:六类型读写、存在性查询、删除清空与 Reopen 持久化闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/multiplatform-settings.git
cd multiplatform-settings
export JAVA_HOME="/path/to/jdk-21"
./gradlew :multiplatform-settings:jvmTest                                    # 交叉验证
./gradlew :multiplatform-settings:compileKotlinOhosArm64 :multiplatform-settings:compileKotlinOhosX64   # klib
./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-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.settings -m entry
hdc shell hilog | grep SettingsNapi

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :multiplatform-settings 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. SDK 的 oh_preferences.h 不能直接喂 cinterop(__availability__ 属性 + napi/native_api.h include 树),必须手写 prefs_min.h;
  4. cinterop 的 Kotlin 视图不是 C 头的镜像:不透明结构体在 cnames.structs.OH_Preferences、bool* 是 kotlinx.cinterop.BooleanVar,用 klib dump-metadata-signatures 对齐,不要猜;
  5. GetString 撞上 Int 键返回的不是 KEY_NOT_FOUND:把"非 OK 非 NOT_FOUND"直接 error() 会让 Snapshot 一颗 Int 键就断流(列表只剩 2 项),类型不匹配要容忍为 null;
  6. 不 Flush 就 Close,Reopen 后 size=0——每次短事务结束必须 Flush,这是持久化的唯一保证;
  7. bundleName 写死必错:与 HAP app.json5 不一致时 Open 直接返回空指针,运行时用 getBundleInfoForSelfSync 取自身包名;
  8. CAdapter 不把业务 @CName 挂到 ArkTS:ELF 里看得到 SettingsCall 也 import 不到,必须 CMake 再包一层 NAPI;
  9. 漏拷 libc++_shared.so 时 dlopen 失败、页面白屏——KN so 的 DT_NEEDED 必须同目录满足,copy 任务按 ABI 各拷一份;
  10. @State 变量不能叫 size(与 CustomComponent.size 冲突),ArkTS 严格模式编译期直接红,改名 kvCount;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  12. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();gradlew 启动自身还需要 JAVA_HOME,org.gradle.java.home 要等 Gradle 起来才生效。

8.2 已知问题

  • coroutines / serialization / no-arg / datastore / make-observable 五个扩展 artifact 未移植(留盘不进构建),需要 Flow 与序列化委托的业务请继续在本仓加模块;
  • Long / Float / Double 是文本存储:能 round-trip,不能与原生 Int 列混做范围查询;
  • keys / size 依赖 sidecar 索引:绕过本类直接改文件的写入会让索引漂移;
  • 没有 ObservableSettings:OH_Preferences_Subscribe 未绑,UI 刷新靠主动 Snapshot;
  • 每次调用 Open / Flush / Close,适合低频配置,不是热路径;
  • 多进程并发写未测试(OH_Preferences 文档允许跨进程);
  • android / js / wasmJs / apple / mingw 目标剥离(源文件留在树里),本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.settings 匹配的签名。

8.3 未来优化方向

  • 绑定 OH_Preferences_GetAll,用真实全量读取替代 sidecar 索引;
  • 绑定 OH_Preferences_Subscribe,补 ObservableSettings 与 coroutines Flow artifact;
  • 在 OhosPreferencesSettings 加 transaction { } 批量接口(一次 Open、多次 Set、一次 Flush);
  • 移植 multiplatform-settings-coroutines / serialization 扩展 artifact;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套。

九、总结

9.1 核心难点回顾

上游 multiplatform-settings(commonMain 零依赖)
    → 工具链对齐 Kotlin 2.2.21-1.0.0(社区标准线,Gradle 8.14.1)
    → 根构建替换(cmp_qrose 模式,扩展 artifact 留盘)
    → ohosArm64 / ohosX64 KLIB + cinterop(手写 prefs_min.h)
    → OhosPreferencesSettings(类型映射 + sidecar 索引 + 短事务 Flush)
    → C ABI(@CName SettingsCall)+ JSON 协议
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析
    → ArkUI 真机单页验收(六类型读写 / Reopen 持久化闭环)

9.2 封装层次

  • 接口层:上游原版 Settings 门面、六类型读写、Factory、属性委托;
  • 平台层:OhosPreferencesSettings actual——OH_Preferences 绑定、类型映射、sidecar 索引、短事务;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、操作分发、类型选择与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 系统能力型库的正确答案是直连公开 C API,而不是绕 ArkTS:KMP 业务在 Kotlin/Native 线程上,边界是同步 C ABI;OH_Preferences 与 SharedPreferences / NSUserDefaults 同构,actual 一个类收口全部平台差异;
  2. cinterop 不要喂 SDK 头:availability 属性和传递 include 会撑爆 clang 前端,手写精简头 + dump-metadata-signatures 对齐 Kotlin 视图,绑定面积只留真正用到的十几个符号;
  3. 存储类库的验收天然落在持久化闭环上:jvmTest 钉住"接口没漂",Reopen 钉住"Flush 真落盘"——每调用一次 Open→op→Flush→Close 的短事务让 Demo 的 Reopen 按钮本身就是验收用例。

9.4 适配成果

当前适配已完成:

  • multiplatform-settings 1.3.0 核心库的双 ABI Kotlin/Native 编译(上游 commonMain 零改动,新增仅 ohos actual 与 cinterop 描述);
  • OH_Preferences 公开 C API 在 KMP 库上的完整落地(手写精简头、类型映射、sidecar 索引、短事务 Flush,链接期 -lohpreferences、运行期系统库解析);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号、llvm-readelf 验证短 soname);
  • DevEco Stage ArkUI 单页验收页面(六类型读写、存在性查询、删除清空、Reopen 持久化、缺 key 失败态);
  • JVM 交叉验证全绿 + 双 ABI 链接(KN so 3.7MB / 3.4MB)+ HAP 构建、签名、真机安装与运行;
  • AtomGit 项目文档、OpenHarmony README(中英双份)、上游 README 留档与本地效果图。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐