本文记录 K-Spinner 接入 OpenHarmony 的完整过程,覆盖上游库盘点、纯 UI 组件的内核边界判定、行为语义提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

K-Spinner 是一个 Compose Multiplatform 下拉选择器(Spinner)库:一个 Material3 Card 锚点加 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 / hiddenonOptionsShown / 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin 21.0.12,org.gradle.java.home 钉绝对路径)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 随 cmp_qrose 模板;2.2.21 线必须 8.x)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.kspinner
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
模块commonMain 文件依赖形态处置
kspinner1(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 查重边界

社区里已有两个"名字里带下拉"的适配,必须先划清界线:

维度DropdownKMP-Searchable-DropdownK-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/noteinitialState(),带首帧怪癖说明
anchorClick状态四元组同上onAnchorClick(state)
selectindex/optionCount + 状态四元组同上onOptionSelected(state, index, optionCount)
dismiss状态四元组同上onDismiss(state)
geometrywidthDp/heightDp(可省)widthDp/heightDp/menuWidthDp/minFallbackanchorGeometry(w, h)
catalog无version/spec/events/ops/quirksKSpinnerSpec 常量清单

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 方法覆盖内核
KSpinnerDispatchkspinnerDispatch(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.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 三 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 下限、钳制、菜单宽 = 锚点宽
内核KSpinnerSpecTextFieldDefaults.MinWidth/MinHeight、ItemContentPadding、CardDefaults.shape 镜像
NativeKSpinnerDispatchC ABI JSON 导出(六 op 一口进)
N-APIkspinnerDispatch(req)向 ArkTS 暴露能力
ArkTSanchorClick()/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
measuredAnchorBelowMinimumsIsClamped120×20:钳到 280×56(防御性钳制)
specMirrorsUpstreamMaterialConstantsM3 常量镜像(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 功能用例

  1. 启动后试验台 Tab 的 catalog 握手行显示 kernel 1.0.1-ohos.1 · min 280×56vp · item padding 16/8vp · radius 12vp(全部来自真实 Native 调用);
  2. 基础下拉 Tab 初始状态面板标题右侧为 shown 0 · hidden 1——首帧怪癖在 UI 上直接可见;
  3. 点锚点展开(150ms 淡入缩放),计数变 shown 1 · hidden 1;
  4. 选择 India:菜单收起、锚点显示 India ✓,计数变 shown 1 · hidden 2;
  5. 连续开合多次后计数单调递增且 hidden - shown = 1 恒成立(首帧怪癖 + 每次开必有关);
  6. 展开后点击页面空白处:菜单收起,计数 hidden +1(op=dismiss 往返);
  7. 已展开时重复点击锚点:菜单保持展开、计数不变(键控 no-op);
  8. 画廊 Tab 四种行样式(Minimal / Subtitle / Badge / Avatar)共用同一内核语义,340vp 宽锚点的菜单宽度实测等于锚点宽度;
  9. 试验台 120×20 按钮返回钳制后的 280×56 与 minFallback:false;未测量按钮返回 280×56 与 minFallback:true;
  10. 错误通道:{"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 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让 :kspinner 的行为内核真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. ArkTS 绝不能 import 'libohoskspinner.so'——HarmonyOS Kotlin 的 CAdapter 不把 @CName 业务符号挂到 ArkTS,必须经 CMake NAPI 薄包装,nm_modname、import 名、index.d.ts 导出名三方必须一致;
  4. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可——DevEco 模拟器是 x86_64;
  5. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  6. API 26 的 NAPI 头把 napi_get_value_string_utf8 缓冲区标非 const,先量长度、开 std::vector<char> 拷贝再构造 std::string;
  7. ohos 目标没有测试运行器:测试统一 JVM 跑 + 根构建全局禁用 Ohos*Test* 任务;
  8. CMake 链接 KN so 必须 IMPORTED_NO_SONAME TRUE + -Wl,-rpath,$ORIGIN,否则 Windows 盘符绝对路径进 NEEDED/RUNPATH;
  9. 桥数值类型:MiniJson num() 返回 Double 而内核几何参数是 Float,直接传参在 ohosArm64 编译期报 Argument type mismatch——显式 .toFloat() 修复;
  10. ArkTS 严格模式三连:this.animateTo 在 5.1.0(18) 不是组件方法(改 this.getUIContext().animateTo);onAnimationEnd 不是 Column 属性(改 setTimeout 卸载面板);组件自有状态不能叫 tabIndex(与 CustomComponent 基类保留属性冲突,改名 currentTab);
  11. Circle().fill() 会触发 “supported since SDK 26” 兼容告警(5.1.0(18) 编译不过新 API 面)——用 borderRadius 圆角 Column 替代;
  12. 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.ui ohos 制品与上游 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 三条经验

  1. 依赖可达性决定适配路线:KStore 有 kotlinx ohos 制品线,答案是"真编译整库";K-Spinner 依赖 androidx.compose.*,答案是"内核提取 + ArkTS 重建"。两条路线都是社区已过审的正解,选哪条只取决于当天的制品可达性,与库的知名度无关;
  2. 纯 UI 组件的"内核"是行为语义,不是像素:上游单文件 @Composable 里真正可提取的是状态迁移、事件时序和几何契约——把 LaunchedEffect 键控、同步回调先于重组后 effect 这些怪癖逐条编码并由 JVM 测试钉死,比复刻视觉重要得多;呈现层(动画、配色)允许近似,语义层零漂移;
  3. 无状态桥让每次决策可审计:状态四元组随请求回传、内核纯函数、响应带 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。

参考文档

Logo

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

更多推荐