本文记录 KMPSwipe 接入 OpenHarmony 的完整过程,覆盖滑动手势库盘点、Compose UI 隔离、手势策略像素化移植、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

KMPSwipe 是一个 Kotlin Multiplatform 滑动手势库:KmpSwipe 给任意组合件(Card、Box、列表项)包一层水平滑动,左滑出「删除」右滑出「归档」,阈值、阻尼、位移上限、方向闸门、弹簧回弹、触觉反馈全部参数化,邮件列表、任务清单那类交互拿来就用。本次适配与 KStore 的「真编译整库」路线有一个本质区别:KMPSwipe 的全部公共 API 都是 Compose UI(KmpSwipe、SwipeLayout、三个 Controller),上游构建(Kotlin 2.3.20 / Compose 1.10.3 / AGP 8.13.2)与社区鸿蒙工具链对不齐,因此走的是 cmp_qrose 确立的「策略与 UI 分离」路径——手势策略(方向闸门、阻尼、限幅、阈值判定、Start/Swiping/End/Cancelled 状态机)以新增的 SwipeEngine 留在 commonMain 编进 ohos,Compose 组件原样迁入 src/composeMain 不参与 ohos 编译,ArkUI 负责 PanGesture 输入与 translate/弹簧渲染。ArkTS 不重写任何策略语义,只喂位移、画偏移。

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

上游仓库: ismoy/KMPSwipe

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

KMPSwipe 为 Compose Multiplatform 应用提供「列表项滑动操作」这一件事的完整答案:KmpSwipe(modifier, onSwipeComplete, leftBackground, rightBackground, ...) 一层包装让任意组件可滑,SwipeController 管状态机,AnimationController 管弹簧,HapticController 管触觉,SwipeLayout 管双层背景。KMP 项目把这些能力放在 commonMain,由 Android、iOS 共享。OpenHarmony 应用同样需要这套交互能力,但上游构建不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以滑得动,却无法证明手势策略(阻尼除法、限幅区间、阈值严格大于、persistent 状态推进)在 OpenHarmony ARM64 设备上完成编译、链接和执行——而策略恰恰是这个库的全部价值。适配需要同时验证策略层、Native 动态库、C ABI、N-API、ArkUI 手势接线、HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.3.20 / Compose 1.10.3 / AGP 8.13.2 与鸿蒙工具链无法共存于一个 Gradle 构建;社区 CMP 是 1.9.2、HarmonyOS Kotlin 是 2.2.21-1.0.0。
全库 UI 化公共 API 全是 Compose 组件,Modifier、pointerInput、Animatable、LocalHapticFeedback 都编不进 ohos。
策略绑着单位系统唯一的策略类 SwipeController 签名里带着 Dp 和 Density,不洗掉就编不了。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
交互语义跨不了桥弹簧回弹属于渲染循环,detectHorizontalDragGestures 与 ArkUI PanGesture 是两套回调模型。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:手势策略的 ohos 编译(像素化移植)、Compose UI 的 composeMain 隔离(cmp_qrose 模式)和 C ABI/N-API 桥接层。策略语义完全由移植版引擎负责并与上游逐条对齐,ArkTS 只负责喂手势位移、解析 JSON 和渲染偏移。

1.2 库提供的能力

Demo 用四个 Tab 覆盖桥接的全部入口:

  • 基础滑动:单卡片左滑删除(红)/ 右滑归档(绿),状态行实时显示 state / direction / offset / progress,验证 Start → Swiping → End/Cancelled 全状态机;
  • 可滑列表:六条邮件逐条滑动,删除后条目淡出、「重置列表」恢复,验证列表化使用与每次手势的引擎复位;
  • 参数调节:阈值(60200vp)、阻尼(1.03.0)、上限倍数(1.0~3.0)三个滑杆 + 允许左滑/右滑两个开关,任一变化即发 config,实时生效;
  • 速度与进度:背景透明度与文字大小跟随 progress、实时速度(px/s)读数、limitReached「已达最大位移」徽标、弹簧回弹。

桥响应的公共字段:

字段作用
ok操作是否成功,失败时 error 带原文。
op / state / direction回显本次操作、状态机状态(Start/Swiping/End/Cancelled)与方向(Left/Right/None)。
changeddrag 专用:方向被闸门挡下时为 false,偏移保持不变。
offset / offsetDp / progress新偏移(px)、代算的 dp 值与进度(|offset| / threshold,0~1)。
complete / hapticend 专用:|offset| > threshold 判完成,触觉条件与上游一致。
limitReached|offset| >= threshold × limitMultiplier,供 UI 画「到顶」徽标。

