# Flutter 面对高频数据：让重建范围等于变化范围

> 一个每秒更新几百次的行情列表，会把按常规写法搭出来的 Flutter 应用拖垮。解法不是换一个更快的状态管理库，而是让被重建的子树小到和真正变化的那个东西一样，并把更新合并到帧上。

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


*English version: [Flutter under a firehose: making rebuild scope match change scope](/blog/flutter-highfreq-state/)。这是[交易类 App 架构](/blog/trading-app-architecture-zh/)的 Flutter 实现篇，背后的原理见[实时数据管线](/blog/realtime-data-pipeline-zh/)。*

屏幕上放 300 只标的，每只每秒更新几次，标准的 Flutter 写法就会垮掉。不是因为 Flutter 慢，而是因为这种写法在每一次“单个格子级别”的变化上，重建了一棵“整屏级别”的子树。

解法只有一个想法，然后把它贯彻到底：**被重建的子树，应该小到和发生变化的那个东西一样。** 下面全部都是它的推论。

## 常规写法为什么会垮

常见的形态是：一个 store 持有行情的 map，暴露给组件树，列表监听它。一跳数据到达，store 发出通知，列表重建，300 行、每行若干组件，于是为了一个数字的变动产生了一千多次 `build()` 调用。

在 60 帧下，你每帧只有 16.6 毫秒来完成全部工作：构建、布局、绘制、光栅化。每秒几百跳配上整屏级别的重建，塞不进去。而它的症状是最坏的那种：用假数据开发时一切正常，偏偏在行情有意思的时候开始卡。

这不是在批评某个状态管理方案。Provider、Bloc、Riverpod 和 `setState` 在这里的行为都是正确的，问题在于**粒度**选错了。用它们中的任何一个都能写坏，也都能写好。

## 只重建叶子

给每只标的一个自己的可监听对象，并且让显示它的那个组件成为唯一的订阅者。

```dart
class QuoteStore {
  final _notifiers = <String, ValueNotifier<Quote>>{};

  ValueListenable<Quote> listen(String symbol) =>
      _notifiers.putIfAbsent(symbol, () => ValueNotifier(Quote.empty(symbol)));

  // 由数据源调用，每秒很多次
  void apply(Quote q) {
    final n = _notifiers[q.symbol];
    if (n == null) return;          // 没人在显示它，不做任何事
    if (n.value == q) return;       // 没有可见变化，不做任何事
    n.value = q;                    // 只通知一棵子树
  }
}
```

```dart
class PriceCell extends StatelessWidget {
  const PriceCell(this.store, this.symbol, {super.key});
  final QuoteStore store;
  final String symbol;

  @override
  Widget build(BuildContext context) => ValueListenableBuilder<Quote>(
        valueListenable: store.listen(symbol),
        builder: (_, q, __) => Text(q.last.toStringAsFixed(2)),
      );
}
```

行、列表和页面都不会重建。`AAPL` 的一跳只重建一个 `Text`。一次更新的成本，从此与“变了什么”成正比，而不是与“屏幕上有什么”成正比。这正是你要的性质。

`apply()` 里有两个细节做了很多事。跳过没人显示的标的，把一次“忘了取消订阅”从事故变成空操作；而相等判断则吸收掉了那个非常常见的情况：一条不带任何可见变化的消息。

## 把更新合并到帧上，而不是合并到跳上

每跳只重建一个叶子已经很便宜，但每只标的每秒 20 跳，仍然有 19 次是浪费的：屏幕一秒只显示 60 帧，而人能感知的还要少得多。

所以把到达的数据缓冲起来，每帧冲刷一次：

```dart
class FrameCoalescer {
  FrameCoalescer(this._flush);
  final void Function(Iterable<Quote>) _flush;
  final _pending = <String, Quote>{};   // 以标的为键，后写覆盖先写
  bool _scheduled = false;

  void add(Quote q) {
    _pending[q.symbol] = q;             // 合并是免费得到的
    if (_scheduled) return;
    _scheduled = true;
    SchedulerBinding.instance.scheduleFrameCallback((_) {
      _scheduled = false;
      final batch = _pending.values.toList(growable: false);
      _pending.clear();
      _flush(batch);
    });
  }
}
```

