开源鸿蒙平台 CMP 三方库「telephoto」适配全流程
本文记录
telephoto接入 OpenHarmony 的完整过程,覆盖手势交互库盘点、Compose 隔离与纯 Kotlin 内核重放、真实手势数学编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
telephoto是 Compose Multiplatform 的图片交互库:Modifier.zoomable()给任意内容加捏合缩放、拖动、双击循环、边缘钳制与 rubber banding,ZoomableImage接入 coil / glide 并以SubSamplingImage分块加载超大图,FlickToDismiss()提供带旋转与阻尼的甩动关闭。本次适配与 KStore 的"整库真编译"路线有本质区别:telephoto 的核心数学(994 行的RealZoomableState与RealFlickToDismissState)全部长在androidx.compose的值类型(Offset/Rect/ScaleFactor/ContentScale/Alignment)和compose.runtime/compose.animation.core上,而社区工具链(Kotlin2.2.21-1.0.0)没有对齐的 Compose ohos 制品——因此走 FlexibleBottomSheet / cmp_qrose 已审核的「Compose 隔离 + 内核重放」路线:上游 Compose 文件 100% 原样迁入src/composeMain留档不编译,手势数学逐字重放为无依赖的ZoomableEngine/FlickEngine编进 OpenHarmony 动态库,29 项 JVM 交叉验证钉住行为不漂移,ArkUI 页面作为验收宿主。
项目地址: AtomGit/oh-tpc/telephoto.git
上游仓库: saket/telephoto
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
telephoto 为 Compose 应用提供"图片怎么摸"这一件事的完整答案:rememberZoomableState() 一行建状态,捏合时质心下的内容点不动(缩放围绕手指中心)、拖动到边缘被钳制、越过缩放上下限出现橡皮筋回弹、双击在 min/max 间循环、松手按速度 fling 或 settle 回界;ZoomSpec 的 max 相对内容原始尺寸、min 相对 base 缩放,OverzoomEffect 三档可调;FlickToDismissState 以「拖动距离超过内容高度 20%」或「松手速度超过 height × ratio × 10」判定关闭,拖动时卡片随按住的半边轻微旋转 ±20°。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套手势语义,但上游构建(Kotlin 2.1.21 / AGP 8.13 / JB Compose 1.8)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示图片,却无法证明公共手势数学在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证手势引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.1.21 / AGP 8.13 / paparazzi / build-logic 组合插件与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| Compose 缺口 | 核心数学 100% 长在 androidx.compose 值类型与 compose.animation.core(帧时钟)上,社区 Maven 没有对齐的 Compose ohos 制品线——这与 KStore 的 kotlinx 制品齐备截然相反。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 手势语义跨不了桥 | 缩放是有状态的(userZoom / userOffset / lastCentroid),动画吃 withFrameNanos 帧时钟,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:手势数学的 ohos 编译(内核重放)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。公式语义完全由上游原版实现负责,ArkTS 只负责手势分发、渲染与动画插值。
1.2 库提供的能力
Demo 提供真实的手势会话,对应桥接的全部入口:
- 建会话(create):
zoom.create传入视口与内容原始尺寸,引擎按contentScale计算 baseZoom、按alignment计算 baseOffset,初始态过上游同一条钳制管道; - 手势增量(delta):一次捏合或拖动增量进、一帧变换出——overzoom 调整、质心保持、边界钳制全部在 Kotlin 侧完成;
- 松手决策(end):越界返回 settle plan(目标缩放钳回限内),界内按速度返回 fling plan(目标偏移经同一条钳制);
- 双击循环(doubleTap):
zoomFraction ≥ 0.95回最小,否则缩到ZoomSpec.maximum,决策公式与上游DoubleClickToZoomListener.cycle()一致; - 程序化入口(to / by / panBy / reset):对应上游
zoomTo/zoomBy/panBy/resetZoom,全部返回动画计划; - Flick 会话:拖动增量、距离阈值 + 速度阈值双判定、关闭目标含旋转补偿距离、渲染阻尼
raw / resistance; - 版本握手(ping):返回
0.20.0-ohos.1,来自真实 Native 调用而非静态文字。
响应(zoom frame)包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
scaleX / scaleY / uiOffsetX / uiOffsetY | 渲染四元组,screen = content × scale + uiOffset。 |
zoomFraction | 0…1 的缩放进度(null 时 ready:false)。 |
overzoom | within / over / under,对应上游 OverzoomBoundaryState。 |
canPanX / canPanY | 嵌套滚动拦截判定(canConsumePanChange)。 |
generation | 状态代数,随每次引擎变更递增。 |
plan | 动画计划(settleZoom / fling / zoom / pan),见 3.3 节。 |
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 42 个 Compose 文件(zoomable 36 + flick 6)逐字迁入 composeMain 留档(git 识别为 100% rename);手势数学逐字重放为 ZoomableEngine.kt / FlickEngine.kt 两个无依赖内核(全仓仅有的新增库源码),公式出处逐条标注。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);zoomable-image(coil/coil3/glide/sub-sampling)、sample、benchmark、zoomable-peek-overlay、gradle/build-logic 留盘不进构建。 |
| 桥接稳定 | 单 C ABI + UTF-8 JSON + 会话 id 寻址,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 内核零依赖(纯 Kotlin + stdlib),不引任何 kotlinx 库,规避 2.2.21 / 2.4.20 两条工具链线的元数据冲突。 |
| UI 完整 | 单页三 Tab 覆盖全部桥接入口:缩放手势、Flick 甩动、诊断(版本握手 + 原始 JSON 日志)。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
Modifier.zoomable/ZoomableImage/FlickToDismisscomposable 隔离在composeMain不参与鸿蒙编译;ArkTS Demo 只是把桥暴露的手势闭环画出来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界(42 个 Compose 文件分类)、确定"内核重放"路线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 钉版、nativeMain 孤儿 actual 处置
第 3 阶段:内核与桥协议 ── ZoomableEngine / FlickEngine 公式重放、23 op JSON 契约、动画 plan 模型、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 捏合/拖动/双击手势分发、scale+translate 渲染、plan 驱动 animateTo、诊断 Tab
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
annotations/ 上游纯 Kotlin 注解模块(进 ohos 构建,jvm + 双 ohos)
zoomable/src/commonMain/…/ZoomableEngine.kt 新增:缩放手势内核(全仓核心新增源码)
zoomable/src/composeMain/ 上游 36 个 Compose 文件原样隔离(不编译,git 100% rename)
zoomable/src/composeTest/ 上游 3 个测试文件(依赖 compose 类型,随迁隔离)
flick/src/commonMain/…/FlickEngine.kt 新增:甩动关闭内核
flick/src/composeMain/ 上游 6 个 Compose 文件原样隔离
example/nativeApp/ Kotlin/Native 动态库(TelephotoBridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
zoomable-image/* sample/ benchmark/ test-util/ zoomable-peek-overlay/ gradle/build-logic/
上游 Android/示例/发布链遗留,留盘不进 settings
doc/ 适配全流程文章与真机效果图
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :annotations、:zoomable、:flick 与 :example:nativeApp,rootProject.name = "ohos_telephoto"。上游的 AGP classpath、paparazzi / dropshots / emulator.wtf 测试插件、vanniktech / dokka / metalava / poko 发布链、gradle/build-logic 组合插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Temurin jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper jar 与 gradlew 取自已验证的 cmp_qrose;2.2.21 线必须降 8.x) |
| 依赖 | 内核零依赖(纯 Kotlin + stdlib),无 kotlinx 制品需求 |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.telephoto |
export JAVA_HOME="/c/Program Files/Eclipse Adoptium/jdk-21.0.12.101-hotspot"
java -version # Git Bash 下需显式导出,Windows 环境变量不继承
1.3 源码依赖度盘点
对 zoomable/src/commonMain 全部 36 个文件逐文件判定依赖形态:
| 分类 | 数量 | 代表文件 | 处置 |
|---|---|---|---|
| 手势检测(pointer/pointerInput) | 4 | transformable.kt、tappableAndQuickZoomable.kt、hardwareShortcuts.kt、PressInteractionEmitter.kt | 迁 composeMain(鸿蒙由 ArkTS 手势接管) |
| 状态与数学(runtime/geometry/animation.core) | 30 | RealZoomableState.kt(994 行)、contentTransformation.kt、dimens.kt、ZoomSpec.kt | 迁 composeMain,公式重放进 ZoomableEngine |
| 无 androidx 导入 | 4 | flows.kt、parcelable.kt、platform.kt、CoordinateSpace.kt | 一并随迁(expect 的 actual 在平台目录,且仅服务 Compose 管线) |
expect 声明全库只有 2 处(platform.kt 的 HostPlatform、parcelable.kt 的 AndroidParcelable),actual 分布在 androidMain / desktopMain / iosMain / nativeMain / webMain——全部随 Compose 隔离,ohos 侧不需要任何 actual。这决定了 telephoto 的适配面:不是补一个 actual(KStore 模式),而是把数学重放进无依赖内核。
flick 同构:6 个文件里只有 math.kt(toRadians 一行)是纯 Kotlin,RealFlickToDismissState 的判定数学长在 Draggable2DState / Animatable 上。
1.4 页面边界
标题区 顶部 Tab:缩放图片 / Flick 关闭 / 诊断
缩放 Tab 状态行(scale/offset/fraction/overzoom)
视口区(自测尺寸 → zoom.viewport;内容 = 固定 600×800 海报)
contentScale 三档(fit/crop/fillbounds)+ maxZoom 三档(2x/4x/8x)+ overzoom 三档
程序化四按钮(zoomTo 2x / zoomBy 1.5x / panBy 80 / reset)
Flick Tab 卡片(icon + 标题 + 说明)、fraction/state 状态行、拖动 → 位移+旋转、判定 → 关闭/回弹
诊断 Tab 版本握手行、库/so 说明、JVM 交叉验证数字、最近请求/响应 JSON、ping/状态按钮
页面启动时先调 ping 做握手,onAreaChange 拿到视口尺寸后建 zoom 会话。
第 2 阶段:目标与依赖打通
2.1 路线判定(本适配的成败手)
KStore 适配时先验证 kotlinx ohos 制品线,齐备后整库真编译。telephoto 对应的问题是:社区 Maven 上有没有 Compose 的 ohos 制品? 结论是没有——社区工具链是 Kotlin 2.2.21-1.0.0,没有对齐 telephoto 上游(JB Compose 1.8 / Compose BOM 1.8.x)的 compose.ui / compose.foundation / compose.animation ohos 变体。已审核的 FlexibleBottomSheet(同为 Compose 依赖库)为此确立了「内核重放」路线:
- Compose 文件整体隔离到
composeMain(未注册源码集,KMP 自动忽略),git 里表现为 100% rename,可审计、可整体回迁; - 手势数学在
commonMain里用自有值类型(EngineOffset/EngineSize/EngineRect/EngineScale等)逐字重放,每条公式标注上游文件出处; - 内核零依赖——不引 coroutines(
flows.kt随迁)、不引 serialization(桥内手写 MiniJson),因此不需要像 KStore 那样把工具链升到 2.4.20 线,直接沿用社区已批准的2.2.21-1.0.0老钉法。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
内核零依赖带来一个直接好处:没有 stdlib 2.4 元数据冲突,不存在"coroutines 制品线逼着升 Kotlin"的问题。2.2.21-1.0.0 是 qrose / haze / FlexibleBottomSheet 一脉的已批准钉法;该线只支持 Gradle 8.x,wrapper 从上游 8.14.3 换钉 8.14.1(wrapper jar 与 gradlew 脚本取自已验证的 cmp_qrose,一次到位)。
settings.gradle.kts 把 eazytec 仓库写在最前:
pluginManagement {
repositories {
// HarmonyOS Kotlin toolchain (ohosArm64/ohosX64 targets) must come first,
// otherwise the ohosArm64()/ohosX64() factory functions and the ohosMain
// source set are reported as unresolved references.
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = "ohos_telephoto"
include(":annotations")
include(":zoomable")
include(":flick")
include(":example:nativeApp")
2.3 编译内核
zoomable/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
// jvm() is the cross-validation target: the engine tests in commonTest run
// against the same sources that are compiled into the ohos .so.
jvm()
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {
api(project(":annotations"))
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
// Upstream Compose sources are quarantined under src/composeMain (and their tests
// under src/composeTest); neither is registered as a source set for these targets.
flick、annotations 同构。实测:
./gradlew :annotations:compileKotlinJvm :zoomable:compileKotlinJvm :flick:compileKotlinJvm # BUILD SUCCESSFUL
./gradlew :zoomable:compileKotlinOhosArm64 :zoomable:compileKotlinOhosX64 # BUILD SUCCESSFUL
./gradlew :flick:compileKotlinOhosArm64 :flick:compileKotlinOhosX64 # BUILD SUCCESSFUL
2.4 一个隐蔽的层级坑:ohos 继承 nativeMain
上游在 zoomable/src/nativeMain/ 放了 parcelable.native.kt(iOS 共用的 actual)。applyDefaultHierarchyTemplate() 下 ohos 目标会继承 nativeMain 源集——expect 已随 Compose 隔离迁走,这个 actual 成了孤儿,compileKotlinOhosArm64 直接报 'public abstract actual interface AndroidParcelable' has no corresponding expected declaration。处置:把它并入同为未注册的 iosMain/(该文件本就是 apple 系共用),删除 nativeMain 目录。
另外两个 Native 专属编译错误一并记录:@JvmInline 在 Native 不存在(UserOffset 用普通 data class,JVM 上 value class 才强制该注解)、java.lang.Math 不存在(换 kotlin.math.PI)。
第 3 阶段:内核与桥协议(技术核心)
3.1 数学搬运:逐字重放
ZoomableEngine.kt 重放 RealZoomableState 的全部离散数学,公式逐条对照:
| # | 公式 | 上游出处 |
|---|---|---|
| 1 | ((currentOffset + centroid/oldZoom) - (centroid/newZoom + panDelta/oldZoom)) 质心保持 | retainCentroidPositionAfterZoom(源自 androidx TransformGestureSamples) |
| 2 | rubber banding 1 ± zoomDelta/250 | 私有 OverzoomEffect.adjust |
| 3 | 越界余量 min 10% / max 40% coerceUserZoomIn | transformableState lambda |
| 4 | 边界钳制 withZoomAndTranslate(-zoom, scaledTopLeft) + 每轴 clamp/align | internal/dimens.kt + contentPlacement.kt |
| 5 | ZoomRange:max 相对原始尺寸、min 相对 base、max(maxZoomAsRatioOfSize, minZoomFactor) 兜底 | RealZoomableState.ZoomRange |
| 6 | baseZoom = contentScale.computeScaleFactor(…)、baseOffset = -align(…)/baseZoom | gestureStateInputsCalculator |
| 7 | ContentScale 七种语义(Fit=min / Crop=max / FillBounds / FillWidth / FillHeight / Inside / None) | androidx.compose.ui.layout.ContentScale |
| 8 | Alignment 九宫格 + Start/End 随 LayoutDirection 翻转 | androidx.compose.ui.Alignment |
| 9 | zoomFraction 含"不能缩放 → 1f"分支 | RealZoomableState.zoomFraction |
| 10 | canConsumePanChange 每轴边缘检测 | 同名方法 |
| 11 | 双击循环 zoomFraction >= 0.95 → reset else zoomTo(max) | DoubleClickToZoomListener.cycle |
| 12 | settle 目标 = coerceUserZoomIn(range).userZoom + 对应钳制 offset | animateSettlingOfZoomOnGestureEnd |
| 13 | 初始态走同一钳制管道、质心 = 视口中心 | initialGestureState |
| 14 | 视口/配置变更:保留 userZoom 重钳制 | SavedGestureState.restore |
| 15 | UI 变换 scale = baseZoom×userZoom、offset = -finalOffset×zoom(负零归一) | RealZoomableContentTransformation.calculateFrom |
FlickEngine.kt 同样重放:offsetFraction = abs(offset.y)/contentHeight 钳 0…1、旋转 offsetFraction × ±20°(按住左半边为负)、判定 willDismissOnRelease ∨ abs(v) ≥ contentHeight × ratio × 10f(FlingSlopMultiplier = 10f)、关闭目标 (contentHeight + (1-sinθ)(θ·diagonal/2)) × sign、渲染阻尼 raw / resistanceFactor(只影响渲染不影响判定,与上游 FlickToDismiss.kt 的 graphicsLayer 应用位一致)、五态状态机 Idle/Dragging/Resetting/Dismissing/Dismissed(ε=0.01)。
3.2 JSON 契约(23 op)
me.saket.telephoto.zoomable.ZoomableEngine / flick.FlickEngine(内核)
↓ 会话注册表(HashMap<id, Session>)
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API telephotoBridge(request)
↓
ArkUI Index.ets(三 Tab)
| 组 | op |
|---|---|
| 会话 | ping、dispose |
| zoom 生命周期 | zoom.create、zoom.config、zoom.viewport、zoom.content、zoom.state |
| zoom 手势 | zoom.delta(zoom/panX/panY/centroidX/Y)、zoom.end(velocityX/Y)、zoom.doubleTap(x/y) |
| zoom 程序化 | zoom.to、zoom.by、zoom.panBy、zoom.reset |
| zoom 动画 | zoom.snap(吸附到目标手势态)、zoom.sync(被打断时从渲染值反同步) |
| flick | flick.create、flick.size、flick.start、flick.drag、flick.end、flick.reset、flick.dismissed、flick.state |
会话以字符串 id 寻址(一张可缩放图 / 一张可甩卡片 = 一个引擎实例),调用只来自 ArkTS UI 线程,无锁(Kotlin/Native 没有 synchronized)。
3.3 动画为什么不跨桥:plan 模型
上游的 settle / fling / zoomTo 动画跑在 AnimationState.animateTo / animateDecay(splineBasedDecay) 上,依赖 withFrameNanos 帧时钟——C ABI 给不了帧回调。处置是把动画拆成「决策在内核、插值在宿主」:
zoom.end
├─ 越界 → plan{kind: settleZoom, toScale, toUiOffset, durationMs, targetUserZoom/Offset}
└─ 界内 → plan{kind: fling, toScale(不变), toUiOffset(=衰减积分后钳制)}
ArkTS: animateTo(plan.durationMs, EaseOut) 插值 to* → onFinish 调 zoom.snap 精确吸附
中断场景: 新手势开始先调 zoom.sync(scale, uiOffset) 把渲染值反灌回内核
两个关键对齐:其一,plan 的 from/to 给的是 UI 空间值(-offset×zoom)——上游 zoomTo 的注释明确"必须在 UI 看到的值之间插值,否则缩放与偏移不同速会产生曲线",plan 模型天然继承;其二,fling 目标用指数衰减积分(distance = v₀ × τ,τ=325ms)近似 splineBasedDecay,再过上游同一条边界钳制——终点位置由钳制保证正确,路径形状是近似(见 8.2)。
3.4 错误通道
导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("TelephotoBridge")
fun telephotoBridge(request: String): String = try {
dispatch(MiniJson.parse(request))
} catch (t: Throwable) {
jsonError(t.message ?: t.toString()) // {"ok":false,"error":"..."}
}
非法 JSON、未知 op、不存在的会话 id、Dismissed 后继续拖动,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libtelephoto_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohostelephoto.so)
│ TelephotoBridge
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制手势语义,所以选择 C ABI + JSON 会话。
4.2 Kotlin/Native 导出函数
@CName("TelephotoBridge")
fun telephotoBridge(request: String): String // 23 op 一口进
一个坑:ohos 发行版里 import kotlinx.cinterop.CName 解析不到(Unresolved reference 'CName'),正确写法是不写 import 直接裸用 @CName(默认导入,与已审核的 cmp_qrose 同款)。
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
TelephotoBridge | telephotoBridge(req) | 会话注册表 + 全部 23 op 分发 + 错误通道 |
libohostelephoto_symbols | (链接期) | KN 运行时符号表(DisposeString 从这里取) |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"telephotoBridge", nullptr, NapiTelephotoBridge, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。两个纪律:KN 返回字符串必须经 libohostelephoto_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free();API 26 头文件把 napi_get_value_string_utf8 的缓冲区标成非 const,std::string::data() 直接传编不过,先量长度开 std::vector<char> 再拷。libohostelephoto_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohostelephoto SHARED IMPORTED)
set_target_properties(ohostelephoto PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(telephoto_napi SHARED napi_init.cpp)
target_link_libraries(telephoto_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohostelephoto)
OHOS_ARCH 为空或 libohostelephoto.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);-Wl,-rpath,$ORIGIN + POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const telephotoBridge: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 手势分发与渲染
// 捏合:PinchGesture 的 scale 是手势开始以来的累计值,差分成本次增量
.onActionUpdate((e: GestureEvent) => {
const zoomRatio = this.lastPinchScale > 0 ? e.scale / this.lastPinchScale : 1;
this.lastPinchScale = e.scale;
const c = this.centroidOf(e); // fingerList 平均 localX/localY
this.gestureDelta(zoomRatio, 0, 0, c[0], c[1]); // → zoom.delta → frame
})
// 拖动:PanGesture 的 offsetX/Y 同样差分;onActionEnd 直接给 velocityX/Y (px/s)
渲染公式来自 frame 的四元组,ArkUI 通用变换的合成顺序是 translate(rotate(scale(p)))——先缩放后平移,正好就是内核的渲染模型:
Column() { this.ZoomPoster() }
.scale({ x: this.zScaleX, y: this.zScaleY, centerX: 0, centerY: 0 })
.translate({ x: this.zOffX, y: this.zOffY })
松手拿到 plan 后用 getUIContext().animateTo 插值,onFinish 调 zoom.snap 吸附:
this.getUIContext().animateTo({
duration: Math.max(plan.durationMs, 60),
curve: Curve.EaseOut,
onFinish: () => this.zoom({ 'op': 'zoom.snap', 'id': 'z1',
'targetUserZoom': plan.targetUserZoom,
'targetUserOffsetX': plan.targetUserOffsetX,
'targetUserOffsetY': plan.targetUserOffsetY }),
}, () => {
this.zScaleX = plan.toScaleX; this.zOffX = plan.toUiOffsetX; // …
});
5.2 三个 SDK 适配点
- GestureEvent 没有
pointX/pointY:本 SDK 的捏合事件不带质心字段,用e.fingerList的localX/localY平均求; - 没有
matrixTransform:用.scale({centerX:0,centerY:0}).translate()组合(见上); - NAPI 类型包要先装:
ohpm install之前telephotoBridge被推断为 any,arkts-no-any-unknown报错——DevEco 打开工程会自动装,命令行要先手跑。
5.3 Flick Tab 与诊断 Tab
Flick 卡片 .translate(renderedOffset).rotate(rotationZ),拖动喂原始手指增量(渲染阻尼在内核侧算 raw/resistance,判定用 raw,与上游一致);松手 flick.end 返回 shouldDismiss 与关闭目标(含旋转补偿距离),关闭动画播完调 flick.dismissed 锁状态。诊断 Tab 展示 ping 版本握手、29 项交叉验证数字、最近一次请求/响应 JSON 与状态快照按钮。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohostelephoto)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/TelephotoBridge.kt @CName × 1 + 会话注册表 + MiniJson
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.telephoto、versionName 0.20.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/libohostelephoto_api.h
├── src/main/cpp/types/libtelephoto_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
./gradlew :zoomable:jvmTest :flick:jvmTest # 29 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
ohpm install # NAPI 类型包
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.telephoto -m entry
四、完整代码对照
4.1 整体架构
me.saket.telephoto.zoomable.ZoomableEngine / flick.FlickEngine(内核重放)
│ 手势数学:质心保持 / 边界钳制 / rubber banding / 阈值判定 / plan 决策
Bridge.kt + MiniJson
│ TelephotoBridge(23 op 会话协议)
libtelephoto_napi.so
│ telephotoBridge
Index.ets
│ 手势差分 + JSON parse + animateTo 插值
▼
ArkUI 真机页面(三 Tab:缩放 / Flick / 诊断)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_telephoto、上游 Android 模块移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0(仅此一项) |
build.gradle.kts(根) | 版本 0.20.0-ohos.1、全局禁用 Ohos*Test* 任务(13 行模板) |
zoomable/build.gradle.kts / flick/build.gradle.kts / annotations/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64 |
zoomable/src/commonMain/…/ZoomableEngine.kt | 缩放手势内核(公式逐字重放 + 出处标注) |
flick/src/commonMain/…/FlickEngine.kt | 甩动关闭内核 |
zoomable/src/composeMain/(36 文件)/ flick/src/composeMain/(6 文件) | 上游 Compose 源码原样隔离(git 100% rename) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/…/TelephotoBridge.kt | 1 个 C ABI、会话注册表、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 三 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(19 节,含 22 条公式搬运清单) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | gestureDelta(zoomDelta, panDelta, centroid) | 一次手势增量 → 新变换(overzoom / 质心 / 钳制全在内) |
| 引擎 | gestureEndPlan(velocityX, velocityY) | 松手决策:settle 或 fling 的动画计划 |
| 引擎 | doubleTapPlan(centroid) | 双击循环决策 |
| 引擎 | zoomToPlan / zoomBy / panByPlan / resetZoomPlan | 程序化缩放目标 |
| 引擎 | canConsumePanChange(panDelta) | 嵌套滚动拦截 |
| 引擎 | syncFromUi(scale, uiOffset) / snapToValues(…) | 动画中断反同步 / 完成吸附 |
| Flick | dragBy / willDismissOnRelease / releasePlan | 拖动状态机 / 双阈值判定 / 关闭决策 |
| Native | TelephotoBridge | C ABI JSON 导出 |
| N-API | telephotoBridge(request) | 向 ArkTS 暴露能力 |
| ArkTS | gestureDelta / runZoomPlan | 手势分发 / plan 插值动画 |
4.4 ArkTS 与 Kotlin 的边界
const frame = JSON.parse(telephotoBridge(JSON.stringify({
'op': 'zoom.delta', 'id': 'img1',
'zoom': 1.02, 'panX': 3, 'panY': 5,
'centroidX': 180, 'centroidY': 240,
}))) as ZoomFrame;
// frame.scaleX/Y + frame.uiOffsetX/Y → .scale().translate() 渲染
// screen = content × scale + uiOffset
"zoom.delta" -> withZoom(json) {
it.engine.gestureDelta(
zoomDelta = json.float("zoom", 1f),
panDelta = EngineOffset(json.float("panX", 0f), json.float("panY", 0f)),
centroid = EngineOffset(json.float("centroidX", 0f), json.float("centroidY", 0f)),
)
zoomFrame(json.str("id"))
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:内核重放而不是等 Compose ohos 制品
telephoto 的数学长在 androidx.compose 值类型与帧时钟上,社区没有对齐制品(与 KStore 的 kotlinx 齐备截然相反)。等制品 = 适配无限期搁置;内核重放 = 先交可验收的手势语义,公式逐字搬运 + 出处标注 + 交叉验证保证不漂。composeMain 里的 42 个文件与上游逐字一致(git rename 100%),制品齐备那天整体改回 commonMain 即可回迁,零成本。
决策 2:工具链沿用 2.2.21 老线 + wrapper 降 8.14.1
内核零依赖意味着不存在"coroutines 制品线逼着升 Kotlin"(KStore 的 2.4.20 理由在此不成立)。2.2.21-1.0.0 是 qrose / FlexibleBottomSheet 一脉的已批准钉法;该线只支持 Gradle 8.x,wrapper 从 8.14.3 换钉 8.14.1。两条工具链线的经验不能互相套用——2.4.20 线可留 Gradle 9.x,2.2.21 线必须降。
决策 3:动画 plan 化——决策在内核,插值在宿主
帧时钟过不了 C ABI,硬在 KN 里跑动画需要自建 MonotonicFrameClock + 跨调用协程调度,复杂度爆炸。plan 模型把权威留在两端:内核出目标(经上游钳制管道保证正确),ArkTS 出帧(animateTo + EaseOut),zoom.snap / zoom.sync 收口。插值空间与上游一致(UI 空间 -offset×zoom),避免缩放与偏移不同速的曲线失真。
决策 4:根构建替换(cmp_qrose 模式),Android 链整体留盘
上游根构建(AGP / paparazzi / dropshots / emulator.wtf / vanniktech / dokka / metalava / poko / build-logic)对鸿蒙没有保留价值。zoomable-image 五模块(coil2 / coil3 / glide / core / sub-sampling)依赖 Android 图像栈与 BitmapRegionDecoder,鸿蒙没有对应物,留盘不进 settings.gradle.kts。
决策 5:测试新建而不是保留上游测试
上游 commonTest(3 个文件)测的是 compose 类型上的函数,随函数一起迁 composeTest 隔离。交叉验证靠新建 29 项内核测试钉行为——与 KStore 的"上游测试零改动"不同:那里测的函数原样编译,这里测的公式换了宿主,测试也必须换宿主,但断言的是同一批不变量(质心不动、边界钳制、rubber band、阈值边界值)。
六、测试与验证
6.1 测试环境
本次验证使用 Windows 11、JDK 21(Temurin jdk-21.0.12.101-hotspot)、DevEco Studio 5.1.0 Release(E:\DE\DevEco Studio,hvigor / node / JBR / ohpm 用其自带)与 HarmonyOS 真机(arm64,挖孔屏)。
6.2 JVM 交叉验证
./gradlew :zoomable:jvmTest :flick:jvmTest
29 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
ZoomableEngineTest | 20 | 就绪门槛、初始 Fit/Crop/宽图居中、质心保持(数值对拍漂移 <0.5px)、pan 精确跟手、双轴边缘钳制、rubber band + 10%/40% 余量、越界状态与 settle 回弹、zoomFraction 三档、双击循环、canConsumePanChange(含"横向耗尽纵向仍可")、fling 钳制、zoomTo 上限钳制、syncFromUi UI 空间往返、padding 缩小视口、视口旋转重钳制 |
FlickEngineTest | 9 | ε 内保持 Idle、距离阈值上下判定、速度阈值 2001/1999 边界、release 双分支、方向跟随、左右半边旋转符号、关闭阻尼只影响渲染、Dismissed 后拒绝拖拽 |
交叉验证的价值在实施中就兑现了一次:测试初版 8 个用例失败,逐条对拍上游公式后确认全部是测试预期写错(pan 跟手方向、maxZoom 相对原始尺寸、baseZoom 先缩大图),引擎行为与上游语义一致——这正是"内核没漂"的闸门。
6.3 编译与 HAP 验证
:annotations/:zoomable/:flick compileKotlinJvm + compileKotlinOhosArm64/X64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm --defined-only(双 ABI): TelephotoBridge / libohostelephoto_symbols 均为全局 T 符号
BuildNativeWithCmake / BuildNativeWithNinja: finished(双 ABI 编出 libtelephoto_napi.so + 三件套)
CompileArkTS: finished
PackageHap: DevEco 自动签名后 finished(裸命令行报 00308018,见 8.1 第 9 条)
install bundle successfully
start ability successfully
libohostelephoto.so arm64 4,051,736 B(≈3.86 MB)、x86_64 3,760,856 B(≈3.59 MB),含 Kotlin/Native 运行时;每个 ABI 目录三件套齐全(libtelephoto_napi.so / libohostelephoto.so / libc++_shared.so)。
6.4 功能用例(真机)
- 启动进入「缩放图片」Tab,ping 握手返回
0.20.0-ohos.1(来自真实 Native 调用,非静态文字); - 视口自测尺寸建会话,600×800 海报按 contentScale 初始缩放居中,状态行实时输出引擎四元组(真机实测见第七节截图:
scale=0.72 offset=(0,0) fraction=0.00 within); - 双指捏合:质心下的内容点视觉不动;单指拖动跟手;
- 拖到内容边缘被钳制,
overzoom状态行随之变化;越过 max 继续 pinch 出现橡皮筋,松手 settle 回弹、状态行追加[settled]; - 双击在 min↔max 间循环;快速甩动后 fling 停在内容边缘不越界;
- 切
crop/fillbounds、max 2x/4x/8x、rubberband/nolimits/disabled,行为随配置即时变化;zoomTo 2x/zoomBy 1.5x/panBy 80/reset四个程序化按钮各自动画到位; - 「Flick 关闭」Tab:拖动卡片位移减半(渲染阻尼)+ 轻微旋转;拖过内容高度 20% 状态行出现「释放即关闭」;轻拖松手回弹;快速轻甩距离不足也关闭;关闭后可重新显示;
- 「诊断」Tab:最近请求/响应 JSON 可见
generation递增;zoom 状态/flick 状态返回快照。
6.5 验证结论
手势引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在 HarmonyOS 真机上成功安装并进入前台,页面展示了真实的手势会话:版本握手、初始变换渲染、捏合/拖动/双击循环与 Flick 判定闭环(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/telephoto.git
cd telephoto
export JAVA_HOME="/path/to/jdk-21"
./gradlew :zoomable:compileKotlinOhosArm64 :flick:compileKotlinOhosArm64 # klib
./gradlew :zoomable:jvmTest :flick:jvmTest # 29 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
ohpm install
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.telephoto -m entry
hdc shell hilog | grep Telephoto
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:zoomable/:flick真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- ohos 目标会继承
nativeMain源集:expect 随 Compose 隔离迁走后,parcelable.native.kt成孤儿 actual,compileKotlinOhosArm64报has no corresponding expected declaration——并入iosMain解决。这是 ohos 层级模板与 iOS 目标不同的隐蔽坑,纯 JVM/Android 思路发现不了; @JvmInline在 Native 不存在(Unresolved reference 'JvmInline'),而 JVM 上 value class 又强制要求它——跨 JVM+Native 的公共代码用普通 data class 最省事;import kotlinx.cinterop.CName在 ohos 发行版解析不到,正确写法是不写 import 裸用@CName(默认导入,cmp_qrose 同款);java.lang.Math不存在 Kotlin/Native,换kotlin.math.PI;- Git Bash 下 Windows 环境变量不继承,
JAVA_HOME需每次显式导出,否则 gradlew 直接退出; - ArkTS 编译的三个 SDK 适配点:
GestureEvent没有pointX/pointY(用fingerList平均求质心)、没有matrixTransform属性(用.scale({centerX:0,centerY:0}).translate(),合成顺序恰好先缩放后平移)、ohpm install之前 NAPI 类型包不生效(telephotoBridge被推断为 any,arkts-no-any-unknown报错); PackageHap在无签名材料的裸命令行环境报00308018——不是工程问题,DevEco 勾选自动签名后正常打包(已审核参照库同样复现);- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free();API 26 的 NAPI 头把字符串缓冲区标非 const,std::string::data()直传编不过,先开std::vector<char>再拷; - 上游 34 个 androidTest 图片是 Git LFS 对象,AtomGit 远端没有迁移 LFS 存储(下载 404)——提交适配时这些路径要保持 HEAD 原样(LFS 指针),不要把"工作区缺失"记录成删除;
- 签名材料由 DevEco 自动写入
build-profile.json5,提交前用git update-index --skip-worktree保护,证书绝不进入 AtomGit。
8.2 已知问题
- Compose 不参与鸿蒙编译:
Modifier.zoomable/ZoomableImage/FlickToDismisscomposable 隔离在composeMain,等社区出现对齐的 Compose ohos 制品后可整体回迁(源码与上游逐字一致); - fling 是目标近似:终点经上游钳制保证正确,路径形状用指数衰减(τ=325ms)近似
splineBasedDecay样条,长距离 fling 的减速手感和上游有可感差异; - 动画时长是估计值(按上游 spring 参数 stiffness 1500/400、damping 1.0 估算),ArkTS 的
EaseOut曲线与上游 spring 帧级不同; - quick-zoom(双击按住拖)与硬件快捷键未移植(上游在 pointer/keyboard 事件模型里,手机鸿蒙 Demo 用不到,需要时加 op 即可,C 签名不变);
GestureStateAdjuster(视口尺寸突变时跨尺寸保持 pan)未移植,引擎用"保留 userZoom + 重钳制 offset"的简化策略;SubSamplingImage/zoomable-image全家未移植:依赖BitmapRegionDecoder、okio、compose.ui.geometry 与 coil/glide,鸿蒙侧超大图分块需要 PixelMap 区域解码方案,属独立工程;- 旋转手势恒 0(上游
rotationZ本就"currently unused",内核同款保留接口位); canPanX/canPanY用固定 10px 探针探测每轴可消耗性,语义等价canConsumePanChange(±10),不是任意增量的精确判定;- 会话无锁:约定全部桥调用来自 ArkTS UI 线程,多线程调用需业务侧自护;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.telephoto匹配的签名。
8.3 未来优化方向
- fling 手感如与上游可感差异明显,把 τ 做成可调参数或换 ArkUI
springMotion驱动; - 把
GestureStateAdjuster与 quick-zoom 补进内核与桥协议; - 评估超大图分块(PixelMap 区域解码 + 上游 tile grid 数学)作为
sub-sampling-image的鸿蒙对应物; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和 uitest 自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 zoomable(36 文件) + flick(6 文件)(数学长在 Compose 值类型上)
→ 判定社区无对齐 Compose ohos 制品 → 确立"内核重放"路线(FlexibleBottomSheet 先例)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(内核零依赖,无需 2.4.20 线)+ Gradle 8.14.1
→ 根构建替换(cmp_qrose 模式)+ Compose 隔离(git 100% rename)
→ ZoomableEngine / FlickEngine 公式逐字重放(22 条出处对照)
→ ohosArm64 / ohosX64 KLIB + nativeMain 孤儿 actual 处置
→ C ABI(@CName × 1)+ 23 op 会话协议 + 动画 plan 模型
→ C++ N-API(DisposeString 纪律)
→ ArkTS 手势差分 + scale/translate 渲染 + animateTo 插值
→ ArkUI 真机三 Tab 验收(缩放 / Flick / 诊断)+ 29 项 JVM 交叉验证
9.2 封装层次
- 引擎层:
ZoomableEngine(baseZoom/baseOffset、质心保持、边界钳制、rubber band、zoomFraction、双击循环、plan 决策)与FlickEngine(状态机、双阈值判定、旋转与阻尼); - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON 会话;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、手势差分、plan 插值动画、日志与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 路线选择必须基于当天的制品可达性:KStore 适配时 kotlinx ohos 制品齐备,答案是"整库真编译 + 补 8 行 actual";telephoto 的 Compose 制品缺失,答案是"内核重放 + composeMain 隔离"。同一个社区、同一周内,两个库的最优路线截然相反——先查制品线再定路线,而不是套用上一篇的结论;
- 有状态手势引擎必须会话制 + plan 化:缩放不是纯函数(userZoom/userOffset/lastCentroid 有记忆),无状态 JSON 进出会把边界钳制做错;动画吃帧时钟过不了 C ABI,把"决策"留在内核、"帧"交给宿主,两端权威用 snap/sync 收口,是跨语言手势引擎的可复制模式;
- 交叉验证是"内核没漂"的唯一可信证明:29 项测试跑在和链进 so 完全相同的源码上;实施中 8 个用例失败全部是测试预期写错而非引擎错——这个方向性判断只有靠"断言上游不变量(质心不动、阈值边界值 2001/1999、钳制终点)"的测试才做得出,靠肉眼跑 Demo 做不出。
9.4 适配成果
当前适配已完成:
- telephoto
0.20.0-SNAPSHOT(上游 commit5d33195c)手势数学的双 ABI Kotlin/Native 编译(42 个上游 Compose 文件 100% rename 隔离,两个无依赖内核重放 22 条公式); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证双 ABI 全局 T 符号); - DevEco Stage ArkUI 三 Tab 验收页面(捏合/拖动/双击/边缘钳制/橡皮筋/fling、Flick 双阈值判定、版本握手、原始 JSON 诊断);
- 29 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
- AtomGit 项目文档(oh-tpc/telephoto,提交
鸿蒙适配)、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)