本文记录 compose-multiplatform-charts 接入 OpenHarmony 的完整过程,覆盖图表库盘点、纯 Kotlin 数学内核提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、软件光栅化渲染引擎、ArkUI 六 Tab 真机页面、HAP 构建和真机验收。

compose-multiplatform-charts 是 netguru 开源的 Compose Multiplatform 图表库,提供六个组件:BarChart(分组柱状图)、LineChart(时间轴平滑折线图)、PieChart(环形/整圆饼图)、Dial(半环仪表盘)、BubbleChart(物理打包气泡图)与 GasBottle(气瓶液位图)。上游全部实现是 @Composable + DrawScope 绘制,没有任何 expect/actual。本次适配走的是社区早期路线(qrose / haze / PreCompose / voyager 的"零依赖内核提取"):与渲染无关的数学内核——区间映射、Y 轴 nice-number 刻度、时间轴整点刻度、网格线像素测量、气泡物理打包——留在 commonMain 原样编译进 OpenHarmony KLIB;43 个 Compose 文件隔离进不参与编译的 src/composeMain;鸿蒙侧由 Kotlin/Native 软件光栅化引擎按上游几何逐行重现六种图表,输出 PNG Base64,刻度文本与图例以 JSON 返回、由 ArkUI 原生 Text 叠加。

项目地址: AtomGit/oh-tpc/compose-multiplatform-charts

上游仓库: netguru/compose-multiplatform-charts

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

