本文记录 single-touch-box 接入 OpenHarmony 的完整过程,覆盖手势库盘点、变换引擎提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI / N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

single-touch-box 是基于 Compose Multiplatform 的单指手势库:给一堆可编辑 Item(图片、贴纸、文本框)提供一根手指的拖动、旋转、缩放——手指按住 Item 中间是平移,按住右下角是绕中心旋转 + 距离比值缩放,左上角固定为删除按钮位(该角永远不触发平移),右下角是缩放旋转控制位。本次适配与社区早期路线(qrose / haze / PreCompose / voyager 的"零依赖内核提取")同源但更进一步:散落在 Compose pointerInput 与 graphicsLayer 回调里的全部变换数学被逐行收拢为纯 Kotlin 引擎 SingleTouchEngine,并让上游 Compose UI 反过来调用同一份引擎——Android / iOS / Desktop 与鸿蒙 so 里跑的不是两份对齐的实现,而是同一份实现的两条编译路径。29 项 JVM 交叉验证测试钉住了这件事。

项目地址: AtomGit/oh-tpc/single-touch-box

上游仓库: 3-BodyCoding/single-touch-box

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

single-touch-box 为 Compose Multiplatform 应用提供"单指摆件"这一件事的完整答案:SingleTouchBox / SingleTouchImage 在一个盒子容器里放任意多个可编辑 Item,SingleTouchState 管 Item 列表(key 唯一性、onlyCurrentEditable 抢焦、增删改),TouchTransformData 持每个 Item 的 centerPoint / offset / rotate / zoom / contentSize。手势语义的核心是两层钳制:平移被钳在「旋转外接矩形不出父容器」内;缩放被钳在「缩放后的外接矩形不出父容器剩余空间」和 zoomRange 内。贴纸编辑器、证件照摆拍、海报排版、红包封面 DIY、白板便签——所有"单指摆件"类 UI 都是这个库的领地。OpenHarmony 应用同样需要这套交互,但上游构建(Kotlin 2.1.21 / Compose 1.8.2 / AGP 8.11.0 / vanniktech 发布插件)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示色块,却无法证明变换引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行——更无法证明鸿蒙上算出来的角度、缩放、钳制边界与 Android 逐位一致。适配需要同时验证变换引擎、手势判定、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.1.21 / Compose 1.8.2 / AGP 8.11.0 与鸿蒙工具链无法共存于一个 Gradle 构建;鸿蒙 Kotlin 钉 2.2.21-1.0.0。
UI 不可编译上游全部源文件 import androidx.compose.*,pointerInput / graphicsLayer / mutableStateOf 在鸿蒙 Kotlin 插件下全部无法解析,必须隔离。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
手势语义跨不了桥角部判定、touch slop、绕中心角度都是逐事件的连续状态,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:变换引擎的逐行收拢与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。渲染完全由 ArkUI 按引擎输出的数值投影(translate / rotate / scale),ArkTS 只负责采集原始触摸坐标、调用 Native 和展示结果。

1.2 库提供的能力

Demo 提供一个真实的手势会话,对应桥接的全部入口(9 个 op + 1 个握手):

  • 会话初始化(init):注入父容器宽高、角区尺寸 cornerSize、控制按钮尺寸 controlSize、初始 Item 尺寸 initItemSize、zoomRange;
  • 添加 Item(add):key 空白/重复抛上游同款异常(key cannot be blank / key already exists…),初始 zoom = min(initW/contentW, initH/contentH),初始 offset 按中心摆放——全部来自引擎函数;
  • 移除 / 可编辑(remove / editable):对齐 SingleTouchState.removeItem / setEditable;把正在手势中的 Item 置为不可编辑会安全结束手势;
  • 手势三拍(down / move / up):桥按父容器局部坐标做命中测试(后添加者优先,镜像 Compose hit testing),只认可编辑 Item;命中后按引擎 analyzeDown 分类角部并开启手势(gestureChanging=true);move 经逆变换转 Item 局部坐标,BottomEnd 角走绕中心旋转+缩放,其余走平移,touch slop 累积过了才应用;
  • 状态回读 / 重置(state / reset):全量 Item 快照与会话状态;清空 Item 保留 init 参数;
  • 版本握手(SingleTouchVersion):返回 {"ok":true,"version":"1.0.0-ohos.1"},来自真实 Native 调用而非静态文字。

