开源鸿蒙平台 KMP 三方库「KMPSwipe」适配全流程
本文记录
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)。 |
changed | drag 专用:方向被闸门挡下时为 false,偏移保持不变。 |
offset / offsetDp / progress | 新偏移(px)、代算的 dp 值与进度(|offset| / threshold,0~1)。 |
complete / haptic | end 专用:|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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Eclipse Temurin,org.gradle.java.home 指向本机安装路径) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(2.2.21 线的配套钉法,wrapper 由上游 8.13 升级) |
| 策略依赖 | 零——commonMain 不依赖任何三方库,这也是能用 2.2 线老钉法的原因 |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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.kt | Animatable / spring(Compose 动画) | 迁 composeMain(回弹职责移交 ArkUI springMotion) |
controllers/HapticController.kt | LocalHapticFeedback(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
}
| 上游 SwipeController | SwipeEngine | 是否一致 |
|---|---|---|
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 | 额外返回字段 | 说明 |
|---|---|---|
config | threshold / resistance / limitMultiplier / directions(归一化回显) | 重建引擎、清零偏移 |
start | state=Start、offset=0 | 手势开始必发;可顺带带全量配置 |
drag | changed / offset / offsetDp / progress / limitReached / velocity | changed=false 表示方向被闸门挡下 |
end | complete / haptic(=complete)/ threshold | 完成判定 + 触觉条件 |
reset | state / 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 方法 | 覆盖内核 |
|---|---|---|
KmpSwipeEngine | kmpSwipeEngine(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.toml | Kotlin 2.2.21-1.0.0 单插件钉版 |
build.gradle.kts(根) | 版本 1.0.2-ohos.1、全局禁用 Ohos*Test* 任务 |
gradle.properties | JDK 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.kt | C ABI、5 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 + SwipeCard 组件) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(25 节) |
4.3 关键 API 对照(上游 KmpSwipe 参数 ↔ 鸿蒙)
| 上游参数 | 鸿蒙对应 | 说明 |
|---|---|---|
onSwipeComplete | end → complete + direction | 完成回调 |
onSwipe(direction, offset) | drag → direction + offset / offsetDp | 拖动中回调 |
onSwipeStateChange | state 字段变化 | ArkTS 侧 diff 后更新状态行 |
swipeThreshold | threshold(vp×density→px) | Tab3 滑杆 |
resistance | resistance | 越大越「沉」 |
swipeLimitMultiplier | limitMultiplier + limitReached | 位移上限 |
swipeDirections | directions("Left,Right") | Tab3 开关 |
onSwipeVelocity | velocity 回显 | 数值语义升级为 px/s(8.2) |
dynamicSwipeThreshold | ArkTS 发桥前自行换算 | 桥只认算好的 px |
springStiffness / dampingRatio | curves.springMotion 参数 | 渲染侧职责 |
vibrationEnabled | haptic 回显 | Demo 不真振、零权限 |
enabled | PanGesture 的 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)已验证启动与基础滑动完成链路(见第七节效果图):
- 启动后状态行出现「引擎已配置 density=…」(来自真实 Native 配置调用);
- 右滑卡片,绿色「归档」背景随偏移露出,状态行滚动
state / direction / offset / progress; - 滑过阈值松手,状态行显示「完成:归档(右滑) · 触觉反馈」,卡片弹簧回弹——完成判定与触觉条件成立;
- 未滑过阈值松手,卡片弹回,状态行回到「未达阈值,回弹」;
- 关掉「允许左滑」后左滑完全不动,状态行显示「拦截:该方向未启用(changed=false)」;
- 列表 Tab 连续滑不同条目每次都生效(验证
start复位了单例引擎); - 阻尼调到 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 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让策略层真正编译成
ohosArm64/ohosX64动态库,且策略语义要与上游逐条对照; - 干净 shell(Git Bash / CI)里
JAVA_HOME往往不存在,gradlew直接报JAVA_HOME is not set——先设 JDK 21 再跑(本仓实撞); settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;@ohos.display是默认导出:import { display } from '@ohos.display'编译期报has no exported member 'display',必须写import display from '@ohos.display'(本仓实撞);- ArkTS 严格模式不允许
@State private组合,状态属性保持默认可见性;catch 块里对异常取字段要用e as Record<string, Object>的已审写法; - 上游
.gitignore把gradle.properties当本机文件忽略,fork 将其移出忽略列表,否则版本钉死(1.0.2-ohos.1)不随仓走; - 上游唯一的策略类
SwipeController签名带Dp/Density,照抄进commonMain编不过——原文件迁composeMain,策略以 px 重写; - 根构建全局禁用
Ohos*Test*任务,鸿蒙线出不了可跑测试的 ohos 目标,不关check会挂; - vp/px 混用会让滑动偏移「跳变」:发桥前统一乘
densityPixels,显示再除回来,只允许一层换算; - 列表第二条滑不动,多半是单例引擎残留上一手势偏移——每次手势
onActionStart必发start; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - 三个名字(CMake
nm_modname、ArkTS import、index.d.ts导出)有一个对不上,表现都是「能安装、一滑就崩」; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free();CMake 的IMPORTED_NO_SONAME+$ORIGINrpath 不能删,否则 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 三条经验
- 路线选择跟着依赖事实走:纯策略库用「像素化移植 + UI 隔离」(cmp_qrose 路径),纯引擎库用「真编译整库」(KStore 路径),判断标准是公共 API 里 Compose 触点的面积,而不是哪个更高级;
- 单位系统是手势库适配的隐藏耦合点:策略和
Dp/Density绑在一起时,把单位留给宿主、引擎只认 px,换算只允许发生在一层; - 状态语义的验收底线是「与上游逐条一致」:能滑不算验收——能滑只证明手势通了,方向闸门、限幅、阈值严格大于、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。
参考文档
更多推荐

所有评论(0)