本文记录 Foliox 接入 OpenHarmony 的完整过程,覆盖翻页组件库盘点、Compose 界面层与翻页引擎的拆层决策、鸿蒙 Kotlin 工具链钉版、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、显示列表渲染协议、ArkUI 真机页面、HAP 构建和真机验收。

Foliox 是基于 Compose Multiplatform 的翻页动画组件库:内置 Curl(折页)、Slide(平移)、Cover(覆盖) 三种翻页效果,并提供可插拔的 PageAnimation API,通过 PageAnimationContent 与 rememberPageAnimationState 驱动渲染。本次适配与 KStore 那类"纯逻辑库直接整库编译"的路线有本质区别:上游公开 API(PageAnimationContent、CurlPageAnimation 等)直接依赖 androidx.compose.ui / androidx.compose.animation,鸿蒙社区 CMP 1.9.2 与上游 Compose 1.11 / Kotlin 2.4 无法共存,Compose 界面层整体不可编译。因此适配采取"拆层"策略:翻页动画真正有价值的部分——折页几何、手势阈值、落点计算、弹簧曲线——全部是纯数学,被重组为一个不含 Compose 的引擎放进 commonMain 并为鸿蒙编译;引擎按帧输出一份有序绘制指令列表,鸿蒙侧 ArkUI Canvas 逐条回放,折页的任何数值都不在 ArkTS 侧重算。

项目地址: AtomGit/oh-tpc/Foliox.git

上游仓库: kitectlab/foliox

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

Foliox 为 Kotlin Multiplatform 应用提供"纸张翻页"这一件事的完整答案:Curl 用一根手指、一个装订角、两个折页点和五段阴影描述纸张从书脊剥离、翻转并露出镜像背面的全过程;Cover 让新页静止、旧页带着 8dp 边缘阴影滑出视口;Slide 让两页一起平移整整一屏;收尾动画统一走临界阻尼弹簧(阻尼比 1、刚度 1500,每 16ms 采样一次)。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套翻页能力,但上游构建(Compose 1.11 / Kotlin 2.4 / AGP 9)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示"翻页"按钮,却无法证明折页几何、方向阈值和弹簧曲线在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证翻页引擎、Native 动态库、C ABI、N-API、显示列表回放、ArkUI 页面和 HAP 交付链路。

障碍具体问题
Compose 界面层不可编译上游公开 API 全部依赖 androidx.compose.*;鸿蒙 CMP 1.9.2 与上游 Compose 1.11 / Kotlin 2.4 无法混编。
工具链不一致上游 Kotlin 2.4 / Gradle 9.6 与鸿蒙工具链无法共存于一个 Gradle 构建;鸿蒙线钉 HarmonyOS Kotlin 2.2.21-1.0.0。
UI 语义跨不了桥@Composable、State、手势回调都是 Compose 侧概念,C ABI 只能同步一来一回。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
渲染责任划分折页裁切、背面镜像、阴影渐变必须由 Kotlin 单方决定,否则 Demo 展示的是"长得像"的山寨实现。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:无 Compose 翻页引擎的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 显示列表桥接层。折页点、滑动/边缘区阈值、阴影宽度、弹簧参数逐字取自上游 CurlPageAnimation / SlidePageAnimation / CoverPageAnimation 与 PageAnimationState,ArkTS 只负责调用 Native、解析 JSON 和按序回放绘制指令。

1.2 库提供的能力

Demo 提供一个真实的翻页阅读会话,对应桥接的全部入口:

  • 方向判定(gesture):拖动 / 点击 / 松手三种手势统一在 Kotlin 侧解析方向——滑动阈值 0.2 × 宽度、点击边缘区 0.3 × 宽度(中间区域刻意不翻页)、松手按"位移过半或超过阈值"决定翻或不翻;
  • 落点计算(target):每种动画的收尾落点由 PageTurnMath 给出——Cover 的落点保证旧页连阴影一起完全离开视口,Slide 平移整整一屏,Curl 落到对侧边缘;
  • 弹簧采样(spring):临界阻尼弹簧按经过时长逐帧采样,鸿蒙侧动画逐帧可复现,与 Compose 侧曲线完全一致;
  • 帧指令(frame):每个动画帧输出一份有序显示列表——页面快照 + 平移、背面镜像(backFace:绕轴心旋转 -θ → 水平镜像 → 旋转 +θ)、裁切路径(折页几何)、阴影渐变、边缘阴影,ArkTS 逐条执行;
  • 版本握手(info):返回引擎身份、能力清单与全部阈值常量,真机握手行来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
