开篇:当折线图开始吞掉帧率
一个行情类页面接了实时数据,每 100 毫秒推送一个点,图表跑了几分钟后滑动开始卡顿,内存也一路攀升。排查下去往往有三个原因:每次数据更新都重建整个 Widget 树、历史点无限累积没有降采样、图表控件没有做重绘边界隔离。
Flutter 的图表实现分两条路:一条是用现成库(fl_chart、syncfusion_flutter_charts),开箱即用但定制受限;另一条是用 CustomPainter 自绘,完全可控但要自己处理坐标、缩放与动画。实时数据场景还要额外处理刷新频率与数据窗口。本文沿着选型、fl_chart 实战、自绘图表、实时数据、交互与动画、性能踩坑这条链路展开。
一、图表方案全景与选型
1.1 常见图表库对比
| 方案 | 实现方式 | 定制能力 | 包体积 | 适用场景 |
|---|---|---|---|---|
fl_chart | 纯 Dart Canvas | 中 | 小 | 常规业务图表 |
syncfusion_flutter_charts | 纯 Dart Canvas | 高 | 大 | 复杂企业图表 |
graphic | 声明式语法 | 高 | 中 | 可视化语法描述 |
CustomPainter | 直接绘制 | 极高 | 无 | 特殊图形、极致性能 |
| WebView 图表 | 内嵌 JS 图表 | 高 | 中 | 复用已有 Web 图表 |
1.2 依赖与引入
# pubspec.yaml
dependencies:
fl_chart: ^0.69.0
1.3 选型决策
- 常规折线、柱状、饼图:
fl_chart足够,API 简洁。 - 需要金融级 K 线、多轴、缩放联动:考虑
syncfusion_flutter_charts或自绘。 - 图形高度定制、数据量极大:直接用
CustomPainter。 - 已有成熟 Web 图表且不介意性能:WebView 方案,但滚动与手势会打架。
一句话总结:先看"图形是否标准、数据量是否大",标准且量小用库,非标且量大就自绘。
二、fl_chart 实战
2.1 折线图
LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: const [
FlSpot(0, 3),
FlSpot(1, 1),
FlSpot(2, 4),
FlSpot(3, 2),
],
isCurved: true,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: Colors.blue.withOpacity(0.15),
),
),
],
titlesData: const FlTitlesData(show: true),
gridData: const FlGridData(show: true),
borderData: FlBorderData(show: false),
),
)
2.2 柱状图与分组柱状图
BarChartGroupData 表示一组柱子,组内可以放多根 BarChartRodData 实现分组对比。
BarChart(
BarChartData(
barGroups: [
BarChartGroupData(
x: 0,
barRods: [
BarChartRodData(toY: 8, color: Colors.blue),
BarChartRodData(toY: 5, color: Colors.orange),
],
),
],
alignment: BarChartAlignment.spaceAround,
),
)
2.3 饼图与环形图
PieChart(
PieChartData(
sections: [
PieChartSectionData(value: 40, title: '40%', radius: 60),
PieChartSectionData(value: 60, title: '60%', radius: 60),
],
sectionsSpace: 2,
centerSpaceRadius: 40, // 大于 0 即为环形图
),
)
2.4 触摸交互
fl_chart 通过 touchData 配置交互回调,可以在用户点击时高亮数据点并展示提示。
LineChartData(
lineTouchData: LineTouchData(
enabled: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) {
return spots.map((s) => LineTooltipItem(
'${s.y}',
const TextStyle(color: Colors.white),
)).toList();
},
),
),
)
一句话总结:
fl_chart把大部分图表封装成数据对象,改数据即改图,日常业务图表够用。
三、自绘图表与 CustomPainter
3.1 何时需要自绘
库满足不了的三类需求适合自绘:图形不在标准图表之列(如甘特图、桑基图)、数据量极大需要极致性能、交互逻辑与绘制深度耦合。
3.2 基础绘制骨架
class SparklinePainter extends CustomPainter {
SparklinePainter({required this.values, required this.color});
final List<double> values;
final Color color;
@override
void paint(Canvas canvas, Size size) {
if (values.isEmpty) return;
final path = Path();
final stepX = size.width / (values.length - 1);
final maxV = values.reduce((a, b) => a > b ? a : b);
for (var i = 0; i < values.length; i++) {
final x = stepX * i;
final y = size.height * (1 - values[i] / maxV);
i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
}
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant SparklinePainter old) =>
old.values != values || old.color != color;
}
3.3 坐标轴与网格
自绘时必须自己做"数据坐标到屏幕坐标"的映射。建议封装一个 Axis 类,负责值域计算、刻度选取与像素换算,避免散落在 paint 方法里。
- 值域:先求数据最小最大值,再向外取整到合适的刻度。
- 刻度:按"好看的数字"(1、2、5、10 的倍数)选取。
- 留白:上下左右留出轴标签的空间。
3.4 与动画结合
自绘图表做入场动画时,把进度值作为 Animation 传入,在 paint 中按进度插值绘制,shouldRepaint 比较进度值即可。
一句话总结:自绘的核心是把"坐标换算"独立出来,绘制方法只负责按坐标画,逻辑才清晰可测。
四、实时数据与高频刷新
4.1 数据窗口与降采样
实时图表不能无限累积数据点。常用做法是固定窗口(如只保留最近 300 个点)加降采样(原始 1000 点抽稀到 200 点展示)。
class DataWindow {
DataWindow({this.capacity = 300});
final int capacity;
final List<double> _points = [];
void push(double value) {
_points.add(value);
if (_points.length > capacity) {
_points.removeAt(0); // 超出窗口丢弃最旧点
}
}
List<double> get points => List.unmodifiable(_points);
}
4.2 刷新频率控制
数据推送频率往往高于渲染帧率,盲目刷新只会浪费。应把刷新对齐到帧:用 Ticker 或节流,把多次数据更新合并为一次重绘。
| 数据频率 | 推荐策略 |
|---|---|
| 低于 10 Hz | 收到即刷新 |
| 10 到 60 Hz | 按帧节流刷新 |
| 高于 60 Hz | 缓冲后按帧消费 |
4.3 重绘边界隔离
用 RepaintBoundary 包住图表,让图表的频繁重绘不影响页面其他部分;同时用 shouldRepaint 精确判断是否需要重绘。
RepaintBoundary(
child: CustomPaint(
painter: SparklinePainter(values: data, color: Colors.blue),
size: const Size(double.infinity, 120),
),
)
一句话总结:实时图表的三件套是"固定窗口、帧对齐刷新、重绘隔离",缺一个都会随时间劣化。
五、交互、动画与无障碍
5.1 手势交互
缩放与平移需要自己维护变换矩阵,常用 InteractiveViewer 或手动处理 GestureDetector 的 onScaleUpdate。
GestureDetector(
onScaleUpdate: (details) {
setState(() {
_scale = (_baseScale * details.scale).clamp(1.0, 5.0);
_offset += details.focalPointDelta;
});
},
child: CustomPaint(painter: ZoomableChartPainter(_scale, _offset)),
)
5.2 动画过渡
数据变化时直接跳变会显得生硬。用 TweenAnimationBuilder 对数值做插值,让折线平滑过渡到新位置。
5.3 无障碍
图表是无障碍的薄弱环节。应为图表提供语义描述,把关键数据以文本形式暴露给读屏软件。
Semantics(
label: '最近七日销量趋势,最高 320,最低 120',
child: CustomPaint(painter: chartPainter),
)
一句话总结:图表要"看得懂"也要"读得出",语义标签是让读屏用户理解数据的最低成本手段。
六、性能与踩坑清单
6.1 常见踩坑清单
- 每次
setState重建整个图表:应只传数据,让shouldRepaint决定是否重绘。 shouldRepaint恒返回true:图表每帧重绘,耗电且掉帧。- 数据点无限累积:内存持续增长,最终 OOM。
- 在
paint中创建Paint/Path:每帧分配大量对象,触发频繁 GC。 - 图表放进
ListView且未设固定高度:布局反复计算,滚动卡顿。 - 未用
RepaintBoundary:图表重绘牵连整页。 - 在
paint里做网络或 IO:阻塞光栅化线程。
6.2 优化手段
// 复用 Paint 对象,避免每帧分配
class ChartPainter extends CustomPainter {
static final _linePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
@override
void paint(Canvas canvas, Size size) {
canvas.drawPath(buildPath(size), _linePaint);
}
@override
bool shouldRepaint(covariant ChartPainter old) => old.data != data;
}
6.3 大数据量策略
- 数据点超过画布像素宽度时,按像素列聚合(每列取最大值与最小值),这是专业图表库的通用做法。
- 超出视野的点不参与绘制,只渲染可视区间。
- 复杂图表在数据更新时用
compute做降采样,避免阻塞 UI 线程。
一句话总结:图表性能问题几乎都出在"重绘范围过大"和"每帧分配对象"两件事上,先把这两点解决,再谈大数据量优化。
FAQ
常见问题:fl_chart 和自绘应该怎么选?
答:先用 fl_chart 实现,只有当图形不在其支持范围、或数据量导致明显卡顿时才考虑自绘。自绘的成本在于要自己处理坐标换算、刻度、缩放与动画,收益是完全可控的性能与形态。
常见问题:实时图表越来越卡怎么排查?
答:按顺序看三处:数据点是否无限累积(应固定窗口)、刷新是否按帧节流(应合并高频更新)、shouldRepaint 是否恒为 true。三者占实时图表卡顿原因的绝大多数。
常见问题:shouldRepaint 返回 true 有什么问题?
答:会导致图表每帧都重绘,即使数据没变。正确做法是比较数据引用或关键字段,数据未变时返回 false,让 Flutter 跳过重绘。
常见问题:图表放进滚动列表后滚动很卡?
答:给图表设置固定高度,并用 RepaintBoundary 隔离;同时避免在 paint 中做重计算。列表里大量图表应做懒加载,只渲染可见项。
常见问题:怎么给图表加缩放和平移?
答:简单场景用 InteractiveViewer 包住 CustomPaint;需要"只缩放数据轴、坐标轴保持固定"时,必须自己在 paint 中应用变换矩阵,通过 GestureDetector 的 onScaleUpdate 维护缩放比与偏移。
常见问题:自绘图表怎么保证无障碍可读?
答:用 Semantics 包住图表并给出关键数据摘要,让读屏软件能读出趋势与极值;同时为交互元素提供可聚焦的语义节点,不要只依赖颜色传达信息。
常见问题:大数据量图表该怎么优化?
答:核心是按像素列聚合而非逐点绘制,同时只渲染可视区间。降采样与聚合属于 CPU 密集任务,数据量大时应放到 isolate 中执行,避免阻塞 UI 线程。
相关阅读
- 自定义绘制与动画 — CustomPainter 与动画结合的完整实践
- Flutter 性能优化 — 重绘边界与帧率分析手段
- Flutter 动画体系 — 图表入场与数据过渡动画的实现基础
- Flutter 无障碍 — 图表语义标签与读屏支持
- Flutter 隔离与并发 — 大数据量降采样与聚合的并发方案
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。