ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Flutter柱状图实战:用fl_chart从入门到性能优化

Flutter柱状图实战:用fl_chart从入门到性能优化

做 Flutter 开发的人迟早会撞上一个需求:往页面上塞一张柱状图。我第一次图省事,找了一堆图形库,结果要么太重,要么 API 反人类。后来换了 fl_chart,把 BarChart 从零到一跑通之后,基本就再没换过别的。这篇文章聊聊我用 fl_chart 画柱状图的那点心得,适合刚从 ListView 毕业、想在项目里加图表的新手,也适合已经接入了但想少踩点坑的中级开发者。全文不涉及复杂的前后端架构,只说 BarChart 这一个组件怎么用顺手。

1. 为什么柱状图要选 fl_chart 而不是自己画

1.1 先说说我在项目里遇到的图表需求

图表这个需求,看着简单,做起来全是细节。我接过一个内部报表页,要求展示某业务在过去七天的数据变化,每根柱子要有颜色区分、顶部要显示具体数值、点击柱子还要弹出详细说明。如果拿 CustomPainter 硬画,绘制逻辑、触摸命中的坐标换算、文字排布、动画过渡都得自己处理,一套下来没个两三天搞不定,还容易在 iOS 和 Android 上出现渲染差异。用 fl_chart 的 BarChart,核心代码几十行就成型了,剩下时间都在调样式。

1.2 fl_chart 和别的图表库比,赢在哪

Flutter 生态里图表库其实不少,但 fl_chart 是维护最活跃、文档和示例最完整的那一批。它把所有图表拆成独立 Widget,BarChart 就是其中之一,用法和环境没有强耦合。它的 API 设计是"数据驱动"的:你把数据模型准备好,剩下的布局、动画、触摸响应交给图表本身。最打动我的细节是它自带动画过渡,数据刷新时柱子会自然地从旧值滑到新值,这个效果在 CustomPainter 里需要自己写插值,在 fl_chart 里是默认行为。

1.3 什么情况下可以直接画而不是用库

不要因为上了 fl_chart 就把所有图都往它身上塞。如果只是画两三个静态矩形,比如简单的进度条变体,那直接放几个 Container 都够了;如果需求是复杂的自定义图形,比如需要标注任意多边形、自由绘制热力图,那 CustomPainter 反而更灵活。fl_chart 适合标准图表类型,尤其是柱状图、折线图、饼图这种"看得见、摸得着"的常规形态。我在项目里总结的判断标准是:如果图表的数据结构能用"一维列表"描述清楚,用 fl_chart 通常是最省事的选择。

2. 从依赖引入到第一个柱状图跑通

2.1 版本怎么选,避免装完就冲突

fl_chart 的版本迭代很快,不同版本的 API 有细微差异。我在 pubspec.yaml 里一般直接写fl_chart: ^0.66.0,安装后先跑一个最小示例确认编译通过,再开始写业务代码。如果项目里还用了老版本的 JSON 解析库或绘图库,fl_chart 和它们基本没有共享依赖,冲突概率很低,但要注意 Flutter SDK 的最低版本要求。如果你用的 Flutter 版本偏旧,建议先看官方 changelog,不要盲目拉最新版。

开发中的提醒:如果不确认当前环境该用哪个版本,可以建一个干净项目先装上 fl_chart,编译通过后再复制业务代码过去。这样能避免版本问题混进你的逻辑排查里。

2.2 最小可运行代码:三条数据画一张图

拿到库之后,第一件要做的事不是看高深用法,而是把一张图"画出来"。下面这个例子是我的起点,用三根柱子代表三天数据。你复制到项目里就能跑。