direction / x / y方向判定结果 / 落点或弹簧采样坐标。
ops有序绘制指令数组:page(快照 + 平移 + 背面镜像 + 裁切)、gradient(折页阴影)、edgeShadow(Cover 边缘阴影)。

引擎侧完整能力(折页几何 CurlGeometry、路径构造 TurnPath、显示列表组装 PageTurnFrames、弹簧 SpringMotion)全部在 commonMain 里随编译进入 .so,JVM 侧保留 23 项单测交叉验证折页点、阴影宽度、阈值与弹簧曲线,证明 ohos .so 里执行的翻页逻辑没有漂移。

1.3 实现适配

维度要求
数值忠实折页点、阈值、阴影宽度、弹簧参数逐字取自上游动画实现;上游 Compose 界面层整体迁移到 composeMain 留盘(不参与 ohos 编译)。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);sampleApp/ 与 build-logic/ 留盘不进构建(依赖 AGP 9 / Kotlin 2.4,鸿蒙编译器无法消费)。
桥接稳定使用 1 个 C ABI 和扁平 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
渲染纪律ArkTS 只回放显示列表,不重算任何折页几何、阈值或曲线——展示的是上游模型,不是山寨实现。
UI 完整单页覆盖全部桥接入口:三种动画切换、手势翻页、方向/帧数 HUD、握手行。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 Compose 界面层(PageAnimationContent 等 9 个文件)整体保留在 src/composeMain 供其他平台参考,鸿蒙侧的 ArkTS Demo 是把桥暴露的翻页模型画出来。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认拆层决策、确定交付物
第 2 阶段:目标与构建打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:引擎抽取       ── 无 Compose 翻页引擎、JVM 单测锁定几何与弹簧
第 4 阶段:桥协议建模     ── 五动作 JSON 契约、显示列表协议、错误通道
第 5 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── Canvas 回放、三动画切换、手势翻页与握手行
第 7 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
foliox-core/                          上游核心模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
foliox-core/src/commonMain/           无 Compose 翻页引擎(8 个文件,本次新增重组)
foliox-core/src/composeMain/          上游 Compose 界面层整体迁移(9 个文件,留盘不编译)
foliox-core/src/jvmTest/              23 项引擎单测(锁定几何、阈值、弹簧、显示列表顺序)
foliox-core/src/composeTest/          上游 Compose 测试迁移留盘
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 页面
sampleApp/ build-logic/               上游多平台示例与约定插件,留盘不进构建
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :foliox-core 与 :example:nativeApp,rootProject.name = "ohos_foliox"。上游的 Android / iOS / JVM / Wasm 示例、约定插件、AGP classpath、发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向本机 JDK 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(2.2.21 线不支持 Gradle 9.x,必须降版)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.foliox
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块文件依赖形态处置
foliox-core/commonMain引擎 8 个纯 Kotlin 数学,零 UI、零 AGP、零三方依赖ohos + jvm 编译(本次新增重组)
foliox-core/composeMain上游界面 9 个androidx.compose.* 密集依赖整体迁移留盘,不参与任何目标编译
sampleApp/上游示例AGP 9 / Compose 1.11留盘不进 settings.gradle.kts
build-logic/约定插件AGP classpath留盘不进构建

拆层决策的依据:翻页动画有价值的部分是"纸张在哪里折起来"——折页点、装订角、五段阴影、8dp 边缘阴影、0.2/0.3 两个阈值、临界阻尼弹簧——这些全部是纯几何与确定性曲线,不依赖任何 Compose 类型;而上游公开入口 PageAnimationContent / rememberPageAnimationState 深度绑定 Compose 状态与手势 API,鸿蒙 CMP 1.9.2 消费不了 Compose 1.11 字节码。因此引擎按上游数值参数重组为 Compose-free 实现,界面层原样留盘。

