本文记录 texty 接入 OpenHarmony 的完整过程,覆盖文字动画库盘点、Compose 源码集隔离、纯 Kotlin 时序引擎提炼、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

texty 是基于 Compose Foundation BasicText 的 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 / offsetYSliding / 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向本机 JDK)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(上游 wrapper 9.7.1 降级;2.2.21 线不支持 Gradle 9.x)
kotlinx无——commonMain 零外部依赖,不依赖任何 ohos 制品线
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.texty
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点

对上游 commonMain 全部 29 个 Kotlin 文件逐一 grep Compose import:

分类文件数依赖形态处置
纯 Kotlin 模型12DisplayStyle / Utility / Repeat / FadingType / LoadingType / RevealingCover / RevealingPattern / RevealingType / ScrollingDirection / SlidingDirection / TransitionDirection / TransitionStyle,零 Compose留在 commonMain 原样编译(jvm + 双 ohos)
Compose API3Texty.kt(入口 composable)、ListDisplayStyle.kt(spacing: Dp)、PackageOverview.kt挪入 composeMain,零改动
Compose 实现17internal/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 模型 + 新引擎,零外部依赖。三条结论:

  1. settings.gradle.kts 的 eazytec 双仓只服务 HarmonyOS Kotlin 插件本身(2.2.21-1.0.0),业务依赖一个都不用从社区 Maven 拉;
  2. 不存在"制品线版本顶高 stdlib 元数据"的问题,Kotlin 不必像 KStore 那样钉到 2.4.20-0.1.0-07,回到 qrose / haze 的 2.2.21-1.0.0 钉法;
  3. 上游 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
Blinkingdelay(interval/2) 后翻转(初始不可见);CountBound 结束冻结为 showAfterCompletestep=floor(e/half),可见 = step 奇数;blinks=(step+1)/2
FadingwithFrameNanos 线性:IN 用 progress,OUT 用 1−progress同公式进 alpha
SlidingAnimatable 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 末项永不退出(上游怪癖)分段游走采样 + 轮次解析(完整轮长 / 末项不出轮长)
LoadinginfiniteTransition 0→N 线性交叉淡化;MusicBar 每条错相 Reverseprogress=(e%cycle)/cycle·N;条 i 相位偏移三角波
TimeKeepingtoken 格式化(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 方法覆盖内核
TextyDispatchtextyDispatch(req)样式参数解析 + TextyEngine 全部 14 种采样器
TextyVersiontextyVersion()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.tomlKotlin 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.kt2 个 C ABI、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(20 节)

4.3 关键 API 对照

层次API作用
引擎TextyEngine.typing/sliding/revealing/...14 种样式采样器,(参数, elapsed) → TextyFrame
引擎TextyEngine.loading交叉淡化帧 / MusicBar 条列
引擎TextyEngine.timeKeepingtoken 时间格式化(含校验与回退)
模型Repeat.Continuous/Once/TimeBound/CountBound四档重复语义(上游原件)
NativeTextyDispatch / TextyVersionC ABI JSON 导出
N-APItextyDispatch(req) / textyVersion()向 ArkTS 暴露能力
ArkTStick()定时拉帧并刷新 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 测试留盘):

