本文记录 kmp-wheel-picker 接入 OpenHarmony 的完整过程,覆盖纯 UI 库的可复用引擎盘点、Compose 滚动物理的字节码级公式核对、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI / N-API 桥接、ArkUI 滚轮页面、HAP 构建与模拟器手势自动化验收。

kmp-wheel-picker 是 Software Mansion 出品的 Compose Multiplatform 滚轮选择器:rememberWheelPickerState 一行建状态,WheelPicker(state) { item } 一个组件滚起来,内部用 draggable 手势 + MultiContentMeasurePolicy 惰性测量 + FloatExponentialDecaySpec 衰减吸附,支持自定义 item 与高亮窗口、按位置渐变淡出、可调 bufferSize / 动画 / 摩擦力、点击定位与程序化滚动。本次适配与社区既有路线(qrose 的"编码器原样编译"、KStore 的"存储引擎原样编译")有本质区别:上游全库只有 3 个 Kotlin 文件,却 100% 依赖 Compose——没有可以"原样编译"的 commonMain。因此本次把滚轮的"决策数学"(吸附物理 + 布局几何)从 Compose 字节码中逐字提取为纯 Kotlin 内核,ohosArm64 / ohosX64 真编真链,ArkTS 滚轮页面作为验收宿主,松手动画逐帧由 Kotlin 内核输出的 spring 轨迹驱动。

项目地址: AtomGit/oh-tpc/kmp-wheel-picker

上游仓库: patrykandpatrick/vico

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

kmp-wheel-picker 为 Compose Multiplatform 应用提供"滚轮选择"这一件事的完整答案:WheelPickerState 管理 index / value / slotHeight 与 InteractionSource,松手后 snap(velocity) 用指数衰减算出吸附槽位、再以 spring 收敛,MeasurePolicy 只测可见范围并按 centerY + (index − value) × slotHeight 居中摆放每一项。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要滚轮选择器,但上游构建(Kotlin 2.2.20 / Compose 1.9.1 / Gradle 9.1.0)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以滚起来,却无法证明上游滚轮引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证滚动物理、布局数学、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
全库皆 Compose3 个源文件全部 import androidx.compose.*,连 WheelPickerState 都依赖 mutableIntStateOf / DraggableState,没有"纯 Kotlin 部分"可直接编译。
工具链不一致上游 Kotlin 2.2.20 / Compose 1.9.1 / Gradle 9.1.0 / AGP 8.13 与鸿蒙工具链无法共存于一个 Gradle 构建。
物理公式不公开吸附目标来自 Compose internal 类 FloatExponentialDecaySpec,文档不公开公式;凭记忆重写必错。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
动画语义跨不了桥Compose 的 spring 动画、MutatePriority 抢占是 Kotlin 侧概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:滚轮决策数学(衰减吸附 + spring 轨迹 + 布局几何)的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI / N-API 桥接层。吸附到哪、点中哪槽、滚多快停,完全由与上游逐字对齐的内核负责;ArkTS 只负责手势采集、像素摆放和展示结果。

1.2 库提供的能力

Demo 提供一个真实可滚的滚轮会话,对应桥接的全部入口:

  • 甩动吸附(fling):拖拽松手后,内核用上游同款 FloatExponentialDecaySpec(friction) 公式计算衰减目标,round 取整并钳制到 [0, itemCount−1],再返回 spring(DampingRatioLowBouncy, StiffnessMediumLow) 的 60fps 逐帧轨迹,ArkTS 按帧回放——吸附手感与 Android 版一致;
  • 点击定位(tap):点击轮面任意位置,内核按上游 detectTapGestures 处理器的公式(含两次整数除法)算出目标槽位并动画滚过去;
  • 程序化滚动(scroll):对应 animateScrollTo(index),零初速 spring 轨迹,Demo 按钮一键触发;
  • 布局校验(layout):对应 MultiContentMeasurePolicy,返回可见项闭区间、槽位数、轮高、中心线与每个可见项的中心 Y,页面实时显示 ✓ 校验行;
  • 版本握手:wheelPickerVersion() 返回 fork 版本号 0.3.0-ohos.1,来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
