卷 II · 三棵树CH 09深度 09/24

State 的生命周期,与热重载为什么保得住状态

直觉过境 「remember / DisposableEffect 和 initState / dispose 差不多。」 改一下

大方向对:都是「进来时初始化、离开时清理」。但 Flutter 的 State 是一个活得比 Widget 久的对象,它有七八个回调,其中两个(didUpdateWidgetdidChangeDependencies)Compose 里没有直接对应物。搞清这条时间线,你就能顺带回答那个经典问题:热重载凭什么不清空状态。

生命周期initState 无 contextdispose 清理热重载

先把这条时间线摆出来

选一个阶段,看那一刻框架按什么顺序调了哪些回调。这不是背诵表——每一条都对应一个「东西该写在哪」的决定。

下面把最关键的几个回调,按「你会在里面写什么」来讲。

createState 与 initState:出生

一个 StatefulWidget 第一次进入树时,框架调 createState() 造出 State 对象,把它挂到 Element 上,然后调 initState()

class _CoursePageState extends State<CoursePage> {
  late final TextEditingController _search;
  late final AnimationController _anim;

  @override
  void initState() {
    super.initState();                    // 必须第一行调
    _search = TextEditingController();     // ✓ 建控制器
    _anim = AnimationController(vsync: this, duration: …);
    _loadCourses();                        // ✓ 发首次请求
    // ✗ 但不要在这里读 Theme.of(context) / MediaQuery.of(context)
  }
}

initState 是「一辈子只跑一次」的初始化:建控制器、发首屏请求、订阅流、加监听器。它对应 Compose 里 LaunchedEffect(Unit) { } 的角色。

⚠ initState 里不能依赖 InheritedWidget

严格说,initStatecontext 是存在的,但你不能用它去 dependOn 某个 InheritedWidget(也就是不能调 Theme.ofMediaQuery.ofLocalizations.of 这些)。原因是此刻「依赖关系」还没建立好,框架会在 assert 里拦你。

需要主题/尺寸/语言来初始化的东西,放到 didChangeDependencies(它在 initState 之后、build 之前紧接着被调一次),或者干脆在 build 里读。

build:一遍遍地被调

这个第 5 章讲透了:纯函数,读状态、返回描述,一秒可能六十次。这里只补一句和生命周期有关的:build 是唯一一个你几乎无法控制调用次数的回调,所以任何「只想干一次」的事都不能放这儿。

didUpdateWidget:父亲给了我一份新配置

这是 Compose 老手最容易忽略、却很重要的一个回调。记住第 6 章那句话:Widget 每帧重造,但 State 对象是同一个。当父组件重建、给你的 StatefulWidget 传来一份新配置时,框架会:把 State 上的 widget 字段换成新的,然后调 didUpdateWidget(oldWidget)

你在这里做「新旧配置对比」——最典型的是外部传进来的 controller、id、URL 变了,要重新订阅:

@override
void didUpdateWidget(CoursePage oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (widget.courseId != oldWidget.courseId) {   // 课程换了
    _loadCourses();                               // 重新加载
  }
  if (widget.controller != oldWidget.controller) {
    oldWidget.controller.removeListener(_onChange);
    widget.controller.addListener(_onChange);
  }
}

Compose 里这件事是 LaunchedEffect(courseId) { }——key 变了就重跑。Flutter 没有这个自动机制,你要手动比对。忘了比、直接在 build 里发请求,就会一秒发六十个(第 5 章的坑)。

didChangeDependencies:我依赖的东西变了

当你 dependOn 过的某个 InheritedWidget 变了(用户切了深色主题、旋转屏幕导致 MediaQuery 变、切了语言),框架调这个回调。它在两种时机触发:initState 之后第一次(所以适合放「需要 context 的初始化」),以及之后每次依赖变化

日常你很少直接重写它——因为 build 里读 Theme.of(context) 时,依赖变了 build 自己会被重跑,够用了。只有当你需要「依赖变化时做一次副作用」(而不只是重画)时才用它。

deactivate 与 dispose:死亡(但 deactivate 后还有一次机会)

Element 从树上被移走时,先调 deactivate()。这里有个第 8 章埋的细节:deactivate 之后不一定就死——如果同一帧内,有一个带 GlobalKey 的地方把这个 Element 认领走了(树间搬家),框架会调 activate() 让它复活。只有到帧结束还没被认领,才真正 dispose()

dispose 是「一辈子最后一次」,你在 initState 里建的东西,都要在这里还回去,否则内存泄漏:

@override
void dispose() {
  _search.dispose();                    // 控制器
  _anim.dispose();                      // 动画控制器
  _sub.cancel();                        // 流订阅
  widget.controller.removeListener(_onChange);
  super.dispose();                      // 必须最后一行调
}
✎ 对称律:一个铁律省掉一半的泄漏

