Compose Multiplatform 三方库 KoalaPlot(koalaplot-core)的 OpenHarmony 鸿蒙化适配实战(上游源码零修改 + 有状态 JSON 桥 + ArkUI Canvas 四种图表 + 交互式 zoom/pan)

库版本:KoalaPlot/koalaplot-core f056809(v0.12.1 线,MIT)|验证环境:Kotlin 2.2.21-1.0.0(鸿蒙定制版)|kotlinx-serialization 1.9.1-1.0.0|DevEco Studio 26.0.0|DevEco 模拟器|HarmonyOS 7.0.0(API 26)

前面几篇我把日历、图标这些"算多画少"的 CMP 库搬上了鸿蒙,这次升级到图表这种算得更狠的类型:KoalaPlot/koalaplot-core 是 Compose Multiplatform 生态里最有名的图表库之一,折线、柱状、散点、热力图都靠它。查了 CPF-KMP-CMP 官方清单和 AtomGit,同样没有鸿蒙版本,于是接着做。
在这里插入图片描述

图表和日历有个本质区别:它是有状态的。用户双击放大、拖拽平移之后,"当前可见的数据范围"就变了,后续每次重绘都要基于这个视域来算。这让桥接协议从"一问一答"升级成了"先拿 id、再操作、最后释放"的会话模式。结论先说:上游核心源文件一字未改,Kotlin/Native 编出双 ABI .so,DevEco 模拟器上折线/柱状/散点(可交互缩放平移)/热力图四个页签加 15 项 API 验收全部真实跑通,70 个单测全绿。

折线图 柱状图 *先睹为快:DevEco 模拟器实测。左:折线图,刻度与数据点偏移全部由 Kotlin/Native 侧的上游 AxisModel 计算;右:柱状图,柱心定位走上游 CategoryAxisModel 的 Full 偏移语义* ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/9734bcfecfa34a42aec9c05543bfda6c.png)

一、先看清楚:图表库的"值钱部分"在哪

照例先翻上游源码的目录分布,koalaplot-core 的 commonMain 下按包分:

目录内容对 Compose 的依赖
xygraph/AxisModel 接口、LinearAxisModel 全家(Double/Float/Int/Long 四个特化)、LogAxisModel、CategoryAxisModel、刻度计算、视域推进Dp 尺寸参数 + 注解
util/Range 扩展(lerp / normalize)、sign几乎为零
heatmap/generateHistogram2D 直方分箱几乎为零
chart/折线/柱状/散点/热力图 ComposableCanvas、Modifier、DrawScope,重度依赖

和日历一样的格局:容易写错的都在算法层,对 Compose 的依赖却只剩一个 Dp 和几个注解。图表里真正难的是这些:给定轴长 300vp,主刻度该取 0-50-100 还是 0-25-50-75-100?用户在画布 50% 的位置放大 2 倍后,可见范围从 0…100 变成多少(答案:25…75,锚点不动)?3 个分类的柱状图,柱心该放在 0.25/0.5/0.75 还是 0/0.5/1?600 个散点样本怎么分到网格里数出每格几个?这些全是 xygraph/、util/、heatmap/ 里纯 Kotlin 完成的,chart/ 那 20 多个 Composable 做的只是"拿着算好的偏移量在 Canvas 上画线画矩形"——这恰好是 ArkUI Canvas 的拿手好戏。

所以路线不变:算法原样复用,绘制用 ArkUI Canvas 重做。
在这里插入图片描述
在这里插入图片描述

二、整体链路

ArkTS (Index.ets / PlotApi.ets)
   │  import plotNative from 'libplot.so'
   ▼  plotNative.call('{"op":"create","type":"xy",...}')
libplot.so            ← C++ NAPI 薄层:字符串进、字符串出
   │  extern "C" OhosPlotCall / OhosPlotFree
   ▼
libohoskoalaplot.so   ← Kotlin/Native(ohosArm64 / ohosX64)
   │  PlotBridge:解析 op → 定位 plot id → 调用引擎 → JSON 序列化
   ▼
PlotEngine 门面(XYPlotEngine / CategoryPlotEngine)
   ▼
上游 AxisModel 系列 / Range / Histogram2D(零修改)+ 最小 shims

工程结构:

