两个类:逐行写一遍
remember 一下,一个函数里就搞定了。」
得扔掉
前面二十四章讲的是「为什么」。这一卷补的是「手指怎么动」——你打开编辑器,光标闪着,第一行该敲什么。这一章把两个类的骨架逐行拆开写一遍:每个词为什么在那儿,哪些位置换个写法就会出事,以及选哪一个只需要问的那一句话。
先把判据说完,剩下都是打字
你写的每一个组件,都要在这两个类里选一个。判据只有一句话,而且它不问界面会不会变:
这个组件需要「自己记住」点什么、并且「自己去改」它吗?
要 → StatefulWidget。不要 → StatelessWidget。
注意「自己」这两个字。一个课程卡片上的文字天天变,但变的原因是爸爸传了新参数——它自己什么都没记,所以它是无状态的。而一个可以展开收起的面板,「展开与否」这件事没人告诉它,是它自己记着的——所以它是有状态的。
从 Compose 过来的人容易在这里绕弯,因为那边你只写函数,状态是靠 remember 挂在调用位置上的,没有「两个类」这回事。所以你会本能地觉得:Flutter 要写两个类,是设计得啰嗦了。
但这不是啰嗦,是寿命不同的东西必须分开放。第 5、6 两章已经给出理由了,这里只把它落到代码上:Widget 那半边每帧重造(它是配置),State 那半边要活过重建(它是记忆)。一个对象不可能同时具备两种寿命,于是只能是两个对象。
StatelessWidget:五行骨架
先写一个课程卡片。这是你在学校那个小 App 里会写几十遍的东西:
class CourseTile extends StatelessWidget {
const CourseTile({super.key, required this.name, required this.room});
final String name;
final String room;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(name),
subtitle: Text(room),
);
}
}
五个部分,每一个都有它必须在那儿的理由:
| 这一行 | 为什么必须这么写 |
|---|---|
extends StatelessWidget | 不是 implements,也不是 with。你继承的是「有一个 build、自己不画东西」这份契约。 |
const 构造函数 | 第 4 章那个性能开关的入口。只要所有字段都是 final,就一定写得出 const,那就一定要写——不写,调用方就没法 const CourseTile(...),整棵子树的跳过优化当场失效。 |
{super.key, ...} | 花括号 = 具名参数。super.key 是 Dart 2.17 起的简写,等于「收一个 key 参数直接转给父类构造函数」。不接这个参数,调用方就没法给你 key,第 8 章那些坑你就没有工具去修。 |
final String name; | 必须 final。Widget 上有 @immutable 注解,写成可变字段分析器直接标黄。 |
@override Widget build(...) | 唯一的必需方法。它是纯函数:读字段,返回描述,不做别的。 |
在 VS Code 里新建一个空 .dart 文件,输入 stless 然后按 Tab——上面整个骨架直接展开,光标停在类名上。stful 出有状态的那一套,stanim 出带 AnimationController 的那一套。这三个片段来自分析服务器本身,装了 Flutter 插件就有,不用装任何 snippet 扩展。第 28 章会把这类「不用打字」的入口列全。
关于 required:Dart 的具名参数默认可空
Dart 的具名参数默认是可选的,这和 Kotlin 正好相反。所以一个不写 required 的非空字段会直接编译失败——你要么加 required,要么给默认值:
const CourseTile({
super.key,
required this.name, // 必填
this.room = '待定', // 可选,有默认值
this.onTap, // 可选,可空 —— 类型得写成 VoidCallback?
});
顺带一个 2026 年的新写法:Dart 3.12 起私有字段也能用具名参数直接初始化了,{required this._name} 是合法的。以前这条路不通,只能写成位置参数再手动赋值。
StatefulWidget:两个类,各管一半
现在做一个可以展开的课程详情面板。「展不展开」是它自己的事,没人从外面传:
class CoursePanel extends StatefulWidget {
const CoursePanel({super.key, required this.name});
final String name; // ① 配置还是放这边,还是 final
@override
State<CoursePanel> createState() => _CoursePanelState(); // ② 唯一的方法
}
class _CoursePanelState extends State<CoursePanel> { // ③ 下划线 = 私有
bool _open = false; // ④ 状态放这边,可变
@override
Widget build(BuildContext context) {
return Column(
children: [
ListTile(
title: Text(widget.name), // ⑤ 读配置要走 widget.
trailing: Icon(_open ? Icons.expand_less : Icons.expand_more),
onTap: () => setState(() => _open = !_open), // ⑥ 改状态要走 setState
),
if (_open) const Padding(
padding: EdgeInsets.all(16),
child: Text('周三 14:00 · 401 教室'),
),
],
);
}
}
六个编号里,第 ⑤ 和第 ⑥ 是新手唯一会真的写错的地方,值得各说一句。
⑤ 为什么读参数要写 widget.name
因为 name 不在 State 上,它在另一个对象上。State 有一个 widget 属性,永远指向当前那一帧的配置对象。父组件重建时,框架会把新造的 CoursePanel 塞进这个属性里(然后调 didUpdateWidget),所以你每次读 widget.name,读到的都是最新值。
推论很重要:不要把 widget.name 复制到 State 的字段里。这是一个真实高频的 bug:
class _CoursePanelState extends State<CoursePanel> {
late String name;
@override
void initState() {
super.initState();
name = widget.name; // ✗ 只在第一次跑,参数变了它不知道
}
}
initState 一辈子只跑一次。父组件后来传了新的 name,这个副本还是旧的,界面就「不刷新」了。真需要缓存(比如按参数算了一个很贵的东西),必须同时在 didUpdateWidget 里再算一遍——这也是第 9 章说「didUpdateWidget 是 Compose 没有对应物的回调」的实际用处。
⑥ setState 是「通知」,不是「赋值」
setState(fn) 做两件事:跑一遍你给的 fn,然后把自己这个 Element 标脏。所以下面两种写法效果一样:
setState(() => _open = !_open); // 惯用写法
_open = !_open; setState(() {}); // 一样能刷新,但别这么写
惯用写法把「改状态」这件事圈在一个可见的括号里,读代码的人一眼能看到状态在哪儿变。而真正会出事的是反过来——改了却没 setState:值变了,界面不动,而且不报错。这是 Flutter 最常见的「诡异行为」来源,没有第二名。
一、别在 build 里调。会抛 setState() or markNeedsBuild() called during build。要在数据到位时改界面,用 initState 里发起的异步、或者回调。
二、dispose 之后别调。异步回来时组件可能已经没了,会抛 setState() called after dispose()。标准写法是先看一眼 mounted:
final data = await api.fetchCourses(); if (!mounted) return; // 这一行是异步 setState 的标配 setState(() => _courses = data);
三、别在 fn 里做耗时的事。setState 的参数是同步执行的,里面 await 是无效的(返回值被丢掉),里面算五毫秒就是掉一帧。先算完,再 setState 只做赋值。
State 里该放什么,不该放什么
这条线画清楚,能省掉后面很多返工:
TextEditingController、ScrollController、一次性的表单校验结果。State 里一换页就没了。有一类东西特别容易放错:控制器。TextEditingController、ScrollController、AnimationController 这些持有真实资源的对象,必须在 State 里建、在 dispose 里还:
class _SearchBarState extends State<SearchBar> {
final _ctrl = TextEditingController(); // 字段初始化就行,不必进 initState
@override
void dispose() {
_ctrl.dispose(); // 不还就是泄漏
super.dispose(); // 这一行放最后
}
…
}
放到 build 里建是彻底的错(每帧新建一个,输入框每帧失去内容);放到 StatelessWidget 的字段上更错(分析器会先骂你不是 final,就算写成 final 也活不过父组件重建)。
亲手看一眼:状态放错地方会怎样
光说不如看着它坏。下面这台机器有两段。第一段把同一个计数器写成三种版本,先点「+1」看各自的反应,再点「让父组件重建一次」——前两种写法会当场归零,第三种不会。第二段是真的三树同步器(第 6 章那台),它把那次重建的账算给你:谁被销毁、谁被复用、State 有没有活下来。
第一段里最值得停一下的是中间那种写法:count 放在 StatefulWidget 上、用 setState 改。点 +1 它是对的,数字会涨——所以它能通过你的手动测试、能通过 code review。它只在父组件重建的那一刻错,而父组件什么时候重建你并不控制(主题变了、键盘弹了、屏幕转了、祖先随便一次 setState)。这类 bug 上线后表现为「偶发的数据丢失」,查起来极贵。判断方法很简单:可变的东西,只能在 State 里。
第二段有三个数字值得你停一下:父组件重建之后,销毁 0 个、新建 0 个,而 State 里攒的 count 还在。这就是「两个类」这个设计换来的东西——Widget 那半边整个换新,Element 和 State 那半边原地不动。
然后把「给子换一个 key」打开再重建一次:销毁 1 个、新建 1 个,count 归零。同一台机器,同一次重建,只因为多了一个 key。这两组数字和本机 Flutter 3.44.8 跑 widget test 量出来的调用次数是一致的(initState 0 次 / 1 次、dispose 0 次 / 1 次),不是这台模拟器自己编的。
默认写无状态,需要时再升级
实战顺序建议是:先全写 StatelessWidget,遇到「这东西得自己记着」的时候再升级。理由不是洁癖:
- 无状态的能
const。有了const才有identical短路,才有整棵子树被跳过。 - 无状态的好测。输入参数、输出 widget,没有中间那层时序。
- 升级几乎零成本。光标放在类名上按
⌘.,选「Convert to StatefulWidget」——它会自动长出第二个类、补上createState,还会把name全部改写成widget.name。第 28 章那台 Quick Fix 台可以让你亲手按一下这条。
反过来也常有:一个组件本来有状态,后来你把状态提升到上层或交给 Riverpod 了,它就该降回 StatelessWidget——同一个菜单里有「Convert to StatelessWidget」。这两个方向都不该手动改,手动改容易漏掉 widget. 前缀。
做一个课表 App,二十来个组件里大概会这样分:无状态——课程卡片、成绩条、头像、空状态插画、底部按钮、每一个纯展示的页面骨架,占八成。有状态——搜索框(要 TextEditingController)、可展开的作业详情、带动画的签到按钮、有本地筛选条件的列表页、底部 Tab 容器(要记当前是第几个)。
如果你发现自己写的有状态组件超过一半,八成有些状态该往上提,或者该交给状态管理了。
和 Compose 的精确对照
Compose:@Composable fun CoursePanel(name: String),状态用 var open by remember { mutableStateOf(false) }。状态挂在「调用位置」上(编译器插件按 slot table 记着),你看不见容器,也不用管它的寿命。
Flutter:配置在 CoursePanel,状态在 _CoursePanelState,容器(Element)是框架的,但你能看见它、能拿 key 影响它。
一一对应表:remember { mutableStateOf } ≈ State 的字段;state.value = x ≈ setState(() => x);DisposableEffect 的 onDispose ≈ dispose();remember(key) 的 key 变了重算 ≈ didUpdateWidget 里手动判断重算。
最实际的差别:Compose 里改状态就是赋值,编译器负责精确通知谁该重组;Flutter 里改状态要显式喊一声 setState,而喊完是整棵子树重建。那声「喊」是你要多做的事,那个「整棵」是你要自己缩小的范围(第 14 章)。
这一章的一句话
两个类不是啰嗦,是两种寿命:Widget 那半边每帧重造、只放 final 配置;State 那半边活过重建、放可变状态和要 dispose 的资源。选哪一个只问一句——它要不要自己记住并改点什么。
骨架有了,下一章往外走一层:这些组件要放进一个「页面」里。而「页面」在 Flutter 里也不是一个特殊概念——它同样只是一个 widget,只不过是一个会替你算高度的 widget。你会看到它到底给你的 body 留了多少空间,以及为什么键盘一弹就 overflow。