BLOG · #Engineering

Flutter 图表:能稳住 60 帧的自绘

K 线图是少数几个“组件树是错误工具”的界面。把它做好归结为三个动作:用绘制代替组合、把静态层和实时层拆开、以及让 shouldRepaint 说实话。

English version: Charts in Flutter: custom painting that holds 60fps。这是交易类 App 架构的 Flutter 实现篇。

大多数 Flutter 性能建议讲的是 UI 线程:少重建、小子树。图表把问题挪到了光栅线程上,那里要问的不是你构建了多少组件,而是你让 GPU 做了多少工作、以及做了多频繁。

不要用组件拼出一张图表

本能反应是把每根蜡烛做成一个组件。于是六百根蜡烛变成六百个需要构建、布局、绘制的组件,还要加上它们的父级;而每一次拖动手势都会把这一切重做一遍。

图表是一幅画,不是许多个对象。用单个 CustomPainter,把内容画进画布。你放弃了框架在该区域内的布局和命中测试,换来的是成本与像素成正比,而不是与元素数量成正比,以及对“每帧到底发生什么”的完全控制。

组件树仍然是工具栏、图例和十字光标读数的正确工具,它只是不适合画序列本身。

shouldRepaint 才是胜负手

这个方法决定了你的图表是便宜还是灾难,而返回 true(那个看起来最安全的答案)意味着从此每一帧都重绘。

class CandlePainter extends CustomPainter {
  CandlePainter({required this.series, required this.viewport, required this.revision});
  final CandleSeries series;
  final Viewport viewport;   // 平移与缩放
  final int revision;        // 数据变化时递增

  @override
  bool shouldRepaint(CandlePainter old) =>
      old.revision != revision || old.viewport != viewport;
}

比较一个廉价的版本号,而不是数据本身。每帧深比较几千根蜡烛,本身就是一个性能问题;而引用比较会在你原地修改列表的那一刻悄悄失效。一个每次数据变化就递增的单调版本号,含义明确而且免费。

把静态和实时拆开

在同一张图表内部,不同部分的变化频率差异极大。网格线、坐标轴标签、已收盘的蜡烛,只在你平移或某根 K 线收盘时才变;而最新价格线、十字光标、正在形成的那根蜡烛,则一直在变。

把它们画在一起,意味着实时元素会强制整幅图重绘。

所以叠两个画笔,各自包在自己的 RepaintBoundary 里:

Stack(children: const [
  RepaintBoundary(child: CustomPaint(painter: StaticChartPainter(...))), // 网格、已收盘蜡烛
  RepaintBoundary(child: CustomPaint(painter: LivePainter(...))),        // 最新价、十字光标
])

现在一跳数据只重绘一个包含两三条线的图层。昂贵的那一层(几千根蜡烛加文字标签)被缓存为纹理并参与合成,而不是被重画。

这是大多数图表实现里收益最高的一处改动,而它遵循的原则,和在 UI 线程上收窄重建范围是同一条:让失效的区域等于发生变化的那个东西。

不要在 paint() 里分配内存

paint() 可能每秒运行 60 次。在那里创建的一切都会以这个频率变成垃圾,而 GC 停顿恰好就是你想避开的那个尾延迟问题。

把 Paint 对象提升为字段。用 reset() 复用 Path,而不是构造新的。每次绘制只计算一次“数据到像素”的变换,而不是每根蜡烛算一次。点数很多时,优先用批量图元(drawRawPoints、drawVertices,或者单个 Path),而不是几千次单独的 drawLine,因为每次画布调用都带固定开销,而当图形很小的时候,这个开销会占主导。

文字是隐藏成本

坐标轴标签和价格刻度看起来是免费的,实际不是:文字排版是绘制过程中较昂贵的操作之一,每帧为四十个标签做一次,会明确出现在你的光栅耗时里。

把 TextPainter 实例按字符串缓存起来,只在标签集合变化时重建。由于价格和时间标签在帧与帧之间高度重复,一个小 map 就能解决大部分问题。

交互:变换画布,而不是变换数据

平移和缩放应当改变一个视口(一个偏移加一个缩放),并把它应用到画布上,而不是重写你的数据:

canvas.save();
canvas.translate(-viewport.offsetX, 0);
canvas.scale(viewport.scaleX, 1);
// 在数据坐标系里绘制
canvas.restore();

在每一帧手势里为每根蜡烛重算像素位置,是图表拖动手感发沉的常见原因。把模型保持在数据坐标系里,让画布变换去干这件事。

命中测试则反向进行:把指针位置换算回数据坐标,再在序列上二分查找最近的一根。这样每个手势事件是一次对数级查找,而不是一次全扫描。

简单说说着色器编译

历史上,第一帧用到某个新着色器时可能会当场编译并丢掉那一帧,这就是经典的“只卡一次,之后再也不卡”的问题,而且只在全新安装时复现。Impeller——截至 Flutter 3.47(2026 年 8 月)是 iOS 上唯一的渲染器、Android API 29+ 上的默认渲染器——会提前编译着色器,消除了这一类卡顿。更老的 Android 设备仍会回退到 OpenGL/Skia 路径,如果你要支持它们,请在那类设备上测一次全新安装。

实践建议很短:留在 Impeller 上,并且让效果保持常规。奇特的混合模式、大范围模糊、以及叠在复杂子树上的 Opacity,在任何后端上都仍然昂贵,因为它们会强制离屏渲染。其中最容易被误用的是包住一大棵子树的 Opacity;如果你本来就在绘制,优先把透明度直接烘进 Paint 的颜色里。

测量光栅线程

打开 DevTools 的性能视图,分开看两条时间线。如果 UI 帧正常而光栅帧偏长,那么修法就在本文:更少的图层、更小的失效区域、更便宜的绘制操作。如果 UI 帧偏长,你的问题在上游的组件树里,属于状态那篇。

要用真实规模的序列来测,比如几千根 K 线,并且用一台中端设备。旗舰机上 50 根蜡烛的流畅,说明不了任何你真正关心的情况。

什么时候直接用现成库

如果你要的是一张标准折线图或柱状图,每分钟更新几次,那么一个图表库就是正确答案,上面的一切都为时过早。

自己写的理由是你需要库给不了的行为:联动的指标副图、自定义画线工具、精确的十字光标语义、上万个数据点,或者按帧率跳动的更新。是这些需求让“自己持有一个画笔”变得合理,而不是“自研代码通常更快”这个信念。

参考

API 与渲染器相关断言按 Flutter 3.47.0 / Dart 3.13(2026 年 8 月)核对。


上一篇:通道、Pigeon 与 FFI · 下一篇:离线存储与本地搜索