本文记录 NumberFlow 接入 OpenHarmony 的完整过程,覆盖 UI 型 CMP 库的适配面盘点、源码集拆分(格式化内核真编译 + Compose UI 隔离)、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 逐位滚动真机页面、HAP 构建和真机验收。

NumberFlow 是基于 Compose AnimatedContent 的 Kotlin Multiplatform 逐位滚动数字组件:数值变化时只有真正变化的数字位做垂直滚动,升值向上滚、降值向下滚,千分位与小数点等非数字字符由 format 决定位置并随位滑动。本次适配与引擎型库(KStore 的"真编译整库 + kotlinx ohos 制品线")路线不同:NumberFlow 的价值大头在 Compose 渲染层(AnimatedContent / slideInVertically / material3 Text),而鸿蒙侧社区 CMP 停在 1.9.2,与上游 Compose 1.10.3 / Kotlin 2.3.21 对不齐——因此走 cmp_qrose 已审核的"内核提取 + 语义复刻"路线:格式化核心(formatWithThousands / groupThousands / thousands() / thousandsLong())原样编译成 OpenHarmony KLIB,NumberFlow composable 隔离进 composeMain 不参与鸿蒙构建,逐位滚动动画按上游语义(方向、450ms、位不变不动)在 ArkTS 侧复刻,格式化 100% 来自 Kotlin,ArkTS 从不自己拼数字。

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

上游仓库: NadeemIqbal/number-flow

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

NumberFlow 为 Compose Multiplatform 应用提供"数字变化该有的动画"这一件事的完整答案:NumberFlow(value) 一个调用,格式化交给 format lambda(NumberFlowDefaults.thousands() 千分位 + 固定小数位、thousandsLong() 整数千分位、PlainDouble / PlainLong 无分组),渲染层把文本拆成逐字符槽位,AnimatedContent 按 key(index) 只让变化的位做 slideInVertically,方向由 value >= lastValue 推导。计分板、价格、仪表盘计数器都靠它获得"只有变化的位在动"的高级感。上游构建(Kotlin 2.3.21 / Compose Multiplatform 1.10.3 / AGP 9.2.0 / Gradle 9.5.0)覆盖 Android / iOS / Desktop / Web,但不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS 静态数字,页面可以显示数字,却无法证明公共格式化核心在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证格式化内核、Native 动态库、C ABI、N-API、ArkUI 滚动动画和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.3.21 / Gradle 9.5.0 / AGP 9.2.0 与鸿蒙工具链无法共存于一个 Gradle 构建。
Compose UI 无 ohos 制品库的公共 API 是 @Composable,依赖 AnimatedContent / material3 Text;鸿蒙社区 CMP 为 1.9.2,版本差两代,UI 层编不进 ohos——这是与引擎型库(如 KStore)的本质差异。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
动画语义跨不了桥FiniteAnimationSpec<IntOffset>、AnimatedContent 过渡是 Compose 侧概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:格式化内核的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。数字的正确性完全由上游原版实现负责(千分位、小数补零、负零、范围校验一个不少),ArkTS 只负责调 Native 取文本、复刻滚动动画和展示结果。

1.2 库提供的能力

Demo 提供一个真实的数字滚动会话,对应桥接的全部入口:

  • 千分位格式化(thousands):NumberFlowDefaults.thousands(grouping, decimalSeparator, decimalPlaces)(value) —— 1234567.891 → 1,236,356.80 式的分组 + 固定小数位(四舍五入 + 补零),decimalPlaces 0…12 强制校验;
  • 整数千分位(thousandsLong):1234567 → 1,234,567,负号处理与分组来自上游原件;
  • 无分组输出(plain):PlainDouble(整数 double 不带尾部 .0)/ PlainLong;
  • 方向感知滚动:升值新位从下滑入、旧位上出,降值反向——复刻上游 slideInVertically 的方向推导;
  • 逐位动画:位不变不动、位变即滚,时长 150…900ms 可调(默认 450ms,与上游 tween(450) 一致);
  • 版本握手:响应内嵌 "library":"number-flow 0.1.0-ohos.1",来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
kind / mode回显数值类型(double/long)与格式模式(thousands/plain)。
text格式化结果字符串——ArkTS 侧唯一的渲染数据源。
length / digits / negative字符数 / 数字位个数 / 负号标记,供状态卡展示。
libraryfork 版本握手(真机版本行的数据来源)。

