做 Flutter 开发这几年我接到过不少图表需求其中出现频率最高的就是柱状图。月度销量、用户增长、库存对比、排行榜几乎全是 BarChart 的活。fl_chart 是我个人常用的图表库它的 BarChart 组件把坐标系、柱体、刻度、网格和手势封装成了一套声明式配置调起来比手写 CustomPainter 稳得多。这篇文章我打算把我用 fl_chart 画柱状图的经验完整梳理一遍最小依赖怎么引入、数据结构长什么样、怎样写一个可点击、可更新、能应付真实数据场景的柱状图最后再聊聊实践中踩过的坑。适合刚接触 fl_chart、或者已经在用但想优化细节的 Flutter 开发者。1. 五分钟准备一个可运行的 BarChart1.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 StateBarChartPage createState() _BarChartPageState(); } class _BarChartPageState extends StateBarChartPage { final Listdouble _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。回到真实业务里接口数据会变写死肯定不行。比如下个月销售额到了 15maxY 还是 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 点击柱体弹出 TooltipBarChart 的点击交互靠BarTouchData控制。上面完整代码里的配置已经能工作我解释几个关键点。touchCallback的回调参数里FlTouchEvent描述手指事件类型BarTouchResponse描述命中了哪个分组。判断event.isInterestedForInteractions是为了过滤掉一些不需要触发点击的移动事件避免手指在图表上滑动时频繁更新状态。response.spot!.touchedBarGroupIndex拿到的是当前命中的分组下标而不是 x 值这一点要小心。如果barGroups的 x 不是从 0 开始连续的下标直接用这个值去索引数据数组就会错位。touchTooltipData里可以定制 tooltip 的背景色、文本内容、文字样式。默认的getTooltipItem返回一个BarTooltipItem里面可以拼出你想展示的任何内容比如带单位、带百分号、甚至换行显示两行数据。如果数据量很大tooltip 可以不跟随手指通过fitInsideVertically等参数让提示框保持在图表内部避免超出屏幕。3.4 数据动态更新与过渡动画真实项目里图表数据不可能永远不变。切换筛选条件、接口返回新数据后柱状图需要跟着变。fl_chart 的处理方式很直接更新数据后调用setStateBarChart 会自动对比新旧数据用一段过渡动画把柱子高度从旧值拉倒新值。动画时长通过swapAnimationDuration控制曲线用swapAnimationCurve控制。默认值其实已经不错我通常会设置 300 到 500 毫秒具体看产品和视觉要求。这里提醒一点动画时长不是越长越好。如果你设置成 2 秒用户频繁切换筛选条件动画会排队播放看起来像系统卡顿体验反而差。另外要注意不要在setState之前直接修改_sales的某个元素然后把同一个 List 对象传回去。因为 fl_chart 内部会比较新旧数据的引用和数据内容直接原地修改有可能造成动画失效。最稳妥的写法是生成一个新 List 再 setState例如_sales List.from(newValues)。4. 真实项目里躲不掉的问题4.1 多组并排柱状图怎么做对比类需求在业务里太常见了比如“本月 vs 上月”。fl_chart 实现并排多柱其实很简单就是在同一个 BarChartGroupData 里放多根 BarChartRodDataBarChartGroupData( 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 到 20minY 至少要设成 -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。这样既能保留图表点击能力又不会阻断页面滚动。实现起来不算复杂但互动体验会明显上一个台阶。真正的顺畅都是在这些细节里磨出来的。
阅读完成 · 觉得有帮助?