本节摘要:界面工程的第一道决策不是"用什么控件",而是"这段状态归谁"。本节给出 Stateless 与 Stateful 的判断标准,走完 State 的完整生命周期钩子,立下 setState 的三条纪律,并用轻记账的记账输入面板把全部内容落成代码。上一节的状态归属讨论(第 2 章末尾)在本节得到正式答案。
判断一个组件该是哪种,只需回答:这个组件在自己的一生里,外观或内容会因内部原因改变吗?
不会变的,用 StatelessWidget:标题栏、图标按钮、账单卡片(数据从外部传入)。它们 build 一次就是最终形态,外界变了才会重 build——变的动因在父级,不在自己。
会变的,用 StatefulWidget:输入框的当前文字、倒计时的剩余秒数、勾选框的选中态。注意"内部原因"这个定语——账单卡片显示的金额来自父级传入,父级数据变了它跟着重 build,这不算内部状态;而输入框里正在敲的字,父级既不知道也不该知道,这才是 State 该管的事。
初学者常犯两个相反的错:什么都写成 StatefulWidget(把从父级拿来的数据再复制进 State,从此两份数据互相打架);什么都不敢写 StatefulWidget(为了"简洁"把可变数据放在全局变量里,界面却不刷新)。两条路的病根相同:没把"状态归谁"想清楚。答案永远是——状态放在唯一需要它的最小范围里,输入面板的文字归输入面板,账单列表归账单页,全局用户信息才上提(那是第 4 章的主题)。
StatefulWidget 本身仍是无配置对象,真正活着的是它的 State。生命周期钩子不多,每个都有明确的岗位纪律:
| 钩子 | 时机 | 该做 | 别做 |
|---|---|---|---|
| createState | 建 Widget 时 | 返回 State 实例 | 无业务逻辑 |
| initState | State 入树,一生一次 | 初始化字段、订阅流、启动动画 | 用不到上下文继承的依赖(用 didChangeDependencies) |
| didChangeDependencies | 依赖的 InheritedWidget 变化 | 重算依赖主题、尺寸的逻辑 | 放昂贵计算 |
| build | 每次需要重建 | 纯粹地根据数据返回 Widget | 改状态、起请求、睡大觉 |
| didUpdateWidget | 父级发来新配置 | 对比新旧、按需同步 | 无脑 setState |
| dispose | 出树,一生一次 | 取消订阅、释放控制器 | 忘记释放(内存泄漏之源) |

两条最容易踩的钩子纪律值得单独强调。其一,initState 里不能同步使用依赖继承的东西(比如读取 Theme),因为此刻继承关系还没就位;要读就放 didChangeDependencies。其二,build 必须是"纯"的:同样的数据进出,返回同样的 Widget,不在这里 setState、不起网络请求、不弹对话框。副作用一旦进了 build,每次重建都会重放一遍——弹窗弹个没停的经典 bug 就是这么来的。
StatefulWidget 的日常就是"改数据,然后通知界面"。setState 不是"刷新界面"的开关,而是"我改了数据,框架请把这里标记为脏"的申报。三条纪律:
一、只包必要的赋值。 setState 的回调里只放真正变化的数据赋值,别把请求、排序、格式化塞进去——它们会被每次刷新重复执行。
二、能在更小的组件里 setState,就不在更大的组件里。 把 setState 的范围圈在最小组件,重建范围就小——这是第 6 章渲染优化的第一手段,从写第一行代码就该养成。
三、build 之外改了状态要申报。 异步回调、定时器里改了数据但忘了 setState,界面与数据就会"两张皮"。用 Stream 或状态管理方案(第 4 章)可以让申报自动化,但机制得先懂。
一个完整的 Stateful 组件:分类选择、金额输入、提交后回传。生命周期钩子与 setState 纪律全部用上:
import 'package:flutter/material.dart'; class BillInputSheet extends StatefulWidget { const BillInputSheet({super.key}); @override State<BillInputSheet> createState() => _BillInputSheetState(); } class _BillInputSheetState extends State<BillInputSheet> { static const categories = ['餐饮', '交通', '购物', '居住']; String _category = categories.first; final _amountCtrl = TextEditingController(); // 文本控制器的状态归它自己 @override void dispose() { _amountCtrl.dispose(); // 纪律:控制器必须释放,否则泄漏 super.dispose(); } void _submit() { final amount = double.tryParse(_amountCtrl.text); if (amount == null || amount <= 0) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('请输入有效金额'))); return; } // 把结果交回父级:输入面板只管输入,账单列表归父级管 Navigator.of(context).pop({'category': _category, 'amount': amount}); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Wrap( spacing: 8, children: [ for (final c in categories) ChoiceChip( label: Text(c), selected: _category == c, onSelected: (_) => setState(() => _category = c), // 纪律一:只包赋值 ), ], ), TextField( controller: _amountCtrl, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration(labelText: '金额'), ), const SizedBox(height: 12), FilledButton.icon( onPressed: _submit, icon: const Icon(Icons.check), label: const Text('记下这笔'), ), ], ), ); } }
拆解几处设计意图:分类是面板自己的状态,所以 setState 圈在面板内;金额文本交给 TextEditingController,它的释放放在 dispose;提交时用 pop 把数据交回父级——面板不持有账单,状态各归各家。这套边界感养成后,第 4 章引入全局状态管理时你会很自然:那不过是把"各归各家"的规则延伸到跨页面共享的那一小撮状态而已。
界面有了骨架和状态,下一步是让它"摆得对"。下一节讲布局系统的约束规则——多数"界面不按写法排"的谜团,答案都是约束。