合并是由那个 map 完成的：两帧之间同一只标的的十跳自动坍缩成一跳，因为同一个键上后写的值会覆盖先写的。这就是[管线那篇](/blog/realtime-data-pipeline-zh/)里的“寄存器”情形，用九行代码表达出来。

现在无论市场怎么动，你的更新频率都被帧率兜住了。剧烈的开盘只会让解码稍微贵一点，渲染成本完全不变。

## 让列表本身保持便宜

即使叶子更新已经做到最优，列表本身也可能把你的成果抵消掉：

- **`ListView.builder` 配 `itemExtent`。** 已知的行高让 Flutter 不必测量子项就能算出滚动几何，对等高行来说收益很大。
- **凡是能加 `const` 的地方都加。** 一个 `const` 组件在重建时会被直接跳过，而不是重新构造再比较。
- **在独立变化的单元格外面加 `RepaintBoundary`**，这样一个闪烁的价格格子不会弄脏整个列表的图层。但要有意识地用：每个边界就是一个图层，几百个会占内存。
- **把 notifier 的查找提出来。** 在 `build()` 里调用 `listen(symbol)` 没问题，只要它是一次廉价的 map 读取；但如果创建它会带来副作用（比如向上游订阅），就绝不要在构建过程中创建。

## 让订阅跟随组件生命周期

页面是否可见应当驱动订阅，否则你会为没人看的数据付费：

```dart
@override
void initState() {
  super.initState();
  _sub = feed.subscribe(widget.symbols);   // 上游做引用计数
}

@override
void dispose() {
  _sub.cancel();                            // 最后一个监听者离开时才退订
  super.dispose();
}
```

在数据源侧做引用计数，让两个显示同一标的的组件只产生一次上游订阅，并在第二个离开时结束。再把它和应用生命周期结合：进入 `AppLifecycleState.paused` 时，降到低频摘要或者干脆停掉。

## 测量正确的那条线程

Flutter 有两条要紧的线程，它们的失败方式不同。**UI 线程**跑你的 Dart 代码：构建、布局和框架本身；**光栅线程**把结果变成像素。

上面所有内容针对的都是 UI 线程。如果 DevTools 显示瓶颈在光栅线程，那么重建范围就不是你的问题，你面对的是昂贵的绘制、过多的图层或着色器编译，那属于[图表那篇](/blog/flutter-chart-rendering-zh/)的范围。

用 DevTools 的重建计数来验证你的假设：给应用接上一个真实速率的数据源，看哪些组件在重建，并核对这个数量是否等于真正发生变化的东西的数量。当这两个数字对上时，这个问题就解决了。

## 什么时候可以跳过全部这些

如果你的数据每几秒才变一次，在页面顶部放一个 `StreamBuilder` 接一条流就是正确的做法，本文全是多余开销。按标的分的 notifier、按帧合并、重绘边界，是当测量结果告诉你“整屏重建已经塞不进一帧”时才动用的手段，不要提前。


## 参考

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

- Flutter 文档，[Performance best practices](https://docs.flutter.dev/perf/best-practices)——`const` 组件、`RepaintBoundary`、`itemExtent`，以及 `Opacity` 与 `saveLayer` 为何昂贵。
- Flutter 文档，[Flutter architectural overview](https://docs.flutter.dev/resources/architectural-overview)——UI 线程与 raster 线程，以及各自跑什么。
- Flutter API，[`ValueListenableBuilder`](https://api.flutter.dev/flutter/widgets/ValueListenableBuilder-class.html) 与 [`SchedulerBinding.scheduleFrameCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/scheduleFrameCallback.html)——叶子重建与按帧合并两个模式所依赖的两个原语。
- Flutter 文档，[Use the Performance view](https://docs.flutter.dev/tools/devtools/performance)——UI 与 raster 时间线、帧预算与重建计数器。

---

*Flutter 实现篇下一篇：[通道、Pigeon 与 FFI](/blog/flutter-native-channels-zh/)，如何在不按跳付费的前提下跨到原生。*

