ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Flutter 柱状图实战:用 fl_chart 优雅实现可交互 BarChart

Flutter 柱状图实战:用 fl_chart 优雅实现可交互 BarChart

做 Flutter 开发这几年,我接到过不少图表需求,其中出现频率最高的就是柱状图。月度销量、用户增长、库存对比、排行榜,几乎全是 BarChart 的活。fl_chart 是我个人常用的图表库,它的 BarChart 组件把坐标系、柱体、刻度、网格和手势封装成了一套声明式配置,调起来比手写 CustomPainter 稳得多。这篇文章我打算把我用 fl_chart 画柱状图的经验完整梳理一遍:最小依赖怎么引入、数据结构长什么样、怎样写一个可点击、可更新、能应付真实数据场景的柱状图,最后再聊聊实践中踩过的坑。适合刚接触 fl_chart、或者已经在用但想优化细节的 Flutter 开发者。

1. 五分钟准备一个可运行的 BarChart

1.1 为什么选 fl_chart 而不是手写 CustomPainter

日常开发里,列表、文本、图片都好说,一旦遇到图表,很多人第一反应是自己画。用 CustomPainter 画几个圆角矩形其实不难,难的是坐标轴刻度、网格线、Tooltip、点击命中判定、动画过渡这些周边能力。我曾经为了做一个带提示框的柱状图,自己处理触摸点落在哪根柱子上这个问题,前前后后折腾了大半天,边界情况还一堆。后来换成 fl_chart,十分钟就做出了效果更好的版本,而且触摸命中、离轴判断这些细节它内部已经处理过。

fl_chart 的 BarChart 是声明式配置,你不需要关心绘制过程,只需要告诉它数据是什么、样式是什么、交互是什么。它把整张图拆成了三层结构:最外层是一个完整的 BarChartData,中间是代表 x 轴每个位置的 BarChartGroupData,最里层是单根柱子的 BarChartRodData。理解这三层关系,后面所有配置都是在填参数而已。

当然 fl_chart 不是万能的。如果有非常特殊的图表需求,比如不规则的异形柱状图、需要精确到像素级控制的自定义图元,那还是得回归 CustomPainter。但针对 90% 的业务看板和报表场景,fl_chart 的 BarChart 足够用了,而且升级迭代比较积极,社区案例也多。

1.2 最小依赖与版本锁定

在项目里引入 fl_chart 很简单,只需要在 pubspec.yaml 里加一行依赖:

dependencies: flutter: sdk: flutter fl_chart: ^0.66.0

我只强调一个点:版本一定要留意。fl_chart 的 API 在不同版本之间变动不算小,尤其是坐标轴这块。早期版本用 FlTitles 和 AxisTitles 的写法与 0.66 之后不完全一致,网上很多教程贴的代码你会看到SideTitles(showTitles: true)这种写法,但也有老的TitlesData写法,如果照搬后编译报错,先别怀疑自己,大概率是版本差异。建议定好版本之后,能不能升级、升级会不会破坏现有图表,单独评估。我一般会锁^0.66.0这样的主版本号,避免小版本里的 breaking change 影响线上功能。

导入代码同样简单,只要一行:

import 'package:fl_chart/fl_chart.dart';

BarChart 本身是一个 Widget,可以直接放到 Column、Stack、ListView 等布局里。但要注意给它一个明确的尺寸约束,这点后面会细说。

1.3 先搞清 BarChart 的数据三层结构

我见过不少刚开始用的同学一上来就粘一段完整代码,跑通了也不明白里面到底什么意思,结果想改颜色、加数据,完全无从下手。我建议先花两分钟把 fl_chart 柱状图的数据结构理清楚。

层级类型作用
最外层BarChartData整张图表的全局配置:最大最小值、坐标轴标题、网格、边框、触摸反馈、柱子数据组
中间层BarChartGroupDatax 轴上的一个分组,可以理解成某个刻度位置上的“一束柱子”
内层BarChartRodData单根柱子的视觉表现:高度、颜色、宽度、圆角、渐变
特殊层RodStackItem堆叠柱状图中的每一段

