开源鸿蒙平台 CMP 三方库「single-touch-box」适配全流程
本文记录
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 的"零依赖内核提取")同源但更进一步:散落在 ComposepointerInput与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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Eclipse Temurin 21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 原样沿用——2.2.21 旧线恰好要求 8.x,一行不用改) |
| 第三方依赖 | 无(上游零依赖,鸿蒙侧零新增) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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.kt | pointerInput / awaitEachGesture + 纯数学 | 挪进 composeMain;角部分类、绕中心缩放旋转、slop 累积改为调引擎 |
state/SingleTouchState.kt | mutableStateListOf + 校验逻辑 | 挪进 composeMain,原样保留(Compose 状态容器) |
state/TouchTransformData.kt | Compose 状态 + 初始摆放数学 | 挪进 composeMain;initContentSize 改为调 initialScale / initialOffset |
ui/TouchBox.kt | Compose 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 里跑的是同一份函数,"同一输入同一数值"不需要两份代码人肉对齐。
纯数学收拢的明细(引擎函数 ← 上游出处):
| 引擎函数 | 上游出处 | 保留的怪癖 |
|---|---|---|
rotatedSize | PointerEventEx.calculateRotatedSize | 高度式的最后一项用了 cosTheta(教科书公式此处是 sinTheta),仅 45° 时两者相等,原样保留 |
rotateBy / rotate | PointerEventEx 同名函数 | Double 三角函数 + Float 截断顺序不变 |
cornerAt | isInCorners | 边界用 <= / >=,边界像素同时属于两个区域;判定顺序不变 |
zoomRatio | calculateZoomSingle | 上一帧距离下限 1f(防除零) |
rotationDelta | calculateRotationSingle | atan2 角度差,±180 翻转到 (−180, 180] |
GestureAccumulator | detectTransformGestures 的 slop 块 | touchSlop 硬编码 1f(上游注释掉了 viewConfiguration.touchSlop) |
analyzeDown | down 块的角部门控 | TopStart 永远禁拖(删除按钮位);TopEnd / BottomStart 仅在对应控制存在时禁拖;BottomEnd 是旋转缩放区 |
initialScale / initialOffset | TouchBox.onSizeChanged + TouchTransformData.initContentSize | 初始 offset = 中心 − (内容+controlSize)/2 |
applyGesture | TouchBox.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 | 请求字段 | 语义(对齐的上游行为) |
|---|---|---|
init | width、height、cornerSize=24、controlSize=24、initItemSize=160、可选 minZoom/maxZoom | 注入父容器与控制尺寸,重置会话 |
add | key、page=0、contentWidth、contentHeight、可选 centerX/centerY | key 校验 + onlyCurrentEditable + 初始摆放 |
remove | key | 对齐 removeItem;手势中则安全结束 |
editable | key、value | 对齐 setEditable |
down | x、y、可选 hasTopEnd/hasBottomStart | 坐标命中测试 + 角部分类 + 开启手势 |
move | x、y | 逆变换 → 角部分支(旋转缩放 / 平移)→ slop → 应用 |
up | x、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 方法 | 覆盖内核 |
|---|---|---|
SingleTouchDispatch | singleTouchDispatch(req) | Session(Item 集合 + 手势状态)+ 引擎全部数学 |
SingleTouchVersion | singleTouchVersion() | 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.toml | Kotlin 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.kt | 29 项交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、Session、坐标逆变换、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 验收页面 |
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 管理语义 |
| Native | SingleTouchDispatch / SingleTouchVersion | C ABI JSON 导出 |
| N-API | singleTouchDispatch(req) / singleTouchVersion() | 向 ArkTS 暴露能力 |
| ArkTS | onItemTouch → 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 项用例全部通过:
| 测试组 | 用例数 | 钉住什么 |
|---|---|---|
| 纯数学 | 6 | rotatedSize 零度/90度/30度(含 cosTheta 怪癖)、rotateBy、rotate |
| 角部分类 | 2 | 四角 + 中心;边界像素同时属于两区(<=/>=) |
| 绕中心缩放/旋转 | 4 | 距离比值、上一帧距离下限 1f、±180 翻转、同点零差 |
| slop 累积 | 4 | 小位移不过阈、累计 pan 过阈、旋转运动过阈、reset |
| down 分析 | 3 | BottomEnd 旋转缩放、TopStart 永远禁拖、自定义角控制只在存在时禁拖 |
| 初始摆放 | 2 | scale 取 min 比值、offset 中心摆放 |
applyGesture | 7 | 平移直传、父容器钳制(130 / −250)、pan×zoom 再旋回、zoom 绝对值 + rotate 增量、剩余空间钳制 ≈1.93、zoomRange 上限 4、slop 之下的 None |
| Item 集合 | 3 | key 空白/重复异常、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.0.0-ohos.1(来自真实 Native 调用,非静态文字); - 点「添加 Item」,item-1 以初始
zoom = min(160/140, 160/100) ≈ 1.14、中心摆放出现——与 JVM 侧initialScale计算一致; - 按住 item 中间拖动,Item 平移且拖不出去(旋转外接矩形钳制生效),状态行实时刷新 offset;
- 按住右下角拖动,Item 绕中心旋转 + 距离比值缩放,最近角部显示
BottomEnd,放大到父容器剩余空间上限后不再变大; - 按住左上角区域拖动,Item 不平移(删除按钮位永远禁拖);
- 打开「顶右/底左角也禁拖」后,顶右/底左角区按下同样不再平移;
- 添加第二个 Item,旧 Item 自动失焦(
onlyCurrentEditable),对第二个 Item 的手势不影响第一个; - 点「重置」后 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 踩坑复盘
- 只改 ArkTS 页面不算 CMP 适配,必须让
:single-touch-box真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- ArkTS 没有
Wrap组件——那是 Flutter 的。写Wrap({ space: 8 })会报Cannot find name 'Wrap',并把后面.width()链打成"Declaration or statement expected"连环错;换行布局用Flex({ wrap: FlexWrap.Wrap }),间距靠子项 margin; - HarmonyOS 的
Display上没有density,属性名是densityPixels(px = vp × densityPixels);直接写.density会连带const density被推断成 any,触发arkts-no-any-unknown; - 版本握手返回裸串会启动闪退:
aboutToAppear里JSON.parse("1.0.0-ohos.1")抛 SyntaxError,页面初始化失败。桥改为返回{"ok":true,"version":…},ArkTS 侧再加双层兜底(JSON 解析失败显示裸串、NAPI 未加载显示提示),任何情况启动不崩; Math.round在 Kotlin/Native 不存在(JVM 的Math),用kotlin.math.round;- data class 的
copy()只复制主构造器属性,写在类体里的var controlSize会在copy后悄悄丢成 0——挪进主构造器; - ArkUI
ForEach的 key 一旦混入变换数值(offset/rotate/zoom),每次 move 都重建组件、打断触摸流,手势动一下就停——key 只用item.key,变换属性靠@State更新流入; - ArkUI
.rotate({ z: 角度 })是错的:RotateOptions里 x/y/z 是轴向量,角度写在angle,2D 旋转是{ x: 0, y: 0, z: 1, angle: rotate }; - move 事件一次拖动几百发,逐条进日志会瞬间刷爆 100 条上限——日志按 op 过滤,move 只刷新状态行;
- CAdapter 链接期的 clang 警告(
non-void function does not return a value in all control paths等 3 条)是 HarmonyOS Kotlin 生成的 napi 胶水,qrose / Flippable 适配同样存在,.so正常链出; - 引擎测试首轮挂 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 三条经验
- 引擎收拢 + 反向调用优于"桥里再抄一份":CMP 手势库适配最容易做成"ArkTS 复刻版"。把数学抽成
commonMain引擎并让 Compose 侧一起调用,交叉验证才成立,上游修 bug 才同步得回来; - 位兼容优先于"看起来更正确":上游的
cosTheta怪癖、被丢弃的Rect.translate、硬编码的 1f slop,都是"bug 级细节",但正是它们决定了 Android 上的实际手感。逐位保留 + 注释说明,是 CMP 适配的验收底线; - ArkTS 严格模式的坑要用真机编译趟:
Wrap不存在、densityPixels不叫density、RotateOptions的轴向量与角度、ForEachkey 稳定性、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。
参考文档
更多推荐



所有评论(0)