1.4 页面边界
标题区          Foliox · OpenHarmony / 握手行(引擎已连接:上游 0.1.0 · fork 0.1.0-ohos.1 · swipe=0.2 · edge=0.3 · coverShadow=8dp)
动画切换        Cover / Slide / Curl 三个胶囊按钮
阅读区          Canvas 翻页视图(6 页样书、纸张渐变底色、页码、边界提示)
HUD 行          当前动画 · 方向 · 坐标 · 帧指令数(如 curl · NEXT · x=0 · ops=6)
底部提示        当前动画说明 + 页码 + "翻页方向、落点、弹簧与折页几何全部由 Kotlin 计算"

页面启动时先调 folioxFrame({"action":"info"}) 做握手,握手行展示引擎返回的真实阈值常量。

第 2 阶段:目标与构建打通

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

本项目走的是社区此前批准的经典钉法(qrose / haze / Flippable 同款):HarmonyOS Kotlin 2.2.21-1.0.0 + Gradle 8.14.1。Foliox 的引擎不依赖 kotlinx 四件套之外的任何三方库,没有 2.4 元数据问题,不必像 KStore 那样升 2.4.20 线;但 2.2.21 线与 Gradle 9.x 不兼容,wrapper 必须从上游的 9.6 降到 8.14.1——两条工具链线的经验不能互相套用。

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_foliox"

include(":foliox-core")
include(":example:nativeApp")
2.2 编译真实引擎

foliox-core/build.gradle.kts 收敛为纯 KMP 模块:

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        // commonMain is Compose-free on purpose: it carries the portable page-turn engine.
        // The upstream Compose surface lives in src/composeMain and is NOT compiled for any
        // target here.
        commonMain.dependencies {}

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

引擎不依赖 kotlinx coroutines / serialization,commonMain.dependencies 为空——这是翻页组件库与存储类库在依赖矩阵上的最大差别,ohos 编译面天然最小。实测:

./gradlew :foliox-core:compileKotlinOhosArm64 :foliox-core:compileKotlinOhosX64   # BUILD SUCCESSFUL
./gradlew :foliox-core:jvmTest                                                     # 23 项全绿

第 3 阶段:引擎抽取

3.1 引擎文件清单
TurnPoint.kt          二维点(含 lerp、距离)
TurnPath.kt           路径构造(moveTo / lineTo / quadraticCurveTo / close 的平台无关表示)
PageTurnDirection.kt  NEXT / PREVIOUS / NONE + 线缆名转换
PageTurnMath.kt       滑动阈值 0.2、边缘区 0.3、点击判定、松手判定、三种动画落点、Cover 阴影宽度 8dp
CurlGeometry.kt       折页几何:装订角约束、折页点求解、五段阴影、正反面路径
SpringMotion.kt       临界阻尼弹簧(阻尼比 1、刚度 1500、16ms 采样、settle 判定)
DrawOp.kt             显示列表指令:Page(快照/平移/背面/裁切)、Gradient、EdgeShadow
PageTurnFrames.kt     按动画类型 + 帧输入组装有序显示列表

所有数值逐字取自上游:SWIPE_THRESHOLD = 0.2f、EDGE_ZONE_FRACTION = 0.3f、COVER_SHADOW_DP = 8f、DAMPING_RATIO = 1f、STIFFNESS = 1500f。折页几何按上游 CurlPageAnimation 的折页点求解与装订角约束重排为 Compose-free 实现。

3.2 JVM 单测锁定几何与弹簧
// 摘自 PageTurnEngineTest(23 项之一)
@Test
fun coverNextTargetClearsTheOutgoingEdgeShadow() {
    for (density in listOf(1f, 2.75f)) {
        val shadow = PageTurnMath.coverShadowWidth(density)
        assertEquals(8f * density, shadow)
        val target = PageTurnMath.coverTarget(PageTurnDirection.NEXT, width * 0.9f, 200f, width, shadow)
        val rightEdge = width * 0.9f + target.x + width
        assertTrue(rightEdge + shadow <= 0.001f, "outgoing shadow must leave the viewport")
    }
}

23 项用例覆盖:折页点求解、装订角约束、Cover 边缘阴影是否完全离开视口、滑动/点击/松手阈值矩阵、弹簧 settle 采样、显示列表顺序。这是"引擎没漂"的闸门。

第 4 阶段:桥协议建模