charts 为 Kotlin Multiplatform 应用提供六种常用图表:BarChartData 组织分类柱簇,LineChartSeries 承载时间戳序列并以贝塞尔曲线平滑,PieChartData 按比例切分扇环,Dial 以半环加刻度尺表现进度,BubbleChart 用物理引擎把数值圆泡互斥排布,GasBottle 以矢量气瓶和液位渐变表现百分比。上游把这些能力全部放在 commonMain,由 Android 与桌面共享;刻度数学(YAxisScale 的 nice-number 算法、TimestampXAxisScale 的整点对齐)、区间映射(mapValueToDifferentRange)与气泡打包物理(BubblePacking)是渲染无关的纯 Kotlin 资产。OpenHarmony 应用同样需要这套图表能力,但上游构建(Kotlin 2.1.20 / Compose 1.7.3 / Gradle 8.11.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示控件,却无法证明公共数学内核在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证数学内核、光栅化引擎、PNG 编码、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
UI 即全部上游 51 个 Kotlin 文件里 40 个直接 import androidx.compose,社区 CMP 1.9.2(Kotlin 2.2.21)与上游 Compose 1.7.3(Kotlin 2.1.20)无法混链,Compose 绘制不能为 ohos 编译。
构建基建不兼容上游 buildSrc(AGP、Shot 截图测试、KotlinPoet 资源生成)与版本目录在鸿蒙工具链下没有意义且无法解析。
语言边界不同ArkTS 不能直接调用 Kotlin 绘制,需要 C ABI、C++ N-API 和 JSON。
渲染语义跨不了桥DrawScope、Color、Dp、PathEffect 是 Compose 概念,C ABI 只能同步一进一出。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:纯 Kotlin 数学内核的 ohos 编译、根构建替换(cmp_qrose 模式)、软件光栅化引擎与 C ABI/N-API 桥接层。绘制几何完全按上游算法移植,ArkTS 只负责调用 Native、解析 JSON、解码 PNG 和叠加文本。

1.2 库提供的能力

Demo 以六个 Tab 覆盖桥接的全部入口:

  • 柱状图:categories 多层嵌套进桥,柱簇布局、柱宽/圆角/柱距参数实时调节,负值柱从零线向下,Y 轴出现负刻度;
  • 折线图:双序列(实线 + 虚线)、时间轴整点刻度、贝塞尔平滑、线下三停渐变填充、线宽 1–8dp 调节;
  • 饼图:扇环 ↔ 整圆切换(对应上游 Dp.Infinity 默认整圆)、间隙 0–8dp、扇区角度严格按 34/22/14/18/12 比例;
  • 仪表盘:半环进度 0–100 滑杆跟随,背景弧 WithDegreeGap 间隙规则逐字移植,主/次刻度尺;
  • 气泡图:BubblePacking 物理引擎随机初速重排,数值/名称标签跟随气泡移动;
  • 气瓶:上游 GasTank ImageVector 六条路径逐条转录,液位渐变与液面双椭圆随百分比变化。

响应包含:

字段作用
ok渲染是否成功,失败时 error 带原文。
width / height / bytesPNG 尺寸与字节数。
labels[]刻度文本(text/x/y/size/color/align),ArkTS 除以 density 得 vp 后用 markAnchor 叠加。
legend[]图例(name/color/dashed/shape),ArkTS 原生渲染。
pngBase64渲染结果,util.Base64Helper 解码后走 image.createPixelMap。

引擎侧完整能力(刻度数学、网格测量、打包物理、六种图表几何)全部在 commonMain 与 example/nativeApp 里随编译进入 .so;JVM 侧保留 compileKotlinJvm 交叉验证与 24 组设备参数矩阵,证明 ohos .so 里执行的数学没有漂移。

1.3 实现适配

维度要求
代码复用数学内核 11 个文件留在 commonMain 原样编译(算法零改动);BubblePacking 以 30 行接口化改造(Size 参数换两个 Float、泛型到 PackableCircle)让物理引擎跨 UI 复用。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);buildSrc 删除;上游 example-app 四模块留盘不进构建。
桥接稳定单个 C ABI(ChartsRender)+ UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
载荷纪律PNG 走真 DEFLATE 压缩(LZ77 + 固定 Huffman,图表位图从 MB 级降到 24–52KB);响应数值做 isFinite 防护,杜绝裸 NaN 令牌。
UI 完整六 Tab 覆盖全部图表入口:参数滑杆、开关、重排按钮、刻度文本叠加、图例与错误诊断行。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。charts 本身就是纯 Compose 绘制库,鸿蒙侧没有 compose.ui 可用;ArkTS Demo 画的是桥暴露的渲染闭环——同一份刻度数学与打包物理驱动的 PNG。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点 51 个源文件的 Compose 依赖度、查重、确定"内核提取"路线
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:源码集手术     ── 43 个 Compose 文件移入 composeMain、@Immutable 剥离、BubblePacking 接口化
第 4 阶段:桥协议与光栅化 ── 树状 JSON 解析器、ChartsRender 契约、六图表几何移植、DEFLATE PNG 编码
第 5 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── 六 Tab 页面、参数控件、文本叠加、图例与诊断行
第 7 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
charts/src/commonMain/                  数学内核 11 文件(区间映射/轴刻度/网格测量/气泡打包,进 ohos 构建)
charts/src/composeMain/                 上游 43 个 Compose 文件(保持原包名,不参与任何目标编译)
example/nativeApp/                      Kotlin/Native 动态库(Bridge + Json + Rasterizer + PngEncoder)
example/harmonyApp/                     DevEco Stage、CMake N-API 和 ArkTS 六 Tab 页面
example-app/*                           上游 Android/Desktop 示例,留盘不进 settings
README.OpenHarmony.md / _CN.md          鸿蒙适配说明(中英双份)
README.upstream.md                      上游原文保存
docs/ assets/                           上游 Dokka 产物与 README 图片,留盘不进构建

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :charts 与 :example:nativeApp,rootProject.name = "ohos_charts"。上游的 ktlint 插件、AGP/Shot/KotlinPoet classpath、Dokka sourceLink 随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Temurin jdk-21.0.12.101-hotspot)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(wrapper 自上游 8.11.1 升级;2.2.21 旧线要求 8.x)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.charts
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点

51 个文件逐个过 import androidx,分三类处置:

类别文件数处置
纯 Kotlin(axisscale 四件、Helpers、Vector、ChartGrid、LineParameters)8留 commonMain 原样编译
纯数学被 Compose 外壳包裹(GridChartDrawing 测量、BubblePacking 物理)3拆分/接口化后留 commonMain
Compose 绘制与配置类(@Composable、DrawScope、Color/Dp)43移入 src/composeMain(普通目录,不编译)

两处关键手术:其一,Helpers.kt 拆出 StartAnimation(Compose 运行时)进 composeMain,mapValueToDifferentRange 四个重载与 Number.round 原样保留;其二,GridChartDrawing.kt 拆成"纯测量函数留 commonMain + DrawScope 包装进 composeMain",上游 BarChart.kt / LineChart.kt 的调用签名不变,将来回接零成本。

1.4 页面边界
Tab 切换        柱状图 / 折线图 / 饼图 / 仪表盘 / 气泡图 / 气瓶(BarMode.Scrollable)
控件区          柱宽+圆角滑杆 / 虚线开关+线宽滑杆 / 圆环开关+间隙滑杆 / 数值滑杆 / 重排按钮 / 液位滑杆
图表区          Stack{ Image(PixelMap) + 刻度 Text 叠加 },宽高来自响应除以 density
图例区          Flex 换行渲染 legend[](line 虚实线 / rect 方块 / circle 圆点)
诊断区          失败红字(chartsRender异常 / 解析异常 / 渲染失败三段式),成功灰字角标(kind 尺寸 字节)

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

2.1 版本目录裁剪

上游 gradle/libs.versions.toml 有 20 个版本、26 个库、7 个插件(AGP、Compose 1.7.3、Shot、klock……)。鸿蒙构建只需要一个插件:

[versions]
kotlin = "2.2.21-1.0.0"

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

settings.gradle.kts 把 eazytec 双仓写在 pluginManagement 与 dependencyResolutionManagement 最前:

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

include(":charts")
include(":example:nativeApp")

gradle.properties 写死 JDK 21 路径、version=0.1-ohos.1、kotlin.native.ignoreDisabledTargets=true 与 kotlin.native.distribution.downloadFromMaven=true;buildSrc/ 整目录删除——它只服务上游 Android 示例,且版本目录裁剪后其 AGP classpath 别名已不存在,留着会让每次构建先炸在 buildSrc。

2.2 编译数学内核

charts/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}
    }
}

无 Compose 插件、无 AGP、无 Dokka,src/androidMain(只有空 manifest)删除。实测:

./gradlew :charts:compileKotlinJvm                                       # BUILD SUCCESSFUL
./gradlew :charts:compileKotlinOhosArm64 :charts:compileKotlinOhosX64    # BUILD SUCCESSFUL
2.3 BubblePacking 接口化(30 行手术)

上游打包引擎操作 Bubble(含 Color 与 ImageVector),被 Compose 类型卡死。手术方式不是搬走算法,而是把"可打包的圆"抽象成接口:

interface PackableCircle {
    var radius: Float
    var position: Vector
    var velocity: Vector
    fun applyForce(force: Vector)
    fun update()
}

class BubblePacking<T : PackableCircle>(
    data: List<T>,
    containerWidth: Float, containerHeight: Float,   // 原 Size 参数换成两个 Float
    distanceBetweenBubbles: Float = 0f
)

composeMain 的 Bubble 实现该接口(函数体零改动),鸿蒙光栅化引擎提供自己的 PackedBubble 实现——同一份物理引擎(分离力、边界钳制、超时迭代、resize 收敛)在 Android 端与鸿蒙端是同一行代码。

第 3 阶段:桥协议建模

3.1 JSON 契约
com.netguru.multiplatform.charts/**(上游数学内核,原样编译)
        ↓ YAxisScale / TimestampXAxisScale / measureChartGrid / BubblePacking
Bridge.kt(@CName × 1,树状 JSON 进出)
        ↓ UTF-8 JSON(kind 分发 + PNG Base64 回传)
N-API chartsRender()
        ↓
ArkUI Index.ets(六 Tab)
请求 : {"kind":"bar","width":1260,"height":781,"density":3,"thickness":14,
        "categories":[{"name":"Q1","entries":[{"name":"电力","y":120,"color":"#3F51B5"},...]},...]}
响应 : {"ok":true,"kind":"bar","width":1260,"height":781,"bytes":44221,
        "labels":[{"text":"-30","x":144.0,"y":592.0,"size":36.0,"color":"#ff212121","align":"right"},...],
        "legend":[{"name":"电力","color":"#ff3f51b5","dashed":false,"shape":"rect"},...],
        "pngBase64":"iVBORw0KGgo..."}

图表数据天然两层以上嵌套(categories[].entries[]、series[].points[][]),qrose 的扁平 MiniJson 不够用,因此手写了 ~180 行递归下降树状解析器(Json.Obj/Arr/Str/Num/Bool/Null)。仍然不上 kotlinx.serialization:不把序列化运行时拖进 KN 链路,C ABI 保持 const char* -> const char*。

3.2 错误通道
@CName("ChartsRender")
fun chartsRender(request: String): String = try {
    /* kind 分发:bar/line/pie/dial/bubble/gasbottle */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、空数据集、颜色解析失败,全部走同一个 ok:false 通道;响应侧所有坐标/字号经 isFinite 防护,绝不输出裸 NaN 令牌(这是真机上踩过的坑,见 8.1)。