策略侧完整语义(方向判定、阻尼、限幅、阈值严格大于、persistent 态推进、触觉条件)全部由 SwipeEngine 与上游 SwipeController 逐条对齐(唯一一处 0 偏移微修正在 8.2 节声明),JVM 目标保留作交叉验证,证明 ohos .so 里执行的策略没有漂移。

1.3 实现适配

维度要求
代码复用上游 7 个源文件零改动保留:2 个枚举留 commonMain,5 个 Compose 文件原样迁 composeMain;仅新增 1 个策略文件 SwipeEngine.kt(全仓唯一新增 Kotlin 源文件)。
平台目标根构建直接声明 jvm + ohosArm64 + ohosX64,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);AGP、vanniktech 发布、js / ios 目标随根构建替换剥离——本 fork 不发 Maven Central。
桥接稳定使用 1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
单位纪律引擎一律 px,vp ↔ px 换算归宿主(ArkTS 用 densityPixels),offsetDp 是桥代算的便利字段。
UI 完整四 Tab 覆盖全部桥接入口与全部策略参数。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游的 Compose 版 KmpSwipe 原样保留在 composeMain,等社区 CMP 工具链对齐后接回;ArkTS Demo 是策略层的验收宿主,不是上游 UI 的像素级复刻。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点 7 个源文件的依赖形态、确定策略/UI 分离边界、确定交付物
第 2 阶段:目标与工具链   ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:策略移植       ── SwipeEngine 像素化移植(全仓唯一新增源文件)、composeMain 隔离
第 4 阶段:桥协议与原生桥接 ── 5 op JSON 契约、@CName C ABI、C++ N-API、内存释放
第 5 阶段:ArkUI 宿主     ── PanGesture 接线、vp/px 换算、弹簧回弹、四 Tab 页面
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
kmpswipe/                             上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
kmpswipe/src/commonMain/              SwipeDirection、SwipeState + engine/SwipeEngine(新增,策略层)
kmpswipe/src/composeMain/             上游 KmpSwipe、SwipeLayout、三个 Controller(ohos 不编)
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :kmpswipe 与 :example:nativeApp,rootProject.name = "ohos_kmpswipe"。上游的 AGP 插件、vanniktech 发布段、Compose 五件套依赖、js / ios 目标全部随根构建替换剥离——本 fork 不发 Maven Central,上游发布能力留在上游仓库。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Eclipse Temurin,org.gradle.java.home 指向本机安装路径)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(2.2.21 线的配套钉法,wrapper 由上游 8.13 升级)
策略依赖零——commonMain 不依赖任何三方库,这也是能用 2.2 线老钉法的原因
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.kmpswipe,versionName 1.0.2
# Windows 干净 shell 里 JAVA_HOME 往往不存在,必须先设(本仓实撞,见 8.1)
set JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot
gradlew.bat --version
1.3 源码依赖度盘点
源文件依赖形态处置
models/SwipeDirection.kt纯枚举留 commonMain(ohos/jvm 都编)
models/SwipeState.kt纯枚举留 commonMain
controllers/SwipeController.kt策略类,签名带 Dp / Density迁 composeMain 保住上游 API;语义以 SwipeEngine 像素化移植
controllers/AnimationController.ktAnimatable / spring(Compose 动画)迁 composeMain(回弹职责移交 ArkUI springMotion)
controllers/HapticController.ktLocalHapticFeedback(Compose 平台层)迁 composeMain(触觉条件上收进桥,haptic 布尔回显)
KmpSwipe.kt组合件 + detectHorizontalDragGestures迁 composeMain(手势输入职责移交 ArkUI PanGesture)
ui/SwipeLayout.kt双层背景布局迁 composeMain(布局职责由 ArkUI Stack 重做)
engine/SwipeEngine.kt新增,纯 Kotlin、零依赖commonMain,策略层唯一实现

expect 声明全库 0 处,不需要补任何平台 actual;上游源码也没带 @JsExport / @JvmOverloads / @Immutable 这类注解,省掉注解剥离这一刀。整个 fork 的 Kotlin 新增面只有 SwipeEngine.kt 一个文件。