import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; class SimpleBarChartPage extends StatelessWidget { const SimpleBarChartPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('fl_chart 柱状图示例')), body: Padding( padding: const EdgeInsets.all(16), child: BarChart( BarChartData( barGroups: [ BarChartGroupData(x: 0, barRods: [ BarChartRodData(toY: 3, color: Colors.blue), ]), BarChartGroupData(x: 1, barRods: [ BarChartRodData(toY: 5, color: Colors.orange), ]), BarChartGroupData(x: 2, barRods: [ BarChartRodData(toY: 7, color: Colors.green), ]), ], ), ), ), ); } }

这段代码不需要手动设置坐标轴,fl_chart 会按数据自动为 Y 轴生成整数刻度。运行后你就能看到三根颜色不同的柱子。很多教程会让你从这里开始,但如果你脑子里的问题已经是"怎么让柱子的文字显示出来",建议直接往下看数据模型。

2.3 图表不显示的常见原因

等我真的把这段代码写进项目时,遇到的最常见问题是:图表区域是空白,连坐标轴都没有。原因通常是BarChartData被写成了空对象,或者barGroups列表根本没有数据。还有一坑是放在了SingleChildScrollView里且没有给高度约束,BarChart 默认会填满父级约束,如果父级高度无限,它绘制时就会直接跳过。解决方法很简单:给 BarChart 包一个固定高度的 Container,比如SizedBox(height: 300)。

另一个诡异情况是柱子显示但 Y 轴刻度奇怪,不是从 0 开始。fl_chart 会基于数据自动计算最小值和最大值,如果你希望它强制从 0 开始,需要手动指定minY和maxY。这两种问题的排查路径完全不同,建议先确认是"没画出来"还是"画出来了不符合预期"。

3. BarChart 的数据模型:读懂三层结构

3.1 BarChartData 是总指挥

很多人第一次接触 fl_chart,会被一堆类名绕晕。其实记住三层结构就够了:最外层是BarChartData,它决定图表的整体行为,包括对齐方式、纵轴范围、格线、Tooltip 等。中间层是BarChartGroupData,它代表一组柱子。最内层是BarChartRodData,它描述一根具体的柱子从底部到顶部的长度、颜色和宽度。举例来说,你展示"周一至周日的销售额",每天就是一个BarChartGroupData,如果某一天有两个品类要对比,那这一组里就有两个barRods。

BarChartData里最需要关心的几个字段:barGroups接收一个列表;alignment控制组之间对齐方式,一般用BarChartAlignment.spaceAround让柱子间距自然;titlesData配置坐标轴标签;barTouchData控制点击交互。搞懂这四块,BarChart 的大部分能力就已经在手里了。

3.2 BarChartGroupData 负责分组

BarChartGroupData的核心是x坐标和barRods列表。x不是柱子的横坐标像素,而是逻辑坐标,你给它 0、1、2,图表会按顺序把它们排开。barRods中的数据会对应地渲染在同一组内。

如果要做"分组柱状图",最常见的写法是把多个BarChartRodData放进同一个BarChartGroupData。比如展示两个月份的销量对比时,每个月份一个分组,组内两根柱子分别代表产品 A 和产品 B。还需要配合barWidth和groupsSpace来调节组与组、柱与柱之间的间距,否则视觉上会挤成一团。

3.3 BarChartRodData 决定每根柱子的长相

到了BarChartRodData这一层,你能控制的细节非常多。toY是柱子顶部对齐的数值,color设颜色,width设柱宽,borderRadius设顶部圆角,还可以用backDrawRodData给柱子加一个背景柱做参考线。我最常用的组合是设置一个渐变颜色,gradient属性可以定义多个颜色之间的过渡,视觉效果明显比纯色好很多,尤其在海报页上。

有一点值得注意:toY支持负数,柱图会从零线向下绘制。这在做盈亏报表时很有用。实现时只需要给对应的BarChartRodData设置toY: -2,图表会自动扩展 Y 轴范围并向下绘制。要是没提前把minY设置为负数,你可能只会看到一根向下延伸到画布外的柱子。

4. 让柱状图不再难看的几个样式细节

4.1 坐标轴与刻度标签

