# Flutter 图表：能稳住 60 帧的自绘

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

- Canonical (HTML): https://kaguc.com/blog/flutter-chart-rendering-zh/
- Date: 2026-08-28


*English version: [Charts in Flutter: custom painting that holds 60fps](/blog/flutter-chart-rendering/)。这是[交易类 App 架构](/blog/trading-app-architecture-zh/)的 Flutter 实现篇。*

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

## 不要用组件拼出一张图表

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

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

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

## `shouldRepaint` 才是胜负手

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

```dart
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` 里：

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

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

这是大多数图表实现里收益最高的一处改动，而它遵循的原则，和[在 UI 线程上收窄重建范围](/blog/flutter-highfreq-state-zh/)是同一条：让失效的区域等于发生变化的那个东西。

## 不要在 `paint()` 里分配内存

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

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

## 文字是隐藏成本

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

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

## 交互：变换画布，而不是变换数据

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

```dart
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 帧偏长，你的问题在上游的组件树里，属于[状态那篇](/blog/flutter-highfreq-state-zh/)。

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

## 什么时候直接用现成库

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

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


## 参考

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

- Flutter 文档，[Impeller rendering engine](https://docs.flutter.dev/perf/impeller)——iOS 上唯一的渲染器；Android API 29+ 默认启用，更低版本回退 OpenGL；桌面端自 3.47 起默认。
- Flutter 文档，[Shader compilation jank](https://docs.flutter.dev/perf/shader)——首次运行的卡顿，以及以 Impeller 为解法。
- Flutter API，[`CustomPainter`](https://api.flutter.dev/flutter/rendering/CustomPainter-class.html) 与 [`RepaintBoundary`](https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html)——`shouldRepaint` 语义与图层隔离。
- Flutter 文档，[Performance best practices](https://docs.flutter.dev/perf/best-practices)——`const` 组件、`RepaintBoundary`、`itemExtent`，以及 `Opacity` 与 `saveLayer` 为何昂贵。
- Flutter 文档，[Use the Performance view](https://docs.flutter.dev/tools/devtools/performance)——UI 与 raster 时间线、帧预算与重建计数器。

---

*上一篇：[通道、Pigeon 与 FFI](/blog/flutter-native-channels-zh/) · 下一篇：[离线存储与本地搜索](/blog/flutter-offline-store-zh/)*