测试组用例数钉住什么
Typing3首字 t=0、每 delay 一字、(len+1)·delay 完成
Blinking3half-interval 翻转、Once 终态、CountBound 冻结
Fading2IN/OUT 线性 alpha 与端点
Sliding4落位公式(p=0/0.5/1)、Continuous 回绕、CountBound 终态(保留 vs 滑出)
Scrolling1纵向公式
Revealing7四种揭示顺序、覆盖字三种变体、ByTotalTime 均分
StickAndReveal2T2B 黏附 → B2T 揭示时间网格、L2R 逐列黏附
Motion2帧切换、CountBound 终态
OneByOne4BASIC 三段、TYPING 逐字进出、FADING alpha、CountBound 保留末项
List 家族2separator 拼接横滑、整列纵滑
Loading3交叉淡化 alpha 和为 1、字形集、MusicBar 相位错开
TimeKeeping4token、EEEE、引号字面量、非法格式回退
Basic1立即完成

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 功能用例

  1. 启动后日志出现 textyVersion() → 1.0.0-alpha-ohos.1(来自真实 Native 调用,非静态文字);
  2. 普通样式 Tab 依次点 8 个样式:Typing 逐字、Blinking 半间隔翻转(状态行 blinks 递增)、Fading 淡入/淡出、Sliding/Scrolling 在裁剪视口内滑过、Revealing 四种揭示模式 + 自定义覆盖字、StickAndReveal 多行黏附后揭示;
  3. 列表样式 Tab:Motion 帧序列、OneByOne 三种过渡(BASIC 硬切 / FADING 淡入出 / TYPING 逐字进出)、SlidingList 拼接横滑、ScrollingList 整列纵滚;
  4. 工具样式 Tab:Spinner/Circular 交叉淡化、MusicBar 错相跳动(条数/循环时长可调)、时间格式实时刷新;把格式改成非法串显示 Invalid Format: ...(与上游回退一致);
  5. 重复策略五档切换:循环永续(done 恒 false)、单次到界、3 次·保留停在可见、3 次·清空消失、10 秒·保留冻结;
  6. 暂停后 elapsed 冻结、帧保持;重播归零重来;
  7. 每步操作的操作日志按时间戳滚动留痕。

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 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让库的 Kotlin 语义真正编译成 ohosArm64 / ohosX64 动态库——对 texty 而言语义=时序,所以先把时序提炼成纯函数再谈编译;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 上游 Gradle 9.7.1 遗留的 gradle-daemon-jvm.properties(daemon criteria,要求 JetBrains JDK 25)与 8.14.1 不兼容,启动期就联网下 JDK 失败——降级 wrapper 时必须一并删除;
  4. HarmonyOS Kotlin 2.2.21 线只支持 Gradle 8.x(qrose / haze 先例 8.14.1);KStore 的"2.4.20 线可留 Gradle 9.x"经验不能互套——Gradle 版本跟工具链线走,不跟上游 wrapper 走;
  5. ListDisplayStyle.kt 只因一个 spacing: Dp 默认值就依赖 androidx.compose.ui.unit——分类不能只看类名语义,要逐文件 grep import;
  6. Kotlin 表达式函数体(= when(...))里不能写裸 return,ohos 编译期报错,改块函数体;
  7. 浮点除零护栏:duration=0、barCount=0 等退化参数会让 alpha 变 NaN 进 JSON,引擎各处显式护栏;
  8. ArkTS Area.width/height 的类型是 Length(string | number | Resource),赋给 number 前必须 Number() 强转,三处 onAreaChange 全中——真机构建第一轮就报 3 个编译错误;
  9. measure 在所用 SDK 版本不从 @kit.ArkUI 导出,改走系统模块 import measure from '@ohos.measure'(默认导出的 namespace);
  10. CAdapter 链接期 api.cpp 的 non-void function does not return 警告是 HarmonyOS Kotlin 生成的 napi 胶水,不是业务代码,so 正常链出(qrose 同款);
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. 签名材料由 DevEco 自动写入 build-profile.json5,仓库内保持空 signingConfigs,证书绝不进入 AtomGit;上游 gradle.properties 里明文提交的 Maven Central 凭据随根构建替换一并移除。

8.2 已知问题

  • HarmonyOS 上没有 Compose:Texty() composables 仍在 composeMain,等社区 CMP 工具链对齐后接回;届时 engine 包退役为纯测试基准或被上游 composables 直接取代;
  • 帧驱动是 ArkTS 33ms 定时器,非 withFrameNanos vsync 对齐,帧率由宿主决定;
  • 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 三条经验

  1. "Compose 浓度"决定适配路线:qrose 有现成内核(直接编译)、KStore 有制品线可依(真编译整库)、texty 什么都没有(先提炼再编译)——盘点上游源码的 Compose 依赖分布,永远是最前置的一步,路线选择基于盘点结论而不是历史结论;
  2. 行为型库的交叉验证要用"时间轴断言"替代 UI 断言:上游测试测的是不可编译的 composables 时,把每种样式的关键时间点写成纯函数断言,是唯一能让"语义没漂"被证明的方式——38 项测试与 .so 跑的是同一份 commonMain;
  3. 拉取式采样是动画语义跨 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。

参考文档

Logo

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

更多推荐