默认的坐标轴很朴素,有时候连单位都不显示。我项目中遇到的实际需求是"把 Y 轴标签改成人性化单位,比如 12 万而不是 1200000"。fl_chart 提供了SideTitles配置,在AxisTitles里设置。

BarChartData( axisTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) { if (value % 10000 != 0) return const SizedBox.shrink(); return Text('${(value / 10000).toStringAsFixed(0)}万'); }, ), ), )

这里有几个细节需要注意:showTitles不写的话,标签默认是隐藏的,很多新手第一步就在这里摔跤;reservedSize是给标签预留的宽度,太小会导致文字溢出绘制区;getTitlesWidget里需要自己控制哪些值显示,否则每个刻度都显示,数字多了会糊成一片。按照上述写法,Y 轴上只会在整万位置显示 "1万""2万" 字样,其余刻度输出一个空白占位。

4.2 柱子的圆角、颜色和宽度

想让柱子好看,核心是让颜色和圆角配合得当。BarChartRodData的修改是在你准备好数据模型之后,最值得调的三个属性是borderRadius、width和gradient。我这里展示一个带渐变的示例:

BarChartRodData( toY: 6, width: 22, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), gradient: const LinearGradient( colors: [Color(0xFF6C63FF), Color(0xFFB388FF)], begin: Alignment.bottomCenter, end: Alignment.topCenter, ), )

圆角的弧度不要太大,否则顶部像一个蘑菇头,比例反而奇怪。宽度方面,如果 interval 是 1,柱宽 22 在大多数手机屏幕上看起来都比较舒适;屏幕宽度较大时可以适当上调到 28。需要提醒的是,width是柱子的物理像素宽度,它不随分组自动缩放。当你在不同分辨率的设备上查看图表时,宽柱子在小屏幕上可能相互遮挡。

4.3 Tooltip 和柱顶数值

柱顶显示数值有两种常见做法:一种是在图表上绘制文字,另一种是配置 Tooltip 让用户点按时看到。前者用BarTouchData配合tooltipBgColor来实现。fl_chart 的默认 Tooltip 已经很好用,只需要在BarTouchData里打开touchTooltipData,并设置getTooltipItems回调。

BarChartData( barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( '${rod.toY}', const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ); }, ), ), )

如果只是想要一根柱子被点击时弹出黑色气泡显示数值,这个写法就够了。但要注意getTooltipItem返回 null 时,这个分组不会弹出 Tooltip,所以逻辑分支要写清楚。另一个重复出现的需求是"在柱顶永久显示数字",fl_chart 没有专门的开关,通常做法是用Stack把文本叠加在图表上方,或者在getTitlesWidget里绘制一个带颜色的文本,但难度不小。

5. 点击交互与动态刷新数据的实践

5.1 打开 handshake 之后发生了什么

BarChart 默认是禁用触摸交互的,开了barTouchData之后,用户点击某一组柱子时,触发的回调会告诉你点击到了哪个坐标组、哪根柱子。这个回调发生在BarTouchData的touchCallback里,它拿到的是一个FlTouchEvent和BarTouchResponse。最常见的用法是:点击后高亮当前柱子,同时更新页面下面的卡片。

BarTouchData( touchCallback: (event, response) { if (event is FlTapUpEvent && response != null) { setState(() { selectedIndex = response.spot.touchedBarGroupIndex; }); } }, enabled: true, )

用setState把点击到的分组索引保存下来,再把这些设置为柱子的颜色或 Tooltip 的显示条件,就能做到"点击切换状态"。若不做任何状态更新,光是回调打印,页面自然没有反应。这里我踩过一个坑:enabled不显式设置为 true 时,触摸事件不会触发。fl_chart 新版里enabled默认值虽然是 true,但如果你自己封装了一个 BarChart 的通用组件,还是要显式写出来,避免某个版本悄悄变了行为。

5.2 用 setState 更新图表数据的注意事项