这里和 cmp_qrose 有一个方向相反的手术:qrose 是把藏在 Painter 文件里的枚举 Code128Type 洗出来下沉 commonMain;KMPSwipe 反过来,策略类本身带着 Dp / Density(onHorizontalDrag 的 density: Density 参数只为把回调偏移转成 Dp)。处理办法:原文件原样迁 composeMain 保住上游 API,策略语义用像素重写成 commonMain 的 SwipeEngine,单位换算交给宿主。这是手势库适配里典型的一刀——策略和单位系统绑在一起时,把单位留给宿主。

1.4 页面边界
标题区          KMPSWIPE · OPENHARMONY / 副标题(CMP 滑动手势库 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换        基础滑动 / 可滑列表 / 参数调节 / 速度与进度
状态行          最近一次桥交互结果(如"完成:归档(右滑) · 触觉反馈")
卡片            邮件样式内容卡 + 按方向显隐的双色背景层,translate 跟随偏移
参数区          Tab3 三个滑杆 + 两个方向开关;Tab4 速度/进度/徽标读数

页面 aboutToAppear 先发一次 config(带本机 densityPixels)完成握手,状态行显示「引擎已配置 density=…」。

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

2.1 为什么是 2.2.21-1.0.0 而不是 2.4 线

社区两条工具链线的经验不能互相套用。KStore 用 2.4.20-0.1.0-07,是因为它的引擎依赖 kotlinx ohos 制品线(stdlib 元数据 2.4.0);KMPSwipe 的策略层零三方依赖——commonMain 里没有 coroutines、没有 serialization、没有 kotlinx-io,不需要任何 ohos 制品变体,因此社区此前批准的老钉法 2.2.21-1.0.0(qrose / haze 同款)直接可用,配套 Gradle 8.14.1。工具链版本跟着依赖走:没有依赖,就用最成熟的钉法。

上游版本号(Kotlin 2.3.20)与本 fork 无关——fork 的 Gradle 只编策略层(commonMain),Compose 源码集不参与编译,源码级兼容问题不存在。

2.2 钉版落点

settings.gradle.kts 把 eazytec 双仓写在最前(pluginManagement 与 dependencyResolutionManagement 都要):

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

include(":kmpswipe")
include(":example:nativeApp")

gradle.properties 钉版本与 JDK,并写 kotlin.native.distribution.downloadFromMaven=true(konan 产物走 Maven):

org.gradle.java.home=C:\\Program Files\\Eclipse Adoptium\\jdk-21.0.12.101-hotspot
version=1.0.2-ohos.1
kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true

注意:上游 .gitignore 把 gradle.properties 当本机文件忽略,fork 将其移出忽略列表——版本钉死要随仓走(cmp_qrose 同款)。

2.3 库模块收敛

kmpswipe/build.gradle.kts 收敛为纯 KMP 模块,AGP、发布插件、Compose 依赖全部不再出现:

plugins {
    alias(libs.plugins.kotlinMultiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}   // 零依赖——composeMain 不参与编译,自然不需要 Compose 五件套
    }
}

根 build.gradle.kts 保留一条全局保险(鸿蒙线出不了可跑测试的 ohos 目标,不关掉 check 会挂):

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

第 3 阶段:手势策略移植(技术难点一)

3.1 引擎与上游逐条对照
class SwipeEngine(
    val swipeThresholdPx: Float,
    val resistance: Float = 1f,
    val swipeLimitMultiplier: Float = 1.5f,
    val swipeDirections: Set<SwipeDirection> = setOf(SwipeDirection.Left, SwipeDirection.Right)
) {
    fun onDragStart()                                        // → Start
    fun onHorizontalDrag(dragAmountPx: Float,
                         currentOffsetPx: Float): Float?     // null = 方向被闸门挡下
    fun onDragEnd(offsetPx: Float): Boolean                  // true = |offset| > threshold
    fun resetDirection()
    fun effectiveDirection(): SwipeDirection
}
上游 SwipeControllerSwipeEngine是否一致
onDragStart() → Start,重置 Swiping 发射标志同一致
方向判定 currentOffset + dragAmount/resistance > 0 → Right,否则 Left同式;候选恰为 0 返回 null除 0 微修正(8.2 声明)
方向不在 swipeDirections → 返回 null、偏移不变同一致
限幅 coerceIn(±threshold×multiplier)(乘数在上游调用点乘好)coerceIn(±swipeLimitPx),内移一致
Swiping 只发射一次状态机天然保证一致
onDragEnd:`offset> threshold` 严格大于 → End + completedDirection
未达阈值 → 回 persistent 态(首完成前 Cancelled,完成过则 End)同一致
触觉条件:松手时 `offset> threshold`
3.2 composeMain 隔离