第 4 阶段:光栅化引擎(技术难点)

4.1 软件画布

PxCanvas 是 IntArray ARGB 缓冲 + srcOver 混合:fillRect / fillRoundRect / fillCircle / fillEllipse、粗线(距线段距离 ≤ 半宽判定)、虚线(沿弧长 5px 开 5px 关,对应上游 dashPathEffect(floatArrayOf(5f,5f)))、扇环、扫描线多边形填充(奇偶/非零环绕)、径向渐变多边形与裁剪渐变/椭圆。无抗锯齿——与 qrose 光栅化同一保真等级。

4.2 六种图表的几何移植纪律

每处几何都在代码注释里标明上游出处,例如柱状图逐行移植 DrawScope.drawBarChart:

val xAxisTickWidth = chartWidth / (categories.size * 2)
val barWidth = (xAxisTickWidth / maxBarsCountInACluster).coerceIn(1f, maximumThickness)
val clusterXOffset = (categoryIndex * 2 + 1) * xAxisTickWidth - clusterWidth / 2
// 负值柱从 zeroPosition 向下,圆角钳 min(w,h)/2(Skia 同款行为)

折线图移植 mapDataToPixels + calculateConnectionPointsForBezierCurve(控制点取前后点 y 于中点 x),三次曲线按弧长 3px 展平描画;线下填充按行着色三停渐变(透明 → α/12 → α/6)。仪表盘逐字移植 drawProgressBar 的 WithDegreeGap start/sweep padding 公式与 drawScale 的主/次刻度几何。气瓶把上游 GasTank ImageVector 的 85×143 视口六条路径(moveTo/curveTo/verticalLineTo/close)逐条转录进路径构建器,三次贝塞尔每段展平 16 折线,even-odd / non-zero 环绕照搬,径向渐变(中心 57.73,51.67、半径 58.19)逐像素计算,液位裁剪对应上游 BlendMode.SrcAtop。

