开源鸿蒙平台 CMP 三方库「compose-multiplatform-charts」适配全流程
本文记录
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物理引擎随机初速重排,数值/名称标签跟随气泡移动; - 气瓶:上游
GasTankImageVector 六条路径逐条转录,液位渐变与液面双椭圆随百分比变化。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 渲染是否成功,失败时 error 带原文。 |
width / height / bytes | PNG 尺寸与字节数。 |
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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Temurin jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 自上游 8.11.1 升级;2.2.21 旧线要求 8.x) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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 方法 | 覆盖内核 |
|---|---|---|
ChartsRender | chartsRender(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.properties | JDK 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.kt | PxCanvas + 六图表几何移植(~1300 行) |
nativeApp/.../PngEncoder.kt | DEFLATE(LZ77 + 固定 Huffman)PNG 编码 |
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 | 鸿蒙适配主文档(25 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 内核 | mapValueToDifferentRange | 值域 → 像素域映射(Long/Float/Double 四重载) |
| 内核 | YAxisScale | nice-number 刻度(1/2/5/10 分段 + 闭区间取整) |
| 内核 | TimestampXAxisScale | 整点对齐 + maxTicks 限定周期 |
| 内核 | measureChartGrid | 网格线像素位置 + 零线(从 DrawScope 外壳拆出) |
| 内核 | BubblePacking<T> | 物理圆填充(分离力/边界/迭代/resize) |
| 渲染 | Rasterizer.bar/line/pie/dial/bubble/gasBottle | 上游几何的软件光栅化移植 |
| 编码 | PngEncoder.encodeArgb | RGBA8 PNG + DEFLATE(stored 起步,后升级真压缩) |
| Native | ChartsRender | C ABI JSON 导出 |
| N-API | chartsRender(req) | 向 ArkTS 暴露能力 |
| ArkTS | generateFor(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 请求生成逻辑):
| 设备档位 | 参数 |
|---|---|
| 手机 A | 1260px / density 3.0 |
| 手机 B | 1440px / density 3.25 |
| 手机 C | 1080px / 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 功能用例
- 启动即渲染柱状图:四分类双序列柱簇、Q4 电力 -30 负值柱从零线向下、Y 轴 -30/20/70/120 刻度、X 轴 Q1–Q4、底部"电力/水费"图例;
- 拖「柱宽」6→30dp、「圆角」0→15dp,柱体即时变化;
- 折线图双序列:室温虚线(5px on/off)与用电量实线、X 轴
MM-dd HH:mm整点(过密自动隔一显示)、线下淡渐变; - 切「虚线」开关、拖「线宽」1→8dp 即时生效;
- 饼图圆环 ↔ 整圆切换、间隙 0→8dp 出现白色楔形;
- 仪表盘数值 0→100 滑杆跟随,0/100 时进度/背景弧按间隙规则消失,主刻度 11 根;
- 气泡图「重新排布」每次布局不同,数值/名称白字跟随气泡移动;
- 气瓶液位 0→100%,液面与颜色(满色 → 空色插值)跟随;
- 任一环节失败时红字三段式诊断(调用异常 / 解析异常 / 渲染失败)直接定位。
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 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:charts真正编译成ohosArm64/ohosX64,example/nativeApp链出双 ABI 动态库; settings.gradle.kts里 eazytec 双仓必须排在最前,否则配置期直接Unresolved reference: ohosArm64;- KN 上没有
java.lang.Math:Math.toDegrees/Math.toRadians在 JVM 调试工程编得过、Native 链接期才报Unresolved reference 'Math'——凡是 JVM 侧验证过的代码,链接 ohos 前先扫一遍Math.; - Long 区间映射除以
max - min:单点序列(时间戳全相同)在 KN 上直接ArithmeticException: / by zero(JVM 上 Float 除零只是 Infinity),退化值域必须加护栏; - 响应构造必须做严格 JSON round-trip:桥里
color字段插值漏了引号,输出"color":#ff212121裸令牌——JVM 侧只用正则提取字段完全测不出来,真机上五个 Tab 齐刷刷SyntaxError: Invalid Token,唯一没有 labels/legend(即没有 color 字段)的气瓶 Tab 反而正常,形成极强的误导; - DEFLATE 双 zlib 头:位流里写了一遍
78 01、拼装流时又补了一遍,第二个头被 inflate 读成 BTYPE=00 stored——invalid stored block lengths。修复后必须用 Inflater round-trip 对拍; - stored DEFLATE 扛不住图表位图:2.7–6.4MB 的 PNG Base64 过 NAPI 是真机"不显示图表"的第一元凶,升级 LZ77 + 固定 Huffman 后 24–52KB;
- ForEach 的 key 要含坐标:气泡重排后文本不变、坐标变,key 只用
索引+文本时 ArkUI 复用旧组件、文字原地不动;图例 key 同理要加索引; - 异步解码期的 ArrayBuffer GC:
createPixelMap()完成前缓冲被回收会得到空 PixelMap——渲染最慢的饼图表现为"旧图残留几秒后空白",解法是解码期间持强引用; Circle({width, height})在真机渲染不稳,图例圆点换borderRadius的 Row;BarPosition枚举只有 Start/End 没有 Top;tabIndex是组件通用属性名,@State不能与之重名;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free();仓库内signingConfigs保持空数组,签名材料由 DevEco 自动回填、绝不进 AtomGit。
8.2 已知问题
- 鸿蒙侧不提供 Compose
@ComposableAPI: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 三条经验
- 纯 UI 库的适配资产是"渲染无关的数学":先做依赖度盘点再定边界——刻度、映射、物理这类内核留下编译,绘制按几何逐行移植并注明上游出处;BubblePacking 用接口化而不是复制,让 Demo 跑的就是库里的算法;
- 载荷尺寸是桥接库的生命线:stored DEFLATE 的 MB 级 PNG 在真机上表现为"不显示图表",这种症状离根因隔了 NAPI、解码、渲染三层;把压缩、JSON 合法性、坐标更新(ForEach key)、缓冲生命周期(GC)这四件事在桥的两侧同时钉死,真机问题才能一次收敛;
- 验证要与被验证物同源:JVM 上只用正则提取响应字段,测不出"生成方自己拼错 JSON";用与设备同源的解析器做 round-trip、复刻 ArkTS 请求生成逻辑做 24 组设备矩阵、Inflate 对拍压缩流——每一层都用自己的消费者验收自己。
9.4 适配成果
当前适配已完成:
charts数学内核的双 ABI Kotlin/Native 编译(上游算法零改动,BubblePacking30 行接口化是唯一结构性手术);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。
参考文档
更多推荐



所有评论(0)