实际的对应关系是:一个 BarChartData 里有多个barGroups,每个 BarChartGroupData 里通过barRods放一根或多根柱子。一个BarChartRodData只描述一根柱子。如果你要画的是“1月到7月每月销售额”,那就需要七个 BarChartGroupData,每个组里放一根 BarChartRodData。如果你要画的是“每月本月与上月对比”,那每个组里放两根 BarChartRodData 就行。如果是堆叠柱状图,则需要用rodStackItems代替barRods,但结构认知是一样的。

理解了这个模型,接下来配置图表就是在回答三个问题:柱子的位置在哪、柱子长什么样、柱子周围有哪些辅助信息。

2. 从零搭一个能显示的柱状图

2.1 页面骨架与数据准备

先准备一个最简单的页面。这里我模拟了一个门店前 7 个月的销售额数据,单位是万元。为了让柱状图支持点击和后续动效更新,我用 StatefulWidget 来管理状态,同时声明一个_touchedIndex用来记录当前点击的是哪根柱子。

class BarChartPage extends StatefulWidget { const BarChartPage({super.key}); @override State<BarChartPage> createState() => _BarChartPageState(); } class _BarChartPageState extends State<BarChartPage> { final List<double> _sales = [4.5, 6.2, 7.8, 5.9, 8.4, 7.2, 9.6]; int _touchedIndex = -1; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('门店月度销售额')), body: Padding( padding: const EdgeInsets.all(16), child: SizedBox( height: 300, child: _buildBarChart(), ), ), ); } }

这里有一个必须注意的点:BarChart 需要确定尺寸。如果你把它直接放在 Column 里,不给高度,很容易出现布局异常或者图表只显示一小块。我习惯在外面包一个SizedBox,或者用Expanded让它在剩余空间里自适应。给一个固定高度 300 是最省事的,也方便调整。

2.2 配置坐标轴和网格线

坐标轴配置是新手最容易卡住的地方。BarChart 的坐标轴通过FlTitlesData管理四个方向:左侧、底部、顶部、右侧。每个方向都是一个AxisTitles,里面有一个SideTitles,控制是否显示、留白宽度、每个刻度怎么渲染。

左侧的 y 轴,一般显示数值刻度。reservedSize表示给这个方向预留多少空间,太小的话数字会被截断,太大则压缩图表区域。我用 32 起步,如果数字是千分位格式,再调整到 40 左右。底部的 x 轴,显示月份名称。这里要留意一个细节:getTitlesWidget回调拿到的 value 是轴坐标,不是数组下标。虽然我们创建barGroups时用下标作为x值,当x从 0 开始连续递增时,两者刚好对应,但处理时最好还是判断一下value.toInt()是否在合法范围内,避免越界。

网格线和边框一起配置。FlGridData控制横竖网格,FlBorderData控制图表的边框线。默认会画横线和竖线,但业务里通常只需要横向参考线,所以把drawVerticalLine关掉,保持画面干净。

2.3 完整渲染代码与运行效果

把上面的数据、坐标轴、网格、柱体组合起来,就是一张完整的 BarChart。下面是我前面提到的_buildBarChart方法的完整实现:

Widget _buildBarChart() { return BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, maxY: 10, barTouchData: BarTouchData( enabled: true, touchCallback: (FlTouchEvent event, BarTouchResponse? response) { if (response == null || response.spot == null) { return; } if (event.isInterestedForInteractions) { setState(() { _touchedIndex = response.spot!.touchedBarGroupIndex; }); } }, touchTooltipData: BarTouchTooltipData( getTooltipColor: (_) => Colors.blueGrey, getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( '${rod.toY.toStringAsFixed(1)} 万', const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ); }, ), ), titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 32, getTitlesWidget: (value, meta) { return Text(value.toInt().toString()); }, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) { const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月']; final index = value.toInt(); if (index < 0 || index >= months.length) { return const SizedBox.shrink(); } return Text(months[index]); }, ), ), topTitles: AxisTitles(), rightTitles: AxisTitles(), ), gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (_) => const FlLine(color: Color(0xffeeeeee), strokeWidth: 1), ), borderData: FlBorderData( show: true, border: const Border.all(color: Color(0xffcccccc), width: 1), ), barGroups: List.generate(_sales.length, (index) { final isTouched = index == _touchedIndex; return BarChartGroupData( x: index, barRods: [ BarChartRodData( toY: _sales[index], width: isTouched ? 22 : 18, color: isTouched ? Colors.orange : Colors.blueAccent, borderRadius: const BorderRadius.vertical( top: Radius.circular(4), ), ), ], ); }), ), swapAnimationDuration: const Duration(milliseconds: 400), swapAnimationCurve: Curves.easeOutCubic, ); }

这段代码跑起来之后,你会看到 7 根蓝色柱状图,点击任意一根,它会被选中变成橙色并且变宽,同时上方弹出一个灰蓝色底色的文字提示,显示销售额数字。整体动画是从 0 到目标高度缓缓升起,时间大约 400 毫秒。如果你的项目里版本稍旧,barRods这个参数名字可能曾经是barRodData,编译不过的时候优先检查这里。

3. 从“能显示”到“能交差”:样式与交互调优

3.1 动态坐标轴范围与刻度间隔

我在第一个版本里直接把maxY写死成 10,因为数据只有 7 个且最大值是 9.6。回到真实业务里,接口数据会变,写死肯定不行。比如下个月销售额到了 15,maxY 还是 10,柱子就会顶到屏幕外面,刻度也是乱的。

正确做法是根据数据动态计算纵轴范围:

double _calculateMaxY() { if (_sales.isEmpty) return 10; final maxValue = _sales.reduce((a, b) => a > b ? a : b); return (maxValue * 1.2).ceilToDouble(); }

逻辑是拿到数据最大值后乘 1.2 作为安全余量,再向上取整。这样柱子最高点不会紧贴图表顶端,视觉上舒服得多。刻度间隔也可以按这个最大值的五分之一来算:

final maxY = _calculateMaxY(); final interval = maxY / 5;

设置进BarChartData后,左边纵轴会自动等分成 5 段。如果数据还需要出现小数点位,可以把间隔和标题格式化一起处理,比如保留一位小数,避免 2.5 这种刻度显示成“3”。

3.2 柱子形状、渐变色和背景柱

BarChartRodData 里可以控制的属性很多,常用的有width、color、gradient、borderRadius、backDrawRodData和rodStackItems。

如果希望柱子有渐变效果,可以设置gradient而不是color。注意两者同时存在时并不会叠加,一般只会看到渐变结果,所以二选一就好。我通常这样写竖向渐变,让柱底颜色深、柱顶颜色浅,模拟一种“由下向上发光”的感觉:

BarChartRodData( toY: _sales[index], width: 18, gradient: const LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Color(0xff3D72FE), Color(0xff9FB9FF)], ), borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), )

柱子的圆角要注意一点:如果直接设置BorderRadius.circular(4),底部也会圆角,柱子和 x 轴零线之间会有缝隙,视觉上不干净。我一般只用BorderRadius.vertical(top: ...)让顶部圆角,底部保持直角,这才是常规柱状图的样式。

还有一种常见需求是展示目标值和实际值。可以在同组里放两根柱子,也可以给实际值柱加一个浅色的背景柱。backDrawRodData的作用就是把背景柱子画在当前柱子后面,通常背景柱的高度是整张图的最大值,颜色用半透明灰,这样像给图表加了一层目标参考系。

3.3 点击柱体弹出 Tooltip

BarChart 的点击交互靠BarTouchData控制。上面完整代码里的配置已经能工作,我解释几个关键点。

touchCallback的回调参数里,FlTouchEvent描述手指事件类型,BarTouchResponse描述命中了哪个分组。判断event.isInterestedForInteractions是为了过滤掉一些不需要触发点击的移动事件,避免手指在图表上滑动时频繁更新状态。response.spot!.touchedBarGroupIndex拿到的是当前命中的分组下标,而不是 x 值,这一点要小心。如果barGroups的 x 不是从 0 开始连续的下标,直接用这个值去索引数据数组就会错位。