4.1 JSON 契约
io.github.kitectlab.foliox.engine/**(无 Compose 翻页引擎)
        ↓ PageTurnFrames / SpringMotion / PageTurnMath
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API folioxFrame()
        ↓
ArkUI FolioxReader(Canvas 回放显示列表)

五个动作:

请求 : {"action":"info"}
       {"action":"gesture","kind":"drag|tap|release","startX":...,"currentX":...,"width":...}
       {"action":"target","animation":"cover|slide|curl","direction":"NEXT|PREVIOUS",...}
       {"action":"spring","startX":...,"targetX":...,"elapsedMs":...}
       {"action":"frame","animation":"curl","direction":"NEXT","offsetX":...,"offsetY":...}
响应 : {"ok":true,"direction":"..."} / {"ok":true,"x":...,"y":...}
       {"ok":true,"progress":...,"settled":false} / {"ok":true,"ops":[...]}
       {"ok":false,"error":"..."}
4.2 显示列表协议(本适配的核心)

frame 返回的 ops 数组是完整的、有序的绘制指令,ArkTS 逐条回放:

{"op":"page","role":"CURRENT","dx":...,"dy":...,
 "backFace":{"pivotX":...,"pivotY":...,"angle":...,"washAlpha":...},
 "clip":[{"rule":"evenodd","commands":[{"v":"M","p":[...]},{"v":"Q","p":[...]},{"v":"Z"}]}]}
{"op":"gradient","fromAlpha":...,"toAlpha":...,"x0":...,"y0":...,"x1":...,"y1":...,"clip":[...]}
{"op":"edgeShadow","edgeX":...,"width":...,"alpha":...}

三个设计要点:其一,折页的一切数值都在 Kotlin 侧算好——裁切路径的点、背面镜像的轴心与角度、阴影的位置与透明度,ArkTS 拿到就是最终值,drawFrame 只做 clip → rotate → scale → drawImage 的机械回放;其二,背面镜像复刻 Compose 变换语义——rotate(-θ) · scale(-1,1) · rotate(θ) 绕轴心级联,背面图画完再按 washAlpha 叠一层白色蒙版,与上游 Curl 的正反面呈现一致;其三,JSON-safe 数值——桥内 fmt() 对非有限值归零、保留三位小数、去掉整数尾巴,杜绝 NaN / Infinity 混进协议。

4.3 错误通道

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

@CName("FolioxFrame")
fun folioxFrame(request: String): String = try {
    dispatch(MiniJson.parse(request))
} catch (t: Throwable) {
    errorJson(t.message ?: "exception in foliox bridge")
}

非法 JSON、未知 action、缺参、不支持的动画类型,全部走同一个 ok:false 通道,ArkTS 侧统一渲染到 HUD。

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

5.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libfoliox_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosfoliox.so)
  │ FolioxFrame
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复算折页几何,所以选择 C ABI + 显示列表 JSON。

5.2 Kotlin/Native 导出函数
@CName("FolioxFrame")
fun folioxFrame(request: String): String   // 五动作一口进
C ABI 符号ArkTS 方法覆盖内核
FolioxFramefolioxFrame(req)PageTurnMath 方向判定 + 三动画落点 + SpringMotion 采样 + PageTurnFrames 显示列表

单符号设计:五个动作共享一个入口,按 action 字段分发,与 qrose / Flippable 移植的扁平协议一致。

5.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"folioxFrame", nullptr, NapiFolioxFrame, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosfoliox_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。API 26 起 napi_get_value_string_utf8 的缓冲区参数非 const,字符串经 vector<char> 中转拷贝(qrose 移植同款规避)。生成头 libohosfoliox_api.h 由链接任务自动拷入 cpp/include/。

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

add_library(foliox_napi SHARED napi_init.cpp)
target_link_libraries(foliox_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosfoliox)

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

export const folioxFrame: (request: string) => string;

第 6 阶段:ArkUI 宿主

6.1 回放显示列表
private drawPage(ctx: CanvasRenderingContext2D, op: DrawOpJson): void {
  ctx.save();
  if (op.clip !== undefined) { this.applyClips(ctx, op.clip); }
  const backFace = op.backFace;
  if (backFace !== undefined) {
    // Mirrors Compose: rotate(-angle, pivot) · scale(-1, 1) · rotate(angle, pivot).
    ctx.translate(backFace.pivotX, backFace.pivotY);
    ctx.rotate(-backFace.angle * Math.PI / 180);
    ctx.scale(-1, 1);
    ctx.rotate(backFace.angle * Math.PI / 180);
    ctx.translate(-backFace.pivotX, -backFace.pivotY);
  }
  ctx.drawImage(bitmap, dx, dy, this.viewWidth, this.viewHeight);
  ctx.restore();

  if (backFace !== undefined && op.clip !== undefined) {
    ctx.save(); this.applyClips(ctx, op.clip);
    ctx.globalAlpha = backFace.washAlpha;       // 背面白色蒙版
    ctx.fillStyle = '#FFFFFF';
    ctx.fillRect(0, 0, this.viewWidth, this.viewHeight);
    ctx.restore();
  }
}

三页快照(PREVIOUS / CURRENT / NEXT)由 OffscreenCanvas 预渲染成 ImageBitmap,翻帧时只按显示列表做裁切、平移、镜像与叠色——快照重建与帧回放彻底分离,拖动过程中的每帧成本只剩 Canvas 指令回放。

6.2 手势与收尾
private settleTo(fromX: number, fromY: number, direction: string): void {
  const target = this.requestTarget(direction);
  const startedAt = Date.now();
  this.timer = setInterval(() => {
    const elapsed = Date.now() - startedAt;
    const sample = this.requestSpring(fromX, fromY, target.x, target.y, elapsed);
    this.drawFrame(direction, sample.x, sample.y);
    if (sample.settled === true) { this.stopSettle(); this.finishTurn(direction); }
  }, 16);   // 与 Kotlin 侧 SpringMotion 的 16ms 采样对齐
}

拖动中每帧调 frame 实时折页;松手后先 target 拿落点、再以 16ms 定时器逐帧 spring 采样驱动到 settle;点击走同一条收尾路径。翻页完成后回读当前页重建三页快照。

6.3 对比度和错误边界

纸张渐变底色 + 装订边阴影还原书页质感;HUD 实时显示 动画 · 方向 · x 坐标 · 指令数(如 curl · NEXT · x=0 · ops=6),帧数据缺失时错误原文进 HUD;首尾页显示 hasPrevious = false / hasNext = false 边界提示,与引擎的 hasNext / hasPrevious 传入值联动。

第 7 阶段:示例与验证

7.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosfoliox)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName("FolioxFrame") + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.foliox、versionName 0.1.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/libohosfoliox_api.h
        ├── src/main/cpp/types/libfoliox_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/
            ├── component/FolioxReader.ets    Canvas 回放组件(快照、手势、收尾、显示列表执行)
            └── pages/Index.ets               ArkUI 验收页面(三动画切换、握手行、底部提示)

copyKnSo* 任务在链接后自动把 .so、libc++_shared.so 与生成的 libohosfoliox_api.h 回填到 harmonyApp,头文件进 cpp/include/ 供 napi_init.cpp 编译。

7.2 构建、签名和安装
./gradlew :foliox-core:jvmTest                                                     # 23 项交叉验证
./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.foliox -m entry

四、完整代码对照

4.1 整体架构

io.github.kitectlab.foliox.engine/**(无 Compose 翻页引擎)
    │ PageTurnMath / CurlGeometry / TurnPath / SpringMotion / PageTurnFrames / DrawOp
Bridge.kt + MiniJson
    │ FolioxFrame(单 C ABI、五动作分发)
libfoliox_napi.so
    │ folioxFrame
FolioxReader.ets
    │ JSON parse + 快照管理 + Canvas 逐条回放
    ▼
ArkUI 真机页面(三动画切换 + 手势翻页)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_foliox、sampleApp/build-logic 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 钉版
gradle/wrapper/gradle-wrapper.propertiesGradle 9.6 → 8.14.1(2.2.21 线约束)
foliox-core/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,commonMain 零依赖
foliox-core/src/commonMain/**/engine/8 个引擎文件(阈值、落点、折页几何、弹簧、显示列表)
foliox-core/src/composeMain/上游 Compose 界面层整体迁移留盘(不编译)
foliox-core/src/jvmTest/**/PageTurnEngineTest.kt23 项引擎单测
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 so + 头文件
nativeApp/.../Bridge.kt单 C ABI、五动作分发、MiniJson、JSON-safe 数值、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString、API 26 缓冲区规避
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/component/FolioxReader.etsCanvas 回放组件
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档

