开源鸿蒙平台 CMP 三方库「K-Spinner」适配全流程
本文记录
K-Spinner接入 OpenHarmony 的完整过程,覆盖上游库盘点、纯 UI 组件的内核边界判定、行为语义提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
K-Spinner是一个 Compose Multiplatform 下拉选择器(Spinner)库:一个 Material3Card锚点加DropdownMenu弹层,三个内容 slot(selectedOptionItem/dropDownOption/dropDownIcon)把行的长相完全交给调用方,选择是受控的(selectedOption是参数、onOptionSelected只上报),开合状态由rememberSaveable管理,外加onOptionsShown/onOptionsHidden两个生命周期回调。本次适配与 KStore 的"真编译整库"路线有本质区别:K-Spinner 的全部对外面就是一个@Composable,上游 Compose 1.8.2 / Kotlin 2.2.20 与社区 CMP 鸿蒙线(1.9.2 / HarmonyOS Kotlin 2.2.21)对不齐,UI 无法编进 ohos——因此走社区已过审的内核下沉路线(cmp_qrose / Dropdown / day-and-night-switch / KMP-Searchable-Dropdown 同款):把上游组件决定的东西(开合状态机、事件回调时序、锚点/菜单几何契约、Material3 常量)逐条提取为无 Compose 内核原样编译成 OpenHarmony KLIB,把上游组件画的东西留给 ArkTS 重建,每一次行为决策都经 NAPI 往返真实 Kotlin 语义。
项目地址: AtomGit/oh-tpc/K-Spinner.git
上游仓库: RufenKhokhar/K-Spinner
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
K-Spinner 为 Compose Multiplatform 应用提供"Material3 外观、slot 化、受控"的单选下拉这一件事的完整答案:KSpinner<T> 一个泛型 Composable 覆盖锚点卡片、弹层菜单、尾部图标三个 slot,点锚点展开、点选项收起并上报、点外部关闭,展开/收起还有埋点回调。KMP 项目把它放在 commonMain,由 Android、iOS 共享。OpenHarmony 应用同样需要这套交互,但上游构建(Kotlin 2.2.20 / Compose 1.8.2 / AGP 8.13)不会生成 OpenHarmony 变体,而且这套 Material3 依赖在社区 CMP 鸿蒙线上编不过。
如果只在 ArkTS 里手写一个下拉,页面可以转起来,却无法证明上游的行为语义在 OpenHarmony ARM64 设备上完成编译、链接和执行——而且手写的状态机必然漂移:上游 LaunchedEffect(showOptions) 在首次组合时就会以 false 触发一次 onOptionsHidden,已展开时重复点击锚点一个事件都不发,选项点击的选择回调先于重组后的隐藏回调——这些怪癖恰恰是埋点、自动化测试最容易踩的坑。适配需要同时验证行为内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.2.20 / Compose 1.8.2 / Material3;社区 CMP 鸿蒙线是 Compose 1.9.2 / HarmonyOS Kotlin 2.2.21-1.0.0,@Composable 进不了 ohos 编译单元。 |
| UI 无法整体编译 | 与 KStore 不同,这个库没有 kotlinx 制品线可用——它的依赖是 androidx.compose.* 本身,社区鸿蒙线没有对应制品。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 语义跨不了桥 | 泛型 T、@Composable slot lambda 是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:行为语义内核(状态机 + 事件契约 + 几何契约 + M3 常量镜像)的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。行为语义完全由提取自上游源码的 Kotlin 内核负责,ArkTS 只负责调用 Native、解析 JSON 和重建呈现。
1.2 库提供的能力
Demo 提供一个真实的下拉选择会话,对应桥接的全部入口:
- 版本握手:
op=catalog返回 fork 版本号与全部 M3 常量(锚点最小宽高、行内边距、圆角),来自真实 Native 调用而非静态文字,ArkTS 侧只留紧急 fallback; - 锚点展开:
op=anchorClick复刻onClick = { showOptions = true }——已展开时键值不变LaunchedEffect不重启,一个事件不发; - 选项选中:
op=select复刻onClick = { showOptions = false; onOptionSelected(option) }——同步选择回调先于重组后的隐藏回调,事件顺序恒为optionSelected → optionsHidden;越界 index 被内核拒绝; - 点外关闭:
op=dismiss复刻onDismissRequest——只在展开时触发,关着时是 no-op; - 初始怪癖:
op=init返回初始状态,hidden=1——上游LaunchedEffect首次组合即以false运行一次,onOptionsHidden在任何交互前已触发; - 几何契约:
op=geometry复刻defaultMinSize(280, 56)下限与Modifier.width(anchorSize.width)——未测量回退下限、低于下限被钳制、菜单宽度恒等于锚点宽度。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op / open / selected | 回显本次操作与最新开合状态、选中索引(-1 = 未选)。 |
shown / hidden | onOptionsShown / onOptionsHidden 的累计触发次数,埋点对账直接可用。 |
lastEvents | 本次迁移触发的事件,按真实时序排列。 |
上游 Composable 的完整行为面(受控选择、rememberSaveable 开合、三个 slot、开合回调)全部映射进内核并随编译进入 .so,JVM 侧保留 12 项语义测试交叉验证,证明 ohos .so 里执行的行为逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 行为语义逐条提取(verbatim 编码),KSpinner.kt / KSpinnerTests.kt git mv 进 src/composeMain 字节不变隔离,不删不改。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample/composeApp、sample/iosApp 留盘不进构建。 |
| 桥接稳定 | 单个 @CName C ABI 和 UTF-8 JSON,无状态设计(ArkTS 持状态、内核纯函数),异常永远不跨 FFI。 |
| 依赖纪律 | 内核零依赖(无 Compose、无 kotlinx),HarmonyOS Kotlin 2.2.21-1.0.0 钉版,版本号 1.0.1-ohos.1 跟随上游。 |
| UI 完整 | 三个 Tab 覆盖全部桥接入口:基础下拉(事件计数)、行样式画廊(四种 slot)、试验台(几何契约 + 错误通道 + 版本握手)。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。K-Spinner 上游全部是 UI,适配面天然最大;ArkTS Demo 的职责是把桥暴露的行为闭环画出来,并让每一次点击都看得见内核的裁决。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、查重(Dropdown / KMP-Searchable-Dropdown)、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、composeMain 源码集隔离
第 3 阶段:桥协议建模 ── 六个 op 的 JSON 契约、状态四元组回传、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 内核驱动的下拉面板、四种行样式、几何试验台和版本握手
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
kspinner/ 库模块(内核进 ohos 构建,jvm + ohosArm64 + ohosX64)
kspinner/src/commonMain/ 新增:KSpinnerSpec(M3 常量镜像)+ KSpinnerKernel(状态机/几何)
kspinner/src/commonTest/ 新增:12 项内核语义测试(jvmTest 运行)
kspinner/src/composeMain/ 上游 KSpinner.kt / KSpinnerTests.kt(git mv 字节不变,不编译)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 三 Tab 页面
README.md / README.OpenHarmony.md 首页与鸿蒙适配说明(中英双份)
README.OpenHarmony_CN.md 鸿蒙适配主文档(25 节)
README.upstream.md 上游原文保存
sample/ 上游 Android/iOS 示例,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :kspinner 与 :example:nativeApp,rootProject.name = "ohos_kspinner"。上游的 compose / android / vanniktech 发布插件、AGP classpath、Maven Central 发布段全部随根构建替换剥离——本 fork 不发 Maven Central,gradle.properties 里上游遗留的签名密钥与账号明文一并清除。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin 21.0.12,org.gradle.java.home 钉绝对路径) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 随 cmp_qrose 模板;2.2.21 线必须 8.x) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.kspinner |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
| 模块 | commonMain 文件 | 依赖形态 | 处置 |
|---|---|---|---|
kspinner | 1(KSpinner.kt,121 行) | androidx.compose.material3 + compose.ui,100% @Composable | 语义提取进 commonMain,原件隔离进 composeMain |
与 KStore 的对照最能说明问题:KStore 的 commonMain 是纯 Kotlin + kotlinx,ohos 制品线补齐后可以整库真编译;K-Spinner 的 commonMain 只有 Compose 依赖,鸿蒙线上没有 androidx.compose.* 制品,整库编译无从谈起。可行切口是把"决策"与"绘制"拆开:
// 上游 KSpinner.kt 里真正做决策的行,一共三块:
var showOptions by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(showOptions) { if (showOptions) onOptionsShown() else onOptionsHidden() }
// ↑ 开合状态机 + 事件契约(含首帧怪癖、键控不重启)
onClick = { showOptions = false; onOptionSelected(option) }
// ↑ 选择时序(同步回调先于重组后 effect)
defaultMinSize(TextFieldDefaults.MinWidth, TextFieldDefaults.MinHeight)
DropdownMenu(..., modifier = Modifier.width(anchorSize.width))
// ↑ 几何契约(下限、实测、菜单贴锚点宽)
这三块 verbatim 编码为 KSpinnerKernel,其余全部留在 composeMain。
1.4 查重边界
社区里已有两个"名字里带下拉"的适配,必须先划清界线:
| 维度 | Dropdown | KMP-Searchable-Dropdown | K-Spinner(本仓) |
|---|---|---|---|
| 上游类型 | 菜单动画库 | 搜索 + 多选下拉 | 受控 Material3 spinner |
| 核心难点 | 动画曲线数学 | 搜索过滤 + 多选上限 | 开合状态机 + 事件时序 + 几何契约 |
| 下沉的东西 | 动画数学(逐点交叉验证) | 内核门面(逻辑 verbatim) | 内核门面(逻辑 verbatim) |
| 行内容 | 动画驱动 | 组件自带行 | 完全 slot 化,行是调用方的 |
| 选择模型 | — | 多选 + 上限 | 单选、受控 |
三者职责不重叠,一个应用可以同时要"可搜索的多选"和"Material3 外观的单选 spinner",两个库各干各的。
1.5 页面边界
标题区 K-Spinner · OpenHarmony
Tab 切换 基础下拉 / 行样式画廊 / 试验台 · 关于
基础下拉 Countries 四国示例(上游 README 同款)+ 事件计数 + 怪癖说明
行样式画廊 Minimal / Subtitle / Badge / Avatar 四种行 + 340vp 宽锚点
试验台 geometry 四态试验、错误通道(未知 op / 越界 index)、catalog 握手
关于区 架构分层、仓库与社区链接
页面启动时先调 op=catalog 做握手,M3 常量全部来自内核,ArkTS 不硬编码上游语义。
第 2 阶段:目标与依赖打通
2.1 为什么是内核提取而不是整库编译
先查依赖可达性:KStore 那次适配的成败手是 kotlinx ohos 制品线;K-Spinner 的依赖表是 compose.runtime / compose.foundation / compose.ui / compose.material3(JB Compose 1.8.2 线)。社区 CMP 鸿蒙线是 1.9.2 / Kotlin 2.2.21,两条 Compose 线互不兼容——上游快照在鸿蒙工具链上 sync 都过不了。结论与 KStore 相反:能带过河的只有 Compose 无关的行为语义,这正好是 qrose / Dropdown / day-and-night-switch / KMP-Searchable-Dropdown 已过审的同一条路。
2.2 工具链钉版(为什么是 2.2.21-1.0.0)
K-Spinner 内核零依赖,没有"制品线反推版本"的问题,直接用社区标准钉法:HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同款),Gradle 降到 8.14.1(2.2.21 线不支持 9.x)。上游 Kotlin 2.2.20 与 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()
}
}
rootProject.name = "ohos_kspinner"
include(":kspinner")
include(":example:nativeApp")
gradle/libs.versions.toml 收敛到只剩一个版本一个插件(compose / android / publishing 全删):
[versions]
kotlin = "2.2.21-1.0.0"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
2.3 编译行为内核
kspinner/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {} // 零依赖内核
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
上游 KSpinner.kt 与 KSpinnerTests.kt 用 git mv 挪进 src/composeMain/——composeMain 不是注册的源码集,Gradle 完全不编译它,字节级保留原地等待社区 compose.ui ohos 制品对齐后接回。实测:
.\gradlew.bat :kspinner:compileKotlinJvm :kspinner:jvmTest # BUILD SUCCESSFUL,12/12
2.4 测试任务的处置:ohos 目标没有测试运行器
与 KStore 同款纪律:语义验证统一在 JVM 跑(jvmTest),根构建全局禁用 ohos 测试任务:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
上游的 Compose UI 测试(compose.uiTest)随原件一起隔离在 composeMain,鸿蒙侧只承担编译与链接。
第 3 阶段:桥协议建模
3.1 JSON 契约
io.github.rufenkhokhar/**(行为内核,语义提取自上游 @Composable)
↓ KSpinnerKernel 状态机 / 事件契约 / 几何契约
Bridge.kt(@CName × 1,扁平 JSON 进出,无状态)
↓ UTF-8 JSON
N-API kspinnerDispatch()
↓
ArkUI Index.ets(三 Tab)
请求与响应:
请求 : {"op":"anchorClick","open":false,"selected":-1,"shown":0,"hidden":1}
{"op":"select","index":2,"optionCount":4,"open":true,...}
{"op":"dismiss","open":true,...}
{"op":"init"} / {"op":"geometry"} / {"op":"geometry","widthDp":340,"heightDp":64}
{"op":"catalog"}
响应 : {"ok":true,"op":"...","open":bool,"selected":int,"shown":int,"hidden":int,
"lastEvents":["optionsShown"]}
{"ok":false,"error":"..."}
一个关键设计:桥是无状态的(与已过审的 KMP-Searchable-Dropdown 同型)。ArkTS 持有控件状态并渲染,但状态四元组(open / selected / shown / hidden)随每次请求回传,内核是纯函数——每一次行为决策都是一次真实的 NAPI 往返,Dubug 时抓一条 hilog 就能对账。
3.2 六个 op 与内核的映射
| op | 请求字段 | 响应字段 | 对应内核 |
|---|---|---|---|
init | 无 | open/selected/shown/hidden/lastEvents/note | initialState(),带首帧怪癖说明 |
anchorClick | 状态四元组 | 同上 | onAnchorClick(state) |
select | index/optionCount + 状态四元组 | 同上 | onOptionSelected(state, index, optionCount) |
dismiss | 状态四元组 | 同上 | onDismiss(state) |
geometry | widthDp/heightDp(可省) | widthDp/heightDp/menuWidthDp/minFallback | anchorGeometry(w, h) |
catalog | 无 | version/spec/events/ops/quirks | KSpinnerSpec 常量清单 |
selected=-1 表示未选;lastEvents 按真实触发顺序排列(选择时恒为 ["optionSelected","optionsHidden"])。catalog 返回版本号 + M3 常量(280/56 下限、16/8 行内边距、12 圆角)+ 事件名清单 + quirks(["initialOptionsHidden"]),ArkTS 启动时握手一次。
3.3 错误通道
导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("KSpinnerDispatch")
fun kSpinnerDispatch(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 JSON、缺 op、不支持的 op、越界 index(内核抛 IllegalArgumentException),全部走同一个 ok:false 通道,ArkTS 侧统一渲染,Demo 的试验台 Tab 有专门的错误通道按钮。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libkspinner_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohoskspinner.so)
│ KSpinnerDispatch
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;@CName 业务符号又不会被 HarmonyOS Kotlin 的 CAdapter 挂到 ArkTS 模块上,所以必须 C ABI + JSON + CMake NAPI 薄包装三层。
4.2 Kotlin/Native 导出函数
@CName("KSpinnerDispatch")
fun kSpinnerDispatch(request: String): String // init/anchorClick/select/dismiss/geometry/catalog 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
KSpinnerDispatch | kspinnerDispatch(req) | 全部六个 op(状态机 + 事件 + 几何 + catalog) |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"kspinnerDispatch", nullptr, NapiKSpinnerDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoskspinner_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoskspinner_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohoskspinner SHARED IMPORTED)
set_target_properties(ohoskspinner PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(kspinner_napi SHARED napi_init.cpp)
target_link_libraries(kspinner_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohoskspinner)
OHOS_ARCH 为空或 libohoskspinner.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const kspinnerDispatch: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 内核驱动的下拉组件
Demo 把上游 KSpinner 复刻为一个可复用的 ArkTS @Component,状态字段与内核 State 一一对应,且只被内核响应更新:
anchorClick(): void {
const parsed = callKernel({
op: 'anchorClick', open: this.open, selected: this.selected,
shown: this.shown, hidden: this.hidden
});
if (this.applyState(parsed) && this.open) {
this.animateMenu(true);
}
}
锚点最小尺寸、行内边距、圆角全部来自启动时 op=catalog 握手的结果;菜单宽度绑定 onAreaChange 实测的锚点宽度——这是上游 Modifier.width(anchorSize.width) 测量 hack 的 ArkTS 表达。开合动画用 getUIContext().animateTo 150ms/100ms 淡入缩放,是呈现层近似(上游动画委托给 M3 DropdownMenu 内建转场);语义层(何时开、何时关、发什么事件)100% 由内核裁决。
5.2 空白点击关闭
页面级透明 scrim 叠在内容后面,内容根节点 hitTestBehavior(HitTestMode.Transparent) 让空白点击穿透到 scrim;scrim 收到点击后经注册表逐个调用打开状态的面板,每个面板各自走一次 op=dismiss 往返——对齐上游 onDismissRequest 语义。打开面板数用 AppStorage 键 kspinnerOpenCount 计数驱动 scrim 挂载。
5.3 页面交互
三个 Tab 自上而下:基础下拉(Countries 四国示例、面板标题右侧实时显示 shown/hidden 计数、怪癖说明卡片)、行样式画廊(Minimal / Subtitle / Badge / Avatar 四种行 + 340vp 宽锚点)、试验台 · 关于(geometry 四态按钮:未测量 / 340×64 / 500×72 / 120×20 钳制;错误通道:未知 op、越界 index;catalog 握手行;架构说明与 AtomGit 链接)。错误原文进红色卡片,Native 调用本身抛错同样被 try/catch 收进显示。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohoskspinner)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName × 1 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.kspinner、versionName 1.0.1-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/include/libohoskspinner_api.h
├── src/main/cpp/types/libkspinner_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
.\gradlew.bat :kspinner:compileKotlinJvm :kspinner:jvmTest # 12 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 `
:example:nativeApp:linkDebugSharedOhosX64
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书不进入仓库。HAP 用 DevEco 打开 example/harmonyApp 构建(打包 JBR 用 DevEco 自带,系统 JDK 8 会让 hvigorw assembleHap 报不相干错误)。
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.kspinner -m entry
四、完整代码对照
4.1 整体架构
io.github.rufenkhokhar/KSpinnerSpec.kt(M3 常量镜像)
io.github.rufenkhokhar/KSpinnerKernel.kt(状态机 / 事件契约 / 几何契约)
│ initialState / onAnchorClick / onOptionSelected / onDismiss / anchorGeometry
Bridge.kt + MiniJson
│ KSpinnerDispatch(六个 op)
libkspinner_napi.so
│ kspinnerDispatch
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 真机页面(三 Tab:基础下拉 / 行样式画廊 / 试验台)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_kspinner、sample 移出 |
gradle/libs.versions.toml | 只剩 Kotlin 2.2.21-1.0.0 + multiplatform 插件 |
build.gradle.kts(根) | 版本 1.0.1-ohos.1、全局禁用 Ohos*Test* 任务 |
kspinner/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,零依赖内核 |
kspinner/src/commonMain/.../KSpinnerSpec.kt | 新增:M3 常量镜像 + 版本号 |
kspinner/src/commonMain/.../KSpinnerKernel.kt | 新增:状态机 + 事件契约 + 几何契约 |
kspinner/src/commonTest/.../KSpinnerKernelTest.kt | 新增:12 项语义测试 |
kspinner/src/composeMain/... | 上游 KSpinner.kt / KSpinnerTests.kt,git mv 字节不变 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、六 op 分发、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 三 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(25 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 内核 | initialState() | 首帧状态:hidden=1(上游 LaunchedEffect 怪癖) |
| 内核 | onAnchorClick(state) | showOptions = true;已开时 no-op 无事件 |
| 内核 | onOptionSelected(state, index, optionCount) | 选择 + 收起;事件顺序 selected → hidden;越界抛异常 |
| 内核 | onDismiss(state) | onDismissRequest;关着时 no-op 无事件 |
| 内核 | anchorGeometry(w, h) | 280×56 下限、钳制、菜单宽 = 锚点宽 |
| 内核 | KSpinnerSpec | TextFieldDefaults.MinWidth/MinHeight、ItemContentPadding、CardDefaults.shape 镜像 |
| Native | KSpinnerDispatch | C ABI JSON 导出(六 op 一口进) |
| N-API | kspinnerDispatch(req) | 向 ArkTS 暴露能力 |
| ArkTS | anchorClick()/optionClick()/dismiss() | 触发迁移并刷新 UI |
4.4 ArkTS 与 Kotlin 的边界
const raw = kspinnerDispatch(JSON.stringify({
op: 'anchorClick', open: false, selected: -1, shown: 0, hidden: 1
}));
const res = JSON.parse(raw);
// res = { ok:true, op:'anchorClick', open:true, selected:-1,
// shown:1, hidden:1, lastEvents:['optionsShown'] }
public fun onAnchorClick(state: State): State {
if (state.open) {
return state.copy(lastEvents = emptyList()) // 键不变,LaunchedEffect 不重启
}
return state.copy(
open = true,
optionsShownCount = state.optionsShownCount + 1,
lastEvents = listOf(EVENT_OPTIONS_SHOWN),
)
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;泛型 T 在桥上表现为索引,行内容由 ArkTS slot 渲染——与上游"行内容是调用方 slot"的设计一致。
五、关键决策说明
决策 1:内核提取而不是整库编译
依赖可达性决定路线:KStore 有 kotlinx ohos 制品线,真编译整库是正解;K-Spinner 的依赖是 androidx.compose.* 本身,鸿蒙线上没有对应制品,整库编译无从谈起。能带过河的只有行为语义——状态机、事件时序、几何契约,提取后由 12 项 JVM 测试钉死,等社区 compose.ui ohos 制品对齐后 composeMain 原件可以直接接回。
决策 2:工具链钉 2.2.21-1.0.0,Gradle 降 8.14.1
内核零依赖,没有制品线反推版本的问题,直接用社区标准钉法(qrose / haze 同款)。2.2.21 线必须配 Gradle 8.14.1;上游 Kotlin 2.2.20 与 2.2.21 同代,源码级兼容成本为零。
决策 3:根构建替换(cmp_qrose 模式),sample 留盘
上游根构建(compose / android / vanniktech 发布 / AGP classpath / Maven Central 签名段)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample/composeApp、sample/iosApp 是上游 Android/iOS 示例,留盘不进 settings.gradle.kts——顺带把上游 gradle.properties 里明文提交的签名密钥与账号凭据清出仓库。
决策 4:无状态桥 + 状态四元组回传
桥不持有状态:ArkTS 传 open/selected/shown/hidden,内核纯函数返回新状态与事件。与有状态桥(day-and-night-switch 的 Animator 持帧进度)相比,无状态设计让"每次决策都经真实 Kotlin 语义"变得可审计——hilog 里一条 JSON 就是一次完整对账,也避免了 so 内状态与 ArkTS 状态失同步的整类 bug。
决策 5:jvmTest 交叉验证 + 上游原件字节不变
行为语义在 commonMain,JVM 与 ohos 跑同一份代码;12 项 jvmTest 钉住全部怪癖(首帧 hidden=1、键控不重启、事件顺序、no-op 迁移、越界拒绝、几何回退/钳制)。上游 KSpinner.kt / KSpinnerTests.kt git mv 进 composeMain 字节不变——"内核是上游行为的逐条编码"这一说法,因原件可随时 diff 对照而成立。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin 21.0.12)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
.\gradlew.bat :kspinner:compileKotlinJvm :kspinner:jvmTest
12 项用例全部通过(tests="12" failures="0"):
| 用例 | 钉住什么 |
|---|---|
initialStateIsClosedWithInitialOptionsHiddenQuirk | 首帧怪癖:closed + hidden=1 + lastEvents=["optionsHidden"] |
anchorClickOpensMenuAndFiresOptionsShown | 锚点点击展开并触发 shown |
anchorClickWhileOpenIsANoOpWithoutEvents | 已开时重复点击:状态不变、零事件(键控 effect) |
selectReportsSelectionThenClosesInThatOrder | 事件顺序 optionSelected → optionsHidden |
selectRejectsOutOfBoundsIndex | 越界(-1 / =count / 负 optionCount)抛 IllegalArgumentException |
selectIsAcceptedWhileClosedToo | 迁移语义不依赖可见性(菜单行只在展开时存在是 UI 事实) |
dismissClosesOpenMenuWithSingleHiddenEvent | 展开时 dismiss:收起 + 单个 hidden 事件 |
dismissWhileClosedIsANoOpWithoutEvents | 关着时 dismiss:no-op 零事件(onDismissRequest 只在展开时触发) |
geometryFallsBackToMaterialMinimumsBeforeMeasurement | 未测量/零:回退 280×56 + minFallback=true |
measuredAnchorDrivesMenuWidth | 实测 340×64:菜单宽 = 340 |
measuredAnchorBelowMinimumsIsClamped | 120×20:钳到 280×56(防御性钳制) |
specMirrorsUpstreamMaterialConstants | M3 常量镜像(280/56/16/8/12)与版本号 |
6.3 HAP 验证
:kspinner:compileKotlinJvm: BUILD SUCCESSFUL
:kspinner:jvmTest: BUILD SUCCESSFUL(12/12)
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm: KSpinnerDispatch / libohoskspinner_symbols 均为全局 T 符号
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}
CompileArkTS: finished(首轮 5 个 ArkTS 兼容错误修复后通过)
PackageHap: finished
install bundle successfully
start ability successfully
libohoskspinner.so debug 体积 arm64 约 3.6MB、x86_64 约 3.3MB(含 Kotlin/Native 运行时),libc++_shared.so 约 1.85MB / 1.73MB;NAPI 包装库极小。
6.4 功能用例
- 启动后试验台 Tab 的 catalog 握手行显示
kernel 1.0.1-ohos.1 · min 280×56vp · item padding 16/8vp · radius 12vp(全部来自真实 Native 调用); - 基础下拉 Tab 初始状态面板标题右侧为
shown 0 · hidden 1——首帧怪癖在 UI 上直接可见; - 点锚点展开(150ms 淡入缩放),计数变
shown 1 · hidden 1; - 选择
India:菜单收起、锚点显示India ✓,计数变shown 1 · hidden 2; - 连续开合多次后计数单调递增且
hidden - shown = 1恒成立(首帧怪癖 + 每次开必有关); - 展开后点击页面空白处:菜单收起,计数
hidden +1(op=dismiss往返); - 已展开时重复点击锚点:菜单保持展开、计数不变(键控 no-op);
- 画廊 Tab 四种行样式(Minimal / Subtitle / Badge / Avatar)共用同一内核语义,340vp 宽锚点的菜单宽度实测等于锚点宽度;
- 试验台
120×20按钮返回钳制后的280×56与minFallback:false;未测量按钮返回280×56与minFallback:true; - 错误通道:
{"op":"nonsense"}返回{"ok":false,"error":"unknown or missing op..."},越界index:99返回index (99) out of bounds for optionCount (4),页面红色卡片显示、HAP 不崩。
6.5 验证结论
行为内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的下拉选择会话:版本握手、锚点展开、选项选中、点外关闭与事件计数闭环(见下节效果图——真机截图上 shown 6 · hidden 7 的计数差正是首帧怪癖的实证)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/K-Spinner.git
cd K-Spinner
export JAVA_HOME="/path/to/jdk-21"
./gradlew.bat :kspinner:compileKotlinJvm :kspinner:jvmTest # 12 项交叉验证
./gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# 用 DevEco Studio 打开 example/harmonyApp 构建签名 HAP,然后:
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.kspinner -m entry
hdc shell hilog | grep KSpinner
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 CMP 适配,必须让
:kspinner的行为内核真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- ArkTS 绝不能
import 'libohoskspinner.so'——HarmonyOS Kotlin 的 CAdapter 不把@CName业务符号挂到 ArkTS,必须经 CMake NAPI 薄包装,nm_modname、import 名、index.d.ts导出名三方必须一致; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可——DevEco 模拟器是 x86_64; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - API 26 的 NAPI 头把
napi_get_value_string_utf8缓冲区标非 const,先量长度、开std::vector<char>拷贝再构造std::string; - ohos 目标没有测试运行器:测试统一 JVM 跑 + 根构建全局禁用
Ohos*Test*任务; - CMake 链接 KN so 必须
IMPORTED_NO_SONAME TRUE+-Wl,-rpath,$ORIGIN,否则 Windows 盘符绝对路径进 NEEDED/RUNPATH; - 桥数值类型:MiniJson
num()返回 Double 而内核几何参数是 Float,直接传参在 ohosArm64 编译期报Argument type mismatch——显式.toFloat()修复; - ArkTS 严格模式三连:
this.animateTo在 5.1.0(18) 不是组件方法(改this.getUIContext().animateTo);onAnimationEnd不是 Column 属性(改setTimeout卸载面板);组件自有状态不能叫tabIndex(与CustomComponent基类保留属性冲突,改名currentTab); Circle().fill()会触发 “supported since SDK 26” 兼容告警(5.1.0(18) 编译不过新 API 面)——用borderRadius圆角 Column 替代;- HAP 打包必须用 DevEco 自带 JBR,系统 JDK 8 会让
hvigorw assembleHap半路报不相干错误;签名材料由 IDE 自动写入build-profile.json5,证书绝不进入仓库。
8.2 已知问题
- 鸿蒙侧无 Compose UI:
KSpinner@Composable仍在composeMain不编译,当前 ArkTS Demo 是语义等价的重建; - 开合动画是演示性近似(上游完全委托 M3
DropdownMenu内建转场),呈现层不逐帧对齐; - Demo 下拉是内联面板 + 页面级 scrim,不是系统弹层窗口,层级能力受页面约束;
rememberSaveable的跨进程恢复语义由 ArkTS@State近似,需要持久化的调用方自行接 AppStorage;- 泛型
T在桥协议里表现为索引,类型信息由 ArkTS 侧持有(语义等价,不是同一套类型签名); - 上游
shape/colors/border三个 Card 外观参数未在桥协议透出(呈现层,需要时加 JSON 字段即可,C 签名不变); - HAP 安装必须使用与 bundle name
org.terminator.ohos.kspinner匹配的签名。
8.3 未来优化方向
- 补模拟器(x86_64)验收矩阵与 uitest 自动化截图脚本(dumpLayout + uiInput click + snapshot_display);
- 把
shape/colors/border透出到桥协议,Demo 增加 Card 外观配置面板; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 等社区
compose.uiohos 制品与上游 Compose 版本对齐后,把composeMain接回commonMain,让KSpinner@Composable在鸿蒙 Compose 容器里直接跑,内核保留为语义规约和 jvmTest 真值源。
九、总结
9.1 核心难点回顾
上游 KSpinner.kt(单文件 @Composable,Material3)
→ 依赖可达性判定:androidx.compose.* 无 ohos 制品 → 内核提取路线
→ 查重(Dropdown / KMP-Searchable-Dropdown 职责不重叠)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1)
→ 根构建替换(cmp_qrose 模式)+ composeMain 字节不变隔离
→ KSpinnerKernel / KSpinnerSpec(状态机 + 事件时序 + 几何契约 + M3 镜像)
→ ohosArm64 / ohosX64 KLIB + 双 ABI sharedLib
→ C ABI(@CName × 1,六 op JSON)+ 无状态桥
→ C++ N-API(DisposeString 纪律)
→ ArkTS 三 Tab 重建(scrim 点外关闭、菜单贴锚点宽)
→ ArkUI 真机验收(事件计数闭环 + 效果图)
9.2 封装层次
- 内核层:提取自上游源码的开合状态机、事件契约(含首帧怪癖与键控 no-op)、几何契约与 M3 常量镜像;
- Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON(六 op);
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理控件树与呈现动画、catalog 握手、状态四元组回传、错误渲染;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 依赖可达性决定适配路线:KStore 有 kotlinx ohos 制品线,答案是"真编译整库";K-Spinner 依赖
androidx.compose.*,答案是"内核提取 + ArkTS 重建"。两条路线都是社区已过审的正解,选哪条只取决于当天的制品可达性,与库的知名度无关; - 纯 UI 组件的"内核"是行为语义,不是像素:上游单文件
@Composable里真正可提取的是状态迁移、事件时序和几何契约——把LaunchedEffect键控、同步回调先于重组后 effect 这些怪癖逐条编码并由 JVM 测试钉死,比复刻视觉重要得多;呈现层(动画、配色)允许近似,语义层零漂移; - 无状态桥让每次决策可审计:状态四元组随请求回传、内核纯函数、响应带
lastEvents与计数——真机截图上shown 6 · hidden 7的计数差能直接当首帧怪癖的验收证据,这种"看得见的语义"是有状态桥给不了的。
9.4 适配成果
当前适配已完成:
- K-Spinner
1.0.1行为内核的双 ABI Kotlin/Native 编译(上游原件字节不变隔离,内核逐条编码 + 12 项测试钉死); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 三 Tab 验收页面(基础下拉事件计数、四种行样式画廊、几何契约试验台、错误通道、版本握手);
- 12 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证(事件计数闭环截图留档);
- AtomGit 项目文档、OpenHarmony README(中英双份 + 25 节中文主文档)和本地效果图。
适配过程 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。
参考文档
更多推荐


所有评论(0)