响应包含:ok(失败时 error 带上游异常原文)、corner(TopStart / TopEnd / BottomStart / BottomEnd / Other)、handled / applied、gestureChanging,以及 item 全量快照(key、page、center、offset、rotate、zoom、content、box、editable)。引擎侧完整能力(旋转外接矩形、平移钳制、剩余空间缩放钳制、zoomRange、slop 累积、角部禁拖门控)全部在 commonMain 里随编译进入 .so,JVM 侧保留 29 项交叉验证用例,证明 ohos .so 里执行的手势数学没有漂移。

1.3 实现适配

维度要求
代码复用上游 11 个 Kotlin 文件原样保留;纯数学逐行收拢进新增的 SingleTouchEngine.kt(唯一的新增引擎源文件),composeMain 反向调用引擎,两端同一份数学。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample/composeApp(Compose 多平台示例)留盘不进构建。
桥接稳定使用 2 个 C ABI 和扁平 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律库本体零第三方依赖(上游即零依赖),鸿蒙侧不引入任何新依赖。
UI 完整单页覆盖全部桥接入口:控制区、手势盒子区、状态面板、操作日志和版本握手。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧的渲染是 ArkUI translate / rotate / scale 按引擎数值投影——引擎输出的 offset / rotate / zoom 与 Android 逐位一致,验收以状态面板与日志里的数值为准。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认工具链、确定交付物
第 2 阶段:引擎与目标打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、引擎收拢 + composeMain 反向调用
第 3 阶段:桥协议建模     ── 9 op 扁平 JSON 契约、会话状态归属、坐标逆变换、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── onTouch 坐标转发、px→vp 换算、控制区、状态面板和日志
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
single-touch-box/                     上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
single-touch-box/src/commonMain/      纯引擎:SingleTouchEngine + TouchCorner + 异常 + ZOOM_RANGE
single-touch-box/src/composeMain/     上游 Compose UI(ohos 不编,数学改为调引擎)
single-touch-box/src/jvmTest/         29 项引擎交叉验证
example/nativeApp/                    Kotlin/Native 动态库(Bridge + Session + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 页面
sample/                               上游 Compose 示例,留盘不进构建
doc/                                  本文章与效果图
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存

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

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Eclipse Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 原样沿用——2.2.21 旧线恰好要求 8.x,一行不用改)
第三方依赖无(上游零依赖,鸿蒙侧零新增)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.singletouchbox
1.3 源码依赖度盘点
上游文件依赖形态处置
state/TouchCorner.kt纯枚举留在 commonMain,原样编译
exception/KeyException.kt / SizeException.kt纯异常留在 commonMain,原样编译
ex/Constants.kt(ZOOM_RANGE = 0.1f..4f)纯常量留在 commonMain,原样编译
ex/PointerEventEx.ktpointerInput / awaitEachGesture + 纯数学挪进 composeMain;角部分类、绕中心缩放旋转、slop 累积改为调引擎
state/SingleTouchState.ktmutableStateListOf + 校验逻辑挪进 composeMain,原样保留(Compose 状态容器)
state/TouchTransformData.ktCompose 状态 + 初始摆放数学挪进 composeMain;initContentSize 改为调 initialScale / initialOffset
ui/TouchBox.ktCompose UI + 整段手势数学挪进 composeMain;onGesture 数学体改为调 SingleTouchEngine.applyGesture
ui/SingleTouchBox.kt / SingleTouchImage.kt / TouchBoxParent.kt / ZoomTransformBox.kt纯 Compose UI挪进 composeMain,原样保留

composeMain 不是 Kotlin 源集,不参与 ohos 编译,留下来是为了对照上游 API,也方便以后 CMP 工具链到齐时再接回去。关键的一刀是让 composeMain 反过来调用引擎:这样 Android / iOS / Desktop 上跑的和鸿蒙 so 里跑的是同一份函数,"同一输入同一数值"不需要两份代码人肉对齐。

纯数学收拢的明细(引擎函数 ← 上游出处):

