开源鸿蒙平台 CMP 三方库「ComposePinchGrid」适配全流程
本文记录
ComposePinchGrid接入 OpenHarmony 的完整过程,覆盖交互式 Compose 库的适配路线选型、鸿蒙 Kotlin 工具链钉版、手势决策引擎提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
ComposePinchGrid(上游aldefy/compose-pinch-grid1.0.0-alpha02,Apache-2.0)是 Compose Multiplatform 的"Google Photos 风格"捏合缩放网格:双指捏合在 1~5 列之间切换列数,带呼吸缩放预览、吸附触觉反馈、中心锚点滚动保持、双击缩放与 None / Crossfade 过渡,核心是一个 246 行的PinchGridState手势状态机加一个LazyVerticalGrid渲染壳。本次适配与社区两条既有路线都有区别:qrose 走"光栅化"(编码器进 commonMain、Painter 停放、输出 PNG),KStore 走"真编译整库"(零依赖直接编 ohos)——而捏合网格是交互式 UI 库,没有可以一次性过桥的渲染产物,光栅化路线天然不适用;同时上游快照是 Kotlin 2.1.20 / CMP 1.8.0,与社区鸿蒙 CMP 线(1.9.2 / HarmonyOS Kotlin 2.2.21-1.0.0)两端版本对不齐,@Composable编不进 ohos target。因此最终路线是社区已过审的第三条:把手势决策引擎逐行提取成纯 Kotlin(PinchGridEngine)编进 ohos,Compose UI 原样停放composeMain不参与 ohos 编译,ArkUI 页面用PinchGesture逐帧驱动引擎、自己承担全部渲染——与 swipe / spinnable / Waterfall 同一条路。
项目地址: AtomGit/oh-tpc/compose-pinch-grid.git
上游仓库: aldefy/compose-pinch-grid
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
ComposePinchGrid 把"捏合手势 → 列数切换"这件事做成了开箱即用的 Composable:PinchGrid(state) { items(...) } 一行接入,PinchGridState 内部用累积器把多帧捏合缩放换算成列数决策——乘法增量转加法累积、死区过滤微抖动、不对称阈值(捏开 0.45×0.85 比捏拢 0.45 更早吸附,补偿捏开天然幅度小)、边界 clamp、双击在当前列数与 1 列之间往返、每次切列快照中心可见项做滚动锚定(Google Photos 的"不爬回顶部"策略)。这些决策逻辑全在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套网格交互能力,但上游构建(Kotlin 2.1.20 / CMP 1.8.0 / AGP 8.9.0)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以画出格子,却无法证明公共手势状态机在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证手势状态机、Kotlin/Native 动态库、C ABI、N-API、ArkUI 手势宿主和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.1.20 / CMP 1.8.0 与鸿蒙工具链无法共存;社区 CMP 线是 1.9.2,@Composable 直接编 ohos 会撞上 Compose 变体缺失。 |
| 库的形态特殊 | 库的全部价值在"交互":手势进 → 状态出 → UI 重排。没有可光栅化的静态产物,桥协议必须逐帧往返。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 手势语义跨不了桥 | animateFloatAsState、Saver、LaunchedEffect 是 Compose 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:手势决策引擎的 ohos 编译、Compose UI 的 composeMain 隔离(cmp_qrose 模式)和 C ABI/N-API 桥接层。列数决策完全由引擎负责,ArkTS 只负责手势采集、JSON 解析和网格渲染。
1.2 库提供的能力
Demo 提供一个真实的捏合缩放会话,对应桥接的全部入口:
- 捏合缩放(scale):ArkUI
PinchGesture每帧把event.scale换算成单帧乘法因子喂给引擎,引擎返回列数、进度、方向与是否吸附; - 双击缩放(tap):
TapGesture({count:2})触发toggleZoom,在当前列数与 1 列之间往返; - 程序化吸附(snap):±列按钮直接
snapToColumn,引擎自行 clamp 到 min/max; - 会话重置(reset):换新引擎,回到默认 3 列;
- 中心锚点保持:每次切列,页面把
anchor(中心可见行号)传给引擎,响应原样带回,页面把该行滚回视口中央——上游"中心锚点"策略在 ArkUI 侧的等价实现; - 呼吸缩放:手势帧中
1 ± progress × 0.10直接赋值(等价上游tween(0)),手势收尾animateTo150ms 回弹(等价BreathingReturnDuration); - 触觉指示:引擎返回
haptic:true时圆点点亮 300ms(Android/iOS 走系统触觉,Desktop/Web/OHOS 为 no-op,demo 用视觉指示对齐语义); - 阈值可调:滑条把
threshold字段透传给引擎,实时验证 0.1~0.8 区间的手感差异。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op | 回显本次操作。 |
columns / prev / from / to | 当前与上次列数,changed:true 时 from→to 驱动过渡动画。 |
progress / zoom | 吸附进度 0~1 与方向(true 展开 / false 收拢 / null 无手势),驱动呼吸缩放。 |
haptic / changed | 吸附事件与触觉信号。 |
anchor / beforeDoubleTap / min / max | 中心锚点行号、双击记忆列数、引擎边界。 |
引擎侧完整能力(累积器、死区、不对称阈值、双击记忆、边界 clamp、手势收尾复位)全部在 PinchGridEngine 里,17 项 jvmTest 交叉验证用例逐行钉住上游 PinchGridState 的行为,证明 ohos .so 里执行的手势逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 决策逻辑逐行移植自上游 PinchGridState(算法零漂移),Compose UI 四个源文件 + 平台 actual 原样停放 composeMain,一个字节不改。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);sample/、website/ 留盘不进构建。 |
| 依赖纪律 | 引擎剥离 Compose 依赖后只剩 Kotlin stdlib——ohos 侧零第三方制品需求,整条 kotlinx 制品线问题被设计掉。 |
| 桥接稳定 | 使用 1 个 C ABI 和 UTF-8 JSON,会话单例持有引擎;异常永远不跨 FFI。 |
| UI 完整 | 单页覆盖全部桥接入口:捏合、双击、±列、重置、阈值滑条、呼吸缩放、锚点保持、触觉指示。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。等社区鸿蒙 CMP 线与上游版本对齐后,
composeMain里的PinchGrid可以原样接回编译——停放不是放弃,是等风来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与手势边界、确认社区过审路线(抽引擎)、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 升 8.14.1
第 3 阶段:引擎提取 ── PinchGridEngine 逐行移植、Compose 四文件停放 composeMain、actual 迁移
第 4 阶段:桥协议建模 ── 单 C ABI PinchGridStep、会话单例、op 分发、错误通道
第 5 阶段:原生桥接 ── C++ N-API、CMake IMPORTED、参数校验和 DisposeString
第 6 阶段:ArkUI 宿主与验证 ── PinchGesture 逐帧驱动、HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
pinch-grid/ 上游库模块(改造为 ohos 构建,jvm + ohosArm64 + ohosX64)
pinch-grid/src/commonMain/ PinchGridEngine(新增,手势决策引擎)+ PinchGridDefaults + ColumnTransitionSpec
pinch-grid/src/composeMain/ 上游 PinchGrid / PinchGridState / PinchGestureModifier / HapticForecast 原样停放(不编译)
pinch-grid/src/jvmTest/ PinchGridEngineTest(新增,17 项交叉验证)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
sample/ website/ 上游示例与文档站,留盘不进 settings
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :pinch-grid 与 :example:nativeApp,rootProject.name = "ohos_compose-pinch-grid"。上游的 AGP / Compose 插件 / binary-compatibility-validator / maven-publish 签名发布段全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,社区过审钉法) |
| JDK | 21(org.gradle.java.home 钉死路径) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 8.12 升级,2.2.21 旧线的要求) |
| 第三方依赖 | 引擎零依赖(纯 Kotlin stdlib),无需任何 ohos 制品线 |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.pinchgrid |
| 版本 | 1.0.0-alpha02-ohos.1(上游版本-ohos.N 格式) |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 源文件 | 原位置 | 依赖形态 | 处置 |
|---|---|---|---|
PinchGridState.kt(246 行状态机) | commonMain | Compose runtime(mutableIntStateOf / Saver / rememberSaveable) | 决策逻辑逐行移植进引擎;原文件停放 composeMain |
PinchGrid.kt(139 行 Composable) | commonMain | CMP foundation / animation / ui | 停放 composeMain,不编译 |
PinchGestureModifier.kt | commonMain | CMP ui input(awaitEachGesture) | 停放 composeMain,不编译 |
HapticFeedback.kt + 4 个 actual | commonMain + 各平台 | CMP runtime expect/actual | expect 与 jvm/wasmJs actual 一并停放 composeMain(见 3.2 踩坑) |
PinchGridDefaults.kt / ColumnTransitionSpec.kt | commonMain | 纯 Kotlin,零 Compose import | 留在 commonMain 原样编译 |
盘点结论:这个库的"可移植资产"恰好是那台状态机——246 行里去掉 Compose 状态包装后,决策核心不到 100 行纯 Kotlin。上游 API 设计(状态与渲染分离)让提取不需要任何算法重写。
1.4 页面边界
标题区 PinchGrid · OHOS / 副标题行(Compose Multiplatform 捏合缩放网格 · Kotlin/Native 引擎)
状态行 columns / progress(2 位小数) / 方向(展开·收拢·—) / anchor 四个 chip
触觉指示行 haptic 圆点(吸附时点亮 300ms)+ 列数变更 from→to 闪现 + 错误文本
阈值区 Slider 0.1~0.8(step 0.05),实时透传引擎 threshold 字段
网格区 Scroll + Grid 48 格 pastel 圆角卡(固定行高 110vp),外层 Stack 承载呼吸缩放与双手势
按钮行 −列 / +列(op snap)/ 重置(op reset 回 3 列)
页面启动时先发 {"op":"reset","columns":3} 做握手——既是引擎初始化,也是整条 NAPI 链路的冒烟测试。
第 2 阶段:目标与依赖打通
2.1 路线选型(本适配的成败手)
动手前先回答"这条库该走哪条社区路线"。三个候选:
| 路线 | 先例 | 判定 |
|---|---|---|
| 光栅化(引擎出 PNG,ArkTS 贴图) | qrose / Vico / Reorderable | 否决:捏合网格是交互式组件,没有一次性渲染产物,光栅化等于把库降级成图片生成器 |
| 真编译整库(@Composable 编 ohos) | KStore(零依赖直接编) | 否决:KStore 靠的是 kotlinx ohos 制品线;本库依赖的是 Compose 本身,社区 CMP 1.9.2 与上游 CMP 1.8.0 的变体矩阵不对齐 |
| 抽决策引擎 + ArkTS 渲染 | swipe / spinnable / Waterfall | 采用:决策逻辑是纯 Kotlin(可零依赖编译),渲染本来就该由宿主承担 |
结论:把 PinchGridState 的状态机抽成 PinchGridEngine。这条路线还有一个隐藏红利——引擎剥离 Compose 后零第三方依赖,KStore 文章里最凶险的"kotlinx 制品线验证"环节在本仓根本不存在。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
社区此前批准的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / Waterfall / swipe 用的),eazytec Maven 双仓写在 pluginManagement 与 dependencyResolutionManagement 最前:
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
google()
mavenCentral()
}
}
rootProject.name = "ohos_compose-pinch-grid"
include(":pinch-grid")
include(":example:nativeApp")
上游 Kotlin 是 2.1.20、无任何第三方依赖,2.2.21-1.0.0 直接可用,不需要 KStore 那样的"跟依赖升 2.4"操作。但 Gradle 必须从 8.12 升到 8.14.1(2.2.21 旧线的要求,社区同款),wrapper jar 一并从过审仓库同款版本替换。根构建收敛为:
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
}
allprojects {
group = "io.github.aldefy"
version = providers.gradleProperty("version").orElse("1.0.0-alpha02-ohos.1").get()
}
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
gradle.properties 钉死 JDK21 与 Kotlin/Native 开关(kotlin.native.ignoreDisabledTargets / kotlin.native.distribution.downloadFromMaven),libs.versions.toml 收敛到只剩 Kotlin 一项——上游的 AGP / Compose / coil / ktor 版本声明全部随根构建替换剥离。
2.3 库模块收敛为纯 KMP
pinch-grid/build.gradle.kts 从 172 行(AGP + Compose 插件 + 发布 + 签名)收敛到 20 行:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm()
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
./gradlew :pinch-grid:compileKotlinOhosArm64 :pinch-grid:compileKotlinOhosX64 # BUILD SUCCESSFUL
./gradlew :pinch-grid:jvmTest # 17 项全绿
引擎与保留的模型文件一个字节没有算法改动,全仓新增的 Kotlin 源文件只有 PinchGridEngine.kt 与 PinchGridEngineTest.kt 两个。
第 3 阶段:引擎提取(技术难点之一)
3.1 逐行移植的纪律
PinchGridEngine 不是"参考着重写",而是与上游 PinchGridState 的 onScale / snapToColumn / toggleZoom / onGestureEnd 逐行对应——同一个累积器、同一个死区判定(abs(delta) < deadZone 直接丢弃)、同一条不对称阈值分支(展开走 thresholdFraction × pinchOutMultiplier)、同一处"越界尝试也复位累积器"的细节(上游在 newCount == columnCount 时照样把累积器清零,这个反直觉细节被测试 spreadAtMinColumnsStaysPut 单独钉住)。Compose 侧的状态包装(mutableIntStateOf / internal set)换成普通属性 + private set,回调换成返回值:
public fun onScale(
scaleFactor: Float,
thresholdFraction: Float = PinchGridDefaults.ThresholdFraction,
deadZone: Float = PinchGridDefaults.DeadZone,
pinchOutMultiplier: Float = PinchGridDefaults.PinchOutThresholdMultiplier,
): StepResult
public class StepResult(
public val changed: Boolean, // 本次是否切列
public val haptic: Boolean, // 是否应触发触觉
public val fromColumn: Int,
public val toColumn: Int,
)
上游快照中心锚点用的是 LazyGridState.layoutInfo,引擎拿不到布局信息,改为宿主喂入:anchorItemIndex 是引擎唯一的 public var,每次 op 前由页面写入、切列时随响应带回——快照时序(“先喂锚点、后改列数”)与上游 snapshotCenterItem() 的调用次序完全一致。
3.2 composeMain 隔离与 actual 迁移踩坑
Compose 四文件挪进 src/composeMain(不是 KMP 源集,只是目录隔离,不参与任何编译)。真正的坑在 actual 文件:jvm() 目标存活意味着 jvmMain 源集是活的,HapticFeedback.jvm.kt 的 actual fun 找不到对应 expect 直接编译报错。处置:expect 与 jvm/wasmJs 两个 actual 一并停放 composeMain(androidMain / iosMain 无目标声明,保持原位惰性)。这条是本仓特有的——qrose 的停放文件里没有 expect/actual 对。
3.3 JVM 交叉验证
17 项 PinchGridEngineTest 按上游语义分组钉住状态机:死区 2 项、累积与进度 2 项、吸附决策 3 项(含"同幅度下展开比捏拢早吸附"的不对称性证明)、边界 clamp 3 项、程序化 snap 2 项、双击 2 项、手势收尾 1 项、脚本序列 2 项(3→1→5 全程与 50 帧正负交替抖动不吸附)。测试跑的就是将来在 ohos .so 里执行的那份 commonMain 代码——这是"引擎没漂"的闸门。
第 4 阶段:桥协议建模
4.1 JSON 契约
io.github.aldefy.pinchgrid/PinchGridEngine(纯 Kotlin 状态机)
↓ PinchGridStep(@CName,扁平 JSON 进出,会话单例持引擎)
N-API pinchGridStep()
↓ UTF-8 JSON
ArkUI Index.ets(PinchGesture 逐帧驱动)
请求与响应:
请求 : {"op":"scale","scale":1.04,"threshold":0.45,"anchor":12}
{"op":"tap"} {"op":"snap","target":2} {"op":"end"} {"op":"reset","columns":3}
响应 : {"ok":true,"op":"scale","columns":4,"prev":3,"accum":0.0,"progress":0.0,
"zoom":false,"changed":true,"haptic":true,"from":3,"to":4,
"beforeDoubleTap":null,"anchor":12,"min":1,"max":5}
{"ok":false,"error":"..."}
与 KStore 桥的关键差异:KStore 每次请求按 path 现场开店(无状态),本仓引擎有状态——累积器、双击记忆都是跨帧概念,而引擎的中间态字段全部 private set 无法注入,会话单例因此成为必须(见决策 4)。reset 是唯一的换引擎入口。可空字段(zoom / beforeDoubleTap)严格序列化为 JSON null,浮点与布尔不带引号。
4.2 错误通道
导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("PinchGridStep")
public fun pinchGridStep(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
err(t.message ?: t.toString())
}
非法 JSON、未知 op、越界 target,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进错误文本区,绝不崩溃。
第 5 阶段:原生桥接
5.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libpinchgrid_napi.so,nm_modname = "pinchgrid_napi")
│ const char*
▼
Kotlin/Native C ABI(libohospinchgrid.so)
│ PinchGridStep
▼
UTF-8 JSON + DisposeString
5.2 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"pinchGridStep", nullptr, NapiPinchGridStep, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohospinchgrid_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohospinchgrid_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
5.3 CMake 和类型声明
add_library(ohospinchgrid SHARED IMPORTED)
set_target_properties(ohospinchgrid PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(pinchgrid_napi SHARED napi_init.cpp)
target_link_libraries(pinchgrid_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohospinchgrid)
$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。Gradle 侧 copyKnSoArm64 / copyKnSoX64 任务在 link 后自动把 KN so 与 ~/.konan 对应 triple 的 libc++_shared.so 回填 entry/libs/{arm64-v8a,x86_64},linkDebugShared* 用 finalizedBy 挂钩。
export const pinchGridStep: (request: string) => string;
第 6 阶段:ArkUI 宿主与验证
6.1 手势采集:累计值换算单帧因子
ArkUI PinchGesture 的 event.scale 是自手势开始的累计值,而引擎要的是单帧乘法因子(上游 detectTransformGestures 的语义)。页面维护 lastScale,每帧做一次除法:
.onActionUpdate((event: GestureEvent) => {
const frame: number = event.scale / this.lastScale; // 累计 → 单帧
this.lastScale = event.scale;
this.pinchStep(frame); // 携带 threshold 与 anchor 调 op "scale"
})
.onActionEnd((_event: GestureEvent) => {
this.lastScale = 1;
this.applyStep(this.step('{"op":"end"}'), true); // 呼吸回弹 150ms
})
onActionStart 复位 lastScale 并喂入当前中心锚点。呼吸缩放在手势帧直接赋值(等价上游 tween(0) 的瞬时跟随),收尾用 this.getUIContext().animateTo 平滑回 1.0。
6.2 中心锚点滚动保持
上游锚定的是 LazyGridState 的中心可见项;ArkUI 侧 Scroller 绑定的是外层 Scroll(单一 Grid 子组件没有行级索引语义,scrollToIndex 属于 List/Grid/WaterFlow 自身),因此用偏移量精确表达 ScrollAlign.CENTER:
// 目标偏移 = 顶部内边距 + anchor 行顶 - 视口富余的一半
const targetY: number = GRID_PADDING + anchorRow * stride - (viewport - ITEM_HEIGHT) / 2;
this.scroller.scrollTo({ xOffset: 0, yOffset: Math.max(0, targetY) });
视口高由 onAreaChange 实测(Area.height 是 Length 类型,须 Number() 收敛),中心行号按 (滚动偏移 + 视口高/2 - 内边距) / 行步长 反推——每次切列,用户注视的那一行回到视口中央,"不爬回顶部"的手感与上游一致。
6.3 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohospinchgrid)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/
│ └── Bridge.kt @CName PinchGridStep + 会话单例 + MiniJson(继承 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.pinchgrid、versionName 1.0.0-alpha02-ohos.1
└── entry/
├── build-profile.json5 externalNativeOptions + abiFilters [arm64-v8a, x86_64]
├── libs/{arm64-v8a,x86_64}/ linkDebugShared* 经 copyKnSo* 回填的三件套(arm64 随仓交付)
├── src/main/cpp/CMakeLists.txt IMPORTED 引入 KN so、$ORIGIN rpath
├── src/main/cpp/napi_init.cpp N-API 导出、参数检查、DisposeString
├── src/main/cpp/include/libohospinchgrid_api.h
├── src/main/cpp/types/libpinchgrid_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.4 构建、签名和安装
./gradlew :pinch-grid:jvmTest # 17 项交叉验证
./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.pinchgrid -m entry
四、完整代码对照
4.1 整体架构
io.github.aldefy.pinchgrid(上游 Compose 实现,停放 composeMain 不编译)
│ PinchGrid / PinchGridState / PinchGestureModifier / HapticFeedback
io.github.aldefy.pinchgrid(PinchGridEngine,逐行移植的纯 Kotlin 状态机)
│ onScale / snapToColumn / toggleZoom / onGestureEnd
Bridge.kt + MiniJson + PinchGridSession
│ PinchGridStep(@CName)
libpinchgrid_napi.so
│ pinchGridStep
Index.ets
│ JSON parse + @State 更新 + Grid 重排 + 呼吸 scale + scrollTo 锚点
▼
ArkUI 真机页面(捏合 / 双击 / 按钮 / 滑条)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_compose-pinch-grid、sample 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单项钉版(上游 AGP/Compose/coil/ktor 版本声明剥离) |
build.gradle.kts(根) | 版本 1.0.0-alpha02-ohos.1、全局禁用 Ohos*Test* 任务 |
pinch-grid/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64(172 行 → 20 行) |
pinch-grid/src/commonMain/.../PinchGridEngine.kt | 新增:手势决策引擎(逐行移植) |
pinch-grid/src/jvmTest/.../PinchGridEngineTest.kt | 新增:17 项交叉验证 |
pinch-grid/src/composeMain/** | 上游 Compose 实现 + expect/actual 停放(零改动) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、会话单例、MiniJson(支持 JSON null)、错误通道 |
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 验收页面(手势/呼吸/锚点/触觉/滑条) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(19 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 上游 Compose | PinchGrid(state) { … } | 完整 Composable(停放 composeMain,Android/iOS/桌面/Web 继续可用) |
| 上游 Compose | PinchGridState / rememberPinchGridState | 状态持有与 Saver(停放) |
| 引擎 | PinchGridEngine(min, max, initial) | 纯 Kotlin 状态机(ohos 侧的库本体) |
| 引擎 | onScale(scale, threshold, deadZone, pinchOut) | 单帧因子 → 列数决策,返回 StepResult |
| 引擎 | snapToColumn / toggleZoom / onGestureEnd | 程序化吸附 / 双击 / 收尾复位 |
| 引擎 | anchorItemIndex / scaleProgress / isZoomingIn | 锚点喂入口 / 吸附进度 / 方向(呼吸缩放数据源) |
| Native | PinchGridStep | C ABI JSON 导出(唯一符号) |
| N-API | pinchGridStep(req) | 向 ArkTS 暴露能力 |
| ArkTS | applyStep / keepAnchorCentered / computeAnchor | 状态镜像、锚点保持、中心行号反推 |
4.4 ArkTS 与 Kotlin 的边界
// 手势帧:累计 scale 除以 lastScale 得单帧因子
const res = JSON.parse(pinchGridStep(
`{"op":"scale","scale":${frame},"threshold":${this.threshold},"anchor":${anchor}}`));
// res.changed === true → Grid 重排 + animateTo 呼吸 + keepAnchorCentered(res.anchor)
// 引擎侧:与上游 PinchGridState.onScale 逐行对应
val delta = scaleFactor - 1f
if (abs(delta) < deadZone) return noChange()
scaleAccumulator += delta
val effectiveThreshold = if (scaleAccumulator > 0f) thresholdFraction * pinchOutMultiplier
else thresholdFraction
// …越过阈值 → (columnCount ± 1).coerceIn(min, max),haptic = true,累积器清零
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:抽决策引擎,而不是光栅化或 ArkTS 重写
捏合网格是"事件 → 状态"的交互库,没有可一次性过桥的渲染产物,qrose 光栅化路线不适用;把状态机用 ArkTS 重写等于手抄算法引入漂移。决策逻辑恰好是纯 Kotlin(上游 API 设计里状态与渲染本就分离),抽出来零依赖编译进 ohos,ArkTS 只承担它擅长的渲染——库的 IP(手感)100% 来自上游同款代码。
决策 2:工具链钉 2.2.21-1.0.0 + Gradle 升 8.14.1
上游无第三方依赖,直接用社区过审钉法,不需要制品线验证环节;Gradle 按 2.2.21 旧线要求从 8.12 升 8.14.1。两条工具链线的经验不能互相套用(KStore 的 2.4.20 线留 Gradle 9.x,本仓的 2.2.21 线必须 8.14.1)。
决策 3:引擎逐行移植 + Compose 原样停放,而不是"参考重写"
引擎与 PinchGridState 逐行对应(含"越界尝试也复位累积器"这类反直觉细节),JVM 测试钉住行为;composeMain 四文件一个字节不改——等社区 CMP 线对齐后原样接回。停放目录同时收纳 expect/actual 对,避开 jvm 存活目标下 actual-without-expect 的编译错。
决策 4:会话单例持有引擎,C ABI 保持无状态形态
引擎中间态(累积器、双击记忆)全部 private set 无法注入,无状态重建传不进跨帧状态;进程级单例 + reset 换引擎是唯一自洽解,同时 C ABI 保持 const char* → const char* 的社区审核形态。代价是 HAP 进程内只有一个引擎实例——对单页面 demo 足够,多实例场景以 reset 划界。
决策 5:jvmTest 交叉验证 + 上游 Compose 测试语义
17 项测试按上游语义分组,含不对称阈值的对比证明(同幅度下展开早于捏拢吸附)与脚本序列(3→1→5 全程)。测试跑的就是 ohos .so 里那份 commonMain 代码,是"引擎没漂"的闸门。
决策 6:ohos 侧零第三方依赖
引擎只用 Kotlin stdlib(kotlin.math.abs + coerceIn),上游的 Compose 依赖被隔离在停放区——整条 kotlinx ohos 制品线问题被设计掉,构建只依赖 eazytec 的 Kotlin 插件与 stdlib 本身。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(org.gradle.java.home 钉死)、DevEco Studio 5.1.0 Release(hvigor / ohpm / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :pinch-grid:jvmTest
17 项用例全部通过:
| 分组 | 用例数 | 钉住什么 |
|---|---|---|
| 死区 | 2 | 微抖动过滤(delta < deadZone 整帧丢弃);恰好越界时累积 |
| 累积与进度 | 2 | 低于阈值不吸附;progress 收敛在 0~1 |
| 吸附决策 | 3 | 展开→列数减、捏拢→列数加;同幅度下展开早于捏拢吸附(不对称性证明) |
| 边界 clamp | 3 | min/max 处越界尝试吸附被拒但仍复位累积器(上游细节);构造参数校验 |
| 程序化 snap | 2 | clamp 后触发、同列 no-op(不碰累积器) |
| 双击 | 2 | 3→1→记忆→3→清记忆;1 列无记忆时 no-op |
| 手势收尾 | 1 | 累积器 / 进度 / 方向全部复位 |
| 脚本序列 | 2 | 3→1→5 全程恰好 6 次吸附;50 帧正负交替抖动零吸附 |
6.3 HAP 验证
:pinch-grid:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:pinch-grid:jvmTest: 17 tests, all passed
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: PinchGridStep 为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libpinchgrid_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 6.4 MB,内含双 ABI 各三件套(libpinchgrid_napi.so / libohospinchgrid.so / libc++_shared.so);HAP 内 strip 后 KN so arm64 约 1.9MB、x86_64 约 1.8MB(debugShared 未 strip 时分别约 3.45MB / 3.17MB),NAPI so 约 64KB。
6.4 功能用例
- 启动后自动发
op:reset握手,状态行显示columns 3——NAPI 链路冒烟通过; - 双指捏开,列数 3→2→1 逐级吸附,每次吸附呼吸放大、触觉圆点点亮 300ms、状态行出现"列数变更 3→2";
- 双指捏拢,列数逐级回升到 5(max),方向 chip 显示"收拢";
- 双击网格,3 列直达 1 列;再双击回到 3 列(
beforeDoubleTap记忆生效); - 滚动到列表中部(如 anchor 2)捏合切列,注视行回到视口中央——中心锚点保持成立,不爬回顶部;
- 拖动阈值滑条到 0.1,同样捏合幅度吸附明显变快——
threshold字段实时透传引擎; - ±列按钮与重置按钮走
op:snap/op:reset,引擎自行 clamp; - 全程任何操作(含快速连续捏合、边界反复捏)无崩溃、无
ok:false错误。
6.5 验证结论
手势状态机、Kotlin/Native 动态库、C ABI、N-API、ArkUI 手势宿主和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的捏合缩放会话:逐帧决策、呼吸缩放、双击缩放、中心锚点保持与阈值手感调节(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-pinch-grid.git
cd compose-pinch-grid
export JAVA_HOME="/path/to/jdk-21"
./gradlew :pinch-grid:compileKotlinOhosArm64 # klib
./gradlew :pinch-grid:jvmTest # 17 项交叉验证
./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.pinchgrid -m entry
hdc shell hilog | grep PinchGrid
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:pinch-grid真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;jvm()目标存活时jvmMain源集是活的:expect 停放进composeMain后,留在jvmMain的HapticFeedback.jvm.kt会报 actual-without-expect——actual 必须随 expect 一起停放(androidMain/iosMain 无目标声明可以留在原位);- ArkUI
PinchGesture.onActionUpdate的event.scale是自手势开始的累计值,直接喂引擎等于每帧都在放大 delta——必须除以lastScale换算成单帧乘法因子(上游detectTransformGestures的语义); - KN so 不能被 ArkTS 直接 import(CAdapter 不会把
@CName业务符号挂到 ArkTS),必须 CMake NAPI 包一层;KN 返回字符串必须DisposeString归还运行时,N-API 层不能free(); onAreaChange回调的Area.height是Length(string|number|Resource)联合类型,直接赋给number状态是 ArkTS 编译错(Type 'Length' is not assignable to type 'number'),须Number()收敛;Circle().fill()是 SDK 26 才有的 API,在compatibleSdkVersion 5.1.0(18)上是兼容性告警——圆点改用backgroundColor+borderRadius的圆角Text,全版本可用;全局animateTo已弃用,统一走this.getUIContext().animateTo;- 测试边界值要过一遍浮点表示:
1.01f实际存1.00999999046f,减 1 后0.00999999f小于死区 0.01 会被正确过滤——边界用例必须选真正越过死区的值(如1.0101f),浮点断言带容差; - Gradle wrapper 启动器先于读
gradle.properties找 Java:命令行跑gradlew必须export JAVA_HOME,org.gradle.java.home只管 Gradle 进程本身;hvigor 命令行打包同理,PackageHap报spawn java ENOENT时把 DevEco 自带 JBR 的bin补进 PATH,并设置DEVECO_SDK_HOME; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; build-profile.json5的signingConfigs留空数组且 product 不引用具体签名,勾选自动签名后由 IDE 回填完整配置,证书绝不进入 AtomGit;- ArkUI 的
Scroller.scrollToIndex是 List/Grid/WaterFlow 的行级语义,外层Scroll没有子项索引——中心锚点用scrollTo偏移量精确表达ScrollAlign.CENTER。
8.2 已知问题
- 鸿蒙侧没有真实
LazyVerticalGrid:ArkUI Grid + 固定行高是近似渲染,等社区鸿蒙 CMP 线与上游版本对齐后,composeMain里的PinchGrid可原样接回编译,届时本引擎可退位为共享内核; - 触觉为 no-op(与 Desktop/Web 同款):demo 用视觉圆点对齐语义;真机震动可后续经
@kit.SensorServiceKit的 vibrator 接入(需声明ohos.permission.VIBRATE); - 引擎会话是 HAP 进程级单例:多网格实例需以
op:reset划界,或后续扩展多会话表; - 锚点行号按固定行高(110vp + 8vp 行距)与
onAreaChange实测视口推算,不是布局引擎的精确可见项信息,极端行高混排下有偏差; - 上游
sample/(coil3/ktor3 网络图)与website/留盘不进构建,鸿蒙 demo 用本地 pastel 色格替代网络图片; - android / ios / wasmJs 目标剥离(源文件留在树里),发布、binary-compatibility-validator 一并剥离,本 fork 不发 Maven Central;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.pinchgrid匹配的签名。
8.3 未来优化方向
- 接入
@kit.SensorServiceKitvibrator,把触觉 no-op 升级为真机震动(对应上游 AndroidCLOCK_TICK/ iOS selection 语义); - 增加多会话支持(引擎实例表),让单页面承载多个
PinchGridEngine; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - Crossfade 过渡规格(
ColumnTransitionSpec.Crossfade)在 ArkTS 侧的等价实现(当前 demo 走默认 None 瞬时切换); - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 PinchGridState(246 行 Compose 状态机)
→ 路线选型:交互库排除光栅化,CMP 版本墙排除真编译,敲定"抽引擎"(swipe/Waterfall 同路)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 升 8.14.1,eazytec 双仓置前)
→ PinchGridEngine 逐行移植(零依赖,actual 随 expect 停放 composeMain)
→ ohosArm64 / ohosX64 KLIB + 17 项 jvmTest 交叉验证
→ C ABI(@CName PinchGridStep)+ 会话单例 + MiniJson(JSON null 纪律)
→ C++ N-API(DisposeString 纪律)+ $ORIGIN 三件套
→ ArkTS 逐帧驱动(累计 scale 换算单帧因子)
→ ArkUI 真机验收(捏合 / 双击 / 锚点保持 / 呼吸缩放 / 阈值手感)
9.2 封装层次
- 决策层:
PinchGridEngine(逐行移植的上游状态机)+PinchGridDefaults/ColumnTransitionSpec模型; - 停放层:上游 Compose 实现四文件 + expect/actual 原样保存(其他平台继续可用,鸿蒙侧等 CMP 对齐接回);
- Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、手势采集(累计→单帧换算)、JSON 解析、呼吸缩放、锚点保持与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 路线选型先看库的"价值形态":算法库可以光栅化、零依赖库可以真编译,而交互式 UI 库的正确答案是抽出决策引擎——判定依据是"库的 IP 能不能脱离 UI 框架单独编译",而不是社区哪种路线更省事;
- 抽引擎的纪律是"逐行移植 + 测试钉住",不是参考重写:上游状态机里"越界尝试也复位累积器"这类反直觉细节,靠通读代码靠不住,靠 17 项按语义分组的交叉验证才靠得住;
- 桥协议的每帧往返要求把两端语义对齐到帧:ArkUI 的累计 scale 与 Compose 的单帧因子差一个除法,
Length与number差一次收敛,Scroll的偏移语义与LazyGridState的锚点语义差一次换算——交互库适配的大部分工作量在"语义守恒"而不是"功能搬移"。
9.4 适配成果
当前适配已完成:
- ComposePinchGrid
1.0.0-alpha02-ohos.1手势决策引擎的双 ABI Kotlin/Native 编译(决策逻辑逐行移植,Compose 实现零改动停放); - ohos 侧零第三方依赖的极简构建(eazytec Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 + JDK 21);
- 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号,DisposeString 纪律); - DevEco Stage ArkUI 单页验收页面(捏合、双击、程序化吸附、阈值滑条、呼吸缩放、中心锚点保持、触觉指示);
- 17 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 6.4MB)、签名、设备安装和真机验证;
- 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)