ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter图表库fl_chart适配OpenHarmony:自定义饼图完整实践

Flutter图表库fl_chart适配OpenHarmony:自定义饼图完整实践

折腾了大半个周末,我终于把 fl_chart 这个 Flutter 图表库完整跑到了 OpenHarmony 上,并且用它的 PieChart 组件实现了一个带中心文案、支持点击交互的自定义饼图。整个过程没有想象中那么恐怖,但绝对算不上一条命令就能跑通。这篇文章把我从踩坑到跑通的完整路径写下来:为什么选它、适配前要准备什么、实际改了哪里,以及最后怎么用 fl_chart 在这个平台上做出一个像样的自定义饼图。如果你也在计划把 Flutter 应用落到 OpenHarmony 上,同时又需要图表类功能,这篇内容应该能帮你少走不少弯路。

坦白说,最开始我并没有把它当成一个“适配项目”来做,只是想在一个已有的 Flutter 业务工程里加一个财务报表用的饼图。结果一编译到 OpenHarmony 目标设备,白屏、字体告警、触摸无响应全来了。后来我才意识到,图表库能不能在 OpenHarmony 上跑,和它是不是“纯 Dart”实现有直接关系。下面我把整个思考过程和实操细节一步步展开。

1. 为什么偏偏是 fl_chart

1.1 Flutter 应用在 OpenHarmony 上到底卡在哪

Flutter 本身是一套跨端 UI 框架,一套 Dart 代码可以在 Android、iOS、Web 和桌面端跑。但 OpenHarmony 并不是 Flutter 官方直接支持的目标平台,想在 OpenHarmony 上运行 Flutter 应用,通常要依靠社区或厂商维护的 Flutter 引擎适配版。这意味着,绝大多数 Flutter 三方库能不能直接跑,关键就看它到底依赖了什么东西。

常见的失败原因并不是 Dart 代码有问题,而是三方库内部调用了 Android 或 iOS 独有的原生能力,也就是 Platform Channel 那一套。比如定位、相机、生物识别这类库,底层必须要走原生插件,而 OpenHarmony 侧如果没有人写对应的原生实现,编译期或运行期就会直接挂掉。

但图表库不一样。像 fl_chart 这类纯 Dart 方案,它的所有绘制都是通过 Flutter 引擎提供的 dart:ui 能力完成的,包括 Canvas、Paint、TextPainter 这些,不依赖任何原生 View,也不走平台通道。所以理论上只要 Flutter 引擎本身能在 OpenHarmony 上跑,fl_chart 就有机会跑起来。真正的难点不在“移植库本身”,而在于处理 OpenHarmony 版本引擎的细节差异。

1.2 为什么不用自绘或其它图表库

决定用 fl_chart 之前,我也考虑过三个方案:直接用 CustomPaint 自绘饼图、使用另一个图表库、用 fl_chart。自绘饼图听起来很简单,画几个扇形加几条线就够了。但一旦你要处理动画、点击命中、文字防重叠、自适应屏幕宽度这些需求,自绘方案的工作量会迅速膨胀,而且后面每次改交互都需要自己维护,实在不划算。

表格对比一下我当时做的选型分析:

方案适配 OpenHarmony 的难度功能完整度长期维护成本
fl_chart较低,纯 Dart 实现,主要是引擎差异处理高,饼图/柱状图/折线图/散点图都有低,社区活跃
另一个老牌图表库中等,部分组件依赖旧版动画机制中,更新节奏慢中偏高
自绘 CustomPaint完全可控,但底层能力全部自己搭低,只适合极简需求高

fl_chart 还有一个很实际的优势:官方文档里的示例足够丰富,饼图的 API 分了数据、交互、动画三个维度,很多需求直接用参数就能配出来,不需要自己造轮子。另外它在 Flutter 官方生态里更新比较频繁,bug 修复也快。相比之下,自绘方案虽然“没有依赖”,但其实是把成本从选型阶段转移到了长期维护阶段。对我这种需要快速交付图表模块的开发者来说,fl_chart 是最稳妥的选择。

2. 适配前的准备工作与工程初始化

2.1 环境版本、工程结构和第一份 pubspec