引擎函数上游出处保留的怪癖
rotatedSizePointerEventEx.calculateRotatedSize高度式的最后一项用了 cosTheta(教科书公式此处是 sinTheta),仅 45° 时两者相等,原样保留
rotateBy / rotatePointerEventEx 同名函数Double 三角函数 + Float 截断顺序不变
cornerAtisInCorners边界用 <= / >=,边界像素同时属于两个区域;判定顺序不变
zoomRatiocalculateZoomSingle上一帧距离下限 1f(防除零)
rotationDeltacalculateRotationSingleatan2 角度差,±180 翻转到 (−180, 180]
GestureAccumulatordetectTransformGestures 的 slop 块touchSlop 硬编码 1f(上游注释掉了 viewConfiguration.touchSlop)
analyzeDowndown 块的角部门控TopStart 永远禁拖(删除按钮位);TopEnd / BottomStart 仅在对应控制存在时禁拖;BottomEnd 是旋转缩放区
initialScale / initialOffsetTouchBox.onSizeChanged + TouchTransformData.initContentSize初始 offset = 中心 − (内容+controlSize)/2
applyGestureTouchBox.onGesture 整段上游 Rect.translate 的返回值被丢弃(Rect 不可变),钳制几何锚在 Item 中心而非屏幕上的实际盒子——原样保留并写进注释;rotate 是增量
1.4 页面边界
标题区          SingleTouchBox · OpenHarmony / 版本握手行(CMP 单指拖拽旋转缩放 · Kotlin/Native ohosArm64/ohosX64)
控制区          添加 Item / 移除选中 / 重置 / 刷新状态 + 选中可编辑 Toggle + 顶右底左角禁拖 Toggle
盒子区          380 高 Stack 容器,onAreaChange 注入父容器尺寸并触发 init;Item 按 translate/rotate/scale 渲染,onTouch 转发原始指针
状态区          选中 Item 的编辑态、最近角部、手势中、实时 offset/rotate/zoom
日志区          带时间戳的操作日志(最新在前,上限 100 条,move 不刷屏)
关于区          桥协议一行说明 + 仓库与社区链接

第 2 阶段:引擎与目标打通

2.1 工具链钉版(为什么是 2.2.21-1.0.0)

ohosArm64() / ohosX64() 只存在于 HarmonyOS Kotlin 插件。settings.gradle.kts 把 eazytec 双仓写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_singletouchbox"

include(":single-touch-box")
include(":example:nativeApp")

Kotlin 版本钉 2.2.21-1.0.0(cmp_qrose / swipe / Flippable 同款已审钉法),Gradle Wrapper 钉 8.14.1,JDK 钉 21。上游仓库用的是 Kotlin 2.1.21 + AGP 8.11 + Compose 1.8 + vanniktech 发布插件,那套在鸿蒙上直接 sync 会在配置期就死。运气好的是:2.2.21 旧线要求 Gradle 8.x,上游 wrapper 正好是 8.14.1,一行都不用改。

2.2 根构建替换

根 build.gradle.kts 收敛为:

plugins {
    alias(libs.plugins.kotlin.multiplatform) apply false
}

allprojects {
    group = "io.github.3-bodycoding"
    version = providers.gradleProperty("version").orElse("1.0.0-ohos.1").get()
}

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

库模块收敛为纯 KMP:

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    // The portable single-touch transform engine in commonMain compiles for
    // every target. The upstream Compose UI stays under src/composeMain and
    // is NOT compiled for any target here.
    jvm()
    ohosArm64()
    ohosX64()

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

不要在库模块上开 binaries.sharedLib,sharedLib 只放 example/nativeApp。libs.versions.toml 只剩一行 Kotlin 版本。

2.3 编译真实引擎
./gradlew :single-touch-box:compileKotlinJvm :single-touch-box:jvmTest   # BUILD SUCCESSFUL,29 项全绿
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

引擎与 Item 集合的全部 commonMain 文件里,SingleTouchEngine.kt 是唯一新增的引擎源文件;SingleTouchItemCollection(纯 Kotlin 的 Item 管理,镜像 SingleTouchState 的 key 校验 / onlyCurrentEditable / 增删改语义)与引擎同文件,供桥的 Session 使用。

第 3 阶段:桥协议建模