target / index吸附后的整槽值与选中索引。
frameMs / frames[]spring 轨迹的采样周期与逐帧值序列(item 单位)。
first / last / count / height / centerY / centers[]layout op 的测量布局结果。

引擎侧完整能力(衰减闭式解、spring 数值积分、可见范围、tap 映射、钳制)全部在上游语义下原样工作,JVM 侧保留 16 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的滚轮数学没有漂移。

1.3 实现适配

维度要求
代码复用上游 3 个 Compose 文件 git mv 到 composeMain 原样保留(零内容改动);新增 238 行零依赖纯 Kotlin 内核(物理 + 几何)与 16 项测试,是全仓仅有的新增库源码。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);sample(依赖 compose/AGP 插件)留盘不进构建。
公式保真衰减/spring/布局公式从本机 Gradle 缓存的 animation-core-desktop-1.9.1.jar(与上游同版本)字节码反编译逐指令核对,不凭记忆。
桥接稳定使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
UI 完整三 Tab 覆盖全部桥接入口:双滚轮 Demo、friction/bufferSize 物理实验、程序化滚动、版本握手与 layout 校验。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit,不出现任何第三方镜像站。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧没有 Compose 运行时;ArkTS WheelView 是原生组件,但它的每一次"决策"(吸附目标、tap 槽位、滚动轨迹、布局参数)都来自 Kotlin 内核的真实 Native 调用。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认"全库皆 Compose"、确定"决策数学"提取面
第 2 阶段:公式核对       ── 反编译 compose-animation-core 1.9.1 字节码,还原 FloatExponentialDecaySpec
                             与 Spring 常量(DampingRatioLowBouncy=0.75、StiffnessMediumLow=400)
第 3 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 4 阶段:内核落地       ── composeMain 隔离 + commonMain 新增 WheelPickerPhysics / WheelPickerGeometry
                             + 16 项 commonTest 交叉验证
第 5 阶段:桥协议与原生桥 ── @CName C ABI、fling/scroll/tap/layout 四 op、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── WheelView(Pan/Click 手势 + spring 轨迹回放)、三 Tab 页面
第 7 阶段:示例与验证     ── HAP、模拟器手势自动化(uitest 甩动/点击)、崩溃修复与效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
lib/                               上游滚轮库
lib/src/commonMain/                新增:WheelPickerPhysics + WheelPickerGeometry(纯 Kotlin 内核)
lib/src/commonTest/                新增:WheelPickerCoreTest(16 项交叉验证)
lib/src/composeMain/               上游 WheelPicker / WheelPickerState / MeasurePolicy
                                   (git mv 隔离,非源集,ohos 不编,零内容改动)
example/nativeApp/                 Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                DevEco Stage、CMake N-API 和 ArkTS 页面
sample/                            上游 CMP 示例(依赖 compose 插件),留盘不进 settings
README.OpenHarmony.md / _CN.md     鸿蒙适配说明(中英双份)
README.upstream.md                 上游原文保存(仓库链接换 AtomGit)

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :lib 与 :example:nativeApp,rootProject.name = "ohos_wheel_picker"。上游的 AGP / compose / dokka / vanniktech 发布插件、Maven Central 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

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 替换;2.2.21 旧线必须用 8.x,不能沿用上游 9.1.0)
DevEco product5.1.0(18) 兼容
DevEco Studio26.0.0.851 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.kmpwheelpicker
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
文件职责Compose 依赖处置
WheelPicker.kt组件:Layout + draggable + pointerInputcompose.ui / compose.foundation / compose.runtime整体隔离到 composeMain
WheelPickerState.kt状态机:value/index、snap、scrollTo、animateScrollTomutableIntStateOf / DraggableState / FloatExponentialDecaySpec整体隔离;其目标计算公式提取进内核
MeasurePolicy.kt惰性测量与居中布局MultiContentMeasurePolicy整体隔离;其布局公式提取进内核

盘点结论:上游没有 expect/actual,也没有一行零 Compose 依赖的代码——这与 qrose(编码器天然纯 Kotlin)、KStore(引擎在 commonMain)相反。可复用的"引擎"不是某个文件,而是三处决策数学:

  1. performSnap 的目标计算:round(FloatExponentialDecaySpec(friction).getTargetValue(value, -velocity / slotHeight).coerceInValueRange);
  2. 吸附动画:animate(value → target, initialVelocity = -velocity / slotHeight, animationSpec = spring(DampingRatioLowBouncy, StiffnessMediumLow));
  3. MeasurePolicy 的布局:可见范围、(2·bufferSize+1) 槽位轮高、itemCenterY = centerY + (index − value) × slotHeight,以及 tap 处理器的槽位映射。