4.3 关键 API 对照

层次API作用
引擎PageTurnMath.dragDirection / tapDirection / releaseDirection拖动 / 点击 / 松手方向判定(阈值 0.2 / 0.3)
引擎PageTurnMath.coverTarget / slideTarget / curlTarget三种动画的收尾落点
引擎CurlGeometry.fold折页点求解 + 装订角约束 + 正反面路径
引擎SpringMotion.sample临界阻尼弹簧逐帧采样(settle 判定)
引擎PageTurnFrames.frames组装一帧的有序显示列表
NativeFolioxFrameC ABI JSON 导出(info / gesture / target / spring / frame)
N-APIfolioxFrame(req)向 ArkTS 暴露能力
ArkTSdrawFrame / settleTo / execute触发指令、驱动收尾、Canvas 回放

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(folioxFrame(JSON.stringify({
  action: 'frame', animation: 'curl', direction: 'NEXT',
  width: this.viewWidth, height: this.viewHeight,
  startX: this.dragStartX, startY: this.dragStartY,
  offsetX: offsetX, offsetY: offsetY
})));
// res.ops:有序显示列表,ArkTS 只做 clip/rotate/scale/drawImage 的机械回放
val ops = PageTurnFrames.frames(animation, PageTurnFrameInput(
    width = width, height = height, density = density,
    startX = startX, startY = startY,
    offsetX = offsetX, offsetY = offsetY, direction = direction))