引擎侧完整能力(借位进位的分组重排、负零、decimalPlaces 范围校验、自定义分隔符)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 12 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的格式化逻辑没有漂移。

1.3 实现适配

维度要求
代码复用NumberFlowInternal.kt(纯 kotlin.math)一行不改;NumberFlowDefaults.kt 仅剥离 Compose 依赖的 AnimationSpec 成员;格式化逻辑零重写。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游五端 sample(composeApp/androidApp/desktopApp/webApp/iosApp)留盘不进构建。
桥接稳定单个 C ABI + UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
UI 完整单页覆盖全部桥接入口:数值步进、随机、自动滚动、类型/模式/小数位/分隔符/时长全参数实时可调。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。NumberFlow 的价值一半在渲染,鸿蒙侧没有对应 Compose 运行时;ArkTS Demo 按上游语义复刻滚动动画,格式化则始终走 Kotlin 桥——数字怎么写由上游原件决定,数字怎么动按上游语义复现。

二、实现路线图

第 1 阶段:项目初始化     ── 源码依赖度盘点(3 个 commonMain 文件逐个定级)、确定"内核提取 + 语义复刻"路线
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前并显式命名、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1 降级
第 3 阶段:桥协议建模     ── kind/mode JSON 契约、字段默认值与钳制、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 逐位滚动(ForEach key 替换 + TransitionEffect)、方向翻转、控制面板、桥接状态卡
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
number-flow/                           格式化内核(进 ohos 构建,jvm + ohosArm64 + ohosX64)
number-flow/src/commonMain/            NumberFlowInternal.kt + NumberFlowDefaults.kt(纯 Kotlin,一行不改/仅去 Compose)
number-flow/src/composeMain/           上游 Compose UI(NumberFlow.kt 原样隔离 + AnimationSpec 扩展属性,不编译)
number-flow/src/commonTest/            12 项纯逻辑测试(jvm 交叉验证)
number-flow/src/skikoTest/             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/                                上游五端示例宿主,留盘不进 settings

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :number-flow 与 :example:nativeApp,rootProject.name = "ohos_number_flow"。上游的 AGP / Compose 编译器 / vanniktech 发布插件、android {} 块、skikoTest 接线全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,社区已审核钉法)
JDK21(org.gradle.java.home 指向 Adoptium 21.0.12,Windows)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(自上游 9.5.0 降级;2.2.21 旧线只支持 Gradle 8.x,qrose 同款)
第三方依赖零(commonMain 不依赖任何 kotlinx 制品,没有制品线约束)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.numberflow
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
文件依赖形态处置
NumberFlowInternal.kt纯 kotlin.math.abs / roundToLong,零第三方整体编译(一行不改,保持 internal 可见性)
NumberFlowDefaults.kt4 个纯成员 + 1 个 Compose 成员(AnimationSpec: FiniteAnimationSpec<IntOffset>)拆分:纯成员留 commonMain,AnimationSpec 移走
NumberFlow.kt全 Compose(AnimatedContent / slideInVertically / material3 Text / Row / runtime)隔离进 composeMain,不参与任何目标编译
commonTest/NumberFlowLogicTest.kt纯 kotlin.test,12 项保留,jvm 交叉验证
skikoTest/NumberFlowUiTest.ktcompose.uiTest(Skiko)留盘不进构建

全库没有任何 expect / actual,拆分的全部工作量集中在"把一个 object 拆成两个源码集"。AnimationSpec 的移法用扩展属性而不是删除——composeMain 里补一个文件:

// number-flow/src/composeMain/kotlin/.../NumberFlowDefaultsCompose.kt
val NumberFlowDefaults.AnimationSpec: FiniteAnimationSpec<IntOffset>
    get() = tween(durationMillis = 450)

object 不能跨源码集拆成员,但扩展属性可以:Compose 消费方继续写 NumberFlowDefaults.AnimationSpec,源码语法与上游逐字相同;commonMain 从此零 Compose import,ohos 目标编译干净。git 视角 NumberFlow.kt 是 100% rename 进 composeMain,上游对照零成本。