饼图扇环后期做了无三角函数优化:扇区归属用两条边界射线 的叉积符号判定(每像素 4 次乘加,替代 atan2),间隙用垂距 ≤ gap/2 判定——这恰好等价于上游中心放射多边形产出的等宽楔形;超过 180° 的扇区拆两半链接。真机渲染耗时约降一半。

4.3 真 DEFLATE 压缩(载荷从 MB 级到 KB 级)

qrose 沿用的 stored DEFLATE 对码图足够,但图表位图直接到 2.7–6.4MB——Base64 后多 MB 字符串过 NAPI,真机上就是超时/OOM。PngEncoder 重写为单块 BTYPE=01 固定 Huffman:LZ77 哈希链匹配(15 位哈希、链深 64、窗口 32K)、长度/距离码表、LSB 位流 + Huffman 码逆序写入。图表平色主导的位图压到 24–52KB(约百倍),并用 java.util.zip.Inflater 做三组 round-trip 对拍把关。

第 5 阶段:原生桥接

5.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libcharts_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoscharts.so)
  │ ChartsRender
  ▼
UTF-8 JSON + DisposeString
5.2 导出与包装
@CName("ChartsRender")
fun chartsRender(request: String): String
C ABI 符号ArkTS 方法覆盖内核
ChartsRenderchartsRender(req)六种图表的刻度数学 + 几何移植 + PNG 编码,一口进
napi_property_descriptor desc[] = {
    {"chartsRender", nullptr, NapiChartsRender, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr}};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoscharts_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。CMake 以 IMPORTED 目标引入 KN so、$ORIGIN rpath、POST_BUILD 把三件套拷到同目录;OHOS_ARCH 为空或 KN so 缺失直接 FATAL_ERROR。

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

