开源鸿蒙平台 CMP 三方库「Foliox」适配全流程
本文记录
Foliox接入 OpenHarmony 的完整过程,覆盖翻页组件库盘点、Compose 界面层与翻页引擎的拆层决策、鸿蒙 Kotlin 工具链钉版、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、显示列表渲染协议、ArkUI 真机页面、HAP 构建和真机验收。
Foliox是基于 Compose Multiplatform 的翻页动画组件库:内置 Curl(折页)、Slide(平移)、Cover(覆盖) 三种翻页效果,并提供可插拔的PageAnimationAPI,通过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并为鸿蒙编译;引擎按帧输出一份有序绘制指令列表,鸿蒙侧 ArkUICanvas逐条回放,折页的任何数值都不在 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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向本机 JDK 21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(2.2.21 线不支持 Gradle 9.x,必须降版) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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 方法 | 覆盖内核 |
|---|---|---|
FolioxFrame | folioxFrame(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.toml | Kotlin 2.2.21-1.0.0 钉版 |
gradle/wrapper/gradle-wrapper.properties | Gradle 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.kt | 23 项引擎单测 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 so + 头文件 |
nativeApp/.../Bridge.kt | 单 C ABI、五动作分发、MiniJson、JSON-safe 数值、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString、API 26 缓冲区规避 |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/component/FolioxReader.ets | Canvas 回放组件 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面 |
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 | 组装一帧的有序显示列表 |
| Native | FolioxFrame | C ABI JSON 导出(info / gesture / target / spring / frame) |
| N-API | folioxFrame(req) | 向 ArkTS 暴露能力 |
| ArkTS | drawFrame / 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 判定 |
PageTurnFrames | Cover / 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 功能用例
- 启动后握手行显示
Kotlin 引擎已连接:foliox 上游 0.1.0 · fork 0.1.0-ohos.1 · swipe=0.2 · edge=0.3 · coverShadow=8dp(来自真实 Native 调用); - 默认 Curl 动画下从右边缘拖动,纸张实时折页、背面镜像带白色蒙版,HUD 显示
curl · NEXT · x=... · ops=6; - 松手后弹簧收尾到对侧边缘,页码前进,三页快照重建;
- 切换 Slide,两页平移整整一屏;切换 Cover,旧页带 8dp 阴影滑出、新页静止;
- 点击视口左侧 / 右侧 30% 区域翻页,点击中间区域 HUD 提示不翻页(上游
EDGE_ZONE_FRACTION = 0.3); - 在第一页向左拖 / 最后一页向右拖,页面不越界(
hasPrevious/hasNext传入引擎); - 反复拖拽、快速切动画、来回翻页无崩溃、无残影(每帧
clearRect重画); - 每步操作的 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 踩坑复盘
- 只改 ArkTS 页面不算 KMP/CMP 适配,必须让翻页引擎真正编译成
ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- ArkTS 自定义组件的成员变量名不能撞
CustomComponent基类的内置方法——@State animation: string与基类animation(AnimateParam)冲突,编译报10505001 Property 'animation' in type 'FolioxReader' is not assignable,改名为animationName解决(发给引擎的 JSON key 仍叫animation,不受影响); - Kotlin/Native 生成的
libohosfoliox_api.h不在 NAPI 的 include 路径上,napi_init.cpp编译会找不到头——在copyKnSo*之后追加copyKnApiHeader任务把生成头拷进cpp/include/; - API 26 起
napi_get_value_string_utf8的缓冲区参数非 const,std::string::data()直传会炸,经vector<char>中转拷贝规避(qrose 移植同款); - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); 2.2.21-1.0.0线不支持 Gradle 9.x,wrapper 必须从上游 9.6 降 8.14.1——与 KStore 类库的 “2.4 线留 9.x” 结论互斥,按依赖矩阵选线;- 根构建的
/build/是根锚定模式,盖不住嵌套目录——example/harmonyApp/.hvigor、example/harmonyApp/entry/.cxx、example/*/build/要单独追加 gitignore,.so三件套靠entry/libs整目录排除,否则混进提交; build-profile.json5的signingConfigs保持空数组,勾选自动签名后由 IDE 回填完整配置,证书绝不进入 AtomGit;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - 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 三条经验
- UI 组件库的适配边界要落在"模型"而不是"界面"上:Compose 界面层跨不过去时,把几何与曲线的数值逐字搬进无 UI 引擎,让鸿蒙 Demo 回放上游模型——数值忠实可以用 JVM 单测直接断言,界面样式的差异留给目标平台 UI 体系;
- 桥协议的抽象级别决定边界质量:输出最终绘制指令(显示列表)而不是中间几何参数,ArkTS 永远不参与折页计算,可测试性与性能(快照/回放分离)同时受益;
- 工具链版本由依赖矩阵决定,不是越新越好:零三方依赖的库用经典钉法
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。
参考文档
更多推荐


所有评论(0)