卷 IV · 状态CH 14深度 14/24

setState 到底标脏了谁

直觉过境 「setState 只会重画真正变了的那部分。」 得扔掉

从 Compose 过来最容易带错的一个假设:以为框架会精准地只重建变化的那一小块。setState 没那么聪明——它把整棵子树从当前 Element 往下无脑重建一遍。这不是缺陷,配合前面几章的机制,它足够快。但「怎么把重建范围压小」这件在 Compose 里编译器代劳的事,从这一章起变成你的手艺。

markNeedsBuild重建范围拆 Widgetconst 刹车

setState 做的事,比你以为的少,也比你以为的多

先说「少」:setState 不会立刻重建。它做的只有两步:

void setState(VoidCallback fn) {
  fn();                        // 1. 跑你的闭包,改状态字段
  _element.markNeedsBuild();   // 2. 把当前 Element 标记为「脏」,丢进脏表
}                              // 然后……就返回了。真正重建在下一帧

所以在一个回调里连调三次 setState,只会重建一次(第 9 章讲过)。这也是为什么闭包里放什么无所谓——setState(() {}) 空着调、把改状态写在外面,效果完全一样;写进闭包只是一种「我改了状态」的信号习惯,框架不看闭包内容。

再说「多」:一旦下一帧真的重建,它不是只建你改的那个字段相关的部分。它从这个 State 所在的 Element 开始,把整棵子树的 build() 重跑一遍——除非中途撞上刹车(const,或者一个 canUpdate 判假的边界)。

亲手跑:重建计数器

下面这台数 build() 到底被调了多少次,以及 const 帮你省了几个节点。先在开关打开(有 const)时点 setState,再关掉对比。

你会看到:有 const 时,那棵 const 子树的节点被完全跳过(第 4 章的 identical 短路在这里兑现);关掉后,同样的子树每次 setState 都要重走一遍。在这个 8 节点的小页面上差别不大,但把它放大到一个 300 节点的真实页面——「被跳过的节点数」就是你不掉帧和掉帧的距离。

手艺一:把 build 拆成小 Widget(不是拆成方法)

这是最重要、也最容易做错的一条。假设你有个页面,顶部一个跟着秒表跳动的计时器,下面一个静态的长课程表。计时器每秒 setState:

// ✗ 全放一个 build 里:计时器每跳一次,整张课程表跟着重建
class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$elapsed 秒'),        // 变的
        _buildCourseList(),          // 不变的,却每秒重建一次
      ],
    );
  }
  Widget _buildCourseList() => Column(children: [ /* 50 个 tile */ ]);
}
⚠ 拆成「方法」不给你任何性能收益

很多人第一反应是把长列表抽成 _buildCourseList() 方法——这只是把代码搬了个地方,一点忙没帮上。因为它没有产生新的 Element,setState 重建时照样把方法重跑一遍。方法不是重建的边界。

正确做法是抽成一个独立的 StatelessWidget

// ✓ 课程表是独立 Widget,且能被 const
class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    return const Column(       // 注意:如果 Text 不 const,这里就不能 const
      children: [
        _Ticker(),             // 有状态的计时器,自己管自己
        CourseList(),          // ← 独立 Widget,const 时重建被跳过
      ],
    );
  }
}

为什么这样就有效?因为 CourseList() 是独立的 Widget,能被 const_Ticker 自己内部 setState 时,那是它自己那棵子树的事,碰不到 CourseList把「会变的」和「不变的」拆成兄弟 Widget,是 Flutter 里最基本的性能手艺。

手艺二:把 setState 下沉到最小的那个 Widget

上面那条的通用版:让持有状态的 StatefulWidget 尽可能小、尽可能靠近真正会变的那块 UI。

还是那个计时器:与其让整个页面是 StatefulWidget、在里面 setState,不如让计时器自己是一个小小的 StatefulWidget。这样 setState 的重建范围就被锁死在计时器那几个节点里,页面其余部分(可能是 const)纹丝不动。

这正是第 6 章那句话的实践:setState 的重建范围 = 那个 State 所在的 Element 往下的整棵子树。State 挂得越低,子树越小,重建越便宜。

手艺三:用 ValueListenableBuilder 精确到一个节点

当你想「只重建这一个 Text,别动周围任何东西」时,setState 太粗了。Flutter 内置了更细的工具——把状态放进一个 ValueNotifier,用 ValueListenableBuilder 只包住要变的那块:

final _count = ValueNotifier<int>(0);   // 存进 State 字段,dispose 里记得 dispose

// build 里,其它全部保持 const/静态
ValueListenableBuilder<int>(
  valueListenable: _count,
  builder: (context, value, child) => Text('$value'),   // 只有这里重建
)

// 改值:不用 setState
_count.value++;

这就非常接近 Compose 的粒度了——只有订阅了这个值的那个 builder 重跑,页面其余部分不参与。它是「原生」的(不用装任何包),做局部高频更新(进度条、秒表、输入字数统计)特别合适。第 16 章的状态管理库,本质上是把这套「精确订阅」做得更成体系。