第 6 阶段:ArkUI 宿主

6.1 请求生成与响应消费
private generate(body: string): void {
  let raw: string = '';
  try {
    raw = chartsRender(body);
  } catch (e) { /* chartsRender异常:name/message/req 前 200 字 */ }
  try {
    const parsed = JSON.parse(raw) as RenderOk;
    if (!parsed.ok) { this.status = '渲染失败: ' + parsed.error; return; }
    this.labels = parsed.labels ?? [];
    this.legend = parsed.legend ?? [];
    this.imgWvp = parsed.width / this.density;   // 显式宽高,不用 aspectRatio
    this.imgHvp = parsed.height / this.density;
    this.decodePng(parsed.pngBase64);
  } catch (e) { /* 解析异常:raw 前 200 字 */ }
}

三段式 try/catch 把失败精确定位到 N-API 调用、JSON 解析还是渲染逻辑,红字直接上屏——这套诊断在真机排障中把"color 字段忘加引号"一击定位(见 8.1 第 5 条)。

6.2 文本叠加与图例

刻度文本是 PNG 之外的 JSON 载荷:Text 以 position(px/density) 定位、markAnchor 按 align(left/center/right)对齐锚点;ForEach 的 key 必须包含坐标——气泡重排后文本不变、坐标变,key 不变则 ArkUI 复用旧组件不刷新位置。图例走原生 Row + borderRadius(line 形态用虚线边框、circle 形态用圆角方块),不进 PNG。

6.3 解码期强引用(真机空白图的解药)

createPixelMap() 是异步的:解码完成前若传入的 ArrayBuffer 被 GC 回收,会得到一个空 PixelMap——渲染管线最慢的饼图率先踩中(旧图残留几秒后变空白)。修复是在解码期间持有 Uint8Array / ArrayBuffer / ImageSource 三个引用,.then / .catch 里再释放。

四、完整代码对照

4.1 整体架构

