本文记录 ComposePinchGrid 接入 OpenHarmony 的完整过程,覆盖交互式 Compose 库的适配路线选型、鸿蒙 Kotlin 工具链钉版、手势决策引擎提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

ComposePinchGrid(上游 aldefy/compose-pinch-grid 1.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)),手势收尾 animateTo 150ms 回弹(等价 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,社区过审钉法)
JDK21(org.gradle.java.home 钉死路径)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 8.12 升级,2.2.21 旧线的要求)
第三方依赖引擎零依赖(纯 Kotlin stdlib),无需任何 ohos 制品线
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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 行状态机)commonMainCompose runtime(mutableIntStateOf / Saver / rememberSaveable)决策逻辑逐行移植进引擎;原文件停放 composeMain
PinchGrid.kt(139 行 Composable)commonMainCMP foundation / animation / ui停放 composeMain,不编译
PinchGestureModifier.ktcommonMainCMP ui input(awaitEachGesture)停放 composeMain,不编译
HapticFeedback.kt + 4 个 actualcommonMain + 各平台CMP runtime expect/actualexpect 与 jvm/wasmJs actual 一并停放 composeMain(见 3.2 踩坑)
PinchGridDefaults.kt / ColumnTransitionSpec.ktcommonMain纯 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.tomlKotlin 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.kt1 个 C ABI、会话单例、MiniJson(支持 JSON null)、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面(手势/呼吸/锚点/触觉/滑条)
README.OpenHarmony_CN.md鸿蒙适配主文档(19 节)

4.3 关键 API 对照

层次API作用
上游 ComposePinchGrid(state) { … }完整 Composable(停放 composeMain,Android/iOS/桌面/Web 继续可用)
上游 ComposePinchGridState / rememberPinchGridState状态持有与 Saver(停放)
引擎PinchGridEngine(min, max, initial)纯 Kotlin 状态机(ohos 侧的库本体)
引擎onScale(scale, threshold, deadZone, pinchOut)单帧因子 → 列数决策,返回 StepResult
引擎snapToColumn / toggleZoom / onGestureEnd程序化吸附 / 双击 / 收尾复位
引擎anchorItemIndex / scaleProgress / isZoomingIn锚点喂入口 / 吸附进度 / 方向(呼吸缩放数据源)
NativePinchGridStepC ABI JSON 导出(唯一符号)
N-APIpinchGridStep(req)向 ArkTS 暴露能力
ArkTSapplyStep / 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展开→列数减、捏拢→列数加;同幅度下展开早于捏拢吸附(不对称性证明)
边界 clamp3min/max 处越界尝试吸附被拒但仍复位累积器(上游细节);构造参数校验
程序化 snap2clamp 后触发、同列 no-op(不碰累积器)
双击23→1→记忆→3→清记忆;1 列无记忆时 no-op
手势收尾1累积器 / 进度 / 方向全部复位
脚本序列23→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 功能用例

  1. 启动后自动发 op:reset 握手,状态行显示 columns 3——NAPI 链路冒烟通过;
  2. 双指捏开,列数 3→2→1 逐级吸附,每次吸附呼吸放大、触觉圆点点亮 300ms、状态行出现"列数变更 3→2";
  3. 双指捏拢,列数逐级回升到 5(max),方向 chip 显示"收拢";
  4. 双击网格,3 列直达 1 列;再双击回到 3 列(beforeDoubleTap 记忆生效);
  5. 滚动到列表中部(如 anchor 2)捏合切列,注视行回到视口中央——中心锚点保持成立,不爬回顶部;
  6. 拖动阈值滑条到 0.1,同样捏合幅度吸附明显变快——threshold 字段实时透传引擎;
  7. ±列按钮与重置按钮走 op:snap / op:reset,引擎自行 clamp;
  8. 全程任何操作(含快速连续捏合、边界反复捏)无崩溃、无 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :pinch-grid 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. jvm() 目标存活时 jvmMain 源集是活的:expect 停放进 composeMain 后,留在 jvmMain 的 HapticFeedback.jvm.kt 会报 actual-without-expect——actual 必须随 expect 一起停放(androidMain/iosMain 无目标声明可以留在原位);
  4. ArkUI PinchGesture.onActionUpdate 的 event.scale 是自手势开始的累计值,直接喂引擎等于每帧都在放大 delta——必须除以 lastScale 换算成单帧乘法因子(上游 detectTransformGestures 的语义);
  5. KN so 不能被 ArkTS 直接 import(CAdapter 不会把 @CName 业务符号挂到 ArkTS),必须 CMake NAPI 包一层;KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  6. onAreaChange 回调的 Area.height 是 Length(string|number|Resource)联合类型,直接赋给 number 状态是 ArkTS 编译错(Type 'Length' is not assignable to type 'number'),须 Number() 收敛;
  7. Circle().fill() 是 SDK 26 才有的 API,在 compatibleSdkVersion 5.1.0(18) 上是兼容性告警——圆点改用 backgroundColor + borderRadius 的圆角 Text,全版本可用;全局 animateTo 已弃用,统一走 this.getUIContext().animateTo;
  8. 测试边界值要过一遍浮点表示:1.01f 实际存 1.00999999046f,减 1 后 0.00999999f 小于死区 0.01 会被正确过滤——边界用例必须选真正越过死区的值(如 1.0101f),浮点断言带容差;
  9. 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;
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  11. build-profile.json5 的 signingConfigs 留空数组且 product 不引用具体签名,勾选自动签名后由 IDE 回填完整配置,证书绝不进入 AtomGit;
  12. 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.SensorServiceKit vibrator,把触觉 no-op 升级为真机震动(对应上游 Android CLOCK_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 三条经验

  1. 路线选型先看库的"价值形态":算法库可以光栅化、零依赖库可以真编译,而交互式 UI 库的正确答案是抽出决策引擎——判定依据是"库的 IP 能不能脱离 UI 框架单独编译",而不是社区哪种路线更省事;
  2. 抽引擎的纪律是"逐行移植 + 测试钉住",不是参考重写:上游状态机里"越界尝试也复位累积器"这类反直觉细节,靠通读代码靠不住,靠 17 项按语义分组的交叉验证才靠得住;
  3. 桥协议的每帧往返要求把两端语义对齐到帧: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。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