BLOG · #Engineering

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

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

English version: Flutter under a firehose: making rebuild scope match change scope。这是交易类 App 架构的 Flutter 实现篇,背后的原理见实时数据管线。

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

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

常规写法为什么会垮

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

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

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

只重建叶子

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

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;                    // 只通知一棵子树
  }
}
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 帧,而人能感知的还要少得多。

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

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 完成的:两帧之间同一只标的的十跳自动坍缩成一跳,因为同一个键上后写的值会覆盖先写的。这就是管线那篇里的“寄存器”情形,用九行代码表达出来。

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

让列表本身保持便宜

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

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

让订阅跟随组件生命周期

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

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

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

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

测量正确的那条线程

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

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

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

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

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

参考

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


Flutter 实现篇下一篇:通道、Pigeon 与 FFI,如何在不按跳付费的前提下跨到原生。