在动手写饼图之前,先把基础环境梳理清楚。我当时的开发机装的是 DevEco Studio,配套的 SDK 和开发工具链都是按 OpenHarmony 应用开发的标准流程来的。与此同时还要准备一个能在 OpenHarmony 上运行的 Flutter 引擎版本,这个版本通常会滞后于 Flutter 官方主分支,所以第一步就是确认 Dart SDK 版本和 Flutter 引擎版本分别是什么。

版本对齐特别重要。我自己第一次踩的坑就是:本机 Flutter 用的是较新的稳定版,而 OpenHarmony 适配引擎是基于旧一点的 Flutter 版本做的,编译的时候一大堆 API 找不到,日志刷得人头皮发麻。所以建议你在创建工程之前,先查清楚 OpenHarmony 适配引擎对应的是哪个 Flutter 版本、哪个 Dart 版本,然后把本机和项目都切到一致。

工程初始化完成后,第一件事是在pubspec.yaml里加上 fl_chart 依赖:

dependencies: flutter: sdk: flutter fl_chart: ^0.66.0

然后执行flutter pub get。如果你的网络环境比较特殊,或者公司内部有统一依赖管理平台,拉取公共源可能会失败。这时候有两种常见替代方案:一种是把 fl_chart 的源码放到本地目录,通过path方式引入;另一种是把依赖包缓存到离线目录,每次构建直接指向缓存。两者的差别在团队协作场景下会比较明显:用固定版本号加 lockfile 的方式可复现性更强,而用本地路径更容易被业务临时改到,适合验证阶段。

2.2 先跑通一个最小可运行工程,别急着画图

这一步是很多教程不会强调但我强烈建议不要跳过的环节。先不要碰 fl_chart,也先不要写任何业务图表,直接在 OpenHarmony 设备或模拟器上跑一个最简单的 Flutter 页面,比如一个只有文本和按钮的计数器。如果这个默认页面能正常显示,说明 Flutter 引擎和 OpenHarmony 原生壳之间的通路是通的,后续问题才可以放心归因到图表库和引擎差异上。

我当时把这个验证页称为“适配基座”,后面所有实验都在这份代码上做增量。基座里只保留了三样东西:Flutter 入口、一个渲染文本的页面、一个按钮触发页面刷新。这样做的价值在于,当饼图出现异常时,我可以快速判断是引擎的基础渲染问题,还是 fl_chart 自身的问题,不会把时间浪费在模糊的“哪里都可能有问题”的排查上。

如果这个最简页面都跑不起来,通常说明问题在 Flutter 引擎构建层或项目配置层,这时候请先解决基线问题,再引入图表库。

3. 从移植到跑通:fl_chart 适配的完整过程

3.1 第一次运行官方饼图样例时的场面

我把基座跑通之后,迫不及待地把 fl_chart 官方示例里的饼图代码粘了进去,目标设备是一台 OpenHarmony 模拟器。结果页面冷启动之后白屏了,控制台里并没有崩溃,但不断有字体相关的警告刷出来。这个现象非常有迷惑性,因为它没有明确报错,只是图表不出现。我一度以为是数据没传进去,后来才发现问题根本不在数据。

经过反复试,最终定位到根因:OpenHarmony 上的 Flutter 引擎由于字体回退表与 Android 不完全一致,TextPainter在测量带有中文的文本时,返回的宽度可能为 0 或异常值,导致 fl_chart 内部计算每个扇区宽度时得到空值,最终整个饼图就没有渲染出来。这个坑相当隐蔽,因为你从 Dart 代码上看,数据、颜色、半径全部都设置对了,但底层测量已经出了问题。

另一个常见报错是构建阶段提示画布相关初始化失败,或者找不到某个渲染后端符号。这类问题通常是 Flutter 引擎切换渲染后端导致的,比如 OpenHarmony 适配版默认走软件渲染,而 fl_chart 内部部分绘制逻辑调用了 GPU 光栅化才有的接口。遇到这种情况,第一步不是去改 fl_chart,而是确认引擎是否提供了软件渲染回退开关。

3.2 关键差异点:字体、Canvas 和触摸映射

把跑通逻辑梳理开之后,fl_chart 适配 OpenHarmony 的核心差异主要集中在三块。