3.1 JSON 契约
com.wzq.singletouchbox.engine/**(纯引擎,原样编译)
        ↓ SingleTouchEngine / SingleTouchItemCollection / GestureAccumulator
Bridge.kt(@CName × 2,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API singleTouchDispatch() / singleTouchVersion()
        ↓
ArkUI Index.ets(单页滚动)

九个 op 一览:

op请求字段语义(对齐的上游行为)
initwidth、height、cornerSize=24、controlSize=24、initItemSize=160、可选 minZoom/maxZoom注入父容器与控制尺寸,重置会话
addkey、page=0、contentWidth、contentHeight、可选 centerX/centerYkey 校验 + onlyCurrentEditable + 初始摆放
removekey对齐 removeItem;手势中则安全结束
editablekey、value对齐 setEditable
downx、y、可选 hasTopEnd/hasBottomStart坐标命中测试 + 角部分类 + 开启手势
movex、y逆变换 → 角部分支(旋转缩放 / 平移)→ slop → 应用
upx、y结束手势,gestureChanging=false
state—全量 Item 快照回读
reset—清空 Item,保留 init 参数

响应格式:

{"ok":true,"op":"move","handled":true,"corner":"BottomEnd","applied":true,"gestureChanging":true,
 "item":{"key":"item-1","page":0,"centerX":200.0,"centerY":250.0,"offsetX":12.5,"offsetY":0.0,
          "rotate":33.5,"zoom":1.18,"contentWidth":140.0,"contentHeight":100.0,
          "boxWidth":164.0,"boxHeight":124.0,"editable":true}}
3.2 坐标为什么在桥里逆变换

上游 pointerInput 收到的 change.position 是 Item 变换后局部空间的坐标。ArkUI 的 onTouch 只给窗口坐标,所以桥做逆变换:

world = R(rotate) · (local − boxCenter) · zoom + boxCenter + offset
local = R(−rotate) · (world − boxCenter − offset) / zoom + boxCenter

其中 boxCenter = (boxWidth/2, boxHeight/2),boxWidth = contentWidth + controlSize(上游外层 Box 含 controlDpSize/2 内边距)。这样引擎看到的坐标与 Compose 管线完全同构,applyGesture 的输出自然逐位一致。ArkTS 侧只负责把 windowX/windowY(px)除以 densityPixels 转成 vp、再减盒子容器的 globalPosition。

3.3 错误通道

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

@CName("SingleTouchDispatch")
fun singleTouchDispatch(request: String): String = try {
    dispatch(MiniJson.parse(request))
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、未 init、key 空白/重复、不支持的 op,全部走同一个 ok:false 通道,且错误文本直接复用上游异常的 message(如 Initial width and height needs to be larger than the control button size),ArkTS 侧原样展示,不二次包装。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libsingletouchbox_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohossingletouchbox.so)
  │ SingleTouchDispatch / SingleTouchVersion
  ▼
UTF-8 JSON + DisposeString

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

4.2 Kotlin/Native 导出函数
@CName("SingleTouchDispatch")
fun singleTouchDispatch(request: String): String   // 9 op 一口进

@CName("SingleTouchVersion")
fun singleTouchVersion(): String = """{"ok":true,"version":"1.0.0-ohos.1"}"""  // 版本握手
C ABI 符号ArkTS 方法覆盖内核
SingleTouchDispatchsingleTouchDispatch(req)Session(Item 集合 + 手势状态)+ 引擎全部数学
SingleTouchVersionsingleTouchVersion()fork 版本握手(真机版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"singleTouchDispatch", nullptr, NapiSingleTouchDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"singleTouchVersion", nullptr, NapiSingleTouchVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(singletouchbox_napi SHARED napi_init.cpp)
target_link_libraries(singletouchbox_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohossingletouchbox)

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

export const singleTouchDispatch: (request: string) => string;
export const singleTouchVersion: () => string;

第 5 阶段:ArkUI 宿主

5.1 触摸转发和 px→vp 换算
aboutToAppear(): void {
  try {
    this.density = display.getDefaultDisplaySync().densityPixels;  // px = vp × densityPixels
  } catch (err) { this.density = 1; }
  // 版本握手(JSON 解析失败兜底显示裸串,NAPI 未加载兜底提示,启动绝不崩)
}

private onItemTouch(key: string, event?: TouchEvent): void {
  const t: TouchObject = event.touches[0];
  const op = t.type === TouchType.Down ? 'down' : (t.type === TouchType.Up || t.type === TouchType.Cancel ? 'up' : 'move');
  const x: number = t.windowX / this.density - this.boxGlobalX;   // px → vp → 盒子局部
  const y: number = t.windowY / this.density - this.boxGlobalY;
  this.dispatchTouch({ op: op, x: x, y: y, key: key });
}

不用 ArkUI 内置 PanGesture / RotateGesture:内置手势的角部判定、slop、旋转中心语义与上游 pointerInput 对不上。Demo 直接转发 onTouch 原始坐标,引擎自己做全部判定——这才是换宿主,不是换实现。

5.2 页面交互

盒子区是 380 高的 Stack(alignContent: TopStart),onAreaChange 记录尺寸与 globalPosition 并触发一次 init。每个 Item 的组件尺寸 = 内容 + controlSize(镜像上游外层 Box 的内边距),按引擎返回值整体刷新:

Stack() { /* 内容块:圆角色块 + key + zoom/rotate 数值 */ }
  .width(item.contentWidth + CONTROL_SIZE)
  .height(item.contentHeight + CONTROL_SIZE)
  .padding(CONTROL_SIZE / 2)
  .translate({ x: item.offsetX, y: item.offsetY })
  .rotate({ x: 0, y: 0, z: 1, angle: item.rotate })   // x/y/z 是轴向量,angle 才是角度
  .scale({ x: item.zoom, y: item.zoom })
  .onTouch((event?: TouchEvent) => this.onItemTouch(item.key, event))