1.4 页面边界
标题区          NUMBER-FLOW · OPENHARMONY(CMP 逐位滚动数字库 · Kotlin/Native ohosArm64/ohosX64)
滚动展示区      大号逐位滚动数字(数字深色固宽、分隔符灰色自然宽)
数值操作区      −100/−10/−1 / +1/+10/+100 步进(减红加绿)、随机(蓝)、自动滚动开关(700ms 随机游走)
参数区          数值类型 Double/Long、格式 千分位/无分组、小数位 0..4、
                千分位分隔符(逗号/点/空格/撇号)、小数分隔符(点/逗号)、动画时长 150..900ms
桥接状态区      状态、text、length/digits、Native 往返耗时、library 版本握手

页面每次数值变化都走一次真实 Native 调用取 text,滚动动画由 ArkTS 状态驱动,方向由本次与上次数值差决定。

第 2 阶段:目标与依赖打通

2.1 源码集拆分(本适配的成败手)

拆分原则只有一条:格式化不许漂。formatWithThousands / groupThousands 原样保留 internal(桥通过公开的 NumberFlowDefaults.thousands() 等入口调用,连可见性都不用改);NumberFlowDefaults.kt 删掉 3 行 Compose import 和 AnimationSpec 成员,其余逐字保留;NumberFlow.kt 整文件移动零修改。拆完后 commonMain 对 Compose 的依赖面为 0,这是 ohos 目标能编译的前提。

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

NumberFlow 零第三方依赖,没有 KStore 那种"制品线反推版本"的约束,直接采用社区已审核的最稳钉法(qrose / haze 同款)HarmonyOS Kotlin 2.2.21-1.0.0。代价是 Gradle 必须从上游 9.5.0 降到 8.14.1(2.2.21 旧线只支持 8.x,新线 2.4.20 才支持 9.x——两条工具链线的经验不能互相套用,KStore 是反方向的例子:它依赖 kotlinx 制品线被迫升 2.4.20、留 Gradle 9.6.1)。AGP 9.2.0 与 Compose 1.10.3 插件随根构建替换整体剥离,libs.versions.toml 收敛到只剩 kotlin 一个版本号。

settings.gradle.kts 把 eazytec 仓库写在最前,并显式命名:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") { name = "EazytecPublic" }
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos") { name = "EazytecHarmonyOS" }
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_number_flow"

include(":number-flow")
include(":example:nativeApp")

显式命名不是画蛇添足:本机实测发现机器级 Gradle init 脚本(阿里云镜像前置)会按"自动生成名冲突"静默丢弃未命名的 eazytec 仓库——自动名 maven / maven2 与镜像仓撞名,重加时被吞掉,插件解析直接失败;有固定名的 Google / MavenRepo 全部幸存。给两个 eazytec 仓起名后问题消失(同一环境里未命名的 qrose 写法复现失败,是这条坑的对照组)。

2.3 编译格式化内核

number-flow/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.bat :number-flow:jvmTest                                            # BUILD SUCCESSFUL(12 项)
.\gradlew.bat :number-flow:compileKotlinOhosArm64 :number-flow:compileKotlinOhosX64   # BUILD SUCCESSFUL
2.4 测试的处置

上游测试分两层:commonTest(12 项纯逻辑)与 skikoTest(Compose UI 测试,只接线 Desktop/iOS)。处置是两条纪律的组合:纯逻辑测试统一在 JVM 跑,根构建全局禁用 ohos 测试任务:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

这样上游测试文件零改动保留(skikoTest 源码留盘不接线),ohos 侧只承担编译与链接——compileTestKotlinOhosArm64 在 build 生命周期里显示 SKIPPED,规则生效。

第 3 阶段:桥协议建模