五个 Compose 文件按原包名迁入 kmpswipe/src/composeMain/kotlin/,不参与 ohos 编译,JVM 也不编。留下来的意义与 qrose 相同:对照上游 API,等社区 CMP 的 ohos 制品能对齐上游 Compose 版本时把 composeMain 接回可编译源集、桥降级成 fallback。不要删除,也不要在文档里写成「已经在鸿蒙上跑 Compose 版 KmpSwipe」——那是假的。

第 4 阶段:桥协议与原生桥接(技术难点二)

4.1 三层桥接
ArkTS(PanGesture 输入 + translate/弹簧渲染)
  │ JSON string
  ▼
C++ N-API entry(libkmpswipe_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoskmpswipe.so)
  │ KmpSwipeEngine
  ▼
SwipeEngine 策略 + MiniJson + UTF-8 JSON + DisposeString
4.2 JSON 契约
请求 : {"op":"config","threshold":300,"resistance":1.0,"limitMultiplier":1.5,"directions":"Left,Right","density":3.0}
       {"op":"start", ...同 config 全量}          // 每次手势开始都发:配置+复位一次完成
       {"op":"drag","dragAmount":6.4,"velocity":233.5}
       {"op":"end","offset":120.5}
       {"op":"reset"}
响应 : {"ok":true,"op":"...","state":"...","direction":"...", ...按 op 增补}
       {"ok":false,"error":"..."}
op额外返回字段说明
configthreshold / resistance / limitMultiplier / directions(归一化回显)重建引擎、清零偏移
startstate=Start、offset=0手势开始必发;可顺带带全量配置
dragchanged / offset / offsetDp / progress / limitReached / velocitychanged=false 表示方向被闸门挡下
endcomplete / haptic(=complete)/ threshold完成判定 + 触觉条件
resetstate / direction对应上游 resetDirection(),弹簧回弹结束后发

progress、offsetDp、limitReached 是桥代算的便利字段,不进引擎——引擎保持与上游一一对应的语义面。

4.3 单例引擎与会话推进

引擎在桥内是单例(EngineHolder 持引擎与当前偏移),一次只服务一个手势:config 重建,start 复位,drag 推进,end 判定,reset 清方向。手势天然串行,列表多条目顺序滑动没有问题,但每次手势开始必须发 start。C ABI 始终保持无状态签名 const char* -> const char*,会话状态由 ArkTS 侧按 op 推进。

4.4 Kotlin/Native 导出与 N-API
@CName("KmpSwipeEngine")
fun kmpSwipeEngine(request: String): String = try { /* op 分发 */ } 
    catch (t: Throwable) { """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}""" }
C ABI 符号ArkTS 方法覆盖内核
KmpSwipeEnginekmpSwipeEngine(req)方向闸门 / 阻尼 / 限幅 / 阈值 / 状态机 / 触觉条件

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

