卷 VII · 手上CH 25深度 25/28

两个类:逐行写一遍

直觉过境 「要状态就 remember 一下,一个函数里就搞定了。」 得扔掉

前面二十四章讲的是「为什么」。这一卷补的是「手指怎么动」——你打开编辑器,光标闪着,第一行该敲什么。这一章把两个类的骨架逐行拆开写一遍:每个词为什么在那儿,哪些位置换个写法就会出事,以及选哪一个只需要问的那一句话。

骨架逐行super.keyState 放什么提升与降级

先把判据说完,剩下都是打字

你写的每一个组件,都要在这两个类里选一个。判据只有一句话,而且它不问界面会不会变

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

这个组件需要「自己记住」点什么、并且「自己去改」它吗?

要 → 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;必须 finalWidget 上有 @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 最常见的「诡异行为」来源,没有第二名。

⚠ 三条 setState 的硬规矩

一、别在 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 里该放什么,不该放什么

这条线画清楚,能省掉后面很多返工:

✓ 放进 State
只有这一个组件关心、并且刷新页面就该丢掉的东西:展开收起、当前 Tab、动画控制器、TextEditingControllerScrollController、一次性的表单校验结果。
↑ 提升出去
两个兄弟组件都要读的东西。往上挪到共同的父组件里,再当参数传下来——这就是「状态提升」,和 Compose 完全一样的做法。
✗ 别放 State
从服务器来的数据、登录状态、购物车、跨页面要用的东西。这些归第 16、17 章那套状态管理,放 State 里一换页就没了。

有一类东西特别容易放错:控制器TextEditingControllerScrollControllerAnimationController 这些持有真实资源的对象,必须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 的一份粗略清单

做一个课表 App,二十来个组件里大概会这样分:无状态——课程卡片、成绩条、头像、空状态插画、底部按钮、每一个纯展示的页面骨架,占八成。有状态——搜索框(要 TextEditingController)、可展开的作业详情、带动画的签到按钮、有本地筛选条件的列表页、底部 Tab 容器(要记当前是第几个)。

如果你发现自己写的有状态组件超过一半,八成有些状态该往上提,或者该交给状态管理了。

和 Compose 的精确对照

⇄ Compose 对照 · 一个函数 vs 两个类

Compose:@Composable fun CoursePanel(name: String),状态用 var open by remember { mutableStateOf(false) }。状态挂在「调用位置」上(编译器插件按 slot table 记着),你看不见容器,也不用管它的寿命。

Flutter:配置在 CoursePanel,状态在 _CoursePanelState,容器(Element)是框架的,但你能看见它、能拿 key 影响它

一一对应表:remember { mutableStateOf }State 的字段;state.value = xsetState(() => x)DisposableEffectonDisposedispose()remember(key) 的 key 变了重算 ≈ didUpdateWidget 里手动判断重算。

最实际的差别:Compose 里改状态就是赋值,编译器负责精确通知谁该重组;Flutter 里改状态要显式喊一声 setState,而喊完是整棵子树重建。那声「喊」是你要多做的事,那个「整棵」是你要自己缩小的范围(第 14 章)。

这一章的一句话

两个类不是啰嗦,是两种寿命:Widget 那半边每帧重造、只放 final 配置;State 那半边活过重建、放可变状态和要 dispose 的资源。选哪一个只问一句——它要不要自己记住并改点什么。

骨架有了,下一章往外走一层:这些组件要放进一个「页面」里。而「页面」在 Flutter 里也不是一个特殊概念——它同样只是一个 widget,只不过是一个会替你算高度的 widget。你会看到它到底给你的 body 留了多少空间,以及为什么键盘一弹就 overflow。