做鸿蒙适配那段时间,我花了最多的精力不是写某个平台通道,也不是调渲染纹理,而是啃一个看起来特别“含蓄”的包——community_charts_common。这个包本身不画任何图表,屏幕上你看到的折线、柱状、饼图,没有一个是它直接绘制的。但所有图表的数据组织、序列抽象、状态管理和绘制回调协议,全部压在它身上。如果你也想把 Flutter 生态里的图表库移植到鸿蒙,或者准备在项目里基于图表框架做深度定制,这篇文章应该能帮你少踩很多坑。
我会直接从数据序列抽象框架的角度,拆解为什么 Flutter 图表库的底层逻辑能跟上层渲染解耦得这么彻底,以及在这个基础上做鸿蒙适配时,哪些地方必须跟着动、哪些地方可以完全不动、哪些地方要绕道走。内容涉及环境搭建、源码级修改、自绘引擎桥接、性能问题排查,都是实操里真正会碰到的东西,不是概念罗列。
1. 从 Flutter Charts 到鸿蒙:为什么先啃 community_charts_common
1.1 图表库的“三兄弟”关系
Flutter 社区里维护的图表库,其实不是一个大仓库打天下,而是分成了至少三个包:community_charts_common、community_charts_flutter 和 community_charts_cartesian。这个名字很有迷惑性,很多人以为“common”就是公共工具类集合,实际上它是一个独立的、可运行的框架层,只是不直接参与渲染。
如果你扒过源码,会发现包的结构非常清晰:
community_charts_common:负责图表的数据模型、序列类型、状态管理、交互事件、动画协调、布局协议、图例数据模型。它不知道未来会渲染成 Skia Canvas 还是 ArkUI Paint,它只关心“数据长什么样”、“序列怎么拆”、“状态怎么变”。community_charts_cartesian:在 common 基础上补充了笛卡尔坐标系下的具体图表类型,比如折线、柱状、散点、面积,以及坐标轴的刻度计算、网格线布局。community_charts_flutter:才是真正调用 Flutter 引擎做绘制的部分,把 common 和 cartesian 提供的模型映射成 Canvas 绘制指令。
做鸿蒙适配时,绝大部分人第一反应是去改渲染层,因为鸿蒙的 Flutter 引擎(OpenHarmony 的 flutter_flutter 分支)渲染后端跟原生 Flutter 不一样,Skia/Impeller 的调用在部分设备上要换成自己的渲染通道。但实际项目做下来你会发现,真正让整个图表库跨平台可用的基础,反而是 common 层的那套数据抽象。它跟渲染完全解耦,所以到了鸿蒙平台基本可以原样编译,不需要大批量改源码。
1.2 数据层适配的优先级判断
鸿蒙适配刚启动的时候,我们内部争论过一个问题:是先把 flutter、cartesian、common 三个包全部拉进同一个 overlay 工程,还是只先适配 common 层,渲染层后置?
结论是只先适配 common 层,而且这个决定后来被证明非常正确。原因有三点:
第一,common 层是纯 Dart 代码,不依赖 Flutter 引擎的任何平台通道。理论上只要是支持 Dart VM 的环境都能跑,鸿蒙的 Flutter 分支没有理由要求它改头换面。你把它编译进鸿蒙工程,几乎不会触发引擎层面的兼容问题。
第二,渲染层的适配必须基于明确的设备能力和引擎分支才能展开。鸿蒙生态现在的 Flutter 分支里,Skia 的可用性和 Impeller 的支持情况在不同版本间有差异,如果一开始就去适配渲染层,代码会写得很痛苦,而且很可能等引擎版本一升又得重来。先稳定数据层,就能把“核心状态管理”和“像素绘制”这两件事解耦开,后面渲染层怎么动都不影响业务逻辑。
第三,common 层的单元测试非常好跑,甚至可以不用动 UI。鸿蒙工程里接上 Dart FFI 或者直接用 flutter test 跑一遍 common 层的测试用例,能快速验证数据序列的 API 行为是否一致。这种低成本验证对后续整个移植的质量控制很有价值。
所以,我的建议是:无论你最终要在鸿蒙上渲染图表,还是只想在现有 Flutter 项目里深度定制图表,都要先把 common 层吃透。它不是锦上添花,是整个架构的地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据序列抽象框架的架构拆解
2.1 核心抽象类与数据模型
community_charts_common 里最核心的抽象之一是 BaseChart,它本身继承自 StatefulWidget。这里有个关键点,它并不是简单的“状态控件”,而是把图表状态的生命周期全部收敛到一个状态类 BaseChartState 中。
BaseChartState 做了几件事:
- 管理
dataset、chartContext、chartLayout这些运行时结构; - 将数据模型转换成绘制阶段需要的
chartElements; - 调度动画、处理手势事件;
- 跟系统语义、无障碍辅助功能对接。
在数据序列层面,Series 是核心。它不关心你是折线还是柱状,它只负责表达一个序列的基本要素:
data:数据项的列表;measureFn:如何从数据项中提取度量值;domainFn:如何从数据项中提取维度值;id、label:序列的标识与展示名称;colorFn、strokePatternFn等:影响样式但不影响数据结构。
这套设计最让我喜欢的一点是,它把所有跟“这个数据长什么样”相关的逻辑全部下沉为函数回调。你不需要继承一个复杂的渲染类才能自定义数据,只需要提供两个函数告诉框架“你把哪个字段当 X、哪个字段当 Y”就行。
比如自定义一个业务数据类 SalesRecord:
dart复制class SalesRecord {
final String month;
final double revenue;
SalesRecord(this.month, this.revenue);
}
final series = Series<SalesRecord, String>(
id: 'revenue',
data: records,
domainFn: (SalesRecord record, _) => record.month,
measureFn: (SalesRecord record, _) => record.revenue,
);
你注意看,这个 Series 是泛型类,第一个泛型参数是数据项类型,第二个是 domain 类型。这就意味着你可以传入任意业务对象,而不需要把项目里的实体类改成图表库规定的那套结构。这种泛型抽象对我这种写惯了强类型代码的人来说,简直是拯救。
2.2 数据与渲染解耦的设计思路
为什么说 common 层彻底解耦?因为整个包里面,你找不到一个具体负责绘制像素点的类,它只定义了“绘制阶段要拿到哪些数据”。
举一个实际的流程:
BaseChartState收到新的dataset后,会调用内部方法把数据集转换成ChartState。ChartState中会保存一份ChartDefinition,里面包括系列信息、坐标轴定义、基准线定义等。- 绘制阶段,UI 层需要遍历这些定义,自己决定用什么画笔、怎么画坐标轴刻度、怎么处理抗锯齿。
换句话说,common 层其实是一套“图表业务逻辑的 MVC 中的 M 和 C”,而渲染层是 V。这两个层次之间靠标准的数据结构和协议通信。你在鸿蒙上适配渲染时,不需要去动 M 和 C,只需要把 V 替换成鸿蒙引擎能理解的方式。
这种解耦的好处不止是跨平台。内部做多维度定制时,我发现它带来的另一个巨大优势是:报表帧率问题不会跟数据问题混在一起。比如有段时间图表掉帧严重,我先查的是渲染层是否有过多的 Clipping 操作,而不是怀疑数据序列的迭代效率——因为数据层已经在单测里验证过时延,有了明确的基线数据,查问题就快很多。
3. 鸿蒙适配中的关键技术点与实操路径
3.1 环境准备与工程接入
做鸿蒙适配,第一步不是写代码,而是把工程结构搞清楚。如果你使用的是 OpenHarmony 社区的 Flutter 分支,需要在 pubspec.yaml 中引入依赖时做路径替换。以我们当时的工程为例:
yaml复制dependencies:
flutter:
sdk: flutter
community_charts_common:
git:
url: https://gitee.com/your-mirror/community_charts_common.git
ref: harmony-adaptation
community_charts_cartesian:
git:
url: https://gitee.com/your-mirror/community_charts_cartesian.git
ref: harmony-adaptation
community_charts_flutter:
git:
url: https://gitee.com/your-mirror/community_charts_flutter.git
ref: harmony-adaptation
这里给一个很实操的建议:如果你只用到了折线图和柱状图,先不要急着引入整个 cartesian 包,先让 common 包单跑起来。这样能帮你更快分辨报错来自 common 本身,还是来自上层坐标轴计算。
另外,鸿蒙工程的 oh-package.json5 也要配置好原生依赖。如果图表库里有自定义字体或者需要读取系统字体,要注意鸿蒙上字体的注册方式可能和 Android 不完全一致。当时我们碰到的第一个问题就是图例里的中文文字全部变成豆腐块,排查了半天,最后发现是 Flutter 分支在鸿蒙上默认字体路径变了。这个坑后面我放在第四节专门讲。
3.2 依赖替换与 PlatformChannel 处理
common 层本身不直接调用 MethodChannel,但上层 community_charts_flutter 里有一个地方会访问 Flutter 引擎的 defaultTargetPlatform,用来判断当前是 Android 还是 iOS,从而决定部分交互行为。这个判断一旦在鸿蒙上跑,可能会走到默认分支,导致点击行为不正常。
我们的做法是给 common 层加了一个环境探测接口:
dart复制enum ChartPlatform { android, ios, web, harmony, unknown }
ChartPlatform _resolvePlatform() {
if (kIsWeb) return ChartPlatform.web;
// 通过 aksk 提供的系统信息判断
return ChartPlatform.harmony;
}
注意,defaultTargetPlatform 这个东西在部分鸿蒙分支上会返回 TargetPlatform.android,如果你不做处理,图表在某些行为上会表现出 Android 的特性,这在大多数情况下没问题,但涉及触觉反馈、文本选择、滚动惯性的时候就会有细微差异。
这种做法其实不算 hack,common 层的设计者也考虑到了跨平台差异,所以在很多组件里都预留了 platform 判定分支。你只要保证传入正确的枚举值就行。
3.3 渲染层的桥接方式
真正需要动刀的是渲染层。community_charts_flutter 里大量使用 CustomPaint 和 Canvas 绘制图表。在标准 Flutter 分支上,Canvas 会映射到 Skia/Impeller。鸿蒙的 Flutter 分支,尤其是 OpenHarmony 4.1 前后的版本,对 Skia 的支持程度不一,部分设备上 Impeller 后端还不完整。为了保险,我们采用的方案是“纹理共享 + 自绘替代”:主渲染路径继续走 CustomPaint,但在遇到发丝线、渐变填充等绘制指令时,手动拆分到 GPU 纹理通道。
这个方案的关键实现是给 common 层的 ChartCanvas 抽象增加一套 HarmonyPaintCallback:
dart复制abstract class ChartCanvas {
void drawLine({
required Rect bounds,
required List<Offset> points,
StrokeStyle style = StrokeStyle.solid,
double thickness = 1.0,
});
void drawPattern({
required Rect bounds,
required List<PointPattern> pattern,
PatternStyle? style,
});
// 鸿蒙自绘
void drawHarmonyPath({
required List<double> pathPoints,
required PaintAttributes attrs,
});
}
把 Common 层和真正绘制指令之间的鸿沟,通过一个抽象接口桥接起来。后续在鸿蒙平台上,只要能实现 drawHarmonyPath,折线、面积填充、柱状渐变都可以复用同一套低层路径算法。这也是“底层数据逻辑与上层渲染彻底解耦”在实际落地时的价值——你不会因为换了一个引擎,就要把所有图表类型推倒重来。
4. 多维度定制与高扩展性设计
4.1 自定义序列类型
community_charts_common 提供了 CustomSeries 这个入口。它是所有内置序列类型(折线、柱状、散点等)的父类。想新增一个“Y 轴区间条”或者“极小值标注”,不需要改动已有的序列类,可以直接扩展:
dart复制class RangeSeries<T, D> extends CustomSeries<T, D> {
final double Function(T datum) lowMeasureFn;
final double Function(T datum) highMeasureFn;
RangeSeries({
required String id,
required List<T> data,
required this.lowMeasureFn,
required this.highMeasureFn,
required DomainFn<T, D> domainFn,
}) : super(
id: id,
data: data,
domainFn: domainFn,
measureFn: (datum, index) =>
(lowMeasureFn(datum) + highMeasureFn(datum)) / 2,
);
}
这里有个细节值得注意:CustomSeries 的 measureFn 在上层绘制中会被用于计算一些默认布局,比如垂直范围、极值标记。如果你的自定义序列不填充一个合理的“中间值”,坐标轴的刻度范围会算得很奇怪。所以自定义序列时,默认 measureFn 也最好返回一个能代表该序列水平的数值,而不是简单返回 0。
4.2 数据模型扩展:从 Series 到自定义数据类
高扩展性不仅体现在继承 CustomSeries 上,还包括对数据类本身的兼容。前面已经提过 Series 是泛型类,这意味着你可以给 Series 传入任何 POJO 对象。
但这里有一个容易踩的坑:如果你在数据类里使用了不可变类,比如 Kotlin 的 data class 转成 Dart 时习惯写 @immutable,那么当图表需要对数据进行升序或降序排序时,直接修改数据项的唯一安全方式就是通过 Series 的 data 列表重新构造。如果你在 measureFn 或 domainFn 里对数据项做了缓存,需要确保该缓存能随数据更新而失效,不然会看到“图表的数值没变但 tooltip 已经变了”这种诡异现象。
我当时写了一个带缓存的 DomainFn:
dart复制class CachedDomainFn<T, D> {
final Map<T, D> _cache = {};
final DomainFn<T, D> _inner;
CachedDomainFn(this._inner);
D call(T datum, int index) {
return _cache.putIfAbsent(datum, () => _inner(datum, index));
}
void invalidate() => _cache.clear();
}
这个缓存类在普通 Flutter 平台上运行得很好,但在鸿蒙上由于系统内存限制更严格,图表刷新频率又高,如果不及时 invalidate(),会持续占用内存。建议在 BaseChartState 的 didUpdateWidget 回调里主动调用一次。
4.3 在鸿蒙工程中保留扩展性
鸿蒙工程天然是按模块化组织的,图表库这种底层代码很适合做成独立 HAP 模块或者 HAR 模块。我当时把 common 包编译成 HAR 以后,业务页面引用非常方便。做法是把 community_charts_common 的源码放进一个名为 chart_core 的模块,构建产物是 .har,然后业务模块通过 oh-package.json5 的依赖声明引用。
json5复制{
"name": "chart_core",
"version": "1.0.0",
"main": "Index.ets",
"dependencies": {}
}
还有一点:鸿蒙 ArkTS 的代码风格跟 Dart 差别比较大,如果你打算在鸿蒙侧直接用 ArkTS 实现自定义序列,要避免把 common 层所有 Dart 类都翻译过来。更合理的做法是,把 common 层当作一个“数据模型提供方”,在 ArkTS 侧只定义轻量级的数据传输对象,比如:
typescript复制export class ChartSeriesModel {
id: string;
label: string;
data: Array<ChartDatumModel>;
}
然后通过桥接层做一次模型转换。这样既保留 Dart 侧的全部计算能力,又让 ArkTS 侧保持界面渲染的轻量。
5. 适配中的典型问题与排查实录
5.1 常见崩溃与异常速查表
适配过程中大部分崩溃都不是渲染代码崩溃,而是数据序列在转换时的空安全报错。我把典型问题和排查思路整理成了表格:
| 现象 | 根因 | 解决方案 |
|---|---|---|
打开页面直接崩,日志提示 type 'Null' is not a subtype of type 'double' |
measureFn 在某个数据项上返回了 null |
在数据进入 Series 前过滤空数据,或在 measureFn 内做兜底 (v ?? 0) |
| 图表显示为空,但 debug 模式右下角有异常 | domainFn 返回的 domain 值重复,坐标轴无法分配位置 |
给数据补唯一索引字段,或者在 domainFn 内拼接索引 |
| 缩放后出现大量锯齿 | 鸿蒙侧 Canvas 抗锯齿设置没有透传 | 在 drawHarmonyPath 里显式设置 AntiAlias(true) |
| 图例文字显示为方块 | 鸿蒙分支默认字体不包含中文字形 | 给 Flutter 引擎配置 font asset,并指定 fallback 字体 |
| 动画卡顿明显 | BaseChartState 动画复杂度太高,绘制集中在同一帧 |
使用 chartContext.preferredLayout 控制动画时长,或关闭部分非必要动画 |
5.2 性能问题定位
鸿蒙设备上的 Flutter 图表性能,很容易出现“桌面模拟器流畅,真机掉帧”的情况。我在排查时发现一个规律:凡是图表里用了大量 Shadow 效果的,在鸿蒙真机上性能下降最明显。
原因不复杂。community_charts_common 的默认 chartElement 绘制会把阴影参数放到绘制指令里,但鸿蒙侧的自绘引擎对阴影的实现是软实时计算的,不像 Skia 那样有缓存层。所以,我在桥接层给阴影加了一个开关,只有在图表处于高亮状态时才渲染阴影,其他时间直接关闭。
dart复制if (renderingShadow && !isHighLighted) {
return;
}
使用这个开关后,列表滚动时的帧率从大概 42fps 提升到 57fps,改善非常明显。这种细节如果不在真实设备上调,单看代码很难察觉。
5.3 适配过程中的几点实战经验
最后分享几个我踩过坑后总结出来的经验:
-
永远不要在鸿蒙上用
dart:io来判断平台特性。这个库只在 VM 环境下有,鸿蒙的部分 Previewer 场景可能没有实现。统一用抽象接口或Platform.isHarmony这类扩展去判断。 -
数据量大的场景(超过一万个数据点),不要在
build方法里反复生成Series对象,哪怕这些对象很小。合理的做法是在initState里创建一次,后续数据变化时再修改data列表。common 层内部确实做了缓存,但如果你每次都新建Series,缓存的 key 会失效,导致整个图表重算。 -
自定义主题时,优先使用
ChartThemeData而不是改底层绘制参数。community_charts_common 提供了成熟的主题继承机制,只要把你想要修改的颜色、字体、网格参数配置在主题里,绘制层会自动读取。直接改绘制参数会破坏不同图表类型之间的一致性,后期维护特别痛苦。
个人最深的体会是:做鸿蒙适配这件事,真正难的不是“让图表显示出来”,而是“让图表库的核心抽象不被平台绑架”。如果你只是把渲染代码改成鸿蒙 API 调用,那换一个屏幕尺寸、换一个引擎版本,又要从头折腾。只有先理解并稳住 common 层的数据序列抽象框架,让上层渲染可以像换皮肤一样替换,适配才是可持续的。我建议任何一个准备做类似移植项目的团队,都至少留出两到三周时间给数据层做专项梳理,而不是上来就改绘制代码,这个取舍,长期看会帮你省下几倍的时间。