add_library(ohoskmpswipe SHARED IMPORTED)
set_target_properties(ohoskmpswipe PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(kmpswipe_napi SHARED napi_init.cpp)
target_link_libraries(kmpswipe_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoskmpswipe)

$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。三个名字必须一致:CMake nm_modname(kmpswipe_napi)、ArkTS import(libkmpswipe_napi.so)、index.d.ts 导出名(kmpSwipeEngine)——任何一个错,表现都是「能安装、一滑就崩」。

4.5 错误通道

非法 JSON、未知 op、引擎异常全部折成 {"ok":false,"error":...},Kotlin 异常永远不跨 FFI——KN 异常跨语言会变成无信息崩溃。

第 5 阶段:ArkUI 宿主

5.1 手势接线
PanGesture({ fingers: 1, direction: PanDirection.Horizontal, distance: 5 })
  .onActionStart(() => { this.lastVp = 0; this.lastTime = Date.now(); this.callStart(); })
  .onActionUpdate((event: GestureEvent) => {
    const delta = event.offsetX - this.lastVp;      // vp
    this.lastVp = event.offsetX;
    this.callDrag(delta);                            // 发桥前 × density 换 px
  })
  .onActionEnd(() => { this.callEnd(); this.springBack(); })
  .onActionCancel(() => { this.callEnd(); this.springBack(); })

这与上游 detectHorizontalDragGestures 的回调结构一一对应(onDragStart / onHorizontalDrag / onDragEnd),调用频率等于系统手势回调频率,不是额外开销。

5.2 vp/px 换算与速度估算

引擎一律 px。vp → px 发桥前乘 display.getDefaultDisplaySync().densityPixels,显示再除回来;阈值滑杆以 vp 计业务值(100vp ≈ 上游 100.dp),这样同一套参数在不同密度设备上手感一致。速度由相邻 onActionUpdate 事件估算(deltaPx / deltaMs × 1000,px/s)——上游 onSwipeVelocity 实际传的是单次位移增量 change.positionChange().x,鸿蒙侧字段名对齐、数值含义升级为真实速度(8.2 声明)。

5.3 弹簧回弹与 reset

回弹是渲染循环的事,留在 ArkTS:上游 springStiffness / dampingRatio 映射为 curves.springMotion(0.4, 0.8),animateTo 把 @State offsetVp 弹回 0,约 400ms 后发 reset 清方向。策略层只回答「松手后算不算完成」,不模拟帧动画——KN 侧没有帧回调,模拟一帧帧推位移只会和 ArkUI 的渲染节奏打架。

5.4 四 Tab 页面

可滑卡片封装成 SwipeCard 子组件:Stack 三层——按方向显隐的双色背景层(正偏移露「归档」靠 Start,负偏移露「删除」靠 End,与上游 SwipeLayout 的 CenterStart/CenterEnd 对齐)、白色内容卡、.translate({ x: offsetVp }) 跟随手势。Tab3 的滑杆变化即发 config 并显示归一化回显;Tab4 的背景透明度/文字字号随 progress 变化,limitReached 显示「已达最大位移」徽标。全页零权限(haptic 只做状态行提示,不申请 ohos.permission.VIBRATE)。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoskmpswipe)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName + 5 op 分发 + MiniJson(扁平 JSON,cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   "signingConfig": "default"
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.kmpswipe、versionName 1.0.2
    └── entry/
        ├── build-profile.json5               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/libohoskmpswipe_api.h
        ├── src/main/cpp/types/libkmpswipe_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面(四 Tab)
6.2 构建、签名和安装
# 策略层编译 + 双 ABI 动态库(本机实测 BUILD SUCCESSFUL in 1m 4s,13 tasks)
gradlew.bat :kmpswipe:compileKotlinJvm :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 的 "signingConfig": "default"),证书不进入仓库。

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.kmpswipe -m entry
6.3 DevEco 侧踩坑

ArkTS 编译器对导入写法是严格检查的:@ohos.display 是默认导出,import { display } from '@ohos.display' 直接报 Module has no exported member 'display',必须写 import display from '@ohos.display'(本仓实撞,见 8.1)。

四、完整代码对照

4.1 整体架构