3.1 JSON 契约
io.github.nadeemiqbal.numberflow/**(上游格式化内核,原样编译)
        ↓ thousands() / thousandsLong() / PlainDouble / PlainLong
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API numberFlowFormat()
        ↓
ArkUI Index.ets(单页滚动)

请求与响应:

请求 : {"kind":"double","value":1234567.89,"mode":"thousands",
        "grouping":",","decimalSeparator":".","decimalPlaces":2}
       {"kind":"long","value":1247,"mode":"plain","grouping":","}
响应 : {"ok":true,"kind":"double","mode":"thousands","text":"1,234,567.89",
        "length":12,"digits":10,"negative":false,"library":"number-flow 0.1.0-ohos.1"}
       {"ok":false,"error":"..."}

字段纪律:kind 缺省 double、mode 缺省 thousands、grouping 缺省 ,、decimalSeparator 缺省 .、decimalPlaces 桥内钳制 0…12(上游 require 的防御前置);decimalPlaces / decimalSeparator 只对 double + thousands 生效,与上游 API 的参数作用域一致。

3.2 为什么单入口而不是两个符号

KStore 用 KStoreDispatch + KStoreVersion 两个 C ABI。本库选择单符号 NumberFlowFormat,版本握手内嵌在响应的 library 字段里:一次调用同时拿到数据与版本,ArkTS 侧少维护一个 import;qrose 同款先例。桥内分发完全照上游 API 语义走:

val text = when (kind) {
    "long" -> if (mode == "plain") NumberFlowDefaults.PlainLong(json.long("value"))
              else NumberFlowDefaults.thousandsLong(grouping)(json.long("value"))
    else   -> if (mode == "plain") NumberFlowDefaults.PlainDouble(json.double("value"))
              else NumberFlowDefaults.thousands(grouping, decimalSeparator, decimalPlaces)(json.double("value"))
}

注意 thousands() 返回的是 (Double) -> String lambda——桥直接调用上游工厂产出的 format 函数,而不是绕过它另写一遍:桥上没有一行数字格式化代码。

3.3 错误通道

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

@CName("NumberFlowFormat")
fun numberFlowFormat(request: String): String = try {
    /* kind/mode 分发 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、decimalPlaces 越界(上游 require 抛出)等全部走同一个 ok:false 通道,ArkTS 侧统一渲染进状态卡。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libnumberflow_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosnumberflow.so)
  │ NumberFlowFormat
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制格式化逻辑,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("NumberFlowFormat")
fun numberFlowFormat(request: String): String   // kind × mode 一口进,library 字段带回版本
C ABI 符号ArkTS 方法覆盖内核
NumberFlowFormatnumberFlowFormat(req)thousands() / thousandsLong() / PlainDouble / PlainLong 全部分发 + 版本握手
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"numberFlowFormat", nullptr, NapiNumberFlowFormat, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosnumberflow_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosnumberflow_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

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

add_library(numberflow_napi SHARED napi_init.cpp)
target_link_libraries(numberflow_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosnumberflow)

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

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

第 5 阶段:ArkUI 宿主(技术难点)

5.1 逐位滚动的 ArkTS 实现

上游 Compose 的做法是 Row 内逐字符 AnimatedContent(targetState = char) 按 key(index) 槽位化。ArkTS 没有 AnimatedContent,等价物是 ForEach 按 位_字符 复合 key 替换 + 组件级 TransitionEffect:

ForEach(this.chars, (ch: string) => {
  Text(ch)
    .fontSize(52)
    .fontWeight(FontWeight.Bold)
    .fontColor(this.isDigit(ch) || ch === '-' ? '#111827' : '#9CA3AF')
    .constraintSize({ minWidth: this.isDigit(ch) ? 30 : 0 })
    .transition(
      TransitionEffect.asymmetric(
        TransitionEffect.OPACITY.combine(
          TransitionEffect.translate({ y: this.increasing ? 56 : -56 })
        ).animation({ duration: this.durationMs, curve: Curve.EaseOut }),
        TransitionEffect.OPACITY.combine(
          TransitionEffect.translate({ y: this.increasing ? -56 : 56 })
        ).animation({ duration: this.durationMs, curve: Curve.EaseOut })
      )
    )
}, (ch: string, idx: number) => `${idx}_${ch}`)

四个要点与上游语义一一对应:

  1. key 设计:${idx}_${ch}——位没变 key 不变、组件复用不动;位变了 key 变、ArkUI 视为"移除旧 + 插入新",exit/enter 过渡同时播放,这就是"位不变不动、位变即滚";
  2. 方向翻转:increasing 来自本次与上次数值差(上游 value >= lastValue 同款推导),升值 enter 从 +56 进、exit 向 −56 出(滚向上),降值镜像;
  3. 动画包裹:状态变更包在 this.getUIContext().animateTo({ duration, curve }) 里,TransitionEffect 再显式绑 .animation(),双保险保证过渡一定播放;
  4. 固宽数字列:数字位 minWidth 30 居中,滚动读起来是老虎机列;逗号/点灰色自然宽,复刻上游"非数字字符按 format 位置排布"的观感。
5.2 一次完整的数值变更流
private applyDelta(delta: number): void {
  const before = this.currentValue();
  const next = before + delta;
  this.setValue(next);                    // 先更新值
  this.refresh(next >= before);           // 再取文本并驱动动画
}

private refresh(increasing: boolean): void {
  const started = Date.now();
  const parsed = JSON.parse(numberFlowFormat(JSON.stringify(this.request()))) as FormatOk;
  this.lastLatency = Date.now() - started;        // Native 往返耗时进状态卡
  if (!parsed.ok || parsed.text === undefined) { /* 错误通道 */ return; }
  this.increasing = increasing;
  const nextChars: string[] = [];
  for (let i = 0; i < parsed.text.length; i++) nextChars.push(parsed.text[i]);
  this.getUIContext().animateTo({ duration: this.durationMs, curve: Curve.EaseOut }, () => {
    this.chars = nextChars;               // key diff → 只有变化的位滚动
  });
}

