开源鸿蒙平台 CMP 三方库「compose-dnd」适配全流程
本文记录
compose-dnd接入 OpenHarmony 的完整过程,覆盖 CMP 拖拽库盘点、纯 Kotlin 判定引擎提取、ohosArm64/ohosX64双目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建与真机验收。
compose-dnd是 Compose Multiplatform 生态的拖拽 / 重排库,对外 API 是Modifier.draggableItem、Modifier.dropTarget、Modifier.reorderableItem三个修饰符,全部实现在 Compose 快照状态与布局坐标系之上。本次适配把它的判定决策内核(悬停目标解析、三种 DropStrategy 打分、重排槽位迟滞几何、边缘自动滚动缓动、九向落点对齐)逐行移植到 OpenHarmony ARM64/x64 运行时,ArkUI 页面作为真机验收宿主——长按拾起、跨槽交换、边缘自动滚动、悬停高亮与落点对齐全部由 Kotlin 引擎裁决,鸿蒙侧的行为契约与 Android/iOS/Desktop 完全同源。
项目地址: AtomGit/oh-tpc/compose-dnd
上游仓库: MohamedRejeb/compose-dnd
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
compose-dnd(上游 MohamedRejeb/compose-dnd 1.0.0-alpha02,Apache-2.0)为 Compose 应用提供一套修饰符完成的拖拽体系:draggableItem 让任意组件可拖、dropTarget 声明接收区、reorderableItem 在 Lazy 布局里做拖拽重排,配合长按拾起、边缘自动滚动、拖动轴锁定与多目标重叠时的 DropStrategy 选择。KMP 项目把这些交互的决策规则放在 commonMain,由 Android、iOS、Desktop、Web 共享。OpenHarmony 应用同样需要这套交互能力,但默认构建不会生成 OpenHarmony 的 Kotlin/Native 变体。
与纯逻辑库不同,拖拽库的全部公开 API 都是 Compose 类型(Modifier、PointerInputScope、LayoutCoordinates、快照状态),不能直接在鸿蒙上编译。而它的决策核心——哪个目标被悬停、交换是否成立、自动滚动多快、落点对齐到哪里——是与渲染体系无关的纯几何与状态机。适配的关键判断是:把判定层与 Compose 层拆开,判定层逐行进入 OpenHarmony,Compose 层原样留在上游源码集。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 上游只有 Android/JVM/JS/Wasm/iOS 目标,必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 动态库。 |
| 工具链断层 | 上游 Kotlin 2.4.21 / Compose 1.12.1 / AGP 9.4.1 / Gradle 9.8.1,且根构建依赖 convention-plugins 复合构建;鸿蒙社区是 HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)另一条版本线。两条插件线不能共存于同一个 Gradle 构建。 |
| 源码面 100% Compose | 通读 compose-dnd 库模块 commonMain:31 个源文件全部锚定 androidx.compose,没有任何一行能直接编给 ohos。 |
| 宿主差异 | 手势识别、布局信息、滚动控制与落点动画在 Compose 与 ArkUI 中是两套体系,交换 / 自动滚动语义必须由引擎统一下发。 |
| 语言边界 | ArkTS 不能持有 Kotlin 对象,@CName 业务符号也不会自动挂给 ArkTS,需要 C ABI + N-API + JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在独立 Gradle 工程、纯 Kotlin 判定引擎和 C ABI/N-API 桥接层。拖拽语义仍由公共 Kotlin 实现负责,ArkTS 只负责采集手势、上报布局快照和应用引擎返回的交换、平移与滚动。
1.2 库提供的能力
示例提供两个可观察场景,对应上游两组 API:
- Reorder 列表:29 个等高条目,长按 500ms 拾起(与上游
dragAfterLongPress触屏默认一致),垂直拖动跨槽交换,拖到上下边缘自动滚动加速,松手落位、打断回弹; - Drop Zones:一张卡片拖入三个放置区域,Surface / SurfacePercentage / CenterDistance 三种策略实时切换,悬停区域高亮,落点按 Center 对齐,状态行实时展示引擎响应。
引擎单步返回的 DndResult 与上游 DragAndDropState 的可观察面一一对应:
| 字段 | 上游对应 | 作用 |
|---|---|---|
dragging | isActiveDrag | 会话是否存活。 |
dragAmountX/Y | DraggedItemState.dragAmount | 轴约束后的拖动量,直接驱动幽灵 .translate。 |
topLeftX/Y | dragPosition | 幽灵左上角,拾起位置 + 拖动量。 |
hoveredKey | hoveredDropTargetKey | 当前悬停目标。 |
enteredKey / exitedKey | onDragEnter / onDragExit | 本步发生的进 / 出事件,宿主据此移动数据。 |
dropKey | onDrop | 松手时接收落点的目标,空表示幽灵归位。 |
dropTopLeftX/Y | getDropTopLeft | 目标内对齐后的落点,九向对齐默认 Center。 |
autoScroll | computeEdgeScrollSpeed | 滚动主轴速度(px/秒,带符号)。 |
共享层完整保留上游四块核心算法:resolveHoveredDropTarget 的候选分区与策略打分、hasReachedSlot 的槽位迟滞几何、DropStrategy 三策略、computeEdgeScrollSpeed 的二次缓动与意图武装。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 判定决策算法逐行移植到独立工程 commonMain,不改判定语义。 |
| 平台目标 | ohosArm64 + ohosX64 双 ABI 动态库,真机与模拟器两条验收路径都可用。 |
| 桥接稳定 | 单个 C ABI + UTF-8 JSON,不传递 Kotlin 对象地址,字符串释放责任明确。 |
| 上游零改动 | 上游根构建(convention-plugins、AGP、samples、发布流)原样保留,鸿蒙交付物全部落在 example/ 独立工程。 |
| UI 完整 | 长按拾起、跨槽交换、边缘自动滚动、取消回弹、悬停高亮、策略切换、落点对齐、状态行实时反馈。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit,不出现第三方镜像站。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。CMP 工程可以在工具链对齐后复用同一份引擎,在自己的 Compose 页面中获得与上游一致的拖拽行为契约。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点源集、划定「上游零改动 + example/ 独立工程」边界
第 2 阶段:目标与依赖打通 ── 加入 ohosArm64/ohosX64、钉死 eazytec 工具链
第 3 阶段:判定引擎建模 ── 逐行移植 hover 解析/槽位迟滞/DropStrategy/自动滚动
第 4 阶段:原生桥接 ── C ABI、C++ N-API、JSON 契约和内存释放
第 5 阶段:ArkUI 宿主 ── 手势采集、布局快照、交换应用和滚动循环
第 6 阶段:示例与验证 ── 黄金测试、双 ABI 链接、HAP、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
example/settings.gradle.kts 独立 ohos 构建(eazytec 仓库放最前)
example/nativeApp/src/commonMain/ 判定引擎九件套 + DndBridge + MiniJson
example/nativeApp/src/jvmTest/ 38 个黄金测试钉死上游语义
example/nativeApp/src/ohosMain/ Bridge.kt —— @CName("DndStep")/("@CName(\"DndVersion\")")
example/harmonyApp/ DevEco Stage:CMake NAPI + ArkTS 双 Tab 页面
doc/img/dnd-ohos-device.jpg 真机效果图
上游根构建一行不改:convention-plugins 复合构建、AGP 9.4.1、Kotlin 2.4.21、Compose 1.12.1 的库模块与 sample/ 照常可构建。example/ 是另一个入口的独立 Gradle 工程,settings.gradle.kts 只 include :nativeApp。这是已过审的 haze / miuix / compose-rich-editor 适配确立的边界:ohos 目标只存在于 HarmonyOS Kotlin 工具链,与上游构建互不污染。DevEco 永远打开 example/harmonyApp,不是仓库根。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | 2.2.21-1.0.0(HarmonyOS Kotlin,来自 eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 已写进 example/gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64 |
| Gradle | 8.14.1(独立 wrapper,不与上游 9.8.1 冲突) |
| DevEco product | 5.1.0(18) / HarmonyOS |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.dnd |
| Fork 版本 | 1.0.0-alpha02-ohos.1 |
settings.gradle.kts 中 eazytec 的两个 Maven 仓库必须放在 pluginManagement 和 dependencyResolutionManagement 的最前面,否则 ohosArm64 目标无法解析:
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()
}
}
1.3 页面边界
标题区 compose-dnd · OpenHarmony / 版本行(aboutToAppear 调 dndVersion())
Tab 区 Reorder 列表 / Drop Zones / 重置
状态区 引擎实时响应(drag hover=#12 off=(8.3,-60.1)、end drop=zoneA 对齐=(133.9,429.8))
Reorder 区 29 条等高条目长按拾起、跨槽交换、边缘自动滚动
Zones 区 策略 Chip ×3 + 卡片托盘 + 三个放置区域
第 2 阶段:目标与依赖打通
2.1 独立工程的库模块
example/nativeApp 是唯一的 Kotlin 模块,引擎直接编进它的 commonMain,不再单独建库模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvm() // 交叉验证目标:黄金测试在这里跑
ohosArm64 {
binaries.sharedLib { baseName = "ohosdnd" }
}
ohosX64 {
binaries.sharedLib { baseName = "ohosdnd" }
}
}
jvm() 保留用于交叉验证引擎行为;根构建禁用所有 Ohos*Test 任务:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
2.2 构建核心库和 Native 产物
cd example
./gradlew :nativeApp:jvmTest
./gradlew :nativeApp:linkDebugSharedOhosArm64 \
:nativeApp:linkDebugSharedOhosX64
链接任务的 finalizedBy 会把产物和匹配 triple 的 libc++_shared.so 一起拷进 DevEco 工程:
example/harmonyApp/entry/libs/arm64-v8a/libohosdnd.so (~8.6 MB,含 KN 运行时)
example/harmonyApp/entry/libs/arm64-v8a/libc++_shared.so
example/harmonyApp/entry/libs/x86_64/libohosdnd.so (~7.8 MB,模拟器用)
example/harmonyApp/entry/libs/x86_64/libc++_shared.so
第 3 阶段:判定引擎建模
3.1 JSON 契约
Index.ets 手势事件
↓ x/y + items 布局快照(onAreaChange 上报,vp 单位)
DndStep(request: String)
↓ MiniJson 解析
DndEngine
│ 相交候选 → topmost zIndex → own-slot 分区 → hasReachedSlot → DropStrategy
▼
DndResult
↓ UTF-8 JSON
N-API dndStep()
↓
ArkUI 应用交换 / 平移 / 滚动 / 高亮
页面不复制任何判定逻辑,每一次交换、高亮和滚动速度都由 Kotlin 引擎计算。
请求协议(坐标单位统一 vp,宿主已把手势 px 除以 densityPixels):
action: version | reset | start | drag | tick | end | cancel | scroll
start → key / x / y / items / axis(free|horizontal|vertical) /
strategy(surface|surfacePercentage|centerDistance) / alignment(九向) /
hysteresisPx(默认8) / intentDistancePx(默认8) /
ghostWidth / ghostHeight / allowedTargets
drag → x / y / items
tick → items(自动滚动循环重估)
end → {}(悬停目标接收落点,带对齐 dropTopLeftX/Y)
cancel → {}(幽灵归位,悬停目标收到 exit)
scroll → scrollAxis / itemStart / itemEnd / pointer / containerStart /
containerEnd / viewportPx / zonePx / minZonePx / maxZonePx /
maxScrollSpeedPx / canScrollTowardStart / canScrollTowardEnd
items[] 每项:key / left / top / width / height / zIndex / canDrop / isSlot
isSlot=true 标记「这个目标的 key 同时是一个可拖条目」——即重排布局里条目自己的槽位,对应上游 dropTargetMap 里 partition { it.key in draggableItemMap } 的那一半。
3.2 结果数据类
class DndResult(
val dragging: Boolean,
val dragAmount: Pt, // 轴约束后的拖动量
val topLeft: Pt, // 幽灵左上角
val hoveredKey: String?,
val enteredKey: String?, // 本步进入的目标(onDragEnter)
val exitedKey: String?, // 本步离开的目标(onDragExit)
val dropKey: String?, // end 专用:接收落点的目标
val dropTopLeft: Pt?, // end 专用:对齐后的落点
)
几何类型 Pt / Sz 是 androidx.compose.ui.geometry.Offset / Size 的纯 Kotlin 平替(data class 保证值相等),TargetRect 是宿主上报的视口矩形快照——与上游从 LayoutCoordinates / LazyListItemInfo 读到的同一份数据。
3.3 运行逻辑
悬停解析(逐行移植上游 handleDrag + resolveHoveredDropTarget):
val candidates = targets.values
.filter { 相交(it) && (allowedTargets.isEmpty() || it.key in allowedTargets) && it.canDrop }
.topmost() // 只留最高 zIndex 一组
val (slots, plainTargets) = candidates
.filter { it.key != ownSlot.key }
.partition { it.isSlot } // 槽位与普通目标分区
val reachedSlots = slots.filter {
hasReachedSlot(ownSlot.center, it.center, it.size,
draggedTopLeft, draggedSize, marginPx = hysteresisPx / 2f)
}
return if (reachedSlots.isNotEmpty()) pick(reachedSlots + plainTargets)
else pick(plainTargets + ownSlot)
槽位迟滞(逐行移植上游 hasReachedSlot):前缘沿两槽连线方向越过目标槽中点至少 marginPx,且横向重叠不少于较窄者的 50%。交换发生后,被甩在身后的旧槽中心落在反方向、超出可达距离,同一手指位置不会要求换回去——这就是重排不抖动的保证:
val centerPastSlot = fromSlot.x * direction.x + fromSlot.y * direction.y
if (centerPastSlot + reach < marginPx) return false // 还没过中点
val overlap = (draggedAcross + slotAcross) / 2f - centersApart
return overlap >= min(draggedAcross, slotAcross) * 0.5f
重进判定(逐行移植上游 isSameSlotReachedAgain):条目拿下了悬停槽、布局重排、同一个槽又一次可达时(两槽相对位移超过 SlotMoveTolerancePx = 0.5),引擎再次发出 enter,让宿主把数据继续前移。
策略打分(逐行移植上游 DropStrategy):
Surface → maxBy { overlappingArea + zIndex * draggedArea }
SurfacePercentage → maxBy { overlappingArea / targetArea + zIndex }
CenterDistance → minBy { 中心距平方 }
自动滚动(逐行移植上游 computeEdgeScrollSpeed / edgeZoneSize / AutoScrollIntent):边缘区宽度 = preferred 钳到 [48,160] 再压到视口 1/4;速度 = maxSpeed * ratio² 的二次缓动;条目拾起时就在边缘区内的那一侧不武装,指针朝该边缘移动 ≥ 8(上游 AutoScrollIntentDistance = 8.dp)才武装,防止一拾起就滚。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libdnd_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosdnd.so)
│ DndStep → DndBridge → DndEngine
▼
UTF-8 JSON + explicit dispose
ArkTS 无法直接调用 @CName 业务符号(HarmonyOS CAdapter 只导出 stdlib 类型),所以用 CMake 再包一层 libdnd_napi.so。导出整数或对象地址都会让页面复制状态语义,所以选择 C ABI + JSON 单入口。
4.2 Kotlin/Native 导出函数
@CName("DndStep")
fun dndStep(request: String): String = DndBridge.step(request)
@CName("DndVersion")
fun dndVersion(): String = MiniJson.write(
linkedMapOf(
"ok" to true,
"upstream" to "1.0.0-alpha02",
"fork" to "1.0.0-alpha02-ohos.1",
),
)
请求解析用文件内手写的 MiniJson 递归下降解析器(object/array/string/number/bool/null),刻意不引入 kotlinx.serialization,保持动态库零额外 KN 依赖。数字输出定点格式:整数不带小数点、浮点最多两位、绝不出现科学计数法。注意 Kotlin/Native 上没有 java.lang.Math,舍入要用 kotlin.math.round / floor / abs。
4.3 C++ N-API 方法分发
static napi_value NapiDndStep(napi_env env, napi_callback_info info) {
// 单 string 参数 → 引擎 → 单 string 返回
const std::string request = JsToUtf8(env, args[0]);
const char *raw = DndStep(request.c_str());
napi_value out = nullptr;
napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
DisposeKnString(raw); // 立即归还 Kotlin/Native 字符串内存
return out;
}
N-API 只负责参数类型检查、字符串转换和释放,不实现任何拖拽语义。API 26 的 NAPI 头把 napi_get_value_string_utf8 缓冲区标成非 const,std::string::data() 直接传入编不过,要先量长度、开 std::vector<char> 中转。释放走 KN 导出的 libohosdnd_symbols()->DisposeString。
4.4 CMake 和类型声明
set(KN_SO ${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/${OHOS_ARCH}/libohosdnd.so)
add_library(ohosdnd SHARED IMPORTED)
set_target_properties(ohosdnd PROPERTIES IMPORTED_LOCATION ${KN_SO} IMPORTED_NO_SONAME TRUE)
add_library(dnd_napi SHARED napi_init.cpp)
target_link_libraries(dnd_napi PUBLIC libace_napi.z.so libhilog_ndk.z.so ohosdnd)
export const dndVersion: () => string;
export const dndStep: (request: string) => string;
$ORIGIN rpath 与 POST_BUILD 拷贝保证三个 .so(NAPI + KN + libc++_shared.so)在每个 ABI 目录内成套出现,缺件安装即报 9568347。cpp/include/libohosdnd_api.h 用 Kotlin/Native 链接时真实生成的头文件核对过(DndStep / DndVersion extern 与 DisposeString 成员逐一一致),不是手写占位。
第 5 阶段:ArkUI 宿主
5.1 调用引擎
import { dndStep } from 'libdnd_napi.so';
private step(req: Record<string, Object | null>): EngineFx {
return JSON.parse(dndStep(JSON.stringify(req))) as EngineFx;
}
应用一步结果:
private applyDrag(fx: EngineFx, phase: string): void {
this.dragDx = fx.dragAmountX ?? 0;
this.dragDy = fx.dragAmountY ?? 0;
this.hoveredKey = fx.hoveredKey ?? null;
if (fx.enteredKey != null && this.tab === 0) {
this.moveByKey(fx.enteredKey); // onDragEnter → list.move,上游 sample 同构
}
this.status = `${phase} hover=${fx.hoveredKey ?? '-'} off=(...)`;
}
moveByKey 与上游 ReorderListScreen 的 onDragEnter = { dragged -> languages.move(dragged.data, language) } 同构:进入谁的槽就把拖动条目挪到谁的位置,进入自己槽位是自然 no-op。
5.2 手势与布局快照
每个条目绑定 GestureGroup(Parallel, LongPressGesture(500ms), PanGesture):长按触发 start(携带命中坐标与全量布局快照),Pan 每帧增量触发 drag,抬起 end、打断 cancel。条目、区域和列表容器的 onAreaChange 实时把全局矩形写进 rects 快照,每次引擎调用前重建 items[] 上报。手势坐标是 px,densityPixels 换算成 vp 后进桥——契约全篇 vp。
拖拽期间 enableScrollInteraction(false) 锁住列表手势滚动,防止与引擎自动滚动双滚;切 Tab 时清空 rects,避免另一 Tab 的过期矩形污染快照。
自动滚动循环:16ms setInterval 调 scroll 动作(条目边缘、视口边界、滚动可达标志),返回速度非零就 scroller.scrollBy(0, speed * 16 / 1000),再发 tick 让引擎按滚动后的布局重估悬停——对应上游「列表动了、指针没动」的 reevaluateDropTargets。
5.3 视觉反馈和错误边界
被拖条目应用 .translate + .zIndex(10) + .scale(1.04) + shadow 浮起;end 后 180ms EaseOut 动画归位;cancel 由宿主播放回弹(对应上游落点动画 / 取消动画语义)。悬停的槽位或区域加边框高亮,Drop Zones 的悬停区显示 hover 徽标。引擎异常以 {"ok":false,"error":...} 返回,状态行直接展示错误文本,页面不崩溃。
ArkTS 严格模式有两个硬约束值得一提:对象字面量必须对应显式类型——请求统一用 Record<string, Object | null> 组装、快照用 interface ItemSnapshot;GestureEvent 上没有 globalX/globalY,指针坐标必须从 fingerList[0] 取。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/src/commonMain/kotlin/ohos/dnd/
│ ├── Geometry.kt / SlotLogic.kt / DropStrategy.kt / AutoScroll.kt
│ ├── DragAxis.kt / DropAlignment.kt / DndEngine.kt
│ └── DndBridge.kt / MiniJson.kt
├── nativeApp/src/jvmTest/kotlin/ohos/dnd/ 5 个文件 38 项测试
├── nativeApp/src/ohosMain/kotlin/ohos/dnd/Bridge.kt
└── harmonyApp/
├── entry/libs/{arm64-v8a,x86_64}/ Gradle 拷入的 KN so
├── entry/src/main/cpp/{CMakeLists.txt,napi_init.cpp,include/,types/}
└── entry/src/main/ets/pages/Index.ets
6.2 构建、签名和安装
cd example
./gradlew :nativeApp:jvmTest \
:nativeApp:linkDebugSharedOhosArm64 :nativeApp:linkDebugSharedOhosX64
# DevEco 打开 example/harmonyApp → 自动签名 → Build Hap(s)
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.dnd
签名遵循仓库规范:build-profile.json5 只保留 "signingConfig": "default" 占位,签名材料由 DevEco 自动签名生成在本机,证书绝不进入仓库。
四、完整代码对照
4.1 整体架构
DndEngine.kt(commonMain,逐行移植上游判定)
│ resolveHoveredDropTarget / hasReachedSlot / isSameSlotReachedAgain
▼
DndBridge.kt + MiniJson.kt
│ @CName("DndStep") JSON 分发
▼
libdnd_napi.so(CMake NAPI 包装)
│ dndStep(request) → response
▼
Index.ets
│ JSON parse + moveByKey + translate + scrollBy + 高亮
▼
ArkUI 真机页面
4.2 文件清单
| 文件 | 职责 |
|---|---|
example/settings.gradle.kts | 独立 ohos 构建,eazytec 仓库放最前 |
example/nativeApp/build.gradle.kts | jvm + ohosArm64/ohosX64 sharedLib,copyKnSo 拷 so |
ohos/dnd/Geometry.kt | Pt/Sz 平替 + MathUtils 四个几何函数移植 |
ohos/dnd/DropStrategy.kt | 三策略打分 + TargetRect 快照模型 |
ohos/dnd/SlotLogic.kt | hasReachedSlot 槽位迟滞几何 |
ohos/dnd/AutoScroll.kt | edgeZoneSize/AutoScrollIntent/computeEdgeScrollSpeed |
ohos/dnd/DragAxis.kt / DropAlignment.kt | 轴约束 / 九向落点对齐 |
ohos/dnd/DndEngine.kt | 会话状态机与悬停解析核心 |
ohos/dnd/DndBridge.kt / MiniJson.kt | JSON 分发与手写解析器 |
ohos/dnd/Bridge.kt(ohosMain) | @CName C ABI 薄壳 |
harmonyApp/.../napi_init.cpp | N-API 导出与参数检查、内存释放 |
harmonyApp/.../Index.ets | ArkUI 双 Tab 拖拽页面 |
README.OpenHarmony.md / _CN.md | 适配文档、接口对照和验收记录 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| KMP | DndEngine.start | 拾起命中条目、冻结指针锚点(上游 handleDragStart) |
| KMP | DndEngine.drag / tick | 累积拖动并解析悬停 / 滚动后重估(handleDrag / reevaluateDropTargets) |
| KMP | DndEngine.end / cancel | 落点对齐交付 / 幽灵归位(handleDragEnd / handleDragCancel) |
| KMP | DndEngine.scrollSpeed | 边缘自动滚动速度(computeEdgeScrollSpeed) |
| Native | DndStep / DndVersion | 单入口 JSON 请求响应 / 版本探针 |
| N-API | dndStep / dndVersion | 向 ArkTS 暴露引擎 |
| ArkTS | applyDrag / moveByKey | 应用平移高亮 / enter 事件移动数据 |
上游 Compose API 与引擎字段的完整映射:
| 上游 API | 引擎 / N-API 字段 |
|---|---|
Modifier.draggableItem(...) | action:start + key |
Modifier.dropTarget(...) | items[] 的 canDrop:true 条目 |
Modifier.reorderableItem(...) | items[] 的 isSlot:true 条目 |
onDragEnter / onDragExit | enteredKey / exitedKey |
onDrop | dropKey + dropTopLeftX/Y |
DropStrategy.Surface / SurfacePercentage / CenterDistance | strategy 字段 |
DragAxis.Free / Horizontal / Vertical | axis 字段 |
dragAfterLongPress | LongPress 500ms → action:start |
onDragEnd(落点动画) | end 响应 + 宿主 180ms 归位动画 |
dragAutoScroll 配置(48dp/160dp/600dp) | scroll 动作的 minZonePx/zonePx/maxScrollSpeedPx |
DraggableItemState.dropTargets 过滤 | allowedTargets 数组 |
DropTargetState.dropAlignment 九向 | alignment 字段(默认 center) |
visibleItemsInfo | items 布局快照 |
4.4 ArkTS 与 Kotlin 的边界
const fx: EngineFx = JSON.parse(
dndStep(JSON.stringify(req))) as EngineFx;
fun step(request: String): String // DndBridge 内分发到引擎
两者之间只传输 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;悬停判定、交换打分和滚动速度全部产自 Kotlin 侧,页面只做渲染映射。
五、关键决策说明
决策 1:判定引擎放独立工程,上游根构建零改动
上游根构建是 convention-plugins 复合构建 + AGP 9.4.1 + Kotlin 2.4.21,与鸿蒙工具链互斥。与其把根构建改造成两条版本线妥协的混合体(上游 samples、BCV、发布流全部受害),不如把鸿蒙交付物整体放进 example/ 独立 Gradle 工程。上游仓库随时可以正常同步上游新版本,ohos 构建互不污染。
决策 2:引擎是逐行移植而非「参考重写」
resolveHoveredDropTarget 的分区顺序、hasReachedSlot 的 margin 语义、isSameSlotReachedAgain 的 0.5px 容差、computeEdgeScrollSpeed 的 ratio² 曲线、AutoScrollIntentDistance = 8 的武装距离,全部与上游逐行对应,由 38 项 JVM 黄金测试钉死。拖拽手感是行为契约,不是「看起来能用」就行——交换抖一下、迟滞差几像素,跨端体验就对不上。
决策 3:双 ABI 目标 ohosArm64 + ohosX64
真机是 arm64,DevEco 模拟器是 x86_64。只出 arm64 会让模拟器安装报 9568347,双目标让两条验收路径都可用。
决策 4:JSON 作为跨语言数据契约,手写 MiniJson
JSON 让 ArkTS、C++ 和 Kotlin/Native 的边界清楚可调试,并允许后续增加字段而不暴露内部对象布局。手写解析器避免为动态库引入 kotlinx.serialization 的额外 KN 依赖,C ABI 保持 const char* -> const char*,ArkTS 侧 dndStep(string): string 永远不用改。
决策 5:ArkUI 作为验收宿主,引擎保留 CMP 行为契约
当前页面验证 OpenHarmony Native 链路与交互语义;社区 CMP 工具链对齐后,同一份引擎可以被 Compose 页面直接消费,重排手感与 Android/桌面一致。引擎不感知 ArkUI 布局细节——非均匀尺寸、多列网格的快照正确性由宿主上报负责,这与上游把布局读取放在 Lazy 布局侧的分工一致。
六、测试与验证
6.1 测试环境
macOS(Homebrew Temurin JDK 21)、Gradle 8.14.1、DevEco Studio(SDK 5.1.0(18)、HarmonyOS Native SDK)、华为 HarmonyOS 真机(ARM64)。
6.2 编译与符号验证
./gradlew :nativeApp:jvmTest # 38 项测试 BUILD SUCCESSFUL
./gradlew :nativeApp:linkDebugSharedOhosArm64 \
:nativeApp:linkDebugSharedOhosX64 # BUILD SUCCESSFUL
$ llvm-nm -D entry/libs/arm64-v8a/libohosdnd.so
0000000000132a28 T DndStep
0000000000132bd4 T DndVersion
000000000013c324 T libohosdnd_symbols
x86_64 侧同样三个符号齐全。黄金测试五组共 38 项:策略打分 7、槽位迟滞 6、自动滚动 7、引擎会话 11、JSON 契约 7(含 MiniJson 编解码)。关键的几个黄金值:拖过中点 4px 迟滞内不换槽、交换后同手指位置不反悔、边缘区 ratio 0.25 → 37.5px/s、拾起即在区内不滚、桥定点两位小数 234.375 → 234.38。
6.3 HAP 验证
CompileArkTS: finished
BuildNativeWithCmake / BuildNativeWithNinja: finished
PackageHap: finished
BUILD SUCCESSFUL
未签名 HAP 内确认双 ABI 六件套齐全(libohosdnd.so + libdnd_napi.so + libc++_shared.so × 2)。签名由 DevEco 自动签名完成。
6.4 功能用例
- Reorder 列表:长按条目 500ms 浮起(放大 + 阴影 + 置顶);拖动跨越相邻条目数据立即重排、被拖条目跟随;拖到上下边缘自动滚动启动并按二次曲线加速;松手落位,状态行显示
end drop=...;拖动被打断回弹归位;「重置」恢复初始顺序; - Drop Zones:卡片拖到区域上方悬停高亮并显示
hover徽标;Surface / SurfacePercentage / CenterDistance 切换时同一位置的高亮判定随策略变化(小区域全覆盖 vs 大区域大面积);松手卡片落入悬停区域,状态行显示end drop=zoneA 对齐=(133.9,429.8)——对齐坐标即引擎getDropTopLeft的 Center 对齐输出;「重置」取回卡片; - 页头
aboutToAppear调dndVersion(),显示compose-dnd 1.0.0-alpha02 · fork 1.0.0-alpha02-ohos.1,证明 ArkTS → NAPI → KN 全链路在设备上活着。
6.5 验证结论
Native 动态库、C ABI、N-API、ArkUI 页面和签名 HAP 已完成链路验证。应用在华为 HarmonyOS 真机上成功安装并进入前台,长按拾起、跨槽交换、边缘自动滚动、悬停高亮与落点对齐全部由 Kotlin 引擎驱动,状态行实时展示引擎响应。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-dnd.git
cd compose-dnd/example
./gradlew :nativeApp:jvmTest \
:nativeApp:linkDebugSharedOhosArm64 :nativeApp:linkDebugSharedOhosX64
# DevEco 打开 example/harmonyApp,自动签名后 assembleHap
hdc -t <设备序列号> install -r \
example/harmonyApp/entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.dnd
八、遗留问题与改进方向
8.1 踩坑复盘
- eazytec Maven 仓库必须放在
settings.gradle.kts列表最前,否则ohosArm64目标 unresolved; - Kotlin/Native 没有
java.lang.Math,writeDouble里的舍入要换kotlin.math,否则 ohos 编译期直接失败而 JVM 测试全绿——先跑compileKotlinOhosArm64再收工; - 几何平替类型必须是
data class,否则黄金测试的assertEquals(Pt(...), ...)按 引用比较全红; - ArkTS 严格模式:对象字面量必须对应显式类型(请求统一
Record<string, Object | null>、快照用 interface);GestureEvent没有globalX/globalY,必须从fingerList[0]取;Column没有minHeight,用constraintSize; Area.width类型是Length(string | number),进Rect前要Number()收口;- 切 Tab 不清
rects会让上一个 Tab 的过期矩形混进快照、错误参与悬停判定——切 Tab 先清空布局快照; @CName业务符号 ArkTS 拿不到,必须 CMake 再包一层 N-API;KN so 旁边必须成套携带libc++_shared.so并设好$ORIGINrpath,缺件安装报9568347;- 自动滚动的意图武装距离要逐值对齐上游
AutoScrollIntentDistance = 8.dp(契约统一 vp 后无需再乘 density),第一版误写成固定 32,靠与上游源码逐行对照发现; - 所有
Ohos*Test任务需在根构建禁用,ohos 测试目标不可用; - 签名工程必须与源码分离,证书不能进入 AtomGit。
8.2 已知问题
- 鸿蒙上没有 Compose UI:三个 Modifier、
DraggedItemShadow、落点动画都在上游源码集里,等社区 CMP 工具链与上游版本线对齐后接回; onAreaChange异步,布局快照可能滞后一帧,交换后的补评估靠tick在下一帧完成;上游用settledPositionInRoot读「动画落点」,本移植以宿主快照近似;- 只有屏幕内条目进快照(与上游 Lazy 布局
visibleItemsInfo一致); - Demo 的
canScrollTowardEnd恒 true(简化),业务接入应按内容高度实算两端; - DropAlignment 引擎与桥实现全部九向,Demo 未做切换 UI;
- 上游的容器级 API(跨容器 kanban 投递)、dragPlaceholder、拖动把手不在本次移植面,会话级判定完整;
- CAdapter 链接期 clang 警告(
api.cpp: non-void function does not return a value in all control paths)与 qrose / haze / compose-rich-editor 一致,是 HarmonyOS Kotlin 生成器的问题,可忽略。
8.3 未来优化方向
- 社区 CMP 工具链就绪后把引擎接进真正的 Compose 页面,ArkUI 宿主降级为对照 Demo;
- 补跨容器投递(kanban 列间移动)与多选拖拽的引擎语义与验收用例;
scroll动作补内容高度实算的可达性上报,替代 Demo 的恒 true;- 在 CI 中加入 Native 链接、符号检查(
llvm-nm)和 HAP 构建; - 补充不同设备 ABI、API 版本和滚动行为的验收矩阵。
九、总结
9.1 核心难点回顾
KMP commonMain 判定引擎(逐行移植上游算法)
→ ohosArm64/ohosX64 Kotlin/Native
→ C ABI(DndStep / DndVersion)
→ C++ N-API(libdnd_napi.so)
→ ArkTS JSON 解析 + 手势采集 + 布局快照
→ ArkUI 真机页面(交换/平移/自动滚动/高亮/对齐)
9.2 封装层次
- KMP 层:悬停解析、槽位迟滞、策略打分、自动滚动缓动与落点对齐的会话状态机;
- Native 层:双 ABI 动态库并通过单个 C ABI 输出 JSON;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:长按 / 拖动手势采集、
onAreaChange布局快照、enter 事件移动数据、滚动循环与视觉反馈; - DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 适配交互库先拆层:与渲染无关的决策算法进
commonMain逐行移植,Compose 壳隔离保留,两边的验收互不阻塞; - 交互手感是行为契约:迟滞 margin、武装距离、缓动曲线这类「看不见的常数」必须与上游逐值对齐,并用黄金测试钉死,否则跨端体验漂移无从察觉;
- 用 JSON 和单个 C ABI 代替跨语言对象传递,并明确字符串释放责任;JVM 黄金测试与 ohos 链接要分开跑——JVM 全绿不代表 Native 能编(
java.lang.Math一类依赖只在 Native 侧暴露)。
9.4 适配成果
当前适配已完成并推送至 AtomGit/oh-tpc/compose-dnd main 分支(提交「鸿蒙适配」,54 个文件):
- 上游根构建零改动的
example/独立 ohos 工程(eazytec 工具链,Kotlin2.2.21-1.0.0、Gradle 8.14.1、JDK 21); - 逐行移植上游判定算法的纯 Kotlin 引擎九件套 + MiniJson +
@CName桥; - 38 项 JVM 黄金测试、
libohosdnd.so双 ABI 链接与三符号导出验证; - DevEco Stage ArkUI 双 Tab 验收页面、CMake NAPI 包装、HAP 构建与自动签名;
- 华为 HarmonyOS 真机验证:长按拾起、跨槽交换、边缘自动滚动、策略切换悬停判定、落点对齐全部由 Kotlin 引擎驱动;
README.OpenHarmony.md/README.OpenHarmony_CN.md双语适配文档与本文,链接统一 AtomGit。
适配过程 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)