koalaplot-ohos-demo/
├── koalaplot/                      # 库模块
│   └── src/commonMain/kotlin/
│       ├── io/github/koalaplot/core/   ← 上游原样(xygraph/util/heatmap)
│       ├── io/github/koalaplot/core/ohos/PlotEngine.kt  ← 新增门面
│       └── androidx/compose/…          ← 新增 shims(Dp 等)
├── example/nativeApp/              # PlotBridge + PlotExport,产出 libohoskoalaplot.so
├── example/ohosApp/                # DevEco 工程(五个页签 Demo)
└── scripts/                        # build-so / copy-so

三、和日历的关键区别:从"无状态"到"有状态"的桥协议

日历的每个接口都是纯函数:传"2026-09、首日周一",返回网格,传多少次结果都一样。图表不行。散点页上用户点了"放大 2 倍",视域从 0…100 变成 25…75;接着点"右移 10%",变成 30…80。之后每次 ticks、points 请求都得基于 30…80 来算——状态必须存在某个地方。

存哪?两个选择:ArkTS 侧存视域、每次请求都带上,Kotlin 侧保持无状态;或者 Kotlin 侧存、ArkTS 拿个 id 来操作。我选了后者,原因有二:一是上游 AxisModel 自己就维护着 viewRange 这个内部状态(zoom/pan 推进的正是它),拆出来存 ArkTS 侧等于把上游的封装撕开一个口子;二是 zoom/pan 之后 ArkTS 需要立即知道新视域(标题栏要显示当前范围),让引擎自己报最顺。

于是桥协议变成会话式:

{"op":"create","type":"xy","xMin":0,"xMax":100,"yMin":0,"yMax":50}
→ {"id":1,"viewX":[0.0,100.0],"viewY":[0.0,50.0]}

{"op":"zoom","id":1,"factor":2,"pivotX":0.5,"pivotY":0.5}
→ {"viewX":[25.0,75.0],"viewY":[12.5,37.5]}

{"op":"points","id":1,"points":[[10,20],[50,40]]}
→ {"points":[{"x":0.1,"y":0.4},{"x":0.5,"y":0.8}],...}

{"op":"free","id":1}
→ {"freed":1}

create 拿 id,后续 13 个 op 里 8 个带 id 操作,页面销毁时 free 释放。registry 就是个 HashMap<Int, Any> 存在 .so 进程里。

这里有个 Kotlin/Native 特有的坑值得单独说:我最初照 JVM 的习惯给 create/free 加了 synchronized,ohos target 直接编译不过——Kotlin/Native 新内存模型下 synchronized 被整个移除了。好在 NAPI 的 call 由 ArkTS 的 JS 线程串行进入,天然满足串行访问约束,直接去锁即可。但如果你的应用有多个 Worker 同时调 .so,就得在 ArkTS 侧自己做串行化——这点写进了 README 的"线程模型"一节。

四、适配过程

4.1 shims:比日历还少

xygraph/ 对 Compose 的依赖只剩 Dp(刻度间距、轴长都是 Dp 单位)。上游里 minimumMajorTickSpacing * 2、axisLength / count 这类 Dp 算术到处都是,所以这个 shim 不能是空壳,得实现完整的成员运算符:

package androidx.compose.ui.unit

@JvmInline
public value class Dp(public val value: Float) {
    public operator fun plus(other: Dp): Dp = Dp(value + other.value)
    public operator fun minus(other: Dp): Dp = Dp(value - other.value)
    public operator fun times(other: Float): Dp = Dp(value * other)
    public operator fun div(other: Float): Dp = Dp(value / other)
    public operator fun compareTo(other: Dp): Int = value.compareTo(other.value)
    public override fun toString(): String = "$value.dp"
}

public inline val Float.dp: Dp get() = Dp(this)

vp 和 Dp 数值 1:1 对应,桥协议里的 xLengthVp / yLengthVp / tickSpacingVp 直接乘 .dp 构造。剩下的 shim 更小:@Immutable 空注解、State/remember/Composable 签名占位、sign() 顶层函数、ohosMain 里一个空的 @JvmName。

4.2 门面层:照抄上游 Composable 的调用姿势

PlotEngine.kt 不含任何算法,只把上游 Composable 内部的三步流程(算刻度 → 算偏移 → 定位)包成公开 API。以数值轴为例:

public class XYPlotEngine(
    xRange: ClosedFloatingPointRange<Double>,
    yRange: ClosedFloatingPointRange<Double>,
    minimumMajorTickSpacing: Dp = 50.dp,
) {
    public val xAxis: DoubleLinearAxisModel =
        DoubleLinearAxisModel(xRange, minimumMajorTickSpacing = minimumMajorTickSpacing)
    public val yAxis: DoubleLinearAxisModel =
        DoubleLinearAxisModel(yRange, minimumMajorTickSpacing = minimumMajorTickSpacing)

    public fun xTickValues(axisLength: Dp): TickValues<Double> = xAxis.computeTickValues(axisLength)
    public fun xOffset(value: Double): Float = xAxis.computeOffset(value)
    /** X 轴反算:偏移 → 数值,用于点击图表时反查数据点。 */
    public fun xValue(offset: Float): Double = xAxis.offsetToValue(offset)

    public val viewX: ClosedRange<Double> get() = xAxis.viewRange.value

    public fun zoom(zoomFactor: Float, pivotX: Float, pivotY: Float) {
        xAxis.zoom(zoomFactor, pivotX); yAxis.zoom(zoomFactor, pivotY)
    }
    public fun pan(amountX: Float, amountY: Float) { xAxis.pan(amountX); yAxis.pan(amountY) }
}

柱状图用 CategoryPlotEngine,X 轴换成 CategoryAxisModel<String>。分类轴有个容易踩的语义坑(下一节展开),所以门面把 categoryAxisOffset 参数原样透传,三种模式都能选。

4.3 分类轴偏移:0.75 不是 0.25

柱状图"柱心画在哪"这件事,上游 CategoryAxisModel 用 CategoryAxisOffset 枚举给了三种答案。以 3 个分类 A/B/C 为例,我写桥接单测时想当然地预期 Full 模式(首尾留整格 padding)下第三个分类 C 在 0.25,跑出来是 0.75——总格数 = 2 个间隙 + 2 个 padding = 4,第 i 个分类的中心在 (i+1)/(n+1):

模式ABC说明
Full(缺省)0.250.500.75首尾各留一整格,中心 (i+1)/(n+1)
Half0.1670.500.833首尾各留半格,中心 (i+0.5)/n
None0.000.501.00无 padding,i/(n-1)

反算(点击柱子反查是哪个分类)用 round(offset * n),越界由上游 clamp。查了上游 CategoryAxisModel.computeOffset 的实现把公式理对之后,把测试预期修正为 0.25/0.5/0.75,并顺手验证了反算:offset 0 → round(0×3) = 0 → “A”。Demo 柱状页默认用 Full 模式,柱心和格子边界的关系一眼可查。

4.4 桥接层:pan 的"到底动没动"

PlotBridge.kt 放在 commonMain,能在 JVM 上直接单测——这是这套架构最省心的地方。13 个 op 里大部分是直接的参数搬运,有两个要绕一下:

pan 检测。上游 pan() 返回 Unit,不告诉你到底动没动。但 ArkTS 侧需要知道(视域顶到边界时给用户个提示)。解法是前后对比视域:

private fun pan(req: JsonObject): JsonObject {
    val beforeX = e.viewX; val beforeY = e.viewY
    e.pan(amountX.toFloat(), amountY.toFloat())
    val pannedX = e.viewX != beforeX; val pannedY = e.viewY != beforeY
    return buildJsonObject {
        put("pannedX", pannedX); put("pannedY", pannedY)
        putView(this, engine(id))
    }
}

这里藏着第二个测试坑:初始视域就是全量程时,pan 会被 clamp 到零位移。0…100 的轴想往右移,左边没有空间,视域原地不动,pannedX 是 false——这是正确行为不是 bug。单测先 zoom factor=2 腾出空间(0…100 → 5…55),再 pan(+0.1),位移生效且 pannedX 为 true。

buildJsonArray 不可变。往数组里边循环边 add 是 JVM 的肌肉记忆,kotlinx.serialization 的 buildJsonArray 构建出来的却是不可变结构。points 这种批量换算得先攒进 ArrayList<JsonElement>,最后一次性 buildJsonArray { out.forEach { add(it) } }。

异常处理照日历的规矩:Kotlin 侧 try 住全部 Throwable,包成 {"error": "类名: 消息"} 返回,绝不穿越语言边界。最终 11 个桥接测试在 JVM 全绿,覆盖了 create/ticks/points/value/zoom/pan/setView/free 的正反路径、三种分类偏移、直方分箱和对数轴。

4.5 NAPI 层与编译部署

C++ 层沿用日历那套"谁分配谁释放"的 82 行薄层:OhosPlotCall 返回的 C 字符串用 OhosPlotFree 释放。CMake 链接 entry/libs/<abi>/libohoskoalaplot.so:

# 1. JVM 单测(70 个:59 上游 + 11 桥)
.\gradlew.bat jvmTest

