Widget 不是视图,是每帧被扔掉的说明书
这一章要说服你接受一件听起来很浪费的事:你写的每一个 Widget,都会在下一帧被整个扔掉、重新造一遍,一秒钟六十次。这不是 Flutter 没优化好,这是它的核心设计。想通这一点,接下来四章会顺得像下坡路。
先看一眼那个让人不安的事实
拿一个最简单的动画:一个数字从 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 是不可变的」不是一个约定,是一条语言层面被保证的规则。这条规则带来一个直接后果:
下面这段是新手最常写的错,而且它不会报错,只会行为诡异:
class Counter extends StatelessWidget { // ✗ Stateless 却想存状态
int count = 0; // ✗ 分析器会警告:字段应为 final
@override
Widget build(BuildContext context) => TextButton(
onPressed: () => count++, // 改了,但界面永远不会变
child: Text('$count'),
);
}
为什么界面不变?因为你改的是一份马上就要被扔掉的说明书。下一帧父组件重新 build,会造一个全新的 Counter,count 又回到 0。状态必须放到那棵活得久的树上——那就是 State,下一章见。
「一切都是 Widget」是实现方式,不是口号
从 Android 过来的人常低估这句话的字面程度。在 Android 里,padding 是 View 的一个属性,可见性是一个属性,透明度是一个属性。在 Flutter 里,它们全都是独立的 Widget:
| 你想做的事 | Android / Compose | Flutter |
|---|---|---|
| 加内边距 | android:padding / Modifier.padding() | Padding —— 一个 Widget |
| 居中 | gravity / Modifier.align() | Center —— 一个 Widget |
| 设透明度 | alpha 属性 | Opacity —— 一个 Widget |
| 限制尺寸 | layout_width 等 | SizedBox / ConstrainedBox —— Widget |
| 响应点击 | setOnClickListener | GestureDetector —— 一个 Widget |
| 读屏幕尺寸 | DisplayMetrics | MediaQuery —— 一个 Widget |
代价你已经见过了:缩进金字塔。收益是什么?你只需要理解一套规则,就能推理整棵树。没有「属性的优先级」「属性之间怎么相互影响」这类隐藏知识——一切都在树的结构里明写着,谁包着谁,谁就先作用。当你看不懂一个布局为什么长这样时,答案一定在嵌套顺序里,这是一个非常干净的可推理性。
顺带说一句:这也是为什么 Flutter 的 Widget 名字这么长这么细(ConstrainedBox、FractionallySizedBox、IntrinsicHeight)。它们不是「组件库很臃肿」,而是把布局能力拆成了正交的小积木。
Widget 其实分三种
这个分类在官方教程里很少强调,但它是理解下一章的前提。你写的所有 Widget,都落在这三类里:
StatelessWidget / StatefulWidget。自己不画任何东西,只在 build() 里说「我等于另外一堆 Widget」。你写的 99% 属于这类。InheritedWidget。也不画东西,只是把一个值挂在树上给子孙们查(Theme、MediaQuery 都是)。第 15 章的主角。RenderObjectWidget。真的会创建 RenderObject,真的参与量尺寸和画像素。Text、Padding、Column、ColoredBox 都是。关键推论:组件型 Widget 是「会被展开掉」的。你写的 CourseTile 在最终的渲染树里根本不存在,它只是一个把自己替换成 Padding → Row → Text 的中间层。所以你随便拆多少层 StatelessWidget,渲染开销都不会增加——增加的只是 Element 树的深度,而那个很便宜。
从 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 需要「记住」点什么、并在自己内部改它吗?
| StatelessWidget | StatefulWidget | |
|---|---|---|
| 有没有可变状态 | 没有,全靠传进来的参数 | 有,存在配套的 State 里 |
| 典型例子 | 课程卡片、头像、一段文字、按钮 | 带动画的组件、输入框、可展开的面板、计时器 |
| 怎么触发重建 | 父组件给它传新参数 | 自己 setState,或父传新参数 |
| 结构 | 一个类,一个 build | 两个类:Widget(配置)+ State(状态 + build) |
实战建议:默认写 StatelessWidget,只有确实需要本地可变状态时才升级到 StatefulWidget。而且很多时候「本地状态」应该被提升到上层(第 14、17 章)或交给状态管理(第 16 章),让这个 Widget 回归无状态——无状态的 Widget 更好测、更好复用、更容易被 const 跳过。VS Code 里有个重构快捷键能一键把 Stateless 转成 Stateful,所以先写简单的,需要时再升级,不吃亏。
新手常觉得「一个 Widget 配一个 State 类,好啰嗦」。但这个拆分正是第 6 章那三棵树的直接要求:Widget 那部分必须不可变、每帧重造(配置),State 那部分要活得久、能变(状态)——两种寿命,只能拆成两个对象。Widget 是那张每帧重印的配置,State 是挂在 Element 上、活过重建的记忆。等你读完下一章,这个「啰嗦」的设计会变得理所当然。
和 Compose 的精确对照
Compose:你调用 Text("你好"),这是一次函数调用,它把「这里有个 Text,参数是这些」发射到当前的 Composer 里,记录进 slot table。没有「Text 对象」这个东西,描述是以「调用记录」的形式存在的。
Flutter:你写 Text('你好'),这是一次对象构造,描述以一个不可变对象的形式存在,然后整棵对象树被交给框架去比对。
为什么这个差别重要:因为描述是对象,Flutter 才能做 identical() 判断(const 优化),才能把 Widget 存进变量、传来传去、放进 List。而 Compose 那种「调用即发射」的模型,让编译器能精确知道「谁读了哪个状态」,从而做细粒度重组。两条路各自换来了不同的东西——这是这两个框架最根本的分岔点,后面每一章的差异几乎都源于此。
这一章的一句话
Widget 不是界面,是一份不可变的、每帧被整个扔掉重造的配置说明书;它不持有任何昂贵资源,所以重建它便宜到可以忽略——真正贵的东西在另外两棵不会重建的树上。
那么问题来了:如果 Widget 每帧都被扔掉,你的 State 凭什么活得下来?滚动位置、动画进度、输入框里的字,为什么没有跟着一起消失?
下一章我们把那两棵藏起来的树拉出来,还给你一台真的同步器——你会亲眼看到,一次 setState 之后,零个 Element 被销毁、零个 RenderObject 被重建。