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 文档,Performance best practices——
const组件、RepaintBoundary、itemExtent,以及Opacity与saveLayer为何昂贵。 - Flutter 文档,Flutter architectural overview——UI 线程与 raster 线程,以及各自跑什么。
- Flutter API,
ValueListenableBuilder与SchedulerBinding.scheduleFrameCallback——叶子重建与按帧合并两个模式所依赖的两个原语。 - Flutter 文档,Use the Performance view——UI 与 raster 时间线、帧预算与重建计数器。
Flutter 实现篇下一篇:通道、Pigeon 与 FFI,如何在不按跳付费的前提下跨到原生。