凡是在 initStatecreate / addListener / subscribe 的,在 dispose 里必有一个对应的 dispose / removeListener / cancel把这俩回调并排写、逐行对照,是 Flutter 里防内存泄漏最有效的一招。DevTools 的 Memory 页能帮你抓漏掉的那些。

这对应 Compose 的 DisposableEffect { onDispose { } }——那边把「建」和「清」写在同一个块里,更难漏;Flutter 把它们分在两个回调里,更容易漏,所以更需要这条纪律。

热重载为什么保得住状态:三棵树给的答案

现在来回答标题里那个问题,它是第 6 章那三棵树的一个漂亮推论。

你改了一行代码、按下保存,Flutter 热重载(hot reload)做的事是:把新代码推给正在运行的 Dart 虚拟机,然后让整棵树从根重新 build() 一遍。注意——它只重跑 build,不重建 Element 树,不调 initState

◆ 热重载 = 只换纸,不动折痕

用三棵树的话说:热重载扔掉旧的 Widget 纸、用你的新代码折出新纸,但那棵 Element 折痕树原封不动地留着。而状态挂在 Element 上——所以:

  • 输入框里打的字:还在(TextEditingController 在 State 里,State 在 Element 上)。
  • 列表滚动的位置:还在。
  • 计数器的数字、当前选中的标签页:全都还在。

这就是 Flutter 开发体验的招牌——你能一边看着一个已经填了半屏数据、跳转了三层的页面,一边调那个页面的样式,不用每次都重新点进去、重新填一遍

那什么时候得热重启(大写 R)

既然热重载不调 initState、不重建 Element,有些改动它就吃不到。这些情况要用热重启(hot restart)——它会销毁整棵树、状态清零、从 main() 重来:

  • 你改了 initState 里的代码(它不会被重跑)。
  • 你改了全局变量或 static 字段的初始值。
  • 你改了 main() 函数、改了 const 常量(规范化过的,见第 4 章)。
  • 你改了枚举、改了类的继承关系这类结构性变化。

还有一个中间产物你会遇到:reassemble()。热重载时框架会调它(生产环境永远不调),一些库用它来在热重载后重建缓存。你自己一般不用管。

⚠ 热重载的一个错觉,害人不浅

你在 initState 里写了个有 bug 的初始化,改好后热重载——没生效,还是旧的错。你怀疑人生,以为改的地方不对。其实代码没问题,只是 initState 热重载时不重跑。按一次大写 R 热重启,问题消失。

记住这条,能省下你无数个「我明明改了啊」的抓狂时刻。判断口诀:改的是「构建界面」的代码 → 热重载够;改的是「初始化/一次性」的代码 → 热重启。

还有一层:App 级的生命周期

上面讲的是单个 Widget 的生死。另外还有一层是整个 App 的前后台切换(对应 Android 的 onPause/onResume),你监听它来「切后台时暂停视频、回前台时刷新数据」:

class _AppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed: … // 回到前台
      case AppLifecycleState.paused:  … // 进入后台
      case AppLifecycleState.inactive:
      case AppLifecycleState.detached:
      case AppLifecycleState.hidden: …
    }
  }
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);   // 又是对称律
    super.dispose();
  }
}

这一层和你在 Android 里熟的 Activity/Fragment 生命周期是对应的,只是 Flutter 把它统一成了几个枚举值。

⇄ Compose 对照 · 副作用 API 的映射
ComposeFlutter State
remember { }initState 里建、存成字段
LaunchedEffect(Unit) { }initState
LaunchedEffect(key) { }didUpdateWidget 里手动比对 key
DisposableEffect { onDispose { } }initState + dispose 配对
LocalConfigurationdidChangeDependencies / build 里读
rememberCoroutineScope无直接对应(没有结构化并发,卷 V)

最大的心态差:Compose 的副作用是「声明式」的——你说「依赖这个 key」,它自动帮你在 key 变时重跑、在离开时清理。Flutter 是「命令式」的——你在明确的回调里亲手做这些。更啰嗦,但也更没有隐藏行为:什么时候跑、跑几次,全写在你眼前。

这一章的一句话

State 是一个活得比 Widget 久的对象,它的生命周期就是「在 initState 里建、在 dispose 里对称地清、在 didUpdateWidget 里响应新配置」;而热重载之所以保得住状态,正因为它只换 Widget 这张纸、不动挂着 State 的 Element 折痕——但也因此,改了 initState 就必须热重启。

卷 II 到此结束。你现在拥有了推理 Flutter 界面的完整框架:三棵树、canUpdate、key、生命周期。下一卷换一个完全不同的话题——布局。它同样只有几句话(真的只有三句),但你得先扔掉一个从 Android 带来的、根深蒂固的错觉:以为父组件可以先问孩子「你想要多大」。