Widget 与三棵树
Flutter 里"界面"是 Widget 拼出来的,但 Widget 只是一份不可变的配置描述,它不持有状态、也不绘制像素。真正支撑界面的是 Element 与 RenderObject。看懂这三棵树的职责,才能理解"为什么 setState 能刷新""为什么 const 能省性能""为什么需要 Key"。
三棵树各自的职责
Widget 不可变配置,每次 build 重新创建,描述"要什么"
↓ createElement()
Element 可变实例,持有 State,负责 diff 与复用,描述"是谁"
↓ createRenderObject()
RenderObject 负责布局(约束→尺寸)与绘制,描述"画在哪、多大"
↓
Layer → 合成上屏
| 树 | 是否可变 | 重建成本 | 你直接接触吗 |
|---|---|---|---|
| Widget | 不可变,频繁重建 | 低(只是对象分配) | 天天写 |
| Element | 可变,尽量复用 | 中 | 通过 Key 间接接触 |
| RenderObject | 可变,只在需要时更新 | 高 | 写自定义布局时接触 |
结论很直接:Widget 重建不等于界面重绘,Flutter 把"重建"做得很便宜,再用 Element 的 diff 把昂贵的工作挡在后面。
build 什么时候被调用
| 触发原因 | 说明 | 代价 |
|---|---|---|
| 父 Widget 重建 | 父的 build 返回新子树 | 不可避免,可被 const 截断 |
setState | 当前 State 通知本子树脏了 | 只重建该子树 |
| 依赖变化 | Theme、MediaQuery 等 InheritedWidget 更新 | 只有订阅者重建 |
| 首次挂载 | 插入树中时调用一次 | 一次性 |
build 必须保持纯粹:不要在里面发请求、建 Controller、打日志,因为它可能被调用几十次。
const 构造函数为什么能省性能
const Widget 在编译期固化为同一个实例,父级重建时新旧实例 identical 相等,Element 直接跳过整棵子树。
void _noop() {} // 顶层函数,可作为 const 构造的参数
class TitleRow extends StatelessWidget {
const TitleRow({super.key, required this.title, required this.onTap});
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.star, size: 18), // 常量子树,永不重建
const SizedBox(width: 8),
Expanded(child: Text(title)),
TextButton(onPressed: onTap, child: const Text('详情')),
],
);
}
}
注意 onTap: _noop 是顶层函数,所以整行调用可以是 const;一旦传闭包,const 就用不上了。这也是"能用 StatelessWidget 就别用 StatefulWidget"之外的日常纪律。
Key:Element 的身份标识
同一位置、同一种 Widget 类型,Flutter 默认认为是"同一个 Element",列表重排时就可能把状态配错项。
| Key 类型 | 作用范围 | 典型场景 |
|---|---|---|
ValueKey | 同一父级内按值匹配 | 列表增删重排,保住各项输入内容 |
ObjectKey | 按对象身份匹配 | 数据模型本身就是唯一对象 |
UniqueKey | 每次构造都是新 key | 强制销毁重建子树 |
GlobalKey | 全树唯一,可跨树访问 | 表单校验、读另一个 State |
class TodoList extends StatefulWidget {
const TodoList({super.key, required this.todos});
final List<String> todos;
@override
State<TodoList> createState() => _TodoListState();
}
class _TodoListState extends State<TodoList> {
final _formKey = GlobalKey<FormState>(); // 跨树拿到 FormState 统一校验
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
// 用内容做 key:删除中间项时,后面输入框的内容不会串位
for (final todo in widget.todos)
TextFormField(
key: ValueKey(todo),
initialValue: todo,
validator: (v) => (v == null || v.trim().isEmpty) ? '不能为空' : null,
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('校验通过')));
}
},
child: const Text('提交'),
),
],
),
);
}
}
GlobalKey 有隐藏成本:它让 Element 无法被随意复用,滥用会拖慢重建,只在表单校验、动画接管、跨树读 State 时用。
UI = f(state) 的思维
把"当前界面长什么样"完整写成 state 的函数,而不是记住上次画了什么再去改。
| 命令式思路(不推荐) | 声明式思路(推荐) |
|---|---|
拿到 Text 控件改它的 data | 改 state,重建时 Text('$_count') |
| 手动 show/hide 某个 View | if (_loading) const LoadingView() |
| 记录列表长度去增删 View | ListView.builder(itemCount: items.length) |
常见坑与调试方法
| 现象 | 原因 | 排查方式 |
|---|---|---|
| 界面不刷新 | 改了字段没调 setState,或改了列表内部元素 | 检查是否新建了集合 |
| 列表项状态串位 | 用 index 当 key 或漏了 key | 换成 ValueKey(item.id) |
const 用不上 | 构造函数里传了闭包或非 const 参数 | 回调提为顶层函数 |
| 重建次数异常多 | build 里创建对象、父级无谓重建 | DevTools 的 "Track widget rebuilds" |
flutter run --profile # 性能问题必须在 profile 模式下观察
flutter analyze # 收集 const 提示与静态问题
小结:Widget 是不可变配置、Element 是可复用实例、RenderObject 负责布局与绘制;build 要写成纯函数,用 const 截断重建、用 Key 保住 Element 身份,界面的唯一真相是 state。