卷 II · 三棵树CH 05深度 05/24

Widget 不是视图,是每帧被扔掉的说明书

直觉过境 「Widget ≈ View / Composable,创建它是有成本的,要省着点。」 得扔掉

这一章要说服你接受一件听起来很浪费的事:你写的每一个 Widget,都会在下一帧被整个扔掉、重新造一遍,一秒钟六十次。这不是 Flutter 没优化好,这是它的核心设计。想通这一点,接下来四章会顺得像下坡路。

不可变配置而非视图一切都是 Widget三种 Widget

先看一眼那个让人不安的事实

拿一个最简单的动画:一个数字从 0 涨到 100,60fps。你的 build 方法会被调用 60 次,每次都执行这样一段:

@override
Widget build(BuildContext context) {
  return Center(                    // new 一个 Center
    child: Column(                  // new 一个 Column
      children: [                   // new 一个 List
        Text('$count'),             // new 一个 Text,还有它的 TextStyle
        const SizedBox(height: 8),  // 这个是 const,不 new
        ElevatedButton(             // new 一个 ElevatedButton
          onPressed: _inc,
          child: const Text('+1'),
        ),
      ],
    ),
  );
}

一秒钟 new 出好几百个对象,然后全部丢给 GC。从 Android 开发的直觉看,这简直是在犯罪——我们被教育「不要在 onDraw 里创建对象」,被 Lint 追着骂了很多年。

但在 Flutter 里,这是正确的、被鼓励的、无法避免的做法。原因只有一句话:

◆ 这一章唯一要你记住的一句

Widget 不是界面,它是一份描述界面应该长什么样的配置。

它不持有画布、不持有位置、不持有尺寸、不持有任何系统资源。它就是一个装着几个 final 字段的小对象——Text('你好') 里面真的就只有一个字符串和几个可空的样式参数。造它的成本,和造一个普通数据类没有区别。

菜谱和菜的关系:build() 返回的是菜谱,不是菜。你每帧重写一份菜谱不花什么钱;真正贵的是照着菜谱炒菜——那件事由另外两棵树负责,而它们不会每帧重建。

「不可变」是被强制的

打开 Flutter 源码里的 Widget 类,第一行就是这个:

@immutable
abstract class Widget extends DiagnosticableTree {
  const Widget({this.key});
  final Key? key;
  …
}

两个细节值得注意。第一,@immutable 注解会让分析器强制检查:你在 StatelessWidget 里写一个非 final 的字段,IDE 会直接标红。第二,构造函数是 const 的——这是上一章那个性能开关的地基。

所以「Widget 是不可变的」不是一个约定,是一条语言层面被保证的规则。这条规则带来一个直接后果:

⚠ 别在 Widget 里存会变的东西

下面这段是新手最常写的错,而且它不会报错,只会行为诡异

class Counter extends StatelessWidget {   // ✗ Stateless 却想存状态
  int count = 0;                          // ✗ 分析器会警告:字段应为 final
  @override
  Widget build(BuildContext context) => TextButton(
    onPressed: () => count++,             // 改了,但界面永远不会变
    child: Text('$count'),
  );
}

为什么界面不变?因为你改的是一份马上就要被扔掉的说明书。下一帧父组件重新 build,会造一个全新的 Countercount 又回到 0。状态必须放到那棵活得久的树上——那就是 State,下一章见。

「一切都是 Widget」是实现方式,不是口号

从 Android 过来的人常低估这句话的字面程度。在 Android 里,padding 是 View 的一个属性,可见性是一个属性,透明度是一个属性。在 Flutter 里,它们全都是独立的 Widget

你想做的事Android / ComposeFlutter
加内边距android:padding / Modifier.padding()Padding —— 一个 Widget
居中gravity / Modifier.align()Center —— 一个 Widget
设透明度alpha 属性Opacity —— 一个 Widget
限制尺寸layout_widthSizedBox / ConstrainedBox —— Widget
响应点击setOnClickListenerGestureDetector —— 一个 Widget
读屏幕尺寸DisplayMetricsMediaQuery —— 一个 Widget

代价你已经见过了:缩进金字塔。收益是什么?你只需要理解一套规则,就能推理整棵树。没有「属性的优先级」「属性之间怎么相互影响」这类隐藏知识——一切都在树的结构里明写着,谁包着谁,谁就先作用。当你看不懂一个布局为什么长这样时,答案一定在嵌套顺序里,这是一个非常干净的可推理性。

顺带说一句:这也是为什么 Flutter 的 Widget 名字这么长这么细(ConstrainedBoxFractionallySizedBoxIntrinsicHeight)。它们不是「组件库很臃肿」,而是把布局能力拆成了正交的小积木

Widget 其实分三种

这个分类在官方教程里很少强调,但它是理解下一章的前提。你写的所有 Widget,都落在这三类里:

① 组件型 COMPONENT
StatelessWidget / StatefulWidget自己不画任何东西,只在 build() 里说「我等于另外一堆 Widget」。你写的 99% 属于这类。
② 代理型 PROXY
InheritedWidget。也不画东西,只是把一个值挂在树上给子孙们查(ThemeMediaQuery 都是)。第 15 章的主角。
③ 渲染型 RENDEROBJECT
RenderObjectWidget真的会创建 RenderObject,真的参与量尺寸和画像素。TextPaddingColumnColoredBox 都是。