注意顺序纪律:先 setValue 再 refresh——request() 读的是组件状态,顺序反了会把旧值格式化一遍(首版踩过,已修)。

5.3 控制面板与错误边界

操作区步进按钮减红加绿(语义与破坏性对齐)、随机蓝、自动滚动 700ms 随机游走(aboutToDisappear 清 timer)。参数区改任何一项都立即 refresh 重排——切 Long 去掉小数、切 plain 去掉分组、拖小数位滑杆实时补零/截断。状态卡实时显示 text / length / digits / Native 往返毫秒数 / library 版本握手;ok:false 的错误原文同样进状态卡。

ArkTS 严格模式有一条这次真机编译踩实的规则:arkts-no-untyped-obj-literals——request() 返回裸对象字面量(返回类型 object)直接编译失败,必须显式声明接口:

interface FormatRequest {
  kind: string;
  value: number;
  mode: string;
  grouping: string;
  decimalSeparator?: string;
  decimalPlaces?: number;
}

UI 组件的 options 字面量(Slider({...})、animateTo({...}))因对应 SDK 已声明接口不受影响。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosnumberflow)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName × 1 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.numberflow、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/libohosnumberflow_api.h
        ├── src/main/cpp/types/libnumberflow_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
.\gradlew.bat :number-flow:jvmTest
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

两个 link 任务 finalizedBy copyKnSoArm64 / copyKnSoX64,把 libohosnumberflow.so 连同 Konan 的 libc++_shared.so 回填进 entry/libs/{arm64-v8a,x86_64};随后用 DevEco Studio 打开 example/harmonyApp(不是仓库根——根目录没有 hvigor 工程),在 File → Project Structure → Signing Configs 勾选自动生成签名,Build Hap(s) 后:

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.numberflow -m entry

四、完整代码对照

4.1 整体架构

io.github.nadeemiqbal.numberflow(上游格式化内核原样编译)
    │ formatWithThousands / groupThousands(internal)
    │ PlainDouble / PlainLong / thousands() / thousandsLong()(public)
example.nativeapp.Bridge + MiniJson
    │ NumberFlowFormat
libnumberflow_napi.so
    │ numberFlowFormat
Index.ets
    │ ForEach key diff + TransitionEffect + animateTo
    ▼