// 折页点、背面角度、阴影位置全部在此计算完毕

两者之间只传输扁平 JSON 字符串与坐标数值,不传输 Kotlin 对象或未校验的动态结构;ArkTS 侧不存在任何折页、阈值或曲线的计算代码。

五、关键决策说明

决策 1:拆层而不是硬搬 Compose 界面层

KStore 那类纯逻辑库可以整库直编,Foliox 不行:上游公开 API 深度绑定 Compose 1.11 / Kotlin 2.4,鸿蒙 CMP 1.9.2 消费不了。硬搬只有两条死路——把整个 Compose 层降级重写(数值必然漂移),或者只改 ArkTS 页面(证明不了引擎在鸿蒙跑)。拆层是第三条路:把"纸张在哪折"的全部数值逐字取自上游动画实现,重组为 Compose-free 引擎进 ohos 编译;Compose 界面层整体留盘供其他平台继续使用。鸿蒙 Demo 展示的是上游的折页模型,不是长得像的山寨实现。

决策 2:显示列表协议而不是渲染参数协议

桥的输出不是"折页点 + 角度"这类中间参数,而是最终绘制指令。中间参数意味着 ArkTS 还要参与几何组装,边界就漏了;显示列表把 clip → transform → draw 的顺序、正反面变换级联、白色蒙版的叠加时机全部在 Kotlin 侧定死,ArkTS 退化为一个绘图执行器。这同时也是可测试性的来源:显示列表的顺序与内容可以被 JVM 单测直接断言。

决策 3:根构建替换(cmp_qrose 模式),sampleApp 与 build-logic 留盘

上游根构建(AGP 9 / Compose 1.11 / 约定插件 / 发布段)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sampleApp/ 与 build-logic/ 依赖 AGP 9 与 Kotlin 2.4,鸿蒙编译器无法消费,留盘不进 settings.gradle.kts——它们的保留方式只有参考这一种。

决策 4:工具链钉 2.2.21-1.0.0 + Gradle 8.14.1,与 KStore 的 2.4 线经验互斥

引擎零三方依赖,没有 kotlinx 制品线的 stdlib 元数据问题,不必升 2.4.20-0.1.0-07 线;沿用社区已审的经典钉法 2.2.21-1.0.0 即可。代价是该线不支持 Gradle 9.x,wrapper 必须降 8.14.1。工具链版本由依赖矩阵决定:零依赖库用经典钉法,coroutines 系库才需要 2.4 线——两条线的经验不能互相套用。

决策 5:jvmTest 交叉验证 + 快照回放分离

引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;23 项单测跑的就是引擎的几何与曲线,是"数值没漂"的闸门。渲染侧把"三页快照预渲染"(OffscreenCanvas,只在翻页/换页时重建)与"帧回放"(每帧只执行显示列表)彻底分离,拖动过程每帧成本只剩 Canvas 指令,这在低端真机上保住了 60fps 的拖动跟手性。

六、测试与验证

6.1 测试环境

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

6.2 JVM 交叉验证

./gradlew :foliox-core:jvmTest

