开源鸿蒙平台 CMP 三方库「texty」适配全流程
本文记录
texty接入 OpenHarmony 的完整过程,覆盖文字动画库盘点、Compose 源码集隔离、纯 Kotlin 时序引擎提炼、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
texty是基于 Compose FoundationBasicText的 Kotlin Multiplatform 文字样式动画库:给定一段文本或一组文本,它提供 8 种普通样式(Basic / Typing / Blinking / Fading / Sliding / Scrolling / Revealing / StickAndReveal)、4 种列表样式(Motion / OneByOne / SlidingList / ScrollingList)与 2 种工具样式(Loading / TimeKeeping),上游实现全部是@Composable函数,由LaunchedEffect+delay循环与Animatable线性补间驱动。本次适配与社区既有两条路线(qrose 的"零依赖内核提取"、KStore 的"kotlinx 制品线齐备后的真引擎整库编译")有本质区别:texty 是这批库里"Compose 浓度"最高的——100% 的行为实现都写在 composables 里,没有任何现成的纯 Kotlin 内核可提取。因此本次走了第三条路:把上游每种样式的时序语义逐行提炼成全新的纯 Kotlin 采样引擎(TextyEngine:给定样式参数 + 时间 → 帧状态),与上游纯 Kotlin 样式模型(12 个文件零改动)一起编译成 OpenHarmony KLIB,再以拉取式 C ABI 桥给 ArkTS 每帧调用——真机上打字机的每个字、闪烁的每次翻转、滑动的每个落位,都来自 Kotlin 侧上游语义的采样,而不是 ArkTS 复刻。
项目地址: AtomGit/oh-tpc/texty.git
上游仓库: ArjunJadeja/texty
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
texty 为 Compose Multiplatform 应用提供"文字怎么动起来"这一件事的完整答案:Texty(text, displayStyle) 一行出打字机,Texty(textList, displayStyle) 一行出逐条轮播,Texty(utility) 一行出加载指示或实时时钟。样式参数与 BasicText 完全对齐(modifier / textStyle / overflow / softWrap / maxLines / minLines / color),Repeat 把重复语义做成 Continuous / Once / TimeBound / CountBound 四档。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套文字动效能力,但上游构建(Kotlin 2.4.10 / Gradle 9.7.1 / AGP 9.3.2 / Compose 1.12.0)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明公共动画语义在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证样式模型、时序引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.4.10 / Gradle 9.7.1 / AGP 9.3.2 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| Compose 全依赖 | 上游 commonMain 的全部行为实现 import androidx.compose.*(runtime / foundation / animation.core / ui),社区 CMP 鸿蒙线是 1.9.2 / HarmonyOS Kotlin 2.2.21-1.0.0,版本对不齐。 |
| 内核不可提取 | qrose 有纯 Kotlin 编码器、KStore 有纯 Kotlin 存储引擎;texty 的"引擎"藏在 composables 的 delay 循环里,没有现成的纯函数可以摘出来编译。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 帧驱动语义跨不了桥 | Compose 的 withFrameNanos / LaunchedEffect 是协程与 vsync 概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:纯 Kotlin 样式模型与时序引擎的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。时序语义完全由上游原版实现负责(引擎逐条对照上游实现提炼,JVM 测试钉死),ArkTS 只负责定时拉帧、渲染文本和管理页面状态。
1.2 库提供的能力
Demo 提供一个真实的文字动画会话,对应桥接的全部入口:
- 版本握手(version):
textyVersion()返回 fork 版本号,来自真实 Native 调用而非静态文字; - 普通样式(basic/typing/blinking/fading/sliding/scrolling/revealing/stickAndReveal):打字机逐字、半间隔闪烁、线性淡入淡出、裁剪视口内横滑/纵滚、四种揭示模式逐步替换覆盖字、覆盖字黏附后再揭示;
- 列表样式(motion/oneByOne/slidingList/scrollingList):帧序列轮播、逐条进出(BASIC/FADING/TYPING 三种过渡)、分隔拼接横滑、整列纵滚;
- 工具样式(loading/timekeeping):Spinner/Circular 交叉淡化、MusicBar 错相跳动、token 时间格式化;
- 重复策略:Continuous / Once / CountBound / TimeBound(含
showAfterComplete终态)全档可切。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
style / text | 回显本次样式与当前帧应显示的文本。 |
alpha | 帧透明度(Fading、Loading 交叉淡化、OneByOne 的 FADING 过渡)。 |
offsetX / offsetY | Sliding / Scrolling 家族的整数落位偏移,公式与上游 roundToInt() 逐位一致。 |
done / blinks | 动画是否到界(onComplete 等价物)与闪烁次数(onBlink 等价物)。 |
elements | 多元素帧:Loading 两层交叉淡化字形、MusicBar 每根条、ScrollingList 每个列表项。 |
引擎侧完整能力(8+4+2 共 14 种样式的全部时序细节:覆盖字三种变体、揭示四种顺序、OneByOne 的 TimeBound/CountBound 上游怪癖、时间格式化校验与回退)全部在上游 commonMain 模型 + 新增 engine 包里随编译进入 .so,JVM 侧保留 38 项时序交叉验证用例,证明 ohos .so 里执行的采样逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 12 个纯 Kotlin 样式模型文件原样编译,不提取、不重写;全部 20 个 Compose 文件零改动挪入 composeMain 不参与 ohos 编译;仅新增 engine 包 3 个纯 Kotlin 源文件(全仓唯一新增库源码)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample 工程(composeApp/androidApp/iosApp)与 CI 留盘不进构建。 |
| 语义忠实 | 引擎每种样式的每个时间点都对照上游 internal/** 实现提炼,并由 38 项 JVM 测试钉死;同一份代码编译进 ohos。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | commonMain 零外部依赖(纯 Kotlin + stdlib),不需要任何 kotlinx ohos 制品线——工具链版本因此不必被依赖顶上去。 |
| UI 完整 | 单页三 Tab 覆盖全部桥接入口:14 种样式、5 档重复策略、播放/暂停/重播、状态行和操作日志。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
Texty()composables 保留在composeMain等工具链对齐后接回;ArkTS Demo 只是把桥暴露的帧状态用Text+.opacity()+.translate()画出来——文字动画的输出本来就是文字,不需要 qrose 那套软件光栅化与 PNG。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与 Compose 依赖边界、确认纯模型文件清单、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1 降级
第 3 阶段:时序引擎提炼 ── 逐样式对照上游实现,(样式参数, elapsed) → 帧状态 的纯函数采样器
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 定时拉帧、参数面板、三 Tab 覆盖、操作日志和版本握手
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
texty/ 上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
texty/src/commonMain/.../ 上游纯 Kotlin 样式模型 12 个文件(未改动)
texty/src/commonMain/.../engine/ 新增:TextyEngine + TextyFrame + TimeFormatEngine(3 个纯 Kotlin 文件)
texty/src/composeMain/ 上游 Texty.kt + internal/** + ListDisplayStyle + PackageOverview(零改动,不进 ohos)
texty/src/commonTest/ 新增:38 项时序交叉验证测试
texty/src/upstream-jvmTest/ 上游 17 个 Compose UI 测试,留盘不进构建
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
sample/ 上游 sample 工程与文档站,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :texty 与 :example:nativeApp,rootProject.name = "ohos_texty"。上游的 dokka / vanniktech 发布插件、AGP classpath、GitHub Pages 组装任务、sample 工程全部随根构建替换剥离——本 fork 不发 Maven Central。上游 gradle.properties 里明文提交的 Maven Central 凭据一并移除。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向本机 JDK) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 9.7.1 降级;2.2.21 线不支持 Gradle 9.x) |
| kotlinx | 无——commonMain 零外部依赖,不依赖任何 ohos 制品线 |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.texty |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
对上游 commonMain 全部 29 个 Kotlin 文件逐一 grep Compose import:
| 分类 | 文件数 | 依赖形态 | 处置 |
|---|---|---|---|
| 纯 Kotlin 模型 | 12 | DisplayStyle / Utility / Repeat / FadingType / LoadingType / RevealingCover / RevealingPattern / RevealingType / ScrollingDirection / SlidingDirection / TransitionDirection / TransitionStyle,零 Compose | 留在 commonMain 原样编译(jvm + 双 ohos) |
| Compose API | 3 | Texty.kt(入口 composable)、ListDisplayStyle.kt(spacing: Dp)、PackageOverview.kt | 挪入 composeMain,零改动 |
| Compose 实现 | 17 | internal/styles/** 全部 import androidx.compose.*;TimeFormatting.kt 依赖 kotlinx-datetime + kotlin.time.Clock | 挪入 composeMain,零改动 |
expect 声明全库 0 处——这比 KStore(1 处 dispatcher actual)还干净:上游把所有平台差异都留给了 Compose 与 Gradle 配置,Kotlin 侧没有任何 expect/actual 要补。真正的难点不在平台缝,在"行为全在 composables 里":Typing 的逐字节奏写在 LaunchedEffect 的 delay 循环里,Sliding 的落位写在 Modifier.layout 的 lambda 里,Loading 的交叉淡化写在 infiniteTransition.animateFloat 里。这决定了第 3 阶段的形态——不是补 actual,而是提炼引擎。
1.4 页面边界
标题区 TEXTY · OPENHARMONY / 版本握手行(CMP 文字动画库 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换 普通样式 / 列表样式 / 工具样式
样式区 普通 8 芯片 / 列表 4 芯片 / 工具 4 芯片(Spinner/Circular/MusicBar/时间)
参数区 文本框、方向芯片、揭示模式、过渡样式、每字延迟/间隔/时长滑条、覆盖字符输入
重复区 循环 / 单次 / 3次·保留 / 3次·清空 / 10秒·保留
控制区 播放 / 暂停 / 重播
渲染区 裁剪视口(横滑/纵滚)或透明度文本 / Loading 双层字形 / MusicBar 条列 / 实时时钟
状态行 当前样式 · elapsed · done · blinks
日志区 带时间戳的操作日志(最新在前,上限 50 条)
页面启动时先调 textyVersion() 做握手,再以 33ms 定时器逐帧拉取 textyDispatch。
第 2 阶段:目标与依赖打通
2.1 依赖面验证(本适配的成败手)
KStore 适配要先验证 kotlinx ohos 制品线(coroutines / serialization / kotlinx-io 全带 ohos 变体)才敢真编译整库。texty 恰好相反:把 Compose 文件隔离之后,commonMain 只剩纯 Kotlin 模型 + 新引擎,零外部依赖。三条结论:
settings.gradle.kts的 eazytec 双仓只服务 HarmonyOS Kotlin 插件本身(2.2.21-1.0.0),业务依赖一个都不用从社区 Maven 拉;- 不存在"制品线版本顶高 stdlib 元数据"的问题,Kotlin 不必像 KStore 那样钉到
2.4.20-0.1.0-07,回到 qrose / haze 的2.2.21-1.0.0钉法; - 上游
commonTest也不需要 turbine 式处置——新引擎测试只用kotlin("test"),测试统一 JVM 跑。
2.2 工具链钉版(Gradle 为什么必须降级)
上游 wrapper 是 Gradle 9.7.1,配套遗留了一个 gradle/gradle-daemon-jvm.properties(Gradle 9.x 的 daemon criteria 文件,要求 JetBrains JDK 25 工具链)。HarmonyOS Kotlin 2.2.21-1.0.0 线只支持 Gradle 8.x(qrose / haze 先例钉 8.14.1),而且 8.14.1 读到 daemon criteria 会在启动期就尝试联网下载 JDK 25 并失败。处置两条:wrapper 降到 8.14.1,daemon criteria 文件删除。
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_texty"
include(":texty")
include(":example:nativeApp")
2.3 编译真实模型
texty/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
./gradlew :texty:compileKotlinJvm :texty:compileKotlinOhosArm64 :texty:compileKotlinOhosX64 # BUILD SUCCESSFUL
上游 12 个模型文件一个没动,composeMain 里的 20 个文件对 ohos 编译器完全不可见——它们不是 Kotlin 源集,连依赖解析都不参与。
2.4 上游测试的处置:Compose UI 测试跑不了
上游 17 个测试在 jvmTest,用 Compose UI Test(headless Skiko + runComposeUiTest)测 composables——本 fork 的 composables 不参与编译,这些测试无从运行。处置不是删,是"留盘换位 + 新建闸门":上游测试整体挪到 texty/src/upstream-jvmTest/(保留供未来 CMP 工具链对齐后接回),交叉验证改由 commonTest 里 38 项纯函数时序测试承担(第 6.2 节),根构建同时全局禁用 ohos 测试任务:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
第 3 阶段:时序引擎提炼(技术难点)
3.1 从"协程驱动"到"时间采样"
上游与鸿蒙引擎的唯一契约是时间轴。把每种样式从"协程推进状态"重投影成"时间查询状态":
com.arjunjadeja.texty 模型(上游原件,原样编译)
↓ DisplayStyle / Repeat / 方向枚举 / LoadingType
TextyEngine(新增,纯函数采样器)
↓ frame = sample(样式参数, elapsedMillis)
Bridge.kt(@CName × 2,扁平 JSON 进出)
↓ UTF-8 JSON
N-API textyDispatch() / textyVersion()
↓
ArkUI Index.ets(33ms 定时拉帧渲染)
拉取式(pull)采样是这次最重要的形态决策:无状态、确定性、可重放——暂停就是不改 elapsed,重播就是归零,JVM 测试可以直接断言任意时间点的帧。
3.2 每种样式的语义提炼
引擎不是照抄上游代码,是照抄上游时间轴。几个代表性对照(完整对照表在 README.OpenHarmony_CN.md 第 7 节):
| 样式 | 上游行为 | 引擎公式 |
|---|---|---|
| Typing | 循环:追加字符 → delay(d);首字符在 t=0;循环后多等一个 delay 再回调 | take(floor(e/d)+1);done = e ≥ (len+1)·d |
| Blinking | delay(interval/2) 后翻转(初始不可见);CountBound 结束冻结为 showAfterComplete | step=floor(e/half),可见 = step 奇数;blinks=(step+1)/2 |
| Fading | withFrameNanos 线性:IN 用 progress,OUT 用 1−progress | 同公式进 alpha |
| Sliding | Animatable 0→1 线性;落位 TOWARDS_END: round(p·(vw+tw))−tw,START 镜像;TimeBound/CountBound 到界后 progress=1(滑出),showAfterComplete 才用静止位 | cycleProgress() + 同款落位公式;视口/文本宽由宿主实测传入 |
| Revealing | 覆盖字(Default=⣿×len;Custom 单字符重复 / 等长原样 / 首非空格重复);四种揭示顺序 | rank(j) 给出每位名次,revealedAt(j) = delay₀+(rank(j)+1)·perChar |
| StickAndReveal | 覆盖字黏附(行/列逐步)→ 停顿 → 揭示(行/列逐步);覆盖字符按 lines.indexOf(line)%len 取(重复行共用首索引的上游怪癖原样保留) | 双阶段时间网格 stickTime(row/col) / revealTime(row/col),逐字符判定 |
| OneByOne | 每项三段(入/保持/出);TYPING 按字符进出、BASIC 整段隐藏、FADING 线性淡入出;TimeBound+showAfter 末项永不退出(上游怪癖) | 分段游走采样 + 轮次解析(完整轮长 / 末项不出轮长) |
| Loading | infiniteTransition 0→N 线性交叉淡化;MusicBar 每条错相 Reverse | progress=(e%cycle)/cycle·N;条 i 相位偏移三角波 |
| TimeKeeping | token 格式化(9 种 token + 引号字面量)、宽松校验 walk、非法回退 Invalid Format: | TimeFormatEngine 逐条镜像;时刻字段由宿主传入 |
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("TextyDispatch")
fun textyDispatch(request: String): String = try {
/* style 分发 + 引擎采样 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 style、非法 JSON、退化参数(零时长/零条数在引擎内另有除零护栏,不产生 NaN),全部走同一个 ok:false 通道,ArkTS 侧统一渲染。
第 4 阶段:原生桥接
4.1 三层桥接
ArkTS
│ JSON string(每帧一次)
▼
C++ N-API entry(libtexty_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohostexty.so)
│ TextyDispatch / TextyVersion
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复刻动画语义,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("TextyDispatch")
fun textyDispatch(request: String): String // 14 种样式一口进
@CName("TextyVersion")
fun textyVersion(): String = "1.0.0-alpha-ohos.1" // 版本握手
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
TextyDispatch | textyDispatch(req) | 样式参数解析 + TextyEngine 全部 14 种采样器 |
TextyVersion | textyVersion() | fork 版本握手(真机版本行的数据来源) |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"textyDispatch", nullptr, NapiTextyDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"textyVersion", nullptr, NapiTextyVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohostexty_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohostexty_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohostexty SHARED IMPORTED)
set_target_properties(ohostexty PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(texty_napi SHARED napi_init.cpp)
target_link_libraries(texty_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohostexty)
OHOS_ARCH 为空或 libohostexty.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const textyDispatch: (request: string) => string;
export const textyVersion: () => string;
第 5 阶段:ArkUI 宿主
5.1 定时拉帧和参数注入
private tick(): void {
const elapsed = Date.now() - this.startTs;
const request = this.buildRequest(elapsed); // 样式 + 参数 + 实测视口/文本尺寸
const res = JSON.parse(textyDispatch(JSON.stringify(request))) as TextyResp;
this.frameText = res.text; // @State 刷新渲染区
this.frameAlpha = res.alpha;
this.frameOffsetX = res.offsetX; // Sliding 家族
this.frameOffsetY = res.offsetY; // Scrolling 家族
this.elements = res.elements; // Loading / ScrollingList
}
aboutToAppear(): void {
this.version = textyVersion(); // 版本握手:来自真实 Native 调用
this.play(); // setInterval(tick, 33)
}
横滑/纵滚需要视口与内容尺寸:视口用容器 onAreaChange 实测,文本尺寸用 @ohos.measure 的 measureText 与文本 onAreaChange 回填——首帧传 0,第二帧起自愈。Area.width/height 的类型是 Length,赋给 number 前必须 Number() 强转(ArkTS 编译期强制,这是真机构建踩到的第一坑)。
5.2 页面交互
三个 Tab 自上而下:标题区(版本握手行)→ Tab 行 → 样式芯片 → 参数区(文本框 / 方向 / 模式 / 滑条)→ 重复策略五档 → 播放/暂停/重播 → 渲染区(裁剪视口或透明度文本)→ 状态行 → 操作日志。每条日志带 [HH:mm:ss] 时间戳,最新在前、上限 50 条。切换样式/方向/重复档位即自动重播,状态行实时回显 elapsed / done / blinks。
5.3 对比度和错误边界
主操作深绿按钮白字,重播深黑;ok:false 时错误原文进状态行并停表。Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行,HAP 不崩。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohostexty)+ copyKnSo* 回填
│ └── src/ohosMain/kotlin/Bridge.kt @CName × 2 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.texty、versionName 1.0.0-alpha-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/libohostexty_api.h
├── src/main/cpp/types/libtexty_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
./gradlew :texty:jvmTest # 38 项时序交叉验证
./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 中勾选自动生成,证书写入本机,不进入仓库(仓库内 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.texty -m entry
四、完整代码对照
4.1 整体架构
com.arjunjadeja.texty 模型(上游原件原样编译)
│ DisplayStyle / Utility / Repeat / 方向与模式枚举
com.arjunjadeja.texty.engine(新增纯 Kotlin)
│ TextyEngine 14 种采样器 / TextyFrame 帧模型 / TimeFormatEngine
Bridge.kt + MiniJson
│ TextyDispatch / TextyVersion
libtexty_napi.so
│ textyDispatch / textyVersion
Index.ets
│ 33ms 拉帧 + JSON parse + @State 更新
▼
ArkUI 真机页面(三 Tab 滚动)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_texty、sample 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单插件别名 |
build.gradle.kts(根) | 版本 1.0.0-alpha-ohos.1、全局禁用 Ohos*Test* 任务 |
texty/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,commonMain 零依赖 |
texty/src/commonMain/.../engine/ | 唯一新增库源码(3 个纯 Kotlin 文件) |
texty/src/composeMain/ | 上游 composables 隔离区(零改动,ohos 不编) |
texty/src/commonTest/ | 38 项时序交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 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 | 鸿蒙适配主文档(20 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | TextyEngine.typing/sliding/revealing/... | 14 种样式采样器,(参数, elapsed) → TextyFrame |
| 引擎 | TextyEngine.loading | 交叉淡化帧 / MusicBar 条列 |
| 引擎 | TextyEngine.timeKeeping | token 时间格式化(含校验与回退) |
| 模型 | Repeat.Continuous/Once/TimeBound/CountBound | 四档重复语义(上游原件) |
| Native | TextyDispatch / TextyVersion | C ABI JSON 导出 |
| N-API | textyDispatch(req) / textyVersion() | 向 ArkTS 暴露能力 |
| ArkTS | tick() | 定时拉帧并刷新 UI |
4.4 ArkTS 与 Kotlin 的边界
const frame = JSON.parse(textyDispatch(JSON.stringify({
style: 'typing', text: 'Hello texty', delayPerChar: 80, elapsed: 240
})));
// frame.text === 'He';下一帧传 elapsed=320 得 'Hel'
val frame = TextyEngine.typing(text, delayPerChar, elapsed)
when (repeat) { /* Continuous / Once / TimeBound / CountBound 各自收敛 */ }
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象、协程或未校验的动态结构。
五、关键决策说明
决策 1:提炼时序引擎而不是重写动画
上游行为全在 composables 里,没有内核可提取——重写动画等于把 14 个状态机翻译成 ArkTS,语义必然漂移。提炼成纯函数采样器让"上游时间轴"以代码形态进入 ohos:每个字符的出现时刻、每次翻转、每个落位偏移都与上游公式逐位一致,并且可被 JVM 测试逐点断言。
决策 2:工具链钉 2.2.21-1.0.0,Gradle 降 8.14.1
commonMain 零外部依赖,不存在 kotlinx 制品线把 stdlib 元数据顶高的问题(KStore 因此被迫升 2.4.20 线),回到 qrose / haze 的 2.2.21 钉法即可。Gradle 方面 2.2.21 线只支持 8.x:wrapper 从上游 9.7.1 降到 8.14.1,同时删除上游遗留的 gradle-daemon-jvm.properties(要求 JetBrains JDK 25 的 daemon criteria,8.14.1 下启动即败)。
决策 3:拉取式(pull)帧采样
把"协程推进状态"反转为"时间查询状态":引擎无时钟、无状态,暂停就是不改 elapsed、重播就是归零。C ABI 保持 const char* -> const char* 同步一来一回,withFrameNanos / LaunchedEffect 这些过不了 C 签名的概念全部留在鸿蒙侧由 33ms 定时器承担。
决策 4:composeMain 隔离 + upstream-jvmTest 留盘
20 个 Compose 文件零改动挪入 composeMain、17 个 Compose UI 测试留盘——等社区 CMP 工具链与 Compose 1.12 对齐后整体接回。上游 sample 与 CI 同理留盘不进构建。这是 qrose 已过审的处置方式,texty 只是把它推到极致(隔离面从 Painter 文件扩大到全部行为实现)。
决策 5:38 项 JVM 交叉验证 + 测试换位
上游 UI 测试测的是不可编译的 composables,本 fork 无法沿用。交叉验证改为新建的纯函数时序测试:上游 17 个测试文件一个字节不动地留盘,38 项新测试把每种样式的关键时间点(首字时刻、半间隔翻转、落位公式端点、四种揭示顺序、TimeBound/CountBound 终态、交叉淡化 alpha 和为 1……)逐点钉死——JVM 与 ohos 跑的是同一份 commonMain 引擎,JVM 绿了,.so 里就没有漂移。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :texty:jvmTest
38 项用例全部通过(新建时序测试,上游 UI 测试留盘):
| 测试组 | 用例数 | 钉住什么 |
|---|---|---|
| Typing | 3 | 首字 t=0、每 delay 一字、(len+1)·delay 完成 |
| Blinking | 3 | half-interval 翻转、Once 终态、CountBound 冻结 |
| Fading | 2 | IN/OUT 线性 alpha 与端点 |
| Sliding | 4 | 落位公式(p=0/0.5/1)、Continuous 回绕、CountBound 终态(保留 vs 滑出) |
| Scrolling | 1 | 纵向公式 |
| Revealing | 7 | 四种揭示顺序、覆盖字三种变体、ByTotalTime 均分 |
| StickAndReveal | 2 | T2B 黏附 → B2T 揭示时间网格、L2R 逐列黏附 |
| Motion | 2 | 帧切换、CountBound 终态 |
| OneByOne | 4 | BASIC 三段、TYPING 逐字进出、FADING alpha、CountBound 保留末项 |
| List 家族 | 2 | separator 拼接横滑、整列纵滑 |
| Loading | 3 | 交叉淡化 alpha 和为 1、字形集、MusicBar 相位错开 |
| TimeKeeping | 4 | token、EEEE、引号字面量、非法格式回退 |
| Basic | 1 | 立即完成 |
tests="38" skipped="0" failures="0" errors="0"——引擎与 JVM 测试来自同一份 commonMain,这就是"语义没漂"的直接证据。
6.3 HAP 验证
:texty:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: TextyDispatch / TextyVersion 均为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libtexty_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
libohostexty.so arm64 约 3.9MB、x86_64 约 3.6MB(debugShared,含 Kotlin/Native 运行时);每个 ABI 目录三件套(libtexty_napi.so / libohostexty.so / libc++_shared.so)缺一不可。
6.4 功能用例
- 启动后日志出现
textyVersion() → 1.0.0-alpha-ohos.1(来自真实 Native 调用,非静态文字); - 普通样式 Tab 依次点 8 个样式:Typing 逐字、Blinking 半间隔翻转(状态行
blinks递增)、Fading 淡入/淡出、Sliding/Scrolling 在裁剪视口内滑过、Revealing 四种揭示模式 + 自定义覆盖字、StickAndReveal 多行黏附后揭示; - 列表样式 Tab:Motion 帧序列、OneByOne 三种过渡(BASIC 硬切 / FADING 淡入出 / TYPING 逐字进出)、SlidingList 拼接横滑、ScrollingList 整列纵滚;
- 工具样式 Tab:Spinner/Circular 交叉淡化、MusicBar 错相跳动(条数/循环时长可调)、时间格式实时刷新;把格式改成非法串显示
Invalid Format: ...(与上游回退一致); - 重复策略五档切换:循环永续(done 恒 false)、单次到界、3 次·保留停在可见、3 次·清空消失、10 秒·保留冻结;
- 暂停后 elapsed 冻结、帧保持;重播归零重来;
- 每步操作的操作日志按时间戳滚动留痕。
6.5 验证结论
样式模型、时序引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的动画会话:版本握手、逐字打字机、闪烁、滑动落位、揭示、列表轮播、加载指示与实时时钟(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/texty.git
cd texty
export JAVA_HOME="/path/to/jdk-21"
./gradlew :texty:compileKotlinOhosArm64 :texty:compileKotlinOhosX64 # klib
./gradlew :texty:jvmTest # 38 项时序交叉验证
./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.texty -m entry
hdc shell hilog | grep TextyNapi
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 CMP 适配,必须让库的 Kotlin 语义真正编译成
ohosArm64/ohosX64动态库——对 texty 而言语义=时序,所以先把时序提炼成纯函数再谈编译; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 上游 Gradle 9.7.1 遗留的
gradle-daemon-jvm.properties(daemon criteria,要求 JetBrains JDK 25)与 8.14.1 不兼容,启动期就联网下 JDK 失败——降级 wrapper 时必须一并删除; - HarmonyOS Kotlin 2.2.21 线只支持 Gradle 8.x(qrose / haze 先例 8.14.1);KStore 的"2.4.20 线可留 Gradle 9.x"经验不能互套——Gradle 版本跟工具链线走,不跟上游 wrapper 走;
ListDisplayStyle.kt只因一个spacing: Dp默认值就依赖androidx.compose.ui.unit——分类不能只看类名语义,要逐文件 grep import;- Kotlin 表达式函数体(
= when(...))里不能写裸return,ohos 编译期报错,改块函数体; - 浮点除零护栏:
duration=0、barCount=0等退化参数会让 alpha 变 NaN 进 JSON,引擎各处显式护栏; - ArkTS
Area.width/height的类型是Length(string | number | Resource),赋给number前必须Number()强转,三处onAreaChange全中——真机构建第一轮就报 3 个编译错误; measure在所用 SDK 版本不从@kit.ArkUI导出,改走系统模块import measure from '@ohos.measure'(默认导出的 namespace);- CAdapter 链接期
api.cpp的non-void function does not return警告是 HarmonyOS Kotlin 生成的 napi 胶水,不是业务代码,so 正常链出(qrose 同款); - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 签名材料由 DevEco 自动写入
build-profile.json5,仓库内保持空signingConfigs,证书绝不进入 AtomGit;上游gradle.properties里明文提交的 Maven Central 凭据随根构建替换一并移除。
8.2 已知问题
- HarmonyOS 上没有 Compose:
Texty()composables 仍在composeMain,等社区 CMP 工具链对齐后接回;届时engine包退役为纯测试基准或被上游 composables 直接取代; - 帧驱动是 ArkTS 33ms 定时器,非
withFrameNanosvsync 对齐,帧率由宿主决定; - MusicBar 的
infiniteRepeatable逐迭代 delay 语义按"起始相位偏移 + 三角波往返"近似,极端参数下时间轴有微小偏差; - OneByOne 的 TimeBound 按"整轮完成"建模(上游在轮间检查时限),超时瞬间冻结在当前状态;
ListDisplayStyle因Dp不进 ohos klib,列表样式的鸿蒙入口走桥参数集(字段与上游一一对应);- 上游 sample / Compose UI 测试 / CI 留盘不进构建,本 fork 不发 Maven Central;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.texty匹配的签名。
8.3 未来优化方向
- 换
@ohos.gfx或 Native 侧渲染探索更帧率的展示(当前 33ms 定时器足够演示 14 种样式); - 把
elements帧扩展为逐字符样式(Revealing 的逐位透明度过渡等上游未覆盖的表现力); - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 等社区 CMP ohos 制品对齐 Compose 1.12 后,把
composeMain整体接回、上游 17 个 UI 测试恢复运行。
九、总结
9.1 核心难点回顾
上游 texty 1.0.0-alpha(CMP 文字动画,100% 实现在 composables)
→ 源码分类:12 个纯 Kotlin 模型留下 / 20 个 Compose 文件零改动隔离进 composeMain
→ 钉工具链:HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1(删 daemon criteria)
→ 提炼 TextyEngine:14 种样式的上游时序语义 → 纯函数采样器(commonMain,零依赖)
→ 38 项 JVM 时序测试钉死语义(同一份代码随后编进 .so)
→ C ABI(@CName × 2)+ MiniJson 扁平 JSON 帧
→ C++ N-API(DisposeString 纪律)
→ ArkTS 33ms 拉帧渲染
→ ArkUI 真机三 Tab 验收(14 样式 × 5 重复策略)
9.2 封装层次
- 模型层:上游原版样式 sealed 接口、Repeat 四档、方向与模式枚举;
- 引擎层:新增
TextyEngine采样器、TextyFrame帧模型、TimeFormatEngine时间格式化; - Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON 帧;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、定时拉帧、参数面板、日志与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- "Compose 浓度"决定适配路线:qrose 有现成内核(直接编译)、KStore 有制品线可依(真编译整库)、texty 什么都没有(先提炼再编译)——盘点上游源码的 Compose 依赖分布,永远是最前置的一步,路线选择基于盘点结论而不是历史结论;
- 行为型库的交叉验证要用"时间轴断言"替代 UI 断言:上游测试测的是不可编译的 composables 时,把每种样式的关键时间点写成纯函数断言,是唯一能让"语义没漂"被证明的方式——38 项测试与
.so跑的是同一份 commonMain; - 拉取式采样是动画语义跨 C ABI 的最小形态:把时钟留在宿主、把状态算式留在 Kotlin,暂停/重播/进度条全部退化成 elapsed 的算术——C 签名永远是
const char* -> const char*,以后加样式只加 JSON 字段。
9.4 适配成果
当前适配已完成:
- texty
1.0.0-alpha样式模型 + 时序引擎的双 ABI Kotlin/Native 编译(上游模型与 composables 文件零改动,仅新增engine包 3 个纯 Kotlin 文件); - 14 种样式(8 普通 + 4 列表 + 2 工具)全入口 JSON 桥与 5 档重复策略;
- 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 三 Tab 验收页面(版本握手、播放/暂停/重播、状态行、操作日志);
- 38 项 JVM 交叉验证全绿 + 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)