◆ 三把凿子,由粗到细

1. setState:重建整棵子树。简单直接,页面小就够用,别妖魔化它。

2. 拆 Widget + const:把不变的部分隔离出去、让它被跳过。这是结构层面的优化,任何时候都该做。

3. ValueListenableBuilder / 状态管理库:精确订阅,只重建真正依赖那个值的节点。数据层面的优化,高频更新或大页面时上。

从 Compose 过来的人常犯的错,是要么全用 setState(该细的地方不细),要么一上来就套重型状态管理(小页面过度设计)。按这三档由粗到细选,绝大多数时候第 2 档就够了。

什么时候该担心,什么时候别瞎优化

别对着一个 10 个节点的页面纠结重建。build 很便宜(第 5 章),一次多建几十个 Widget 对象根本感觉不到。真正需要动手的信号是:

  • DevTools 的 Performance 页显示某帧超了 16ms(60fps 的预算),且时间花在 Build 阶段。
  • 一个高频 setState(动画、输入、滚动监听)触发了一大棵子树重建。
  • 打开 Performance Overlay(或 DevTools 里的「Track Widget Rebuilds」),看到不该重建的东西在重建。
✎ 直接看谁在重建

DevTools 的 Widget Rebuild Stats(重建统计)能列出每个 Widget 在当前屏幕重建了多少次。跑一下你的页面、做几次交互,看哪个 Widget 的重建次数离谱——那就是该拆、该 const、该上 ValueListenableBuilder 的地方。凭数据优化,别凭感觉。

那个 builder 参数的 child:一个被忽略的免费优化

ValueListenableBuilder 和很多类似的 builder(AnimatedBuilder)都有一个第三参数 child,专门用来解决「重建区里混着一块不变的东西」:

AnimatedBuilder(
  animation: _controller,
  child: const ExpensiveStaticLogo(),        // ← 只构建一次,之后一直传进来
  builder: (context, child) => Transform.rotate(
    angle: _controller.value * 6.28,
    child: child,                             // ← 复用那个不变的 logo,不重建它
  ),
)

动画每帧重跑 builder,但 child(那个昂贵的静态 logo)只在最开始构建一次,之后每帧原样传进来复用。把重建区里「不依赖变化值」的部分塞进 child,就把它排除在高频重建之外了——这是第 4 章 const 思路的另一种形态,很多人从没用过这个参数,白白让静态内容跟着动画每帧重建。

一个反直觉的真相:多数时候你不用优化

这一章讲了一堆缩小范围的手艺,但要给它踩个刹车,免得你陷入过早优化:build 真的很便宜。第 5 章说过,一次重建就是造一批小对象;只要没触发 Layout 和 Paint 的大范围重算,几十上百个 Widget 重建根本感觉不到。

所以正确的心态是:结构性的优化(拆 Widget、加 const)当成习惯随手做——它们零成本、还让代码更清晰;而针对性的优化(ValueListenableBuilderRepaintBoundary)只在 DevTools 告诉你某帧超预算时才上。别对着一个静态页面纠结「这里会不会重建太多」——那是在解决不存在的问题。学校 App 里真正需要动手的地方屈指可数:一个跟手滚动时联动的头图、一个每秒刷新的倒计时、一个几百项还带图的长名单。其余的,setState 到底,交付要紧。

⇄ Compose 对照 · 谁来划范围

Compose:编译器给每个可组合函数插桩,运行时精确知道「谁读了哪个 State」,状态一变只重组读了它的那个作用域。你几乎不用操心范围——代价是要理解稳定性、@Immutable、为什么传 List 会破坏跳过(这些其实也不简单)。

Flutter:没有这个编译器。setState 重建整棵子树,划范围是你的活儿——靠拆 Widget、加 const、用 ValueListenableBuilder更啰嗦,但规则透明:重建到哪里,你看树的结构就知道,没有隐藏的「为什么这里没重组」的谜题。

一句话对照:Compose 帮你把范围划到最小、但你得懂它的隐式规则;Flutter 要你自己划、但你能一眼看清范围在哪。这是这两个框架在状态上最根本的性格差异。

这一章的一句话

setState 只做两件事——改字段、把当前 Element 标脏——然后从这个 Element 往下重建整棵子树,唯一的自动刹车是 const;所以「缩小重建范围」是你的手艺:把会变的和不变的拆成兄弟 Widget、给不变的加 const、高频处用 ValueListenableBuilder,由粗到细三档按需选。

setState 有个天生的局限:它只能重建自己这棵子树。如果状态要给很远的、跨了好几层的子孙用呢?总不能一层层用构造函数往下传(那叫 prop drilling,噩梦)。下一章我们看 Flutter 内置的答案——InheritedWidget,以及 Theme.of(context) 为什么能 O(1) 拿到值。