第一块是字体测量。刚才说的白屏问题,我的处理方式是在 Flutter 全局 theme 里显式指定字体回退列表,把中文字体路径加进fontFamilyFallback。这样TextPainter在测量的时候就不会因为找不到字形而返回异常宽度。对于饼图这种大量依赖文本测量来确定扇区标题位置和中心文字布局的场景,字体配置直接影响渲染正确性。

第二块是 Canvas 的兼容性。saveLayer、阴影、渐变这类绘制操作在软件渲染和 GPU 渲染两种模式下表现差异很大。我在真机上曾经看到过饼图扇区带渐变后出现一块块色斑,模拟器上却完全正常。后来定位到是因为渐变绘制走了某个 GPU 特性,而 OpenHarmony 适配引擎在特定驱动下没有正确回退。我的解决方案是比较保守的:在真机运行的版本里暂时关掉阴影,改用纯色加扇形边缘分隔线来保持视觉层次。这样做虽然牺牲了一点立体感,但换来了跨设备的一致性,数据看板本身要的是准确,而不是花哨。

第三块是触摸事件映射。fl_chart 的交互依赖 Flutter 的手势事件,而 OpenHarmony 设备上输入事件要先经过 Flutter 引擎的事件桥接,再透传给 Dart 层。实际测试下来,基础点击事件基本可用,但如果在不同屏幕密度设备之间切换,命中区域可能会有几个像素的偏差。处理办法是在使用触摸回调时,加上MediaQuery.devicePixelRatio对命中坐标做一次校准,或者放宽点击命中范围。

3.3 用最小数据集完成第一版可用适配

在适配过程中,我没有一次性把所有功能都堆上去,而是准备了一个“最小数据集”:只有三个扇区,颜色各不相同,没有动画,没有交互,没有渐变。先把这三段数据在 OpenHarmony 上稳定渲染出来,再逐步把中心文字、渐变、点击效果加回去。

这种增量式的做法非常有效。每加一个功能,如果出了问题,我都能明确判断是这一步改动引入的,而不是前面已经积累了几十个变量。大约用了一下午,我就把 fl_chart 官方饼图样例完整跑通了。需要注意的一点是,官方样例里如果是用 GoogleFonts 加载远程字体的,在 OpenHarmony 开发阶段不要直接搬进来,因为离线或者网络受限环境下,字体加载会直接导致页面闪退。把网络字体替换成本地字体是更稳妥的路线。

4. 用 fl_chart 实现自定义饼图

4.1 先搭出饼图骨架

适配跑通之后,真正有意思的部分才开始。我需要的不只是官方示例那种普通环形图,而是要一个能放进业务看板里的自定义饼图:中空圆环、每个扇区独立颜色、点击某个扇区后中心文字联动更新。fl_chart 的 PieChart 组件对这种需求支持得很好,核心数据结构就是PieChartData包一层扇区列表。

先看基础骨架代码:

import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; class CustomPieChartPage extends StatefulWidget { const CustomPieChartPage({super.key}); @override State<CustomPieChartPage> createState() => _CustomPieChartPageState(); } class _CustomPieChartPageState extends State<CustomPieChartPage> { final List<_PieItem> _items = [ _PieItem('渠道A', 30, const Color(0xFF4FC3F7)), _PieItem('渠道B', 25, const Color(0xFFFFB74D)), _PieItem('渠道C', 20, const Color(0xFF81C784)), _PieItem('渠道D', 15, const Color(0xFFE57373)), _PieItem('其它', 10, const Color(0xFF9575CD)), ]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('自定义饼图')), body: Padding( padding: const EdgeInsets.all(16), child: PieChart( PieChartData( startDegreeOffset: -90, centerSpaceRadius: 70, sectionsSpace: 3, sections: _items .map( (item) => PieChartSectionData( value: item.value, color: item.color, radius: 68, title: '${item.value}%', titlePositionPercentageOffset: 0.6, titleStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white, ), ), ) .toList(), ), ), ), ); } } class _PieItem { const _PieItem(this.label, this.value, this.color); final String label; final double value; final Color color; }

这里几个参数需要重点解释。startDegreeOffset控制饼图起始角度,设为 -90 后第一块数据从 12 点方向开始,符合大多数看板的阅读习惯。centerSpaceRadius是中空圆环的半径,值越大中心空白区域越大,给中心文字留出空间。sectionsSpace是扇区之间的间隙,设为 2 到 3 会让饼图有分隔感,看起来更精致。titlePositionPercentageOffset控制扇区标题离中心的距离,0.6 表示从中心到边缘这条半径上 60% 的位置,太大会导致文字溢出扇区,太小又会和中心文字重叠。