io.github.ismoy.kmpswipe.engine/**(SwipeEngine,上游策略的像素化移植)
    │ 方向闸门 / 阻尼 / 限幅 / 阈值 / 状态机
io.github.ismoy.kmpswipe.models/**(上游枚举原样保留)
Bridge.kt + MiniJson
    │ KmpSwipeEngine(5 op)
libkmpswipe_napi.so
    │ kmpSwipeEngine
Index.ets(SwipeCard × 四 Tab)
    │ JSON parse + @State 更新 + PanGesture + springMotion
    ▼
ArkUI 真机页面

composeMain 里的上游 Compose 组件不参与以上任何一层——它们是留给未来 CMP 工具链的。

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_kmpswipe
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单插件钉版
build.gradle.kts(根)版本 1.0.2-ohos.1、全局禁用 Ohos*Test* 任务
gradle.propertiesJDK 21、konan 走 Maven;从 .gitignore 移出随仓提交
kmpswipe/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零依赖
kmpswipe/src/composeMain/**上游 5 个 Compose 文件原样保留(ohos 不编)
kmpswipe/.../engine/SwipeEngine.kt唯一新增源文件(策略层,上游语义逐条对齐)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.ktC ABI、5 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 + SwipeCard 组件)
README.OpenHarmony_CN.md鸿蒙适配主文档(25 节)

4.3 关键 API 对照(上游 KmpSwipe 参数 ↔ 鸿蒙)

上游参数鸿蒙对应说明
onSwipeCompleteend → complete + direction完成回调
onSwipe(direction, offset)drag → direction + offset / offsetDp拖动中回调
onSwipeStateChangestate 字段变化ArkTS 侧 diff 后更新状态行
swipeThresholdthreshold(vp×density→px)Tab3 滑杆
resistanceresistance越大越「沉」
swipeLimitMultiplierlimitMultiplier + limitReached位移上限
swipeDirectionsdirections("Left,Right")Tab3 开关
onSwipeVelocityvelocity 回显数值语义升级为 px/s(8.2)
dynamicSwipeThresholdArkTS 发桥前自行换算桥只认算好的 px
springStiffness / dampingRatiocurves.springMotion 参数渲染侧职责
vibrationEnabledhaptic 回显Demo 不真振、零权限
enabledPanGesture 的 enabled 属性手势开关留在 ArkUI
leftBackground / rightBackground背景层按方向与 progress 显隐着色删除/归档两层
content(state, direction)卡片内容随 state/direction 渲染状态行 + 条目淡出

4.4 ArkTS 与 Kotlin 的边界

// ArkTS:喂 vp×density 的 px 位移,消费状态 JSON
const res = JSON.parse(kmpSwipeEngine(JSON.stringify({
  op: 'drag', dragAmount: deltaVp * density, velocity: v
}))) as EngineResult;
if (res.ok && res.changed) {
  this.offsetVp = (res.offset ?? 0) / density;   // px → vp 渲染
  this.progress = res.progress ?? 0;
}
// Kotlin:策略只认 px,逐条对齐上游 SwipeController
val newOffset = engine.onHorizontalDrag(amount, holder.offset)   // null = 方向被闸门挡下
    ?: return ok("drag", changed = false, state = engine.currentSwipeState)
holder.offset = newOffset

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

五、关键决策说明

决策 1:策略像素化移植而不是整库编译

与 KStore 相反的选择,理由同样基于依赖事实:KStore 的引擎是纯 Kotlin,能整库真编译;KMPSwipe 的全部公共 API 都是 Compose UI,上游 Kotlin 2.3.20 / Compose 1.10.3 与社区线对不齐,整库编译等于再打一套 Compose 发行版。而策略——阻尼除法、限幅区间、阈值严格大于、状态机——只有两个 Dp/Density 触点,洗掉的成本远小于等待工具链。移植版引擎逐条对照上游,0 偏移一处微修正显式声明。

决策 2:composeMain 保留而不是删除

五个 Compose 文件按原包名留在 composeMain。删除等于放弃未来——社区 CMP 的 ohos 制品一旦能对齐上游版本,KmpSwipe 直接接回,桥降级成 fallback。现在把 ArkUI 渲染写进库的公共 API 会让以后拆不掉,所以它只活在 example/harmonyApp,不活在 kmpswipe 模块。

决策 3:引擎全 px,Dp 归宿主

上游策略类带 Dp/Density 的两处(onSwipe 回调的 toDp、阈值 toPx)全部移到宿主:ArkTS 拿 vp 发桥前乘 densityPixels,桥回 px 后除回来。单位换算只发生在一层,业务参数以 vp 表达(100vp ≈ 上游 100.dp),不同密度设备手感一致。

决策 4:单例引擎 + start 复位

C ABI 保持无状态签名,会话由 ArkTS 按 op 推进;引擎在桥内是单例,每次手势 onActionStart 必发 start(配置+复位一次完成,顺带解决参数热更新)。手势天然串行,这约束在交互层天然满足,却换来桥实现的极简与崩溃安全。

决策 5:弹簧动画留 ArkTS

上游 springStiffness/dampingRatio 映射为 curves.springMotion 参数。回弹属于渲染循环,KN 侧没有帧回调,策略层只回答「松手后该回哪、算不算完成」。

决策 6:工具链用 2.2 线老钉法

策略层零三方依赖,不需要 kotlinx ohos 制品线,社区已审的 2.2.21-1.0.0 + Gradle 8.14.1 钉法直接沿用——工具链跟着依赖走,没有依赖就用最成熟的路径。

六、测试与验证

6.1 测试环境

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

6.2 Gradle 侧验证

:kmpswipe:compileKotlinJvm                      # 策略层与 Compose 彻底解耦的证明
:example:nativeApp:linkDebugSharedOhosArm64     # arm64-v8a/libohoskmpswipe.so 3,656,624 B
:example:nativeApp:linkDebugSharedOhosX64       # x86_64/libohoskmpswipe.so 3,359,600 B
BUILD SUCCESSFUL in 1m 4s(13 tasks,copyKnSo* 自动带拷 libc++_shared.so)
llvm-nm -D: KmpSwipeEngine 均为全局 T 符号(双 ABI)
llvm-readelf -d: NEEDED 全部短系统名,无本机盘符路径

compileKotlinJvm 通过本身就是一层验收:commonMain 里没有任何 androidx.compose 引用,策略层与 UI 彻底解耦在编译期可见。

6.3 HAP 验证

BuildNativeWithCmake: finished(双 ABI 编出 libkmpswipe_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully
start ability successfully

HAP 内含双 ABI 各三件套(libkmpswipe_napi.so / libohoskmpswipe.so / libc++_shared.so);debugShared 的 KN so arm64 约 3.5MB、x86_64 约 3.2MB(含 Kotlin/Native 运行时)。

6.4 功能用例

真机(arm64)已验证启动与基础滑动完成链路(见第七节效果图):

  1. 启动后状态行出现「引擎已配置 density=…」(来自真实 Native 配置调用);
  2. 右滑卡片,绿色「归档」背景随偏移露出,状态行滚动 state / direction / offset / progress;
  3. 滑过阈值松手,状态行显示「完成:归档(右滑) · 触觉反馈」,卡片弹簧回弹——完成判定与触觉条件成立;
  4. 未滑过阈值松手,卡片弹回,状态行回到「未达阈值,回弹」;
  5. 关掉「允许左滑」后左滑完全不动,状态行显示「拦截:该方向未启用(changed=false)」;
  6. 列表 Tab 连续滑不同条目每次都生效(验证 start 复位了单例引擎);
  7. 阻尼调到 3.0 后同位移偏移约为 1/3,上限倍数调到 1.0 后位移到阈值即到顶(limitReached 徽标)。

四个 Tab 的完整逐项验收清单见仓库 README.OpenHarmony_CN.md 第 16 节。

6.5 验证结论

手势策略、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的策略会话:引擎配置、方向闸门、阻尼、限幅、阈值判定、状态机与触觉条件全部来自 libohoskmpswipe.so 内的 SwipeEngine,ArkTS 只做输入与渲染。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/KMPSwipe.git
cd KMPSwipe
set JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot
gradlew.bat :kmpswipe:compileKotlinJvm                                          # 策略层 klib(JVM 对照)
gradlew.bat :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.kmpswipe -m entry
hdc shell hilog | findstr KmpSwipe

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让策略层真正编译成 ohosArm64 / ohosX64 动态库,且策略语义要与上游逐条对照;
  2. 干净 shell(Git Bash / CI)里 JAVA_HOME 往往不存在,gradlew 直接报 JAVA_HOME is not set——先设 JDK 21 再跑(本仓实撞);
  3. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  4. @ohos.display 是默认导出:import { display } from '@ohos.display' 编译期报 has no exported member 'display',必须写 import display from '@ohos.display'(本仓实撞);
  5. ArkTS 严格模式不允许 @State private 组合,状态属性保持默认可见性;catch 块里对异常取字段要用 e as Record<string, Object> 的已审写法;
  6. 上游 .gitignore 把 gradle.properties 当本机文件忽略,fork 将其移出忽略列表,否则版本钉死(1.0.2-ohos.1)不随仓走;
  7. 上游唯一的策略类 SwipeController 签名带 Dp/Density,照抄进 commonMain 编不过——原文件迁 composeMain,策略以 px 重写;
  8. 根构建全局禁用 Ohos*Test* 任务,鸿蒙线出不了可跑测试的 ohos 目标,不关 check 会挂;
  9. vp/px 混用会让滑动偏移「跳变」:发桥前统一乘 densityPixels,显示再除回来,只允许一层换算;
  10. 列表第二条滑不动,多半是单例引擎残留上一手势偏移——每次手势 onActionStart 必发 start;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  12. 三个名字(CMake nm_modname、ArkTS import、index.d.ts 导出)有一个对不上,表现都是「能安装、一滑就崩」;
  13. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();CMake 的 IMPORTED_NO_SONAME + $ORIGIN rpath 不能删,否则 so 的 NEEDED 里出现本机盘符。

8.2 已知问题

  • 鸿蒙上没有 Compose 版 KmpSwipe:上游组件留在 composeMain,等社区 CMP 工具链对齐后接回;当前 Demo 是 ArkUI 重做的渲染,不是上游 UI 的像素级还原;
  • velocity 字段名与上游对齐、数值语义升级:上游 onSwipeVelocity 实际传单次位移增量 change.positionChange().x,鸿蒙桥回显相邻事件估算的 px/s,依赖旧语义的业务需自行换算;
  • 引擎是单例,同一时刻只服务一个手势,跨手指并发不适用;会话由 ArkTS 按 op 推进;
  • 一处有意的微修正:候选偏移恰为 0 时上游会判成 Left 并照常推进状态机,引擎返回 null(不判方向、偏移不动),浮点下几乎不可触达;
  • Demo 不做真振动:haptic 是桥的布尔回显,演示保持零权限,真振动需 ohos.permission.VIBRATE;
  • 上游发布链(AGP、vanniktech、js/ios 目标)从 fork 构建剥离,要发 Maven 仍走上游仓库。

8.3 未来优化方向

  • 四 Tab 全量真机截图留档(当前已验收基础滑动,其余 Tab 按 README.OpenHarmony_CN.md 第 16 节清单补齐)与 uitest 自动化截图脚本;
  • JVM 交叉验证工程化:把「同一组位移序列喂上游 SwipeController 与 SwipeEngine、逐状态比对」固化成 jvmTest 用例;
  • 补充真机(x86_64)验收矩阵;
  • 若社区 CMP 的 ohos 制品对齐上游 Compose 版本,把 composeMain 接回可编译源集,桥改成 fallback。

九、总结

9.1 核心难点回顾

上游 7 个源文件(全在 commonMain,公共 API 全是 Compose UI)
    → 依赖盘点:2 枚举纯 Kotlin,5 文件 Compose,1 策略类带 Dp/Density
    → 工具链钉版 Kotlin 2.2.21-1.0.0(策略层零依赖,老钉法可用)
    → 根构建替换(cmp_qrose 模式,AGP/发布/js/ios 剥离)
    → composeMain 隔离 + SwipeEngine 像素化移植(唯一新增源文件)
    → ohosArm64 / ohosX64 KLIB + 双 ABI sharedLib
    → C ABI(@CName × 1,5 op JSON 契约)+ 单例引擎会话推进
    → C++ N-API(DisposeString 纪律)
    → ArkUI PanGesture 接线 + vp/px 换算 + springMotion 回弹
    → HAP、签名、真机安装运行(arm64 实测)

9.2 封装层次

  • 策略层:SwipeEngine 与上游 SwipeController 逐条对齐(方向闸门、阻尼、限幅、阈值严格大于、persistent 态、触觉条件);
  • 模型层:上游 SwipeDirection / SwipeState 枚举原样保留;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、手势接线、单位换算、弹簧渲染、四 Tab 与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 路线选择跟着依赖事实走:纯策略库用「像素化移植 + UI 隔离」(cmp_qrose 路径),纯引擎库用「真编译整库」(KStore 路径),判断标准是公共 API 里 Compose 触点的面积,而不是哪个更高级;
  2. 单位系统是手势库适配的隐藏耦合点:策略和 Dp/Density 绑在一起时,把单位留给宿主、引擎只认 px,换算只允许发生在一层;
  3. 状态语义的验收底线是「与上游逐条一致」:能滑不算验收——能滑只证明手势通了,方向闸门、限幅、阈值严格大于、persistent 态推进都对得上,才算策略 100% 复用。

9.4 适配成果

当前适配已完成:

  • KMPSwipe 1.0.2(1.0.2-ohos.1)手势策略的双 ABI Kotlin/Native 编译(上游 7 文件零重写,唯一新增 SwipeEngine.kt);
  • composeMain 隔离与策略/UI 分离架构(cmp_qrose 模式在手势库上的落地);
  • 1 个 C ABI 符号(KmpSwipeEngine,5 op JSON 契约)和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 四 Tab 验收页面(基础滑动、可滑列表、参数调节、速度与进度,零权限);
  • Gradle 侧编译链接全绿(JVM + 双 ABI so)+ HAP 构建、签名、设备安装和真机验证(arm64);
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