touchTooltipData里可以定制 tooltip 的背景色、文本内容、文字样式。默认的getTooltipItem返回一个BarTooltipItem,里面可以拼出你想展示的任何内容,比如带单位、带百分号、甚至换行显示两行数据。如果数据量很大,tooltip 可以不跟随手指,通过fitInsideVertically等参数让提示框保持在图表内部,避免超出屏幕。

3.4 数据动态更新与过渡动画

真实项目里,图表数据不可能永远不变。切换筛选条件、接口返回新数据后,柱状图需要跟着变。fl_chart 的处理方式很直接:更新数据后调用setState,BarChart 会自动对比新旧数据,用一段过渡动画把柱子高度从旧值拉倒新值。

动画时长通过swapAnimationDuration控制,曲线用swapAnimationCurve控制。默认值其实已经不错,我通常会设置 300 到 500 毫秒,具体看产品和视觉要求。这里提醒一点:动画时长不是越长越好。如果你设置成 2 秒,用户频繁切换筛选条件,动画会排队播放,看起来像系统卡顿,体验反而差。

另外要注意不要在setState之前直接修改_sales的某个元素,然后把同一个 List 对象传回去。因为 fl_chart 内部会比较新旧数据的引用和数据内容,直接原地修改有可能造成动画失效。最稳妥的写法是生成一个新 List 再 setState,例如_sales = List.from(newValues)。

4. 真实项目里躲不掉的问题

4.1 多组并排柱状图怎么做

对比类需求在业务里太常见了,比如“本月 vs 上月”。fl_chart 实现并排多柱其实很简单,就是在同一个 BarChartGroupData 里放多根 BarChartRodData:

BarChartGroupData( x: index, barRods: [ BarChartRodData( toY: current[index], width: 8, color: Colors.blueAccent, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), BarChartRodData( toY: previous[index], width: 8, color: Colors.orangeAccent, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), ], )

这里要注意每根柱子的宽度之和不要超过该组可用的空间,否则柱子和柱子会重叠或压到相邻组。组与组之间的距离由BarChartAlignment控制,我一般用spaceAround,视觉效果比较均衡。如果两组柱子分属于不同的数据维度,你需要在底部标题或图例中把颜色含义标明,否则用户看不懂哪个是本月、哪个是上月。

另一个容易忽略的点是 Tooltip 里的rodIndex。在 getTooltipItem 回调里,rodIndex代表当前命中该组中的第几根柱子,0 是第一根。做多柱对比时,可以用它拼接不同的单位说明,例如“本月销售额 8.4 万”和“上月销售额 7.2 万”。

4.2 空数据、零数据和负数数据

空数据处理不好,图表会表现得很诡异。_sales为空时,BarChartData的maxY可能是 0,纵轴完全错乱。更严重的是,如果你按索引去计算刻度标题,可能访问数组越界。我的经验是:空数组时不要渲染 BarChart,直接显示一个“暂无数据”的占位视图;全为 0 时,maxY 至少给定一个下限,比如 10。

负数数据的处理也要提前想清楚。toY可以为负数,柱状图会从零线向下生长,但前提是BarChartData的minY要设置成负数或者足够小,否则负柱子会被截断。比如数据范围是 -5 到 20,minY 至少要设成 -6 或 -5 以下,这样负柱才能完整显示。我在一个项目里因为只设置了 maxY,没管 minY,导致负数柱状图只露出一个头,排查了好久才发现是 minY 的问题。

4.3 长标签与中文标签的处理

底部 x 轴的标签如果是“1月、2月”这种短文本还好,一旦遇到“七月销售额汇总”“第四季度预计收入”这种长文本,柱间距不够,文字重叠是必然的。最简单的办法是缩短文案,比如只保留关键值,或者换行显示。

还有一种比较通用的做法是给标题加旋转角。用Transform.rotate包一层,让文字斜着放。角度参数是弧度制,-0.5 大概对应 -28 度。旋转之后记得把 bottom 的reservedSize加大,否则文字会被裁切。我实测下来,字号 11、旋转 -0.5 弧度、reservedSize 设到 56 左右,效果基本够看。

getTitlesWidget: (value, meta) { const labels = ['一月', '二月', '三月', '四月', '五月', '六月', '七月']; final index = value.toInt(); if (index < 0 || index >= labels.length) return const SizedBox.shrink(); return Transform.rotate( angle: -0.5, child: Text(labels[index], style: const TextStyle(fontSize: 11)), ); }