4.2 让饼图长得像产品想要的样子

骨架完成后,自定义样式主要集中在几个方向。

中心文字是产品侧比较关注的功能,但 fl_chart 本身并不直接在饼图中心渲染文案,常规做法是用 Stack 把饼图和中心文本叠起来。因为centerSpaceRadius已经预留了中心空白区,只要把文本放在 Stack 的中心,视觉上就像饼图的一部分。这种方法实现简单,还便于后面做点击联动。

扇区颜色方面,我建议不要在PieChartSectionData里反复写死颜色,而是像示例代码一样,把每个数据项的颜色集中定义在数据模型里。这样后续如果要做主题切换,只要替换数据模型里的颜色值即可,不需要碰布局和图表配置。

渐变和阴影需要谨慎。fl_chart 的PieChartSectionData提供了gradient属性,但如果目标是 OpenHarmony 真机,一定要先在目标机型上验证。刚才提到的色斑问题就是因为渐变和阴影叠加触发了不同的渲染路径。我的做法是把渐变做成可选开关,默认关闭,只有在确认设备渲染正常时才打开。

扇区上还可以加badgeWidget,这个属性允许你在扇区外侧放一个自定义小组件,比如带阴影的小圆点或者数值标签。我在模拟项目X里用它在关键扇区外侧放了一个“+12%”的增长箭头,效果比普通文本标题直观很多。注意badgeWidget的使用要控制数量,扇区太多时有遮挡风险。

4.3 交互和动画:点击联动中心文字

自定义饼图最核心的交互是点击扇区后更新选中的状态。fl_chart 处理这一步很方便,只需要在PieTouchData里注册回调,然后通过setState更新当前点击的索引即可。完整交互代码:

class _CustomPieChartPageState extends State<CustomPieChartPage> { int _touchedIndex = -1; String _centerLabel = '总计'; double _centerValue = 100; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('自定义饼图')), body: Stack( alignment: Alignment.center, children: [ Padding( padding: const EdgeInsets.all(16), child: PieChart( PieChartData( startDegreeOffset: -90, centerSpaceRadius: 70, sectionsSpace: 3, swapAnimationDuration: const Duration(milliseconds: 400), swapAnimationCurve: Curves.easeOutCubic, pieTouchData: PieTouchData( touchCallback: (FlTouchEvent event, PieTouchResponse? response) { if (event is FlTapUpEvent && response?.touchedSection != null) { final index = response!.touchedSection!.touchedSectionIndex; final item = _items[index]; setState(() { _touchedIndex = index; _centerLabel = item.label; _centerValue = item.value; }); } }, ), sections: List.generate(_items.length, (i) { final item = _items[i]; final isTouched = i == _touchedIndex; return PieChartSectionData( value: item.value, color: item.color, radius: isTouched ? 78 : 68, title: '${item.value}%', titlePositionPercentageOffset: 0.6, titleStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white, ), showTitle: true, ); }), ), ), ), Column( mainAxisSize: MainAxisSize.min, children: [ Text( _centerLabel, style: const TextStyle(fontSize: 16, color: Colors.grey), ), Text( '${_centerValue.toStringAsFixed(0)}%', style: const TextStyle( fontSize: 32, fontWeight: FontWeight.bold, color: Colors.black87, ), ), ], ), ], ), ); } }

点击切换后,被选中的扇区radius从 68 变成 78,会产生向外凸出的效果,这个反馈非常直观。中心文字同时变成对应渠道的名称和占比。由于 fl_chart 的扇区状态变化默认有动画过渡,整体体验很顺滑。动画时长用swapAnimationDuration设置,曲线用swapAnimationCurve来控制,不需要自己写补间动画。

这里有一个容易忽略的细节:FlTapUpEvent事件类型非常重要,如果只判断event is FlTapDownEvent,你会发现用户触摸的瞬间就触发了更新,而 fl_chart 内部可能还在进行动画,最终导致点击命中位置偏移。我在适配过程中就是因为这个问题,点击右侧扇区时经常触发左侧扇区,加上设备像素密度差异,浪费了不少时间。