动态刷新数据是图表里最常见的高频操作。我最早实现的是每三秒从接口拉一次数据,然后替换barGroups再setState。这个流程跑起来没什么问题,但有个隐藏坑:如果整个BarChartData每次都被重新创建,新旧数据因为对象的引用变化,动画会强制重新触发,视觉上会有闪烁感。

更好的做法是只更新变化的数据结构,例如只更新barRods里对应数值和颜色,其他配置项保持不变。另外,如果你在build方法里临时构造BarChartData,每次重建时那些配置对象也会全部重建,这虽然不影响功能,但会白白消耗性能。把BarChartData写成独立的计算函数,尽量缓存不变的部分,能明显减少掉帧。

性能提醒:当你有 20 组以上的数据时,build方法里不要直接写一长串对象初始化,优先抽成私有方法。这样既方便单元测试,也方便你在日志里定位是哪段数据出了问题。

6. 我在实际项目中踩过的 BarChart 坑

6.1 数据过密时柱子变成一条线

第一次遇到这个问题是在某运营后台,需要展示 30 天的数据。30 组柱子塞进 300 宽的图表里,每组算下来只有 10 像素,再加上柱宽设置的 12,柱子之间直接重叠,整张图看起来就是几根实线。解决办法有两个方向:一是增大图表的横向滚动,二是在 UI 上降密。fl_chart 在短期内更推荐降密,比如每 5 天显示一个刻度,柱子只画平均值,然后再配合一个可以横向滑动的外层容器。

我当时用的是SingleChildScrollView横向包住 BarChart,并给 BarChart 设置一个宽于屏幕的固定宽度,比如 600。这个方法立竿见影,但要注意触摸交互需要让它和滚动容器协同,否则拖动手势会被 Tooltip 抢走。更稳妥的做法是限制交互,只在"点击选柱子"模式下使用横向滚动,并额外判断手势方向。

6.2 空数据崩溃与数据边界

数据为空的时候,barGroups传一个空列表,页面会渲染一个空图表,不崩,但如果你拿到的是 null,直接放进barGroups,就会在绘制时触发空指针异常。这个问题的重现概率非常高:从接口拿数据时,数组字段可能是 null,或者解析结果缺省。我在封装的组件里会先做一个兜底:

final groups = data == null ? <BarChartGroupData>[] : data.map(...).toList();

还要注意toY全是 0 的情况。图表把所有柱子画成零高度,坐标轴刻度也会变得很奇怪。我习惯在空数据或全 0 数据时展示一张占位图,而不是让用户看到一坨无意义的空白坐标轴。minY和maxY不要依赖自动计算,尽量根据业务范围手动指定,尤其在数据波动极小的场景下,自动刻度会夸张地拉伸高度差。

6.3 性能优化与 RepaintBoundary

图表一旦放进列表页,性能问题就来了。你滑动列表时 BarChart 会持续重绘,如果图表本身还带着动画,掉帧会非常明显。偶然发现一个简单的优化:用RepaintBoundary把图表包起来,它能把图表的绘制隔离在一个独立的图层里,不会因为列表滚动触发无关的重绘。

RepaintBoundary( child: BarChart( ... ), )

还有一个更根本的优化是给BarChartData增加duration控制动画速度。实时刷新数据时把动画时长缩短到 200 毫秒左右,视觉上既顺滑又不拖沓;但如果你的页面同时有多个图表,动画时间越长,滚动时的性能损耗越大。我的经验是,超过两张图表同时显示时,把动画时长设置在 300 毫秒以下,必要时直接关掉BarChartData的animationDuration。

最后再分享一个实际项目里的习惯:把所有图表配置抽成一个函数,入参是数据模型,出参是BarChartData。测试时可以单独验证配置的正确性,也方便团队成员在不知道 fl_chart 内部细节的情况下快速维护。每次改动图表,你只需要盯住一个函数,不用担心页面里散落着十几个BarChartData实例那种失控的场面。

RELATED READING

延伸阅读

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