这三处全部提取为纯 Kotlin(WheelPickerPhysics / WheelPickerGeometry),这就是整个 fork 在库侧的新增源码。

1.4 页面边界
标题区          KMP-WHEEL-PICKER · OPENHARMONY / 副标题(CMP 滚轮选择器 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换        滚轮 Demo / 物理参数 / 关于
滚轮 Demo       Item 轮 ×15(primary 12% 圆角窗口)+ 图标轮 ×9(1px 描边窗口)
                item 按距离渐变 alpha 与颜色(与上游 sample 同式)
物理参数        friction 滑条(1–15)、bufferSize 切换(1–5)、程序化滚动按钮、layout 校验行
关于区          版本握手、调用链分层、layout 校验、仓库与社区链接
日志区          带时间戳的操作日志(fling/tap/scroll 每次调用的目标与帧数,上限 100 条)
状态行          index=value 实时数值

页面启动时先调 wheelPickerVersion() 做握手,再执行一次 layout 校验并渲染 ✓ 行。

第 2 阶段:目标与依赖打通

2.1 公式核对(本适配的成败手)

滚轮的手感全部压在 FloatExponentialDecaySpec 上,而它是 compose-animation-core 的 internal 实现,公开文档不写公式。适配的第一步不是写代码,而是取证:解包本机 Gradle 缓存里与上游同版本的 animation-core-desktop-1.9.1.jar,javap -c 逐指令还原:

构造:  friction = -4.2f × max(1e-4f, frictionMultiplier)
        threshold = max(1e-7f, |absVelocityThreshold|),默认 0.1f
目标:  |v0| ≤ threshold → 返回 startValue
        否则 duration = ln(|thr / v0|) / friction × 1000
        target = start − v0/friction + (v0/friction)·exp(friction·duration/1000)
采样:  x(t) = (start − v0/friction) + (v₀/friction)·exp(friction·t_ms/1000)
        v(t) = v0·exp(friction·t_ms/1000)

friction=8 时衰减系数为 -33.6:1 槽/秒的初速最终只移动 1/33.6 ≈ 0.03 槽——滚轮"重手感"的来源,也是必须逐字对齐而不能"写个大概"的原因。反编译前按直觉写的 target = start − v0/ln(friction) 是错的,差一个数量级。Spring 侧核对常量:DampingRatioLowBouncy = 0.75、StiffnessMediumLow = 400。

2.2 工具链钉版

社区已审的钉法:HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同款)。上游源码本来就是 Kotlin 2.2.20 编的,2.2.21 与之同代,源码级兼容成本为零;Gradle 经验按工具链线区分——2.2.21 旧线必须降 8.14.1,wrapper 直接替换。

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_wheel_picker"

include(":lib")
include(":example:nativeApp")
2.3 编译真实内核

lib/build.gradle.kts 收敛为纯 KMP 模块:

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()                            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }

    explicitApi()                    // 上游纪律保留
}

实测:

./gradlew :lib:compileKotlinOhosArm64 :lib:compileKotlinOhosX64   # BUILD SUCCESSFUL
./gradlew :lib:jvmTest                                           # 16 项全绿

上游 Compose 三文件一个字没动(git 识别为 100% rename),只有 238 行内核与测试是新的。

2.4 测试依赖的处置

commonTest 只依赖 kotlin("test")(有全平台制品),无需换线;根构建按社区惯例全局禁用 ohos 测试任务,测试统一在 JVM 跑:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

第一版测试的 snap 越界预期写反了方向(-20000px/s 在 friction=8 下只走 12.4 槽,不会触界),被 jvmTest 当场拦下——这正是交叉验证的价值。

第 3 阶段:桥协议建模