5. 适配 OpenHarmony 时的实战心得与踩坑记录

5.1 问题排查速查表

把这次适配过程中遇到过的典型问题整理成表,方便你以后遇到类似情况直接对照排查。

现象可能原因处理方式
页面白屏,日志反复出现字体测量异常中文字体回退表不一致,TextPainter 返回异常宽度在全局 theme 中配置 fontFamilyFallback,显式指定中文字体
构建报错,找不到渲染后端相关符号Flutter 引擎版本与 OpenHarmony 适配引擎不一致统一 Flutter/Dart 版本,优先选择官方推荐的适配引擎版本
饼图显示色斑或颜色发灰渐变、阴影走了不兼容的 GPU 渲染路径关闭阴影,纯色扇区加边框分隔,或确认目标设备渲染稳定后再开渐变
点击扇区经常命中错误区域事件桥接或屏幕密度导致命中坐标偏移结合 devicePixelRatio 校准点击位置,并优先用 FlTapUpEvent 判断完成状态
离线环境下运行时闪退第三方字体远程加载失败改用本地字体资源,不引入 GoogleFonts 等远程字体方案
扇区标题文字与中心文字重叠titlePositionPercentageOffset 设置不合理确保标题偏移量大于中心半径占比,并在不同尺寸屏幕上验证
动画卡顿明显debug 模式下软件渲染性能不足用 release 模式验证动画,必要时减少扇区阴影和渐变

5.2 三个值得记住的适配原则

第一个原则是版本要锁死。OpenHarmony 上的 Flutter 引擎版本经常落后于官方主分支,而 fl_chart 新版本可能会用到更新版本的 dart:ui API。如果你发现最新版 fl_chart 编译不过,不要死磕,检查一下当前引擎支持什么版本,然后锁定一个兼容版本。稳定运行比用最新功能重要得多。

第二个原则是使用最小数据集隔离问题。饼图出问题时,先把数据减到三段,颜色用纯色,样式全部默认,确认基础渲染没问题再加复杂度。这个习惯帮我在排查过程中节省了大量时间。很多开发者一遇到白屏就怀疑库本身,最后排查半天发现是自己数据里有空值,或者字体配置有问题。

第三个原则是模拟器不能替代真机验证。我在模拟器上一切正常,真机上渐变和阴影却出现色斑,这个案例充分说明了差异可能存在于渲染后端和驱动层面。所以建议在开发计划里预留真机验证时间,尤其是涉及图形绘制和触摸交互的功能,必须以真机为准。

5.3 后续可以怎么扩展

这套适配验证方法并不只适用于饼图。fl_chart 里的柱状图、折线图、散点图其实都是同样思路,底层依然依赖 dart:ui 的绘制能力,所以适配经验大体可以复用。如果你后面要做复杂仪表盘,还可以在同一份Stack方案里把饼图和环形进度组件组合起来,视觉层次会更丰富。另外,由于 fl_chart 是纯 Dart 实现,整个适配过程基本不需要碰原生代码,打包体积也不会因为引入图表库而明显上升,这一点在 OpenHarmony 应用里也是加分项。

6. 最后再分享一个实用技巧

写到这里,整个 fl_chart 适配 OpenHarmony 并实现自定义饼图的路径已经比较完整了。最后分享一个我在这类适配任务里一直坚持的实用技巧:始终保留一个“最小验证 Demo”,专门用来验证引擎、渲染库和交互链路是否正常。这个 Demo 不要掺杂业务代码,也不要混入过多三方依赖,只包含一个页面、一组静态数据和最基本的交互。

当业务代码出现问题时,先切到最小验证 Demo 里复现。如果 Demo 能跑,说明问题出在业务侧或者依赖冲突上;如果 Demo 也跑不了,说明问题在引擎或图表库的适配层。这套二分法让我在排查时几乎不用猜,直接能找到问题边界。这次适配饼图时,我也是一开始就把三个测试场景固定在了最小验证 Demo 里:静态色块、动画切换、渐变阴影。基于这三个场景去隔离问题,效率非常高。另外提醒一句,官方文档里写得再兼容,实际跑起来要多拿模拟器和真机对比过一遍,这次在模拟器上正常、真机上渐变掉色的问题,就是对比后暴露出来的。希望对你有帮助。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进