23 项用例全部通过:

测试对象钉住什么
CurlGeometry折页点求解、装订角约束、正反面路径闭合
PageTurnMath三种动画落点、Cover 阴影完全离开视口、滑动 0.2 / 边缘区 0.3 阈值矩阵
SpringMotion临界阻尼采样曲线、settle 判定
PageTurnFramesCover / Slide / Curl 三种显示列表的顺序与内容

引擎零三方依赖、数值逐字取自上游,测试全绿直接证明"拆层没有引入数值漂移"。

6.3 HAP 验证

:foliox-core:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:foliox-core:jvmTest: BUILD SUCCESSFUL(23 tests, 0 failed)
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
符号校验: FolioxFrame 存在于 libohosfoliox.so 导出面
BuildNativeWithCmake: finished(双 ABI 编出 libfoliox_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

libohosfoliox.so 未 strip 时 arm64 约 3.9 MB、x86_64 约 3.6 MB(含 Kotlin/Native 运行时);copyKnSo* 回填后每个 ABI 目录三件套齐整(libfoliox_napi.so / libohosfoliox.so / libc++_shared.so)。

6.4 功能用例

  1. 启动后握手行显示 Kotlin 引擎已连接:foliox 上游 0.1.0 · fork 0.1.0-ohos.1 · swipe=0.2 · edge=0.3 · coverShadow=8dp(来自真实 Native 调用);
  2. 默认 Curl 动画下从右边缘拖动,纸张实时折页、背面镜像带白色蒙版,HUD 显示 curl · NEXT · x=... · ops=6;
  3. 松手后弹簧收尾到对侧边缘,页码前进,三页快照重建;
  4. 切换 Slide,两页平移整整一屏;切换 Cover,旧页带 8dp 阴影滑出、新页静止;
  5. 点击视口左侧 / 右侧 30% 区域翻页,点击中间区域 HUD 提示不翻页(上游 EDGE_ZONE_FRACTION = 0.3);
  6. 在第一页向左拖 / 最后一页向右拖,页面不越界(hasPrevious / hasNext 传入引擎);
  7. 反复拖拽、快速切动画、来回翻页无崩溃、无残影(每帧 clearRect 重画);
  8. 每步操作的 HUD 与页码状态实时联动。

6.5 验证结论

翻页引擎、Native 动态库、C ABI、N-API、显示列表回放、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的翻页会话:引擎握手、三种动画的实时折页 / 平移 / 覆盖、边缘点击翻页与弹簧收尾闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/Foliox.git
cd Foliox
export JAVA_HOME="/path/to/jdk-21"
./gradlew :foliox-core:compileKotlinOhosArm64    # klib
./gradlew :foliox-core:jvmTest                    # 23 项交叉验证
./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.foliox -m entry
hdc shell hilog | grep FolioxNapi

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP/CMP 适配,必须让翻页引擎真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. ArkTS 自定义组件的成员变量名不能撞 CustomComponent 基类的内置方法——@State animation: string 与基类 animation(AnimateParam) 冲突,编译报 10505001 Property 'animation' in type 'FolioxReader' is not assignable,改名为 animationName 解决(发给引擎的 JSON key 仍叫 animation,不受影响);
  4. Kotlin/Native 生成的 libohosfoliox_api.h 不在 NAPI 的 include 路径上,napi_init.cpp 编译会找不到头——在 copyKnSo* 之后追加 copyKnApiHeader 任务把生成头拷进 cpp/include/;
  5. API 26 起 napi_get_value_string_utf8 的缓冲区参数非 const,std::string::data() 直传会炸,经 vector<char> 中转拷贝规避(qrose 移植同款);
  6. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  7. 2.2.21-1.0.0 线不支持 Gradle 9.x,wrapper 必须从上游 9.6 降 8.14.1——与 KStore 类库的 “2.4 线留 9.x” 结论互斥,按依赖矩阵选线;
  8. 根构建的 /build/ 是根锚定模式,盖不住嵌套目录——example/harmonyApp/.hvigor、example/harmonyApp/entry/.cxx、example/*/build/ 要单独追加 gitignore,.so 三件套靠 entry/libs 整目录排除,否则混进提交;
  9. build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填完整配置,证书绝不进入 AtomGit;
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  11. Compose 层迁移到 composeMain 后必须确认它不被任何目标编译——KMP 默认不识别 composeMain 这个目录名,反而是"天然隔离";但上游 composeTest 引用要一并迁移,否则单测配置期报缺失依赖。

8.2 已知问题

  • 上游 Compose 界面层(PageAnimationContent / PageAnimation 密封体系 / ReadPaper / 背景组件)在鸿蒙侧不可用,留盘仅作参考;鸿蒙侧翻页由 ArkUI Canvas + 显示列表承担;
  • PageAnimation 自定义 API 不跨桥:自定义动画是 Kotlin 类,需要新效果的接入方在引擎侧扩展 PageTurnFrames,或按既有三类参数在桥上扩展 animation 分支;
  • 三页快照按 pageIndex ± 1 重建,翻页动画过程中到达的尺寸变化(旋转屏幕)会触发快照全量重建,极端场景下首帧可能掉帧;
  • 弹簧收尾用 16ms setInterval 驱动而非 vsync 回调,帧率受 ArkTS 定时器精度约束;
  • android / ios / js / wasmJs / jvm 发布、约定插件一并剥离(源文件留在树里),本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.foliox 匹配的签名。

8.3 未来优化方向

  • 用 vsync 回调(Canvas 的帧回调或 XComponent)替代 setInterval,把弹簧收尾对齐到显示刷新;
  • 把上游 PageAnimation 自定义能力映射为桥上的注册式动画扩展(Lua/JSON 参数化的自定义折页参数包);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 Foliox(Compose 1.11 / Kotlin 2.4,鸿蒙不可编译)
    → 拆层决策:折页几何/阈值/弹簧逐字抽取为无 Compose 引擎,Compose 层留盘
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1,零三方依赖无需 2.4 线)
    → 根构建替换(cmp_qrose 模式,sampleApp/build-logic 移出)
    → ohosArm64 / ohosX64 KLIB + 23 项 JVM 单测锁数值
    → C ABI(@CName × 1)+ 五动作显示列表协议
    → C++ N-API(DisposeString 纪律 + API 26 缓冲区规避)
    → ArkTS Canvas 逐条回放(快照预渲染与帧回放分离)
    → ArkUI 真机单页验收(三动画 + 边缘点击 + 弹簧收尾)

9.2 封装层次

  • 引擎层:上游数值原样的无 Compose 翻页引擎(方向阈值、三动画落点、折页几何、临界阻尼弹簧、显示列表);
  • Native 层:生成双 ABI 动态库并通过单 C ABI 输出五动作 JSON;
  • N-API 层:完成参数检查、字符串转换、API 26 兼容和 Native 内存释放;
  • ArkTS 层:管理三页快照、手势分发、弹簧定时收尾、Canvas 逐条回放与 HUD 文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. UI 组件库的适配边界要落在"模型"而不是"界面"上:Compose 界面层跨不过去时,把几何与曲线的数值逐字搬进无 UI 引擎,让鸿蒙 Demo 回放上游模型——数值忠实可以用 JVM 单测直接断言,界面样式的差异留给目标平台 UI 体系;
  2. 桥协议的抽象级别决定边界质量:输出最终绘制指令(显示列表)而不是中间几何参数,ArkTS 永远不参与折页计算,可测试性与性能(快照/回放分离)同时受益;
  3. 工具链版本由依赖矩阵决定,不是越新越好:零三方依赖的库用经典钉法 2.2.21-1.0.0 + Gradle 8.14.1 即可,coroutines 系库才需要 2.4 线——KStore 与 Foliox 两条适配线的钉版结论互斥,但决策依据相同。

9.4 适配成果

当前适配已完成:

  • Foliox 0.1.0 翻页引擎的双 ABI Kotlin/Native 编译(阈值、落点、折页几何、弹簧数值逐字取自上游,Compose 界面层整体留盘);
  • 23 项 JVM 单测全绿(折页点、阴影越界、阈值矩阵、弹簧曲线、显示列表顺序);
  • 单 C ABI 符号(FolioxFrame,五动作显示列表协议)和 C++ N-API 包装层(符号导出校验通过);
  • DevEco Stage ArkUI 验收页面(Cover / Slide / Curl 三动画切换、手势翻页、边缘点击、弹簧收尾、引擎握手、帧指令 HUD);
  • HAP 构建(双 ABI 三件套 so)、签名、设备安装和真机验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