ArkUI 真机页面(单页滚动)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前 + 显式命名、ohos_number_flow、sample 移出
gradle/libs.versions.toml只剩 Kotlin 2.2.21-1.0.0 一个版本号
build.gradle.kts(根)版本 0.1.0-ohos.1、全局禁用 Ohos*Test* 任务
number-flow/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
number-flow/src/commonMain/**上游内核(NumberFlowInternal.kt 零改动;NumberFlowDefaults.kt 去 Compose 化)
number-flow/src/composeMain/**上游 Compose UI 隔离(NumberFlow.kt 零改动 + AnimationSpec 扩展属性)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt单 C ABI、上游 format 工厂直调、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 逐位滚动验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(24 节)

4.3 关键 API 对照

层次API作用
内核formatWithThousands(value, grouping, decimalSeparator, decimalPlaces)四舍五入 + 补零 + 分组(internal,require 范围校验)
内核groupThousands(digits, grouping)从右每三位插分隔符(internal,纯字符串操作)
内核NumberFlowDefaults.thousands(...) / thousandsLong(...)公开 format 工厂——桥直接调用它们产出的 lambda
内核NumberFlowDefaults.PlainDouble / PlainLong无分组输出(整数 double 不带尾部 .0)
composeMainNumberFlow(value, format, style, color, animationSpec)上游 composable(鸿蒙不编译,语义由 ArkTS 复刻)
NativeNumberFlowFormatC ABI JSON 导出(kind × mode 分发 + library 版本握手)
N-APInumberFlowFormat(req)向 ArkTS 暴露能力
ArkTSrefresh(increasing) / applyDelta(delta)取文本、驱动 key diff 滚动、方向翻转

4.4 ArkTS 与 Kotlin 的边界

const req: FormatRequest = {
  kind: 'double', value: 1234567.89, mode: 'thousands',
  grouping: ',', decimalSeparator: '.', decimalPlaces: 2
};
const res = JSON.parse(numberFlowFormat(JSON.stringify(req))) as FormatOk;
// res.text === '1,234,567.89',res.digits === 10,res.library === 'number-flow 0.1.0-ohos.1'
val text = if (mode == "plain") NumberFlowDefaults.PlainDouble(json.double("value"))
           else NumberFlowDefaults.thousands(grouping, decimalSeparator, decimalPlaces)(json.double("value"))

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;ArkTS 侧没有任何一行数字格式化代码。

五、关键决策说明

决策 1:格式化内核真编译 + Compose UI 隔离,而不是整库编译或纯 ArkTS 重写

库的公共 API 是 @Composable,鸿蒙没有对应 Compose 运行时,整库编译此路不通;反过来只写 ArkTS 页面、格式化也用 TS 重写,数字正确性(负零、借位重排、补零、范围钳制)就和上游脱钩,漂移只是时间问题。本次走中间线:上游怎么算,.so 里就怎么算;上游怎么动,ArkTS 就怎么动(方向、时长、逐位三条语义钉死)。这延续了 qrose 对"编码器复用、Painter 隔离"的划分,与 KStore"真编译整库"互为镜像——路线由库的依赖形态决定,不由偏好决定。

决策 2:工具链钉 2.2.21-1.0.0,Gradle 降 8.14.1

库零第三方依赖,没有制品线约束,选社区已审核的最稳钉法(qrose 同款)。代价是 Gradle 9.5.0 → 8.14.1 降级与 AGP / Compose 插件整体剥离——2.2.21 旧线只支持 Gradle 8.x。KStore 是反例方向:它被 kotlinx 制品线推上 2.4.20、反而留 Gradle 9.x。两条线的经验必须分开记,不能互相套用。

决策 3:根构建替换(cmp_qrose 模式),五端 sample 留盘

上游根构建(AGP / Compose 编译器 / vanniktech 发布 / wasm / 五端 sample 接线)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample/composeApp 等宿主留盘不进 settings.gradle.kts——它们是上游行为的历史参照,也是未来 Compose 侧回归测试的素材。

决策 4:单 C ABI + library 字段内嵌版本握手

一个符号 NumberFlowFormat 覆盖全部能力,版本握手内嵌响应 library 字段。相比"dispatch + version"双符号少一次 Native 往返和一条 import;对数字组件这种"每次变更都要调"的高频路径,调用面越小越好。

决策 5:jvmTest 交叉验证 + 测试零改动

格式化逻辑在 commonMain,JVM 与 ohos 跑同一份代码;12 项 jvmTest 跑的就是上游原测试文件,是"内核没漂"的闸门。skikoTest(Compose UI 测试)不适用鸿蒙,处置是留盘不接线而不是删——测试文件一个字节不动,交叉验证的有效性才成立。

六、测试与验证

6.1 测试环境

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

6.2 JVM 交叉验证

.\gradlew.bat :number-flow:jvmTest

12 项用例全部通过(上游原测试文件,零改动),覆盖:分组短串边界(空串 / 1…3 位原样返回)、从右每三位插入、自定义分组符(. / 空格)、整数无小数、小数四舍五入与补零、负数与负零(-0.x 显示 -、-0.0 不显示)、decimalPlaces 0…12 范围校验(assertFailsWith)、默认参数。

6.3 HAP 验证

:number-flow:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:number-flow:jvmTest: BUILD SUCCESSFUL(12 项)
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(copyKnSo* 回填 entry/libs)
符号检查: NumberFlowFormat / libohosnumberflow_symbols 均在 dynsym 导出
BuildNativeWithCmake: finished(双 ABI 编出 libnumberflow_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully
start ability successfully

libohosnumberflow.so(debugShared 未 strip)arm64 约 3.6MB、x86_64 约 3.3MB,含 Kotlin/Native 运行时;每个 ABI 随 HAP 携带三件套(libnumberflow_napi.so / libohosnumberflow.so / libc++_shared.so)。

6.4 功能用例

  1. 启动即显示千分位 + 2 位小数的大数字(默认 1234567.89 起步,自动滚动开启后随机游走,截图时刻为 1,236,356.80);
  2. 点 +1 只有最后一位向上滚动,其余位静止不动;
  3. 点 −10 十位与受借位影响的连续位向下滚动,方向翻转正确;
  4. 切换 Long 后无小数位与 decimalPlaces 控件,整数千分位生效;
  5. 切换 无分组 后分隔符消失(PlainDouble 语义:整数不带尾部 .0);
  6. 拖动小数位 0…4,实时补零或截断重排;
  7. 切换千分位分隔符(逗号 / 点 / 空格 / 撇号)与小数分隔符即时生效;
  8. 点 随机 大跳变,多位同时滚动;
  9. 自动滚动 开启后每 700ms 随机游走一次,关闭即停(页面销毁清 timer);
  10. 桥接状态卡实时刷新 text / length / digits / Native 往返毫秒数 / library 版本握手(真机版本行的数据来源);
  11. 拖动动画时长 150…900ms,下一次变更立即按新时长滚动。

6.5 验证结论

格式化内核、Native 动态库、C ABI、N-API、ArkUI 逐位滚动和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的数字滚动会话:方向感知逐位动画、千分位/小数位/分隔符全参数实时重排与 Native 版本握手(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/number-flow.git
cd number-flow
export JAVA_HOME="/path/to/jdk-21"        # Windows: $env:JAVA_HOME="C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
./gradlew :number-flow:jvmTest            # 12 项交叉验证
./gradlew :number-flow:compileKotlinOhosArm64 :number-flow:compileKotlinOhosX64   # klib
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp → 自动签名 → Build Hap(s)
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.numberflow -m entry
hdc shell hilog | grep NumberFlow

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :number-flow 的 commonMain 真正编译成 ohosArm64 / ohosX64 动态库——格式化必须仍来自 Kotlin,否则数字正确性与上游脱钩;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,且显式命名:机器级阿里云镜像 init 脚本会按自动名冲突静默丢弃未命名仓库(自动名 maven / maven2 撞名被吞,固定名的 Google / MavenRepo 幸存)——本机实测未命名的 qrose 写法构建失败、命名后通过;
  3. Gradle 9.5.0 与 HarmonyOS Kotlin 2.2.21-1.0.0 不兼容,必须降 8.14.1(2.2.21 线只支持 8.x;2.4.20 线才支持 9.x,两条线经验不能互套);
  4. AGP 9.2.0 / Compose 1.10.3 插件与鸿蒙工具链无法共存,根构建替换时 libs.versions.toml 里的别名要一起清,否则配置期解析失败;
  5. arkts-no-untyped-obj-literals:ArkTS 严格模式禁止无类型对象字面量,request() 返回类型 object 直接编译失败——必须显式声明 FormatRequest 接口(真机编译期踩实;UI 组件 options 字面量因有 SDK 接口类型不受影响);
  6. ForEach 的 key 必须是 ${idx}_${ch} 复合键:只用 ch 会在字符相同位置不同时错位复用,只用 idx 则字符变化不触发替换——"位不变不动、位变即滚"全靠这个 key;
  7. ArkTS 侧先 setValue 再 refresh——request() 读组件状态,顺序反了会格式化旧值;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI(真机是 x86_64),每个 ABI 三件套缺一不可;
  9. DevEco Studio 打开的是 example/harmonyApp 子工程,不是仓库根——根目录没有 hvigor 工程,CMake 找不到 libohosnumberflow.so 的 FATAL_ERROR 也是先跑 gradle link 任务的信号;
  10. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  11. KN .so 里的 Kotlin 字符串按 UTF-16 存储,用 ASCII grep 验证符号时只有 dynsym 的 C 符号名(NumberFlowFormat)能命中,版本字符串命中不了属正常,别误判成常量被裁剪;
  12. 签名材料由 DevEco 自动写入 build-profile.json5,仓库内保持 signingConfigs 空数组,证书绝不进入 AtomGit。

8.2 已知问题

  • NumberFlow composable(style / color / animationSpec 参数、Int 重载、prefix/suffix 自定义 format)不编译 ohos,隔离在 composeMain——鸿蒙侧能力边界是"桥接格式化 + ArkTS 复刻动画";
  • ArkTS 滚动是语义复刻不是移植:方向、450ms 默认时长、逐位行为与上游对齐,但 FiniteAnimationSpec 体系(spring/tween 曲线库)不跨桥,动画时长是毫秒数值而非 spec 对象;
  • 上游五端 sample 与 skikoTest UI 测试剥离(源文件留在树里),CI(macOS 向 build/publish workflow)继承自上游、不含 ohos 目标——ohos 验证在本地与真机完成;
  • 每次数值变更一次 Native 同步调用,桥没有批量/防抖——当前毫秒级往返(状态卡实测)下无感,若做高频行情流需要 ArkTS 侧节流;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.numberflow 匹配的签名。

8.3 未来优化方向

  • 补上游 roadmap 的 staggered per-digit delay(逐位错峰入场)到 ArkTS 复刻层;
  • 动画曲线对齐上游 spring 物理默认(当前 EaseOut 近似);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 number-flow(3 个 commonMain 文件)
    → 源码依赖度盘点(内核 / 混合 / 全 Compose 三档定级)
    → 拆源码集(commonMain 去 Compose 化 + AnimationSpec 扩展属性保语法兼容)
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1 降级,qrose 同线)
    → 根构建替换(cmp_qrose 模式,eazytec 仓显式命名)
    → ohosArm64 / ohosX64 KLIB
    → C ABI(@CName × 1,上游 format 工厂直调)
    → C++ N-API(DisposeString 纪律)
    → ArkTS 逐位滚动(ForEach 复合 key + TransitionEffect 方向翻转)
    → ArkUI 真机单页验收(全参数实时重排 + Native 版本握手)

9.2 封装层次

  • 内核层:上游原版 formatWithThousands / groupThousands 与 NumberFlowDefaults format 工厂(internal 保持原可见性);
  • 动画语义层:ArkTS 按上游语义复刻方向感知逐位滚动(key diff + TransitionEffect);
  • Native 层:生成双 ABI 动态库并通过单个 C ABI 输出 JSON(含版本握手);
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、请求构造、key diff 驱动、控制面板与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. UI 型 CMP 库先做依赖度三档定级再定路线:纯逻辑(NumberFlowInternal)整体编译、混合(NumberFlowDefaults)拆分、全 Compose(NumberFlow)隔离 + 语义复刻——值在渲染的库走"内核提取",值在逻辑的库(KStore)走"真编译整库",路线由依赖形态决定;扩展属性是拆 object 跨源码集时保住上游源码语法的钥匙;
  2. 工具链线经验按线积累:2.2.21 线配 Gradle 8.14.1 + 显式命名仓库,2.4.20 线配 Gradle 9.x——换库先查它的依赖钉在哪条线,再决定升降;机器级 init 脚本对仓库列表的副作用(自动名撞名被吞)要靠显式命名免疫;
  3. "内核没漂 + 语义没歪"要分别证明:12 项 jvmTest(上游原测试文件零改动)证明格式化没漂;方向翻转、位不变不动、450ms 默认时长三条语义钉死在 ArkTS 复刻层并用真机用例验收——数字类组件的验收重点天然落在"只有变化的位在动"这条观感闭环上。

9.4 适配成果

当前适配已完成:

  • number-flow 0.1.0 格式化内核的双 ABI Kotlin/Native 编译(上游 commonMain 零重写:NumberFlowInternal.kt 一行不改,NumberFlowDefaults.kt 仅剥离 Compose 成员);
  • 1 个 C ABI 符号和 C++ N-API 包装层(.so 内验证 NumberFlowFormat / libohosnumberflow_symbols 导出);
  • DevEco Stage ArkUI 单页验收页面(方向感知逐位滚动、全参数实时重排、Native 版本握手、桥接状态卡);
  • 12 项 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