com.netguru.multiplatform.charts/**(上游数学内核原样编译)
    │ mapValueToDifferentRange / YAxisScale / TimestampXAxisScale / measureChartGrid
com.netguru.multiplatform.charts.bubble/**(物理打包,接口化复用)
    │ BubblePacking<T : PackableCircle> / Vector
Bridge.kt + Json.kt
    │ ChartsRender(kind 分发 + 嵌套数据集解析)
Rasterizer.kt + PngEncoder.kt
    │ 六图表几何移植 + DEFLATE PNG
libcharts_napi.so
    │ chartsRender
Index.ets
    │ JSON parse + PixelMap 解码 + 文本叠加
    ▼
ArkUI 真机页面(六 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_charts、example-app 移出
gradle/libs.versions.toml裁剪为单一 kotlin-multiplatform 别名
build.gradle.kts(根)版本 0.1-ohos.1、全局禁用 Ohos*Test* 任务
gradle.propertiesJDK 21 路径、KN 开关
charts/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
charts/src/commonMain(11 文件)数学内核 + PackableCircle 接口(唯一新增源文件)
charts/src/composeMain(43 文件)上游 Compose 代码,rename 保留 100% 历史
example/nativeApp/build.gradle.kts双 ABI sharedLib(baseName=ohoscharts)、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt@CName 导出、kind 分发、NaN 防护、错误通道
nativeApp/.../Json.kt树状递归下降解析器(~180 行)
nativeApp/.../Rasterizer.ktPxCanvas + 六图表几何移植(~1300 行)
nativeApp/.../PngEncoder.ktDEFLATE(LZ77 + 固定 Huffman)PNG 编码
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鸿蒙适配主文档(25 节)

4.3 关键 API 对照

层次API作用
内核mapValueToDifferentRange值域 → 像素域映射(Long/Float/Double 四重载)
内核YAxisScalenice-number 刻度(1/2/5/10 分段 + 闭区间取整)
内核TimestampXAxisScale整点对齐 + maxTicks 限定周期
内核measureChartGrid网格线像素位置 + 零线(从 DrawScope 外壳拆出)
内核BubblePacking<T>物理圆填充(分离力/边界/迭代/resize)
渲染Rasterizer.bar/line/pie/dial/bubble/gasBottle上游几何的软件光栅化移植
编码PngEncoder.encodeArgbRGBA8 PNG + DEFLATE(stored 起步,后升级真压缩)
NativeChartsRenderC ABI JSON 导出
N-APIchartsRender(req)向 ArkTS 暴露能力
ArkTSgenerateFor(index)组请求、消费响应、刷新 UI

4.4 ArkTS 与 Kotlin 的边界

const raw: string = chartsRender(
  '{"kind":"bar","width":' + w + ',"height":' + h + ',"density":' + density +
  ',"categories":[{"name":"Q1","entries":[{"name":"电力","y":120,"color":"#3F51B5"}]}]}'
);
const parsed = JSON.parse(raw);   // { ok, pngBase64, labels, legend, ... }
"bar" -> Rasterizer.bar(
    width = width, height = height, density = density,
    categories = categories,                       // 树状解析后的嵌套数据集
    yAxisScale 由 :charts 的 YAxisScale 构造,grid 由 measureChartGrid 测量
)

两者之间只传输 JSON 字符串与 Base64 PNG,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:内核提取而不是整库编译

KStore 那批存储库能整库编译,是因为社区 Maven 补齐了 kotlinx ohos 制品线;图表库的"全部"是 Compose 绘制,社区 CMP 1.9.2 与上游 Compose 1.7.3 分属两条无法混链的版本线。可复用的资产是渲染无关的数学——刻度、映射、打包物理,这些留在 commonMain 原样编译;绘制按上游几何逐行移植到软件画布。路线选择基于库的依赖形态:纯逻辑库整库编译,纯 UI 库内核提取。

决策 2:PNG 回传而不是 ARGB 缓冲

跨 NAPI 传大数组要 typed-array 约定与生命周期管理;PNG Base64 一个字符串搞定,可存档、可直接喂 image.createImageSource。代价是必须自带压缩——stored DEFLATE 在图表位图上是 MB 级载荷(真机不显示图表的第一嫌疑),升级 LZ77 + 固定 Huffman 后降到 24–52KB。

决策 3:刻度文本走 JSON + ArkTS 原生叠加

KN 侧没有字体引擎。坐标文本返回 text/x/y/size/color/align,ArkTS 用原生 Text 渲染——清晰锐利、可本地化、字号随 density 缩放,PNG 只承载图形。

决策 4:保留 jvm() 目标 + 设备参数矩阵

引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码。除 compileKotlinJvm 编译闸门外,专门复刻 ArkTS 请求生成逻辑构建 24 组矩阵(4 种设备分辨率/密度 × 6 种图表,含中文标签),每组做响应 JSON 严格 round-trip、NaN 扫描与 PNG 解码校验——"内核没漂"靠这套矩阵说话。

决策 5:BubblePacking 接口化而不是复制

物理引擎是这类库最硬的资产,复制一份到 nativeApp 等于宣告"演示用的不是库"。30 行接口化(PackableCircle + 泛型 + 两个 Float 容器参数)让鸿蒙 Demo 调的就是 :charts 里那行算法。

六、测试与验证

6.1 测试环境

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

6.2 JVM 交叉验证

./gradlew :charts:compileKotlinJvm          # 数学内核编译闸门

设备参数矩阵(独立 JVM 调试工程,复刻 ArkTS 请求生成逻辑):

设备档位参数
手机 A1260px / density 3.0
手机 B1440px / density 3.25
手机 C1080px / density 2.75
模拟器1920px / density 1.5

4 档 × 6 图表 = 24 组全部通过:ok:true、响应 JSON 严格解析(与设备同源的解析器 round-trip)、无 NaN/Infinity 令牌、PNG 全部可解码。另附 DEFLATE 三组 Inflater round-trip(50B / 29B / 3000B 输入)与六张渲染图逐张视觉验收(柱簇/扇角/半环/气泡/瓶体几何与上游语义比对)。

6.3 HAP 验证

:charts:compileKotlinJvm: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: ChartsRender / libohoscharts_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libcharts_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully
start ability successfully

libohoscharts.so arm64 约 4.2MB、x86_64 约 3.9MB,加 Konan libc++_shared.so(1.8/1.7MB)与 CMake libcharts_napi.so 组成每 ABI 三件套;Demo PNG 载荷 24–52KB/张。链接期有一条 clang api.cpp:448 告警,出自 Kotlin/Native CAdapter 生成代码(qrose 同款),属工具链噪声。

6.4 功能用例

  1. 启动即渲染柱状图:四分类双序列柱簇、Q4 电力 -30 负值柱从零线向下、Y 轴 -30/20/70/120 刻度、X 轴 Q1–Q4、底部"电力/水费"图例;
  2. 拖「柱宽」6→30dp、「圆角」0→15dp,柱体即时变化;
  3. 折线图双序列:室温虚线(5px on/off)与用电量实线、X 轴 MM-dd HH:mm 整点(过密自动隔一显示)、线下淡渐变;
  4. 切「虚线」开关、拖「线宽」1→8dp 即时生效;
  5. 饼图圆环 ↔ 整圆切换、间隙 0→8dp 出现白色楔形;
  6. 仪表盘数值 0→100 滑杆跟随,0/100 时进度/背景弧按间隙规则消失,主刻度 11 根;
  7. 气泡图「重新排布」每次布局不同,数值/名称白字跟随气泡移动;
  8. 气瓶液位 0→100%,液面与颜色(满色 → 空色插值)跟随;
  9. 任一环节失败时红字三段式诊断(调用异常 / 解析异常 / 渲染失败)直接定位。

6.5 验证结论

数学内核、光栅化引擎、PNG 编码、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,六个 Tab 的图表、参数调节、文本叠加与图例全部按预期工作(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/compose-multiplatform-charts
cd compose-multiplatform-charts
export JAVA_HOME="/path/to/jdk-21"
./gradlew :charts:compileKotlinJvm                                              # 内核交叉验证
./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.charts
hdc shell hilog | grep ChartsDemo

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :charts 真正编译成 ohosArm64 / ohosX64,example/nativeApp 链出双 ABI 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. KN 上没有 java.lang.Math:Math.toDegrees / Math.toRadians 在 JVM 调试工程编得过、Native 链接期才报 Unresolved reference 'Math'——凡是 JVM 侧验证过的代码,链接 ohos 前先扫一遍 Math.;
  4. Long 区间映射除以 max - min:单点序列(时间戳全相同)在 KN 上直接 ArithmeticException: / by zero(JVM 上 Float 除零只是 Infinity),退化值域必须加护栏;
  5. 响应构造必须做严格 JSON round-trip:桥里 color 字段插值漏了引号,输出 "color":#ff212121 裸令牌——JVM 侧只用正则提取字段完全测不出来,真机上五个 Tab 齐刷刷 SyntaxError: Invalid Token,唯一没有 labels/legend(即没有 color 字段)的气瓶 Tab 反而正常,形成极强的误导;
  6. DEFLATE 双 zlib 头:位流里写了一遍 78 01、拼装流时又补了一遍,第二个头被 inflate 读成 BTYPE=00 stored——invalid stored block lengths。修复后必须用 Inflater round-trip 对拍;
  7. stored DEFLATE 扛不住图表位图:2.7–6.4MB 的 PNG Base64 过 NAPI 是真机"不显示图表"的第一元凶,升级 LZ77 + 固定 Huffman 后 24–52KB;
  8. ForEach 的 key 要含坐标:气泡重排后文本不变、坐标变,key 只用 索引+文本 时 ArkUI 复用旧组件、文字原地不动;图例 key 同理要加索引;
  9. 异步解码期的 ArrayBuffer GC:createPixelMap() 完成前缓冲被回收会得到空 PixelMap——渲染最慢的饼图表现为"旧图残留几秒后空白",解法是解码期间持强引用;
  10. Circle({width, height}) 在真机渲染不稳,图例圆点换 borderRadius 的 Row;BarPosition 枚举只有 Start/End 没有 Top;tabIndex 是组件通用属性名,@State 不能与之重名;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  12. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();仓库内 signingConfigs 保持空数组,签名材料由 DevEco 自动回填、绝不进 AtomGit。

8.2 已知问题

  • 鸿蒙侧不提供 Compose @Composable API:composeMain(43 文件)待 ohos compose 工件可用后回接;
  • 光栅化无抗锯齿、无动画(静态终态渲染,valueScale/alpha 恒为 1),与 qrose 光栅化同一保真等级;
  • 饼图间隙以垂距 ≤ gap/2 近似上游中心放射多边形,gap > 6dp 时可见差异;气泡 material-icons 图标不进 PNG(数值/名称仍叠加);
  • Y 轴标签宽度按 56dp 左边距预留,极长数值可能溢出;
  • 上游触摸交互(OverlayInformation 气泡、折线游标)未移植:PNG 是静态终态;
  • example-app(Android/Desktop Compose 示例)留盘不进构建;android/js 目标随根构建替换剥离,本 fork 不发 Maven Central。

8.3 未来优化方向

  • ohos compose 工件可用后把 composeMain 重新接回编译,直接输出 Compose 目标;
  • 折线触摸游标与柱状选中气泡的 JSON 化(返回命中区域,ArkTS 侧叠加浮层);
  • 动画支持:请求带进度参数,ArkTS 侧驱动逐帧重渲;
  • 在 CI 中加入 ohos 编译、ELF 符号检查、设备参数矩阵与 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和 uitest 自动化截图。

九、总结

9.1 核心难点回顾

上游 charts(51 文件全 Compose)
    → 依赖度盘点:11 纯 Kotlin / 3 可拆 / 43 Compose
    → 工具链对齐 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 + JDK 21
    → 根构建替换(cmp_qrose 模式,buildSrc 删除)
    → ohosArm64 / ohosX64 KLIB + BubblePacking 接口化
    → C ABI(@CName × 1)+ 树状 JSON 协议
    → 软件光栅化(六图表几何逐行移植)+ DEFLATE PNG
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析 + 文本叠加 + 解码期强引用
    → ArkUI 真机六 Tab 验收(参数调节 / 图例 / 诊断行)

9.2 封装层次

  • 内核层:上游原版区间映射、nice-number 刻度、整点时间轴、网格测量与打包物理;
  • 渲染层:软件画布 + 六种图表的上游几何移植 + 无三角函数扇环;
  • 编码层:LZ77 + 固定 Huffman 的 DEFLATE PNG;
  • Native 层:双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:请求生成、JSON 消费、PixelMap 解码、文本叠加与错误诊断;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 纯 UI 库的适配资产是"渲染无关的数学":先做依赖度盘点再定边界——刻度、映射、物理这类内核留下编译,绘制按几何逐行移植并注明上游出处;BubblePacking 用接口化而不是复制,让 Demo 跑的就是库里的算法;
  2. 载荷尺寸是桥接库的生命线:stored DEFLATE 的 MB 级 PNG 在真机上表现为"不显示图表",这种症状离根因隔了 NAPI、解码、渲染三层;把压缩、JSON 合法性、坐标更新(ForEach key)、缓冲生命周期(GC)这四件事在桥的两侧同时钉死,真机问题才能一次收敛;
  3. 验证要与被验证物同源:JVM 上只用正则提取响应字段,测不出"生成方自己拼错 JSON";用与设备同源的解析器做 round-trip、复刻 ArkTS 请求生成逻辑做 24 组设备矩阵、Inflate 对拍压缩流——每一层都用自己的消费者验收自己。

9.4 适配成果

当前适配已完成:

  • charts 数学内核的双 ABI Kotlin/Native 编译(上游算法零改动,BubblePacking 30 行接口化是唯一结构性手术);
  • example/nativeApp 光栅化引擎:六种图表几何移植 + 树状 JSON 解析 + DEFLATE PNG(图表位图 24–52KB);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号,双 ABI);
  • DevEco Stage ArkUI 六 Tab 验收页面(参数调节、刻度叠加、图例、三段式诊断);
  • JVM 交叉验证 + 24 组设备参数矩阵全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机六 Tab 全功能验证;
  • AtomGit 项目文档、OpenHarmony README(中英双份)、25 节中文主文档和本地效果图。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