中文标签还有一个坑:不同字体、不同手机上文本宽度不同,所以不要把自己的机器上看到的“刚好不重叠”当成普适结论。建议在底部标签较多时,优先用短格式,或者从产品层面要求简化文案,而不是靠旋转硬扛。

4.4 性能与状态保持注意点

柱状图的数据点一旦多起来,比如几百根柱子,getTitlesWidget和barGroups里的生成逻辑会被频繁调用,这里别写复杂逻辑。不要在回调里执行网络请求、文件 IO 或者创建大的数组对象,尽量做到“只根据入参返回一个轻量 Widget”。

如果你的页面里 BarChart 放在 Tab 或者 PageView 中,切走再切回来时会因为页面重建触发一遍入场动画。动画本身没什么问题,但频繁切换会造成视觉抖动。如果需要保持图表状态,可以考虑给 State 混入AutomaticKeepAliveClientMixin,然后重写wantKeepAlive返回 true。这个做法在长列表和 Tab 页面里都适用。

还有一个隐蔽的性能点:RepaintBoundary。图表这类 Canvas 绘制组件,如果周围有大量动态 Widget 在刷新,图表区域也会被牵连重绘。给 BarChart 包一层RepaintBoundary,可以减少无关绘制,对低端机的流畅度提升有帮助。

5. 我踩过的坑,直接给你避雷

5.1 interval 不要拍脑袋写死

我第一次做图表的时候,直接把interval: 1写进配置里,数据只有 7 根柱子时还挺好。后来数据量扩大到 100 个,y 轴刻度变成 0、1、2、3,密密麻麻排列,数字都叠在一起。你可能会说,那 maxY 会自适应吗?不会,如果你同时把 maxY 定死了,问题更严重。

后来我养成了一个习惯:所有的轴间隔和最大值,都从一个公共方法计算出来,不要手写在 UI 代码里。尤其后端返回的数据可能随时从“个位数”变成“百万”,写死的规模和单位都会变成隐患。动态算 maxY 和 interval,配合格式化函数统一在标题回调里做,后续维护轻松得多。

5.2 动画时长别设太长

之前有次为了追求“高级感”,我把swapAnimationDuration设成了 1200 毫秒,想让柱子慢慢升起来。结果产品反馈“图表是不是卡了”,测试也抱怨切筛选条件后响应很慢。后来我把时长改成 400 毫秒,曲线用 easeOutCubic,效果明显清爽。

另外,在页面销毁的时候如果动画还没跑完,某些低版本 Android 上可能触发 setState 相关的报错。虽然 fl_chart 自己处理了很多生命周期问题,但我的建议是动画时长不要超过 600 毫秒。太长的动画除了好看,对交互体验基本是负作用。

5.3 嵌套滚动场景里的手势冲突

BarChart 放在 ListView 或者 SingleChildScrollView 里时,手指在图表上移动会触发两套手势:一套是图表的 Tooltip 触摸反馈,另一套是整个页面的滚动。我遇到的真实情况是,手指在柱状图上横向滑动,图表里的提示框刚显示出来,页面却开始上下滚动,两者互相干扰,体验很怪。

解决办法要看业务倾向。如果图表内部交互更重要,可以在外层滚动区域给图表包一层GestureDetector并配合NeverScrollableScrollPhysics,让手势完全交给图表。如果页面滚动更重要,比如一个很长的详情页,用户不太会在图表上停留太久,那就干脆把BarTouchData.enabled设成 false,或者只在点击时启用,不处理长按滑动。没有绝对标准的答案,关键是要明确用户在这块区域里的主要操作是什么。

我自己常用的做法是:在图表区域外面再包一层Listener,监听指针落点,落点不在柱子上时直接忽略触摸事件,柱子范围内才启用BarTouchData。这样既能保留图表点击能力,又不会阻断页面滚动。实现起来不算复杂,但互动体验会明显上一个台阶。真正的顺畅,都是在这些细节里磨出来的。

RELATED READING

延伸阅读

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