State 的生命周期,与热重载为什么保得住状态
大方向对:都是「进来时初始化、离开时清理」。但 Flutter 的 State 是一个活得比 Widget 久的对象,它有七八个回调,其中两个(didUpdateWidget 和 didChangeDependencies)Compose 里没有直接对应物。搞清这条时间线,你就能顺带回答那个经典问题:热重载凭什么不清空状态。
先把这条时间线摆出来
选一个阶段,看那一刻框架按什么顺序调了哪些回调。这不是背诵表——每一条都对应一个「东西该写在哪」的决定。
下面把最关键的几个回调,按「你会在里面写什么」来讲。
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 里 context 是存在的,但你不能用它去 dependOn 某个 InheritedWidget(也就是不能调 Theme.of、MediaQuery.of、Localizations.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(); // 必须最后一行调
}
凡是在 initState 里 create / 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 | Flutter State |
|---|---|
remember { } | 在 initState 里建、存成字段 |
LaunchedEffect(Unit) { } | initState |
LaunchedEffect(key) { } | didUpdateWidget 里手动比对 key |
DisposableEffect { onDispose { } } | initState + dispose 配对 |
读 LocalConfiguration 等 | didChangeDependencies / build 里读 |
rememberCoroutineScope | 无直接对应(没有结构化并发,卷 V) |
最大的心态差:Compose 的副作用是「声明式」的——你说「依赖这个 key」,它自动帮你在 key 变时重跑、在离开时清理。Flutter 是「命令式」的——你在明确的回调里亲手做这些。更啰嗦,但也更没有隐藏行为:什么时候跑、跑几次,全写在你眼前。
这一章的一句话
State 是一个活得比 Widget 久的对象,它的生命周期就是「在 initState 里建、在 dispose 里对称地清、在 didUpdateWidget 里响应新配置」;而热重载之所以保得住状态,正因为它只换 Widget 这张纸、不动挂着 State 的 Element 折痕——但也因此,改了 initState 就必须热重启。
卷 II 到此结束。你现在拥有了推理 Flutter 界面的完整框架:三棵树、canUpdate、key、生命周期。下一卷换一个完全不同的话题——布局。它同样只有几句话(真的只有三句),但你得先扔掉一个从 Android 带来的、根深蒂固的错觉:以为父组件可以先问孩子「你想要多大」。