关键推论:组件型 Widget 是「会被展开掉」的。你写的 CourseTile 在最终的渲染树里根本不存在,它只是一个把自己替换成 Padding → Row → Text 的中间层。所以你随便拆多少层 StatelessWidget渲染开销都不会增加——增加的只是 Element 树的深度,而那个很便宜。

✎ 于是有了这条实战建议:大胆拆 Widget

从 Android 过来的人常有「层级越浅越好」的执念(因为 View 层级深真的会拖慢测量)。在 Flutter 里这个执念要放下:组件型 Widget 不产生 RenderObject,拆一百层也不会多画一个像素。

而且拆开还有实打实的性能收益——每个独立的 Widget 都能被单独 const、被单独跳过重建。第 14 章会用引擎给你数出这个收益具体是多少。

build() 到底该写成什么样

既然 build 一秒可能跑六十次,它就必须满足两个纪律:

纪律一:不能有副作用

@override
Widget build(BuildContext context) {
  fetchCourses();                    // ✗ 一秒发六十个网络请求
  _controller = TextEditingController();  // ✗ 每帧新建一个,旧的全泄漏
  analytics.log('页面曝光');          // ✗ 曝光量会离谱
  return …;
}

build 只能是一个纯函数:读状态,返回描述。要发请求、要建控制器、要打点,全部放到 initState 或者事件回调里(第 9 章讲这些回调分别在什么时候被调用)。

这条纪律和 Compose 完全一致——那边你也不能在可组合函数里直接发请求,要用 LaunchedEffect。区别是 Compose 有编译器和 Lint 帮你盯着,Flutter 这边没人拦你,只有线上的异常量会告诉你

纪律二:不能假设它跑了几次

build 可能因为很多你没预料的原因被调用:父组件重建、主题变了、键盘弹出导致 MediaQuery 变了、屏幕旋转、热重载……你唯一能假设的是:它会被调用很多次,而且次数不可预测。

反过来,有一件事你不用担心

build 里 new 这么多对象,GC 会不会抖?」——不会,原因有两个。第一,Dart 的 GC 是分代的,新生代回收采用半空间复制,回收短命对象的成本几乎只和「存活对象」的数量成正比,而 Widget 恰好是「造出来立刻就死」的典型,正是这套 GC 最擅长的输入。第二,真正贵的东西(RenderObject、图片解码、纹理)根本不在 Widget 里,它们在第三棵树上,而那棵树是复用的。

StatelessWidget 还是 StatefulWidget:怎么选

你写的每个组件型 Widget 都要在这两者里选一个,判据其实只有一句:这个 Widget 需要「记住」点什么、并在自己内部改它吗?

StatelessWidgetStatefulWidget
有没有可变状态没有,全靠传进来的参数有,存在配套的 State
典型例子课程卡片、头像、一段文字、按钮带动画的组件、输入框、可展开的面板、计时器
怎么触发重建父组件给它传新参数自己 setState,或父传新参数
结构一个类,一个 build两个类:Widget(配置)+ State(状态 + build)

实战建议:默认写 StatelessWidget,只有确实需要本地可变状态时才升级到 StatefulWidget。而且很多时候「本地状态」应该被提升到上层(第 14、17 章)或交给状态管理(第 16 章),让这个 Widget 回归无状态——无状态的 Widget 更好测、更好复用、更容易被 const 跳过。VS Code 里有个重构快捷键能一键把 Stateless 转成 Stateful,所以先写简单的,需要时再升级,不吃亏。

✎ 为什么 StatefulWidget 要拆成两个类

新手常觉得「一个 Widget 配一个 State 类,好啰嗦」。但这个拆分正是第 6 章那三棵树的直接要求:Widget 那部分必须不可变、每帧重造(配置),State 那部分要活得久、能变(状态)——两种寿命,只能拆成两个对象。Widget 是那张每帧重印的配置,State 是挂在 Element 上、活过重建的记忆。等你读完下一章,这个「啰嗦」的设计会变得理所当然。

和 Compose 的精确对照

⇄ Compose 对照 · 一次调用 vs 一个对象

Compose:你调用 Text("你好"),这是一次函数调用,它把「这里有个 Text,参数是这些」发射到当前的 Composer 里,记录进 slot table。没有「Text 对象」这个东西,描述是以「调用记录」的形式存在的。

Flutter:你写 Text('你好'),这是一次对象构造,描述以一个不可变对象的形式存在,然后整棵对象树被交给框架去比对。

为什么这个差别重要:因为描述是对象,Flutter 才能做 identical() 判断(const 优化),才能把 Widget 存进变量、传来传去、放进 List。而 Compose 那种「调用即发射」的模型,让编译器能精确知道「谁读了哪个状态」,从而做细粒度重组。两条路各自换来了不同的东西——这是这两个框架最根本的分岔点,后面每一章的差异几乎都源于此。

这一章的一句话

Widget 不是界面,是一份不可变的、每帧被整个扔掉重造的配置说明书;它不持有任何昂贵资源,所以重建它便宜到可以忽略——真正贵的东西在另外两棵不会重建的树上。

那么问题来了:如果 Widget 每帧都被扔掉,你的 State 凭什么活得下来?滚动位置、动画进度、输入框里的字,为什么没有跟着一起消失?

下一章我们把那两棵藏起来的树拉出来,还给你一台真的同步器——你会亲眼看到,一次 setState 之后,零个 Element 被销毁、零个 RenderObject 被重建