3.1 JSON 契约
com.swmansion.kmpwheelpicker/**(纯 Kotlin 内核:物理 + 几何)
        ↓ snapTarget / springTrack / tapTargetIndex / visibleRange
Bridge.kt(@CName × 2,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API wheelPickerDispatch() / wheelPickerVersion()
        ↓
ArkUI Index.ets(三 Tab 滚轮页面)

请求与响应:

fling  : {"op":"fling","value":7.0,"velocity":4000,"slotHeight":56,"itemCount":15,"friction":8}
         → {"ok":true,"target":12.0,"index":12,"frameMs":16.666667,"frames":[7.0,7.05,…]}
scroll : {"op":"scroll","value":7.0,"index":3,"itemCount":15}
         → 同上(零初速 spring 轨迹)
tap    : {"op":"tap","value":7.0,"tapY":168,"height":392,"slotHeight":56,"itemCount":15}
         → {"ok":true,"index":7}
layout : {"op":"layout","value":7,"itemCount":15,"bufferSize":3,"slotHeight":56}
         → {"ok":true,"first":4,"last":10,"count":7,"height":392,"centerY":196,
            "indexes":[4,…,10],"centers":[56.0,…,336.0]}

两个不对称设计:frames 让动画主权留在 Kotlin——ArkTS 不写物理,只按帧回放;layout 让测量结果可被运行期校验——ArkTS 渲染用的同一行摆放公式,其输入参数由内核独立计算并在页面显示 ✓。

3.2 轨迹回放而不是只回目标

ArkUI 的 animateTo 只对可动画 UI 属性插值,无法驱动"数值状态逐帧变化"。因此 fling/scroll 一次返回整条 60fps spring 轨迹(首帧 = 当前值,尾帧 = 吸附槽位,上限 600 帧),ArkTS 用 setInterval 逐帧赋值 value,每帧触发重渲。松手动画 100% 由 Kotlin 内核的数值积分驱动,ArkTS 零物理代码。

3.3 错误通道

每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("WheelPickerDispatch")
fun wheelPickerDispatch(request: String): String = try {
    /* op 分发 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、缺字段、不支持的 op,全部走同一个 ok:false 通道,ArkTS 侧统一渲染到状态行与日志。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libwheelpicker_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoswheelpicker.so)
  │ WheelPickerDispatch / WheelPickerVersion
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制滚轮语义,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("WheelPickerDispatch")
fun wheelPickerDispatch(request: String): String   // fling / scroll / tap / layout 一口进

@CName("WheelPickerVersion")
fun wheelPickerVersion(): String = "0.3.0-ohos.1"  // 版本握手
C ABI 符号ArkTS 方法覆盖内核
WheelPickerDispatchwheelPickerDispatch(req)snap 吸附轨迹 + animateScrollTo + tap 槽位 + MeasurePolicy 布局
WheelPickerVersionwheelPickerVersion()fork 版本握手(版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"wheelPickerDispatch", nullptr, NapiWheelPickerDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"wheelPickerVersion", nullptr, NapiWheelPickerVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoswheelpicker_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoswheelpicker_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。

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

add_library(wheelpicker_napi SHARED napi_init.cpp)
target_link_libraries(wheelpicker_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoswheelpicker)

OHOS_ARCH 为空或 libohoswheelpicker.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const wheelPickerDispatch: (request: string) => string;
export const wheelPickerVersion: () => string;

第 5 阶段:ArkUI 宿主

5.1 触发操作与轨迹回放
private fling(velocityY: number): void {
  const req = new DispatchRequest();          // ArkTS 严格模式禁无类型对象字面量
  req.op = 'fling';
  req.value = this.value;
  req.velocity = velocityY;                   // PanGesture 松手速度 px/s
  req.slotHeight = this.slotHeight;
  req.itemCount = this.items.length;
  req.friction = this.friction;
  const res = this.dispatch(req);
  if (!res.ok || res.frames === undefined || res.target === undefined) {
    this.report(`fling 失败: ${res.error ?? 'empty'}`);
    return;
  }
  this.playTrack(res.frames, res.target, res.frameMs ?? 16.7);
}

private playTrack(frames: number[], target: number, frameMs: number): void {
  this.stopTrack();
  let i = 0;
  this.timerId = setInterval(() => {
    this.value = frames[i];
    i += 1;
    if (i >= frames.length) { this.value = target; this.stopTrack(); }
  }, Math.max(frameMs, 8));
}

拖拽期直接复刻上游 onScrollDelta:value -= dy / slotHeight(不做钳制,与上游一致,越界由吸附目标收回)。点击走 op:tap 拿槽位,再走 op:scroll 播轨迹——对应上游 tap 后 animateScrollTo 的两段语义。

5.2 页面交互

滚轮 Demo Tab 的两个轮分别复刻上游 sample 的 WheelPicker1(15 项文本 + primary 12% 圆角窗口)与 WheelPicker2(9 个图标 + 1px 描边窗口),item 淡出用上游同式 alpha = (BUFFER_SIZE − |value − index|).coerceIn(0,1)、颜色 lerp(primary, onSurfaceVariant, |value−index|)。物理参数 Tab 用一个实验轮联动 friction 滑条(1–15)与 bufferSize 切换(1–5),三个按钮触发程序化滚动。每条日志带 [HH:mm:ss] 时间戳,显示请求意图与内核响应(如 fling v=-230px/s → 吸附 index 13(37 帧))。

5.3 对比度和错误边界

选中项加粗、窗口 12% 主色底或描边,与破坏性操作对齐的语义缺失(本 Demo 无破坏性操作,程序化滚动按钮按去向分色);ok:false 时错误原文进日志区。Native 调用本身抛错同样被 try/catch 收进日志。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoswheelpicker)+ copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName × 2 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-hap.cmd                         命令行 HAP 构建脚本(DEVECO_SDK_HOME/JBR/PATH 一并设置)
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.kmpwheelpicker、versionName 0.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/include/libohoswheelpicker_api.h
        ├── src/main/cpp/types/libwheelpicker_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面(WheelView + 三 Tab)
6.2 构建、签名和安装
./gradlew :lib:jvmTest                                             # 16 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
.\build-hap.cmd                                                    # hvigorw assembleHap(debug)

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

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.kmpwheelpicker -m entry

四、完整代码对照

4.1 整体架构

com.swmansion.kmpwheelpicker/**(纯 Kotlin 内核)
    │ WheelPickerPhysics:decayTarget / snapTarget / springTrack / flingTrack
    │ WheelPickerGeometry:可见范围 / 轮高 / itemCenterY / tapTargetIndex / 钳制
Bridge.kt + MiniJson
    │ WheelPickerDispatch / WheelPickerVersion
libwheelpicker_napi.so
    │ wheelPickerDispatch / wheelPickerVersion
Index.ets(WheelView + 三 Tab)
    │ JSON parse + 逐帧回放 + @State 更新
    ▼
ArkUI 滚轮页面(模拟器/真机)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_wheel_picker、sample 移出
gradle/libs.versions.toml砍到 6 行:Kotlin 2.2.21-1.0.0 + multiplatform 插件
build.gradle.kts(根)11 行:group/version 0.3.0-ohos.1、全局禁用 Ohos*Test*
lib/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,explicitApi 保留
lib/src/commonMain/…/WheelPickerPhysics.kt衰减公式(字节码核对)/ snapTarget / spring 数值积分 / 轨迹
lib/src/commonMain/…/WheelPickerGeometry.kt可见范围 / 轮高 / 居中 / tap 映射 / 钳制
lib/src/commonTest/…/WheelPickerCoreTest.kt16 项交叉验证
example/nativeApp/build.gradle.kts双 ABI sharedLib、copyKnSo* 回填
nativeApp/…/Bridge.kt2 个 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.etsWheelView(手势+轨迹回放)+ 三 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(22 节)

4.3 关键 API 对照

层次API作用
内核WheelPickerPhysics.decayTargetCompose FloatExponentialDecaySpec.getTargetValue 逐字移植
内核WheelPickerPhysics.snapTargetperformSnap 的目标计算(round + 钳制)
内核WheelPickerPhysics.springTrack / flingTrackspring(0.75, 400) 轨迹采样(8 子步半隐式欧拉)
内核WheelPickerGeometry.*MeasurePolicy 与 tap 处理器的布局数学
NativeWheelPickerDispatch / WheelPickerVersionC ABI JSON 导出
N-APIwheelPickerDispatch(req) / wheelPickerVersion()向 ArkTS 暴露能力
ArkTSWheelView.fling / scrollToIndex / playTrack触发操作并逐帧回放

4.4 ArkTS 与 Kotlin 的边界

const req = new DispatchRequest();
req.op = 'fling';
req.value = 7.0;                 // 拖拽累计的滚动值(item 单位)
req.velocity = 4000;             // 松手速度 px/s,向下为正
req.slotHeight = 56;
req.itemCount = 15;
req.friction = 8;                // 上游默认摩擦
const res = JSON.parse(wheelPickerDispatch(JSON.stringify(req)));
// res.ok === true;res.index = 吸附槽位;res.frames 逐帧回放后 value === res.target
public fun flingTrack(
    value: Float, velocityPxPerSec: Float, slotHeight: Int, itemCount: Int,
    frictionMultiplier: Float = DefaultFriction,
): FloatArray =
    springTrack(
        value = value,
        target = snapTarget(value, velocityPxPerSec, slotHeight, itemCount, frictionMultiplier),
        initialVelocity = -velocityPxPerSec / slotHeight,   // 与 performSnap 同号
    )

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:提取"决策数学",而不是编译 UI 或重写滚轮

上游是纯 UI 库,没有可"原样编译"的部分;重写一个鸿蒙版滚轮会让引擎漂移(吸附手感、边界、可见范围都会偏离)。物理与几何是上游组件的灵魂且零 Compose 依赖——提取它们,UI 渲染交给 ArkTS 用同一行公式复刻,并由 layout op 做运行期一致性校验。

决策 2:公式从同版本字节码核对,不凭记忆

FloatExponentialDecaySpec 的 -4.2 × frictionMultiplier 归一化、阈值分支、闭式解全部从 animation-core-desktop-1.9.1.jar(与上游 Compose 1.9.1 同版)反编译还原。internal API 的数学必须取证:反编译前的"直觉版"公式错了一个数量级。

决策 3:轨迹回放,动画主权留在 Kotlin

ArkUI animateTo 无法驱动数值状态逐帧变化;只回目标值又会让 ArkTS 自己写物理。桥一次返回整条 spring 轨迹,ArkTS 按帧赋值——动画曲线 100% 来自 Kotlin 内核积分,ArkTS 零物理代码,动画语义不漂移。

决策 4:根构建替换(cmp_qrose 模式),sample 留盘

上游根构建(AGP / compose / dokka / vanniktech 发布)对鸿蒙没有保留价值;sample 依赖 compose 插件与鸿蒙工具链互斥。整个替换为极简 ohos 构建,sample 留盘不进 settings.gradle.kts。

决策 5:JVM 交叉验证 + 测试钉住上游语义

内核在 commonMain,JVM 与 ohos 跑同一份代码;16 项测试钉住吸附方向、越界钳制、count 偶数补奇(6 项轮显示 7 槽的上游行为)、tap 整除等"看着奇怪但就是上游"的语义。第一版测试自己的预期写反,正是被这层验证拦下。

六、测试与验证

6.1 测试环境

本次验证使用 Windows 构建机(arm64 仿真前的全链路)、JDK 21(Temurin 21.0.12)、DevEco Studio 26.0.0.851(命令行 hvigor)、OpenHarmony 模拟器(x86_64,hdc 直连)与 uitest 手势自动化。

6.2 JVM 交叉验证

./gradlew :lib:jvmTest

16 项用例全部通过(上游无测试,这是本 fork 新增的"内核没漂"闸门):

测试组用例数钉住什么
衰减物理3阈值内保值、方向单调、闭式解与长时间采样一致
吸附目标5零速取整、方向、friction 单调性、±40000px/s 越界钳制
spring / fling 轨迹4首帧=当前值、尾帧=整槽、已收敛单帧、时长上限
几何4可见范围对称、count 偶数补奇、itemCenterY 居中、tap 整除映射

6.3 HAP 验证

:lib:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: WheelPickerDispatch / WheelPickerVersion 均为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libwheelpicker_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 6.7 MB,内含双 ABI 各三件套(libwheelpicker_napi.so 约 65 KB / libohoswheelpicker.so arm64 strip 后约 1.9 MB、x64 约 1.85 MB / libc++_shared.so);KN so debug 未 strip 时 arm64 约 3.6 MB、x64 约 3.3 MB(含 Kotlin/Native 运行时)。

6.4 功能用例

  1. 启动后关于页与日志出现 wheelPickerVersion() → 0.3.0-ohos.1(来自真实 Native 调用,非静态文字);
  2. 滚轮 Demo 两个轮初始分别为 Item 8(index 7)与第 5 个图标(index 4),窗口高亮居中;
  3. uitest uiInput swipe 模拟甩动(两次反向),松手后日志出现 fling v=-230px/s → 吸附 index 13(37 帧),滚轮以 spring 回弹手感吸附整槽;
  4. 甩到边界继续甩,吸附目标被钳制在 [0, itemCount−1](实测吸附 Item 15,index=14,value=14.00);
  5. uitest uiInput click 点击轮面,走 tap 槽位映射再走 scroll 轨迹,动画滚到被点槽位;
  6. 物理参数 Tab:friction 拉到 15 明显"重"、拉到 2 滑得很远;bufferSize 1→5 轮高实时变化;
  7. ✓ layout(value=7, buffer=3) → 可见 [4..10] count=7 height=392 centerY=196 校验行随参数刷新;
  8. 每步操作日志按时间戳滚动留痕,全程进程存活、无 jscrash。

6.5 验证结论

滚动物理、布局数学、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在 OpenHarmony 模拟器(x86_64)上成功安装并进入前台,页面展示了真实的滚轮会话:甩动吸附、点击定位、程序化滚动、参数实验与版本握手(见下节效果图)。期间发现并修复一处桥响应 JSON 缺陷(见 8.1 第 13 条),修复后手势自动化复测通过。

七、运行效果

7.1 运行效果图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/kmp-wheel-picker.git
cd kmp-wheel-picker
export JAVA_HOME="/path/to/jdk-21"
./gradlew :lib:compileKotlinOhosArm64 :lib:compileKotlinOhosX64           # klib
./gradlew :lib:jvmTest                                                   # 16 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
.\build-hap.cmd                                                          # hvigorw assembleHap
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.kmpwheelpicker -m entry
hdc shell hilog | grep WheelPicker

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :lib 真正编译成 ohosArm64 / ohosX64 动态库;
  2. 纯 UI 库的隔离单位是依赖面不是文件——最初以为能像 qrose 那样"编码器留在 commonMain",实际连 WheelPickerState 都依赖 Compose 状态 API,三个文件全部隔离;
  3. internal API 的数学必须取证:FloatExponentialDecaySpec 的归一化是 -4.2 × frictionMultiplier,直觉公式 start − v/ln(friction) 差一个数量级;
  4. snap 方向容易搞反:手指向下(velocityY > 0)value 减小,performSnap 传 -velocity/slotHeight;第一版测试预期写反,被 jvmTest 拦下;
  5. tap 公式的 (height / slotHeight) / 2 是两次整数除法,按 Float 算点击中心会偏半槽;
  6. 手拼 JSON 的数组必须带方括号:第一版 frames 输出成 "frames":8.15,8.108,...(漏 []),松手瞬间 JSON.parse 抛 SyntaxError 闪退(jscrash 栈直指 dispatch 行);修复后手势自动化复测通过;
  7. ArkTS 严格模式禁无类型对象字面量(arkts-no-untyped-obj-literals),JSON.stringify({...}) 编译失败,必须声明显式 DispatchRequest 类逐字段赋值;
  8. hvigor 的 PackageHap 会 spawn java,只设 JAVA_HOME 不够,%JAVA_HOME%\bin 必须进 PATH;
  9. settings.gradle.kts 里 eazytec 双仓必须排在最前,否则配置期直接 Unresolved reference: ohosArm64;
  10. 库模块不开 binaries.sharedLib(否则每个库都吐带运行时的 so),只有 example/nativeApp 开;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. build-profile.json5 的 signingConfigs 缺 material 字段会被 hvigor schema 拒绝——仓库内留空数组,DevEco 自动签名回填,证书绝不进仓库;
  13. HAP 缺当前 ABI 的 so 报安装失败 9568347,abiFilters 必须双 ABI,每 ABI 三件套缺一不可;
  14. 根 .gitignore 盖不住嵌套产物:example/harmonyApp/entry/libs/、.hvigor/、entry/build/、*.so 要单独追加。

8.2 已知问题

  • 鸿蒙侧无 Compose 运行时:滚轮像素渲染是 ArkTS WheelView 复刻(摆放公式与上游一致并由 layout op 运行期校验);composeMain 保留上游实现,等社区 CMP 工具链对齐后接回;
  • spring 以数值积分采样(8 子步半隐式欧拉,60fps),与 Compose 解析解视觉不可区分但非同一代码路径;衰减目标(吸附到哪槽)则完全同式;
  • InteractionSource 交互流、rememberSaveable 状态恢复、MutatePriority 抢占矩阵是 Compose 侧概念,不跨 C ABI(新交互以 stopTrack 简化抢占);
  • 轨迹回放用 setInterval(≥8ms),极低端帧率下动画节奏可能与 60fps 采样轻微漂移;
  • android / ios / js / wasmJs / desktop 打包目标剥离(composeMain / sample 源码留盘),dokka、发布、CI 为上游遗留死配置,本 fork 不发 Maven Central。

8.3 未来优化方向

  • arm64 真机验收矩阵与自动化截图脚本(模拟器 x86_64 已过,真机待补);
  • 轨迹回放换 @ohos.graphics.display 帧回调,消除低端机节奏漂移;
  • 评估 MutatePriority 抢占矩阵与 InteractionSource 语义的桥接;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 等社区 CMP 工具链对齐后把 composeMain 接回正式源集,内核成为 Compose 实现的共享底座,完成"一套数学、两套渲染"合流。

九、总结

9.1 核心难点回顾

上游 WheelPicker / WheelPickerState / MeasurePolicy(全库皆 Compose)
    → 反编译 animation-core 1.9.1 字节码还原衰减/spring 公式
    → 提取决策数学为 238 行零依赖内核(composeMain 隔离上游原件)
    → 根构建替换(cmp_qrose 模式,Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21)
    → ohosArm64 / ohosX64 KLIB + 16 项 JVM 交叉验证
    → C ABI(@CName × 2)+ 轨迹回放协议(fling/scroll/tap/layout)
    → C++ N-API(DisposeString 纪律)
    → ArkTS WheelView(手势 + 逐帧回放)
    → ArkUI 三 Tab 验收 + uitest 手势自动化(甩动/点击/钳制)

9.2 封装层次

  • 内核层:衰减公式、snapTarget、spring 数值积分、可见范围与居中/tap/钳制数学;
  • Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:手势采集、像素摆放(同一行公式)、轨迹逐帧回放、日志与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和模拟器运行。

9.3 三条经验

  1. 纯 UI 库的适配答案是"决策数学提取":上游没有可原样编译的 commonMain 时,先找"组件的灵魂"——对滚轮而言是"吸附到哪、怎么滚过去、item 摆在哪"。UI 可以换渲染器,这些数学不能漂;
  2. internal API 的公式必须取证而非回忆:与上游同版本的 jar 就在 Gradle 缓存里,javap -c 十分钟拿到逐指令真相;凭直觉写公式,错一个数量级都不自知;
  3. 轨迹回放让动画主权留在 Kotlin:跨语言 UI 适配最大的坑是"两边各写一半物理";桥一次返回整条逐帧轨迹,ArkTS 只做哑播放器,动画语义就不会漂移——配合 16 项 JVM 交叉验证,"换皮不换核"才有说服力。

9.4 适配成果

当前适配已完成:

  • kmp-wheel-picker 0.3.0 滚轮决策数学的双 ABI Kotlin/Native 编译(上游 Compose 文件零改动隔离保留,新增 238 行内核 + 16 项测试);
  • 与 Compose 1.9.1 字节码逐字对齐的衰减/spring/布局公式(含 tap 整除与偶数补奇等上游怪癖);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 三 Tab 验收页面(双滚轮 Demo、friction/bufferSize 物理实验、程序化滚动、版本握手、layout 校验、操作日志);
  • 16 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 6.7 MB)+ OpenHarmony 模拟器安装、uitest 手势自动化验证与运行效果图;
  • AtomGit 项目文档、OpenHarmony 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