本文记录 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 的可观察面一一对应:

字段上游对应作用
draggingisActiveDrag会话是否存活。
dragAmountX/YDraggedItemState.dragAmount轴约束后的拖动量,直接驱动幽灵 .translate。
topLeftX/YdragPosition幽灵左上角,拾起位置 + 拖动量。
hoveredKeyhoveredDropTargetKey当前悬停目标。
enteredKey / exitedKeyonDragEnter / onDragExit本步发生的进 / 出事件,宿主据此移动数据。
dropKeyonDrop松手时接收落点的目标,空表示幽灵归位。
dropTopLeftX/YgetDropTopLeft目标内对齐后的落点,九向对齐默认 Center。
autoScrollcomputeEdgeScrollSpeed滚动主轴速度(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 Multiplatform2.2.21-1.0.0(HarmonyOS Kotlin,来自 eazytec Maven)
JDK21(Temurin,org.gradle.java.home 已写进 example/gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64
Gradle8.14.1(独立 wrapper,不与上游 9.8.1 冲突)
DevEco product5.1.0(18) / HarmonyOS
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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.ktsjvm + ohosArm64/ohosX64 sharedLib,copyKnSo 拷 so
ohos/dnd/Geometry.ktPt/Sz 平替 + MathUtils 四个几何函数移植
ohos/dnd/DropStrategy.kt三策略打分 + TargetRect 快照模型
ohos/dnd/SlotLogic.kthasReachedSlot 槽位迟滞几何
ohos/dnd/AutoScroll.ktedgeZoneSize/AutoScrollIntent/computeEdgeScrollSpeed
ohos/dnd/DragAxis.kt / DropAlignment.kt轴约束 / 九向落点对齐
ohos/dnd/DndEngine.kt会话状态机与悬停解析核心
ohos/dnd/DndBridge.kt / MiniJson.ktJSON 分发与手写解析器
ohos/dnd/Bridge.kt(ohosMain)@CName C ABI 薄壳
harmonyApp/.../napi_init.cppN-API 导出与参数检查、内存释放
harmonyApp/.../Index.etsArkUI 双 Tab 拖拽页面
README.OpenHarmony.md / _CN.md适配文档、接口对照和验收记录

4.3 关键 API 对照

层次API作用
KMPDndEngine.start拾起命中条目、冻结指针锚点(上游 handleDragStart)
KMPDndEngine.drag / tick累积拖动并解析悬停 / 滚动后重估(handleDrag / reevaluateDropTargets)
KMPDndEngine.end / cancel落点对齐交付 / 幽灵归位(handleDragEnd / handleDragCancel)
KMPDndEngine.scrollSpeed边缘自动滚动速度(computeEdgeScrollSpeed)
NativeDndStep / DndVersion单入口 JSON 请求响应 / 版本探针
N-APIdndStep / dndVersion向 ArkTS 暴露引擎
ArkTSapplyDrag / moveByKey应用平移高亮 / enter 事件移动数据

上游 Compose API 与引擎字段的完整映射:

上游 API引擎 / N-API 字段
Modifier.draggableItem(...)action:start + key
Modifier.dropTarget(...)items[] 的 canDrop:true 条目
Modifier.reorderableItem(...)items[] 的 isSlot:true 条目
onDragEnter / onDragExitenteredKey / exitedKey
onDropdropKey + dropTopLeftX/Y
DropStrategy.Surface / SurfacePercentage / CenterDistancestrategy 字段
DragAxis.Free / Horizontal / Verticalaxis 字段
dragAfterLongPressLongPress 500ms → action:start
onDragEnd(落点动画)end 响应 + 宿主 180ms 归位动画
dragAutoScroll 配置(48dp/160dp/600dp)scroll 动作的 minZonePx/zonePx/maxScrollSpeedPx
DraggableItemState.dropTargets 过滤allowedTargets 数组
DropTargetState.dropAlignment 九向alignment 字段(默认 center)
visibleItemsInfoitems 布局快照

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 功能用例

  1. Reorder 列表:长按条目 500ms 浮起(放大 + 阴影 + 置顶);拖动跨越相邻条目数据立即重排、被拖条目跟随;拖到上下边缘自动滚动启动并按二次曲线加速;松手落位,状态行显示 end drop=...;拖动被打断回弹归位;「重置」恢复初始顺序;
  2. Drop Zones:卡片拖到区域上方悬停高亮并显示 hover 徽标;Surface / SurfacePercentage / CenterDistance 切换时同一位置的高亮判定随策略变化(小区域全覆盖 vs 大区域大面积);松手卡片落入悬停区域,状态行显示 end drop=zoneA 对齐=(133.9,429.8)——对齐坐标即引擎 getDropTopLeft 的 Center 对齐输出;「重置」取回卡片;
  3. 页头 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 踩坑复盘

  1. eazytec Maven 仓库必须放在 settings.gradle.kts 列表最前,否则 ohosArm64 目标 unresolved;
  2. Kotlin/Native 没有 java.lang.Math,writeDouble 里的舍入要换 kotlin.math,否则 ohos 编译期直接失败而 JVM 测试全绿——先跑 compileKotlinOhosArm64 再收工;
  3. 几何平替类型必须是 data class,否则黄金测试的 assertEquals(Pt(...), ...) 按 引用比较全红;
  4. ArkTS 严格模式:对象字面量必须对应显式类型(请求统一 Record<string, Object | null>、快照用 interface);GestureEvent 没有 globalX/globalY,必须从 fingerList[0] 取;Column 没有 minHeight,用 constraintSize;
  5. Area.width 类型是 Length(string | number),进 Rect 前要 Number() 收口;
  6. 切 Tab 不清 rects 会让上一个 Tab 的过期矩形混进快照、错误参与悬停判定——切 Tab 先清空布局快照;
  7. @CName 业务符号 ArkTS 拿不到,必须 CMake 再包一层 N-API;KN so 旁边必须成套携带 libc++_shared.so 并设好 $ORIGIN rpath,缺件安装报 9568347;
  8. 自动滚动的意图武装距离要逐值对齐上游 AutoScrollIntentDistance = 8.dp(契约统一 vp 后无需再乘 density),第一版误写成固定 32,靠与上游源码逐行对照发现;
  9. 所有 Ohos*Test 任务需在根构建禁用,ohos 测试目标不可用;
  10. 签名工程必须与源码分离,证书不能进入 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 三条经验

  1. 适配交互库先拆层:与渲染无关的决策算法进 commonMain 逐行移植,Compose 壳隔离保留,两边的验收互不阻塞;
  2. 交互手感是行为契约:迟滞 margin、武装距离、缓动曲线这类「看不见的常数」必须与上游逐值对齐,并用黄金测试钉死,否则跨端体验漂移无从察觉;
  3. 用 JSON 和单个 C ABI 代替跨语言对象传递,并明确字符串释放责任;JVM 黄金测试与 ohos 链接要分开跑——JVM 全绿不代表 Native 能编(java.lang.Math 一类依赖只在 Native 侧暴露)。

9.4 适配成果

当前适配已完成并推送至 AtomGit/oh-tpc/compose-dnd main 分支(提交「鸿蒙适配」,54 个文件):

  • 上游根构建零改动的 example/ 独立 ohos 工程(eazytec 工具链,Kotlin 2.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。

参考文档

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