# 2. 双 ABI release .so(arm64 2.6 MB / x86_64 2.5 MB)
.\gradlew.bat :example:nativeApp:linkReleaseSharedOhosArm64 :example:nativeApp:linkReleaseSharedOhosX64

# 3. 拷到 entry/libs/<abi>,hvigor 打 hap,安装启动
powershell -File example\ohosApp\build-hap.ps1
powershell -File example\ohosApp\install-run.ps1

上游 59 个单测里唯一没搬的是依赖 createComposeRule 的 LongLinearAxisModelTest(Compose UI 测试在鸿蒙 JVM 环境不可用),其余全部原样保留并通过。

五、运行效果(DevEco 模拟器实测)

冷启动 hilog 里 PlotNapi tag 下能看到真实的 NAPI 调用,每条请求和响应的头 80 字符都打出来了,ArkTS 侧另有 PlotDemo tag 记录每次调用的耗时。

5.1 折线与柱状:算法验证页

折线页走最标准的链路:create(xy) → ticks 拿主次刻度 → points 把 12 个正弦采样点换成 0…1 偏移 → ArkUI Canvas 画轴、画折线。刻度是上游按轴长和 minimumMajorTickSpacing 算出来的"好看数字"(0/25/50/75/100 这种,不会出现 0/17/34/51):

折线图 *折线页:X 轴 0..100 均匀取 5 个主刻度(上游刻度算法输出),折线按归一化偏移定位*

柱状页用 create(category),5 根柱子的中心就是 Full 偏移的 1/6、2/6、…、5/6:

柱状图 *柱状页:柱心由上游 CategoryAxisModel 的 Full 偏移定位((i+1)/(n+1)),柱宽是格子宽度减去间隙*

5.2 散点:交互式 zoom/pan 视域推进

这是本次适配的重头戏——上游 AxisModel 的视域推进能力完整透出。100 个正态随机点铺在 0…100 × 0…50 上,四个按钮操作视域:

散点图 *散点页:标题栏实时显示当前视域(viewX/viewY 来自 Kotlin 侧 AxisModel 的状态),放大/缩小/平移/重置按钮每次操作后全图重算*

每次点击按钮:zoom/pan op 推进视域 → 返回新 viewX/viewY → ArkTS 重新 ticks + points → Canvas 重画。放大后刻度自动变密(轴长不变、范围变小、上游刻度算法重新选主刻度间距),视域顶到边界时 pannedX/pannedY 返回 false,UI 可以据此提示"已到边界"。

5.3 热力图:600 样本直方分箱

generateHistogram2D 是上游 heatmap 包的纯函数:600 个样本(两个正态团 + 噪声)分到网格里数每格计数,返回 counts 二维数组。ArkUI 按计数着色画格子:

热力图 *热力图页:颜色深浅 = 该格样本计数,两个正态团清晰可见;分箱逻辑全部在上游 generateHistogram2D 里*

5.4 API 验收页:15 个用例一键跑

最后一页把 13 个 op 拆成 15 个可断言用例,每行显示请求 JSON 和 Kotlin 侧返回的原始 JSON,异常路径也在内(free 一个不存在的 id、create 传非法 type):

API 验收页 *API 验收页:一键执行 15 项,逐项展示请求与响应;autoScale/lerp/normalize/logAxis/zoom/pan/setView/clamp/分类偏移/反算/直方图全覆盖*

几个值得看的返回值:

用例模拟器返回
autoScale([3.2, 97.7] 一组值)min: 0, max: 100(按数量级取整的"好看"范围)
logAxis(-1..3)major: [0.1, 1, 10, 100, 1000],指数轴刻度
zoom factor=2, pivot=0.5(0…100)viewX: [25, 75](锚点不动,两侧各缩一半)
pan 初始视域 = 全量程pannedX: false(clamp 到零位移,正确行为)
value 分类反算 xOffset=0(Full, A/B/C)x: "A"(round(0×3)=0)
histogram 600 样本total: 600,max 为两个团心所在格

六、踩坑记