ForEach 的 key 只用 item.key——key 里一旦混入变换数值,每次 move 都会重建组件、打断触摸流。控制区四个按钮用 Flex({ wrap: FlexWrap.Wrap }) 换行;选中可编辑与双角禁拖各一个 Toggle,后者让 down 请求带上 hasTopEnd / hasBottomStart,即时生效。

5.3 对比度和错误边界

主操作「添加 Item」深色按钮白字,次操作浅灰底黑字;ok:false 时错误原文进状态行;handled:false 的 down 明确提示「未命中可编辑 Item」。桥调用整体 try/catch 收进日志区,Native 异常(理论不该发生)不让页面崩。move 事件高频,日志按 op 过滤不刷屏,只刷新状态行。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohossingletouchbox)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName × 2 + Session + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.singletouchbox、versionName 1.0.0-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohossingletouchbox_api.h
        ├── src/main/cpp/types/libsingletouchbox_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :single-touch-box:jvmTest                                              # 29 项交叉验证
./gradlew :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 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

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

四、完整代码对照

4.1 整体架构

com.wzq.singletouchbox.engine/**(纯引擎,逐行移植 + 纯 Item 集合)
    │ rotatedSize / cornerAt / zoomRatio / rotationDelta / GestureAccumulator / applyGesture
com.wzq.singletouchbox.composeMain/**(上游 Compose UI,反向调用引擎)
    │ SingleTouchBox / TouchBox / detectTransformGestures
Bridge.kt + Session + MiniJson
    │ SingleTouchDispatch / SingleTouchVersion
libsingletouchbox_napi.so
    │ singleTouchDispatch / singleTouchVersion
Index.ets
    │ onTouch 转发 + JSON parse + translate/rotate/scale
    ▼
ArkUI 真机页面(单页滚动)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_singletouchbox、sample 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单行钉版
build.gradle.kts(根)版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务
single-touch-box/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零依赖
engine/SingleTouchEngine.kt唯一新增引擎源文件:纯数学 + GestureAccumulator + applyGesture + SingleTouchItemCollection
composeMain/**(8 文件)上游 Compose UI 原样保留,数学改为调引擎
jvmTest/SingleTouchEngineTest.kt29 项交叉验证
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、Session、坐标逆变换、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面

4.3 关键 API 对照

层次API作用
引擎analyzeDown(...)角部分类 + 旋转缩放/平移门控
引擎GestureAccumulator.accumulate(...)touch slop 累积(上游 1f 阈值)
引擎applyGesture(item, parent, zoomRange, pan, zoom, rotate)平移钳制 / 剩余空间缩放钳制 + 增量旋转
引擎initialScale / initialOffset新 Item 初始摆放
集合SingleTouchItemCollection.add/remove/setEditable/disableEditAll镜像 SingleTouchState 管理语义
NativeSingleTouchDispatch / SingleTouchVersionC ABI JSON 导出
N-APIsingleTouchDispatch(req) / singleTouchVersion()向 ArkTS 暴露能力
ArkTSonItemTouch → dispatchTouch触摸转发 + 刷新渲染

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(singleTouchDispatch(JSON.stringify({
  op: 'move', x: 182.4, y: 96.2
})));
// res.ok === true;res.item.rotate / zoom / offsetX / offsetY 整体刷新 translate/rotate/scale
val local = toLocal(item, x, y)                    // 逆变换到 Item 局部
val past = accumulator.accumulate(panX, panY, zoomChange, rotationChange, centroidDistance)
if (past) applyGesture(item, panX, panY, zoomChange, effectiveRotation)

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;ArkTS 侧不做任何增量累加,一律以桥返回的 item 快照为准,否则两边漂移。

五、关键决策说明

决策 1:引擎收拢 + composeMain 反向调用,而不是桥里再抄一份

把上游数学抄进桥是最快的路,但那样 Android 和鸿蒙就是两份实现,后续上游修 bug 要人肉同步两处。本适配把数学逐行收拢进 commonMain 的 SingleTouchEngine,再让 composeMain 的 detectTransformGestures / TouchBox / TouchTransformData 反向调用它——上游 Compose 侧的行为由同一份函数保证,29 项 JVM 测试直接钉住鸿蒙 so 里的数学。上游的三个怪癖(rotatedSize 高度式复用 cosTheta、Rect.translate 返回值被丢弃导致钳制锚在中心、touchSlop 硬编码 1f)全部原样保留并写进注释,因为位兼容优先于"看起来更正确"。

决策 2:工具链钉 2.2.21-1.0.0,Gradle 沿用上游 8.14.1

2.2.21-1.0.0 是社区多条已审仓库(qrose / swipe / Flippable)的既有钉法,eazytec Maven 置前即可解析。与 KStore 那类"工具链跟着 kotlinx 制品线走 2.4"的仓不同,本库零第三方依赖,没有制品线反推版本的问题——按已审旧钉法走最稳。2.2.21 旧线要求 Gradle 8.x,上游 wrapper 8.14.1 原样保留,一行不改。

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

上游根构建(AGP / Compose 插件 / vanniktech 发布 / Maven Central)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample/composeApp 是 Compose 多平台示例,鸿蒙工具链编不了,留盘不进 settings.gradle.kts——它的存在方式只有这一种。

决策 4:onTouch 原始坐标,不用内置手势

ArkUI 内置 PanGesture 只给累计增量,RotateGesture 自带双指语义,都与上游"按下位置定角部 + 逐事件位置差"的模型对不上。Demo 用 onTouch 转发 Down/Move/Up 的原始窗口坐标,px→vp 一次换算后全部交给桥,角部判定、slop、旋转缩放全部在引擎里完成。这保证引擎看到的事件流与 Compose awaitEachGesture 同构。

决策 5:会话状态在 Kotlin 侧 + 29 项 JVM 交叉验证

C ABI 是同步一来一回,Item 列表与"当前手势"(activeKey、角部、slop 累积器、上一帧局部坐标)留在桥的 Session 里,语义镜像 SingleTouchState + detectTransformGestures 的局部变量。引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;29 项 jvmTest 跑的就是这些函数,是"内核没漂"的闸门。

六、测试与验证

6.1 测试环境

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

6.2 JVM 交叉验证

./gradlew :single-touch-box:jvmTest

29 项用例全部通过:

测试组用例数钉住什么
纯数学6rotatedSize 零度/90度/30度(含 cosTheta 怪癖)、rotateBy、rotate
角部分类2四角 + 中心;边界像素同时属于两区(<=/>=)
绕中心缩放/旋转4距离比值、上一帧距离下限 1f、±180 翻转、同点零差
slop 累积4小位移不过阈、累计 pan 过阈、旋转运动过阈、reset
down 分析3BottomEnd 旋转缩放、TopStart 永远禁拖、自定义角控制只在存在时禁拖
初始摆放2scale 取 min 比值、offset 中心摆放
applyGesture7平移直传、父容器钳制(130 / −250)、pan×zoom 再旋回、zoom 绝对值 + rotate 增量、剩余空间钳制 ≈1.93、zoomRange 上限 4、slop 之下的 None
Item 集合3key 空白/重复异常、onlyCurrentEditable / setEditable / disableEditAll、remove/replace 与 boxWidth

6.3 HAP 验证

:single-touch-box:compileKotlinJvm / jvmTest: BUILD SUCCESSFUL(29/29)
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: SingleTouchDispatch / SingleTouchVersion 均为全局 T 符号(双 ABI 各验一次)
BuildNativeWithCmake: finished(双 ABI 编出 libsingletouchbox_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully
start ability successfully

libohossingletouchbox.so arm64 ≈3.6 MB、x86_64 ≈3.3 MB(含 Kotlin/Native 运行时),libc++_shared.so 随 triple 各约 1.7 MB;HAP 内每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可,缺 KN so 安装直接报 9568347。

6.4 功能用例

  1. 启动后版本行显示 引擎版本 1.0.0-ohos.1(来自真实 Native 调用,非静态文字);
  2. 点「添加 Item」,item-1 以初始 zoom = min(160/140, 160/100) ≈ 1.14、中心摆放出现——与 JVM 侧 initialScale 计算一致;
  3. 按住 item 中间拖动,Item 平移且拖不出去(旋转外接矩形钳制生效),状态行实时刷新 offset;
  4. 按住右下角拖动,Item 绕中心旋转 + 距离比值缩放,最近角部显示 BottomEnd,放大到父容器剩余空间上限后不再变大;
  5. 按住左上角区域拖动,Item 不平移(删除按钮位永远禁拖);
  6. 打开「顶右/底左角也禁拖」后,顶右/底左角区按下同样不再平移;
  7. 添加第二个 Item,旧 Item 自动失焦(onlyCurrentEditable),对第二个 Item 的手势不影响第一个;
  8. 点「重置」后 Item 全部清空,「刷新状态」回读与会话一致;每步操作在日志区按时间戳滚动留痕。

6.5 验证结论

变换引擎、手势判定、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的手势会话:版本握手、Item 添加与初始摆放、单指平移、右下角旋转缩放、角部禁拖与日志留痕(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/single-touch-box.git
cd single-touch-box
./gradlew :single-touch-box:jvmTest                                              # 29 项交叉验证
./gradlew :single-touch-box:compileKotlinOhosArm64                               # klib
./gradlew :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.singletouchbox -m entry
hdc shell hilog | grep SingleTouch

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让 :single-touch-box 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. ArkTS 没有 Wrap 组件——那是 Flutter 的。写 Wrap({ space: 8 }) 会报 Cannot find name 'Wrap',并把后面 .width() 链打成"Declaration or statement expected"连环错;换行布局用 Flex({ wrap: FlexWrap.Wrap }),间距靠子项 margin;
  4. HarmonyOS 的 Display 上没有 density,属性名是 densityPixels(px = vp × densityPixels);直接写 .density 会连带 const density 被推断成 any,触发 arkts-no-any-unknown;
  5. 版本握手返回裸串会启动闪退:aboutToAppear 里 JSON.parse("1.0.0-ohos.1") 抛 SyntaxError,页面初始化失败。桥改为返回 {"ok":true,"version":…},ArkTS 侧再加双层兜底(JSON 解析失败显示裸串、NAPI 未加载显示提示),任何情况启动不崩;
  6. Math.round 在 Kotlin/Native 不存在(JVM 的 Math),用 kotlin.math.round;
  7. data class 的 copy() 只复制主构造器属性,写在类体里的 var controlSize 会在 copy 后悄悄丢成 0——挪进主构造器;
  8. ArkUI ForEach 的 key 一旦混入变换数值(offset/rotate/zoom),每次 move 都重建组件、打断触摸流,手势动一下就停——key 只用 item.key,变换属性靠 @State 更新流入;
  9. ArkUI .rotate({ z: 角度 }) 是错的:RotateOptions 里 x/y/z 是轴向量,角度写在 angle,2D 旋转是 { x: 0, y: 0, z: 1, angle: rotate };
  10. move 事件一次拖动几百发,逐条进日志会瞬间刷爆 100 条上限——日志按 op 过滤,move 只刷新状态行;
  11. CAdapter 链接期的 clang 警告(non-void function does not return a value in all control paths 等 3 条)是 HarmonyOS Kotlin 生成的 napi 胶水,qrose / Flippable 适配同样存在,.so 正常链出;
  12. 引擎测试首轮挂 7 项,全是测试期望值算错、引擎全对:钳制边界按直觉算(实际 maxBottom = 600−350 = 250)、zoom 上限被父容器剩余空间先钳住(400×600 父容器 + 中心 (200,300) 时上限恒为 min(270/140, 230/100) ≈ 1.93,想摸到 range 上限得给足四周空间)、rotationDelta 的 ±180 翻转方向想反、rotateDelta 是增量不是绝对角。这反向验证了"钳制语义必须读上游代码而不是凭直觉"。

8.2 已知问题

  • 父容器双指缩放(SingleTouchParentZoomBox / ZoomTransformBox)未桥接:引擎数学已就位,后续加 op=parentZoom 即可,C 签名不变;
  • rememberSaveable / Saver 不跨桥:持久化恢复是 Compose 概念,鸿蒙侧业务自己保存 state 回读的 JSON、重启后按 add + editable 重放;
  • ArkUI translate/rotate/scale 的矩阵合成顺序是平台实现细节,与 Compose graphicsLayer 不保证逐位一致——引擎输出的数值逐位一致,视觉上可能有亚像素级差异,验收以数值为准;
  • 多指按单指处理(与上游 changes.firstOrNull 一致),第二根手指被忽略;
  • 触摸坐标 px→vp 换算依赖 densityPixels;若未来 SDK 把 windowX 改成 vp 单位,数值会整体缩放 density 倍(现象与处理见主文档故障排查);
  • add 的 page 字段已透出但多页分组逻辑未桥接(上游 PagerWithSingleTouch 场景),字段先占位;
  • CAdapter clang 警告仍在,等 HarmonyOS Kotlin 修生成器。

8.3 未来优化方向

  • 增加 op=parentZoom 桥接父容器双指缩放,补齐 SingleTouchParentZoomBox 语义;
  • 多页分组(page 维度的命中与回读),对齐上游 PagerWithSingleTouch 场景;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)验收矩阵与自动化截图脚本;
  • 若社区 compose.ui 的 ohos 制品出现,让 composeMain 直接参与 ohos 编译,SingleTouchBox 在 ArkUI 嵌入容器里跑,桥降级为 fallback。

九、总结

9.1 核心难点回顾

上游 8 个 Compose 文件(pointerInput + graphicsLayer + 状态)
    → 纯数学逐行收拢为 SingleTouchEngine(保留三个上游怪癖)
    → composeMain 反向调用引擎(两端同一份数学)
    → 根构建替换(cmp_qrose 模式,零第三方依赖免制品线之痛)
    → ohosArm64 / ohosX64 KLIB + 双 ABI sharedLib
    → C ABI(@CName × 2)+ Session + 坐标逆变换
    → C++ N-API(DisposeString 纪律)
    → ArkTS onTouch 转发(px→vp 一次换算)
    → ArkUI 真机单页验收(平移 / 旋转缩放 / 角部禁拖 / 日志)

9.2 封装层次

  • 引擎层:SingleTouchEngine 纯数学 + GestureAccumulator + applyGesture + SingleTouchItemCollection;
  • Compose 层:上游 8 个文件原样保留在 composeMain,数学委托引擎,JVM / 未来 CMP 工具链可随时接回;
  • Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放(DisposeString);
  • ArkTS 层:管理页面状态、触摸转发、op 分发、日志与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 引擎收拢 + 反向调用优于"桥里再抄一份":CMP 手势库适配最容易做成"ArkTS 复刻版"。把数学抽成 commonMain 引擎并让 Compose 侧一起调用,交叉验证才成立,上游修 bug 才同步得回来;
  2. 位兼容优先于"看起来更正确":上游的 cosTheta 怪癖、被丢弃的 Rect.translate、硬编码的 1f slop,都是"bug 级细节",但正是它们决定了 Android 上的实际手感。逐位保留 + 注释说明,是 CMP 适配的验收底线;
  3. ArkTS 严格模式的坑要用真机编译趟:Wrap 不存在、densityPixels 不叫 density、RotateOptions 的轴向量与角度、ForEach key 稳定性、catch 参数的 any 推断——这些都不在 Kotlin 侧的经验里,只有 DevEco 编译器和真机能告诉你。

9.4 适配成果

当前适配已完成:

  • single-touch-box 1.0.0(1.0.0-ohos.1)变换引擎的双 ABI Kotlin/Native 编译(上游文件零重写,唯一新增引擎源文件 + 8 个 Compose 文件数学委托);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 双 ABI 验证全局 T 符号,手写头与生成头逐字比对一致);
  • DevEco Stage ArkUI 单页验收页面(添加/移除/可编辑/重置/状态回读、单指平移、右下角旋转缩放、角部禁拖、版本握手、操作日志);
  • 29 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 三件套)、签名、设备安装和真机验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