#坑现象解法
1Kotlin/Native 无 synchronizedcreate/free 加锁编译不过依赖 NAPI JS 线程串行进 .so,去锁;多 Worker 场景在 ArkTS 侧串行化(写入 README 线程模型节)
2分类偏移预期错单测预期 C=0.25,实际 0.75Full 模式中心是 (i+1)/(n+1),查上游 computeOffset 理对公式,修正测试预期
3初始视域 pan 无位移pannedX 恒 false,疑似 bug视域==全量程时被 clamp 是正确行为;单测先 zoom 腾出空间再 pan
4buildJsonArray 不可变循环内 add 编译不过先攒 ArrayList<JsonElement>,最后一次 buildJsonArray
5Compose UI 测试不可用createComposeRule 相关单测无法在鸿蒙 JVM 环境跑唯一没搬的 1 个上游单测,其余 59 个原样通过
6Dp 算术 shim 缺运算符上游 minimumMajorTickSpacing * 2 等编译不过shim 实现完整 plus/minus/times/div/compareTo 成员运算符

七、FAQ

Q1:为什么不直接用 Compose Multiplatform 的鸿蒙支持,让上游 Composable 直接跑?
图表 Composable 重度依赖 DrawScope/Modifier/组合渲染体系,当前鸿蒙侧 Compose 渲染栈尚不成熟。而拆开看,上游"值钱"的部分(刻度计算、视域推进、偏移换算、直方分箱)恰恰是纯 Kotlin,不含 Compose 渲染——把算法层原样编译成 .so、绘制用 ArkUI Canvas 重做,是当前最稳的路径,且上游零修改,后续跟版本只需重新同步 commonMain。

Q2:图表交互(捏合缩放、拖拽)怎么做?
桥协议已透出 zoom(factor, pivotX, pivotY) 和 pan(amountX, amountY),ArkTS 侧把手势识别(PinchGesture/PanGesture)的回调映射到这两个 op,再重新拉取 ticks/points 重画即可。Demo 散点页用按钮演示了同一链路,换成手势只是触发源不同。

Q3:多页面/多图表实例怎么管理?
create 每次返回新 id,registry 支持多实例并存。每个图表页面 aboutToAppear 时 create、aboutToDisappear 时 free,互不干扰。

Q4:性能如何?每帧都走 JSON 序列化?
静态图表(折线/柱状/热力图)只在进入页面和数据变化时调用一次,无每帧开销。交互场景(散点页)每次手势结算 2 次 op(zoom/pan + points),单次 .so 调用含序列化在模拟器上毫秒级(hilog 的 PlotDemo tag 有实测耗时),600 点规模无压力;更大的数据量建议在 Kotlin 侧做聚合/抽稀后再过桥。

Q5:能出 PieChart(饼图)吗?
上游 pie 包的算法核心是占比换算(纯 Kotlin),绘制是 Canvas 画扇形——同样的"算法过桥 + ArkUI 绘制"套路即可扩展。本次为控制篇幅先落了四种主流图表,饼图属于后续增量。

八、总结

这次 KoalaPlot 适配把这套"上游零修改"路线从"纯函数库"推进到了"有状态库":

  1. 算法与绘制解耦的判断再次应验。图表库 20 多个 Composable 看着吓人,但价值密度最高的 xygraph//util//heatmap/ 对 Compose 的依赖只有一个带运算符的 Dp shim——上游源码一字未改,双 ABI 直接编过。
  2. 有状态库的桥协议范式。create → 带 id 的操作 op → free 的会话模式,把上游 AxisModel.viewRange 的内部状态原封不动留在了 Kotlin 侧,ArkTS 只持有句柄。这个范式对后面所有有状态库(动画、手势识别、状态机类)都通用。
  3. Kotlin/Native 与 JVM 的差异要心里有数。synchronized 整个不存在、buildJsonArray 不可变、Compose UI 测试跑不了——这三个坑都在 JVM 单测阶段暴露或规避,说明"桥放 commonMain、先在 JVM 把逻辑测透"这套流程的价值。
  4. 验收闭环:70 个单测(59 上游 + 11 桥)全绿 + DevEco 模拟器四图表 + 15 项 API 验收页真实跑通 + hilog 全链路可追溯。

适配成果已推至 https://atomgit.com/oh-tpc/koalaplot,含双 ABI .so、完整 ArkTS Demo 与双语 README。下一篇继续按 CPF-KMP-CMP 清单往下啃。

本文代码与资源

OpenHarmony 三方库社区地址:https://atomgit.com/oh-tpc
OpenHarmony 三方库适配地址:https://atomgit.com/oh-tpc/koalaplot
github 三方库地址:https://github.com/KoalaPlot/koalaplot-core
官方文档地址:https://koalaplot.github.io/koalaplot-core/
鸿蒙定制仓库地址:https://maven.eazytec-cloud.com/nexus/repository/maven-public/
鸿蒙适配版(库模块):https://atomgit.com/oh-tpc/koalaplot/tree/main/koalaplot

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