Flutter 基础体系 · 第 6/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 状态与生命周期:StatefulWidget、BuildContext、Key 和更新边界

Flutter 界面并不是一棵只有“控件对象”的树。一次完整的界面更新至少涉及三层结构:

  1. Widget 树:描述界面配置,通常是不可变对象。
  2. Element 树:保存 Widget 与实际运行状态之间的关联,是更新和生命周期的核心。
  3. RenderObject 树:负责布局、绘制和命中测试。

StatefulWidgetBuildContextKey 的行为,必须放在这三层关系中理解。否则容易产生几个典型误解:

  • 以为 StatefulWidget 自身保存可变状态;
  • 以为 BuildContext 是一个全局上下文或可以长期缓存;
  • 以为给任何 Widget 加上 Key 都能“强制刷新”;
  • 以为 setState 会立即执行 build
  • 以为 Widget 从屏幕上暂时消失后,State 一定已经销毁。

本文先建立更新模型,再分别解释 StatefulWidgetStateBuildContextKey 和更新边界,最后用一个可运行示例把它们串起来。


一、先建立模型:Widget 不是状态,Element 才连接两者

1. Widget 是不可变配置

一个 Widget 通常只描述:

  • 使用什么类型的组件;
  • 组件的参数是什么;
  • 子节点是什么;
  • 如何根据当前输入构建界面。

例如:

class UserCard extends StatelessWidget {
  const UserCard({
    super.key,
    required this.name,
  });

  final String name;

  @override
  Widget build(BuildContext context) {
    return Text(name);
  }
}

UserCard 实例中的 name 在创建后不能改变。若 name 发生变化,通常是父组件重新创建一个新的 UserCard

UserCard(name: 'Alice')

变为:

UserCard(name: 'Bob')

这里的“重新创建 Widget”并不等于“销毁并重新创建整个界面对象”。Flutter 会尝试把新 Widget 与旧 Widget 进行匹配,并复用对应的 ElementRenderObject

2. Element 保存运行时关系

可以把 Element 理解成 Widget 配置与运行时对象之间的持久节点:

Widget 配置
    │
    ▼
Element:父子关系、挂载状态、依赖关系、State 关联
    │
    ▼
RenderObject:布局、绘制、命中测试

对于 StatefulWidget,关系还可以表示为:

StatefulWidget
      │ createState()
      ▼
StatefulElement ───── StatefulWidget
      │
      ▼
State

State 保存可变数据;StatefulWidget 只保存不可变配置。StatefulElement 在 Widget 更新时决定:

  • 是否继续复用原来的 State
  • 是否调用 didUpdateWidget
  • 是否重新执行 build
  • 是否将旧节点卸载并调用 dispose

因此,Flutter 中的“状态是否保留”,本质上是一个 Element 是否被复用 的问题。


二、Widget 更新的基本匹配条件

Flutter 是否复用一个旧 Element,可以先用一个简化但非常有用的条件表示:

canUpdate(oldWidget,newWidget)=(oldWidget.runtimeType=newWidget.runtimeType)(oldWidget.key=newWidget.key)\operatorname{canUpdate}(oldWidget, newWidget) = (oldWidget.runtimeType = newWidget.runtimeType) \land (oldWidget.key = newWidget.key)

这对应 Flutter 中 Widget.canUpdate 的核心语义:

  • Widget 类型相同;
  • Key 相同。

如果条件成立,Flutter 通常会更新现有 Element,而不是创建新的 Element。

这里的“Key 相同”有两个层次:

  • 两个 Key 都为 null,可视为相同;
  • 两个非空 Key 通过相等性判断相同。

但这个公式还隐含了一个重要前提:两个 Widget 必须位于适合比较的同一个父节点的子节点更新范围内。Key 不是跨整个应用的全局身份证。

1. 同一位置、同一类型、无 Key:通常复用

Column(
  children: const [
    CounterTile(),
  ],
)

下一次仍然返回:

Column(
  children: const [
    CounterTile(),
  ],
)

CounterTile 是同类型且位置没有变化,旧 Element 通常会被复用,因此其中的 State 会保留。

2. 同一位置、类型改变:通常不复用

旧树:

Text('Loading')

新树:

CircularProgressIndicator()

类型不同,Flutter 不能把原来的 Text Element 当作进度指示器使用,因此会拆除旧节点并创建新节点。

3. 类型相同但 Key 改变:主动切断复用

DetailsPage(key: const ValueKey('user-1'))

变为:

DetailsPage(key: const ValueKey('user-2'))

虽然类型仍然是 DetailsPage,但 Key 不同,Flutter 会把它们视为不同身份。原 State 会进入移除流程,新的 Widget 获得新的 State


三、StatefulWidget 与 State 的完整生命周期

1. StatefulWidget 负责创建 State

一个典型的 StatefulWidget 如下:

class CounterPage extends StatefulWidget {
  const CounterPage({
    super.key,
    this.initialValue = 0,
  });

  final int initialValue;

  @override
  State<CounterPage> createState() => _CounterPageState();
}

这里有两个需要区分的事实:

  • initialValue 是 Widget 的不可变配置;
  • count 应该放进 _CounterPageState,因为它会在运行过程中变化。
class _CounterPageState extends State<CounterPage> {
  late int count;

  @override
  void initState() {
    super.initState();
    count = widget.initialValue;
  }

  @override
  Widget build(BuildContext context) {
    return Text('$count');
  }
}

widget 是 State 当前关联的 Widget 配置。父组件传入的新配置到达时,widget 引用会更新,但 State 对象可能仍然是原来的 State。

2. 生命周期时序

在正常挂载、更新和卸载过程中,典型顺序如下:

flowchart TD
    A[创建 StatefulWidget] --> B[createState]
    B --> C[State.initState]
    C --> D[State.didChangeDependencies]
    D --> E[State.build]
    E --> F{Widget 配置或依赖变化?}
    F -->|父组件传入新配置| G[didUpdateWidget]
    F -->|Inherited 依赖变化| H[didChangeDependencies]
    G --> E
    H --> E
    E --> I{节点暂时移除?}
    I -->|可能重新插入| J[deactivate]
    J --> K[activate]
    K --> E
    I -->|最终移除| L[dispose]

下面分别说明这些阶段的约束。

3. initState:只执行一次的初始化

initState 在 State 第一次插入树后调用一次:

@override
void initState() {
  super.initState();

  _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 300),
  );
}

适合做:

  • 初始化字段;
  • 创建 Controller;
  • 注册一次性的对象;
  • 启动与当前 State 生命周期绑定的工作;
  • 读取不依赖 InheritedWidget 的初始配置。

不适合在这里调用依赖祖先 InheritedWidget 的操作。例如:

@override
void initState() {
  super.initState();

  // 不应把依赖 context 的 InheritedWidget 查找作为这里的主要初始化依据。
  // 依赖关系通常在 didChangeDependencies 中建立。
}

原因是 State 此时刚刚挂载,依赖关系还没有完成建立。需要使用 context.dependOnInheritedWidgetOfExactType 的逻辑,应放入 didChangeDependencies,或者放入 build

4. didChangeDependencies:依赖的初次建立和后续变化

如果组件依赖 InheritedWidget,Flutter 会在依赖建立后调用 didChangeDependencies

@override
void didChangeDependencies() {
  super.didChangeDependencies();

  final locale = Localizations.localeOf(context);
  // 根据 locale 更新需要缓存的资源或计算结果。
}

它不仅在第一次挂载时调用,也可能在相关依赖变化时调用。例如:

  • Theme 变化;
  • MediaQuery 变化;
  • Localizations 变化;
  • Provider/Riverpod 等基于 InheritedWidget 的依赖变化。

若逻辑只依赖 widget 的输入参数,不需要放在这里。

5. build:根据当前配置和状态生成 Widget

build 应被视为一个可重复执行的纯描述函数:

@override
Widget build(BuildContext context) {
  return Text('count: $_count');
}

“纯”不是指绝对不能访问任何对象,而是指它不应依赖一次性副作用来维持正确性。build 可能因为很多原因重复执行:

  • 调用了 setState
  • 父 Widget 重建;
  • InheritedWidget 依赖变化;
  • MediaQuery、主题、语言环境变化;
  • 热重载;
  • 某些框架内部更新。

错误示例:

@override
Widget build(BuildContext context) {
  _loadData(); // 可能在每次 build 时重复发起请求
  return const Text('Loading');
}

如果 _loadData 会发送网络请求、写数据库或注册监听器,就会导致重复副作用。应把它移到 initStatedidChangeDependencies,或由明确的事件触发。

6. didUpdateWidget:配置改变但 State 被保留

当新旧 Widget 满足 canUpdate,旧 State 会被复用。此时框架会先更新 State 的 widget 引用,再调用:

@override
void didUpdateWidget(covariant CounterPage oldWidget) {
  super.didUpdateWidget(oldWidget);

  if (oldWidget.initialValue != widget.initialValue) {
    // 根据新配置调整 State。
  }
}

常见用途是切换外部对象的监听:

class UserPanel extends StatefulWidget {
  const UserPanel({
    super.key,
    required this.userId,
  });

  final String userId;

  @override
  State<UserPanel> createState() => _UserPanelState();
}

class _UserPanelState extends State<UserPanel> {
  StreamSubscription<User>? _subscription;

  @override
  void initState() {
    super.initState();
    _subscribe(widget.userId);
  }

  void _subscribe(String userId) {
    _subscription = userStream(userId).listen((user) {
      if (!mounted) return;
      setState(() {
        // 保存或处理 user
      });
    });
  }

  @override
  void didUpdateWidget(covariant UserPanel oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.userId != widget.userId) {
      _subscription?.cancel();
      _subscribe(widget.userId);
    }
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text(widget.userId);
  }
}

didUpdateWidget 返回后,框架会安排一次 build。因此仅仅因为 didUpdateWidget 被调用而再调用一次 setState 通常是冗余的;如果要处理外部对象的切换,则应在这个方法中完成取消旧监听和建立新监听。

7. setState:标记更新,不是立即重建

setState 的正确形式是:

setState(() {
  _count++;
});

回调会同步执行,执行完后框架知道这个 State 需要重新构建。它并不意味着 build 在这一行代码返回前立即执行。

可以把过程抽象为:

用户点击
  │
  ├─ 同步执行 setState 回调:_count++
  │
  ├─ 标记对应 Element 为 dirty
  │
  └─ 下一次框架帧中执行 build

错误示例:

setState(() {
  _count++;
  _sendAnalytics(); // 副作用混在状态变更中,难以控制异常和重复行为
});

更清晰的写法是:

setState(() {
  _count++;
});
_sendAnalytics();

如果回调中抛出异常,状态更新和副作用的边界会变得不清楚。setState 回调还不能声明为 async

// 错误:setState 的回调不能返回 Future
setState(() async {
  await saveData();
});

异步操作应在外部执行,并只在同步确定新状态时调用 setState

Future<void> _save() async {
  setState(() {
    _saving = true;
  });

  try {
    await saveData();
    if (!mounted) return;

    setState(() {
      _saving = false;
    });
  } catch (error) {
    if (!mounted) return;

    setState(() {
      _saving = false;
      _error = error.toString();
    });
  }
}

8. mounted:异步回调的生命周期闸门

State 挂载到树上时,mountedtruedispose 执行后,mountedfalse。异步任务可能在 State 已经移除后才返回,因此不能直接使用 context 或调用 setState

Future<void> _load() async {
  final result = await repository.fetch();

  if (!mounted) return;

  setState(() {
    _result = result;
  });
}

这只解决“返回后不再更新已销毁 State”的问题,并不取消网络请求本身。若请求支持取消,生产代码还应在 dispose 中取消请求;否则请求仍可能占用网络和资源,只是结果被丢弃。

还要注意竞态条件:

Future<void> _search(String query) async {
  final requestId = ++_requestId;
  final result = await repository.search(query);

  if (!mounted || requestId != _requestId) return;

  setState(() {
    _result = result;
  });
}

这里除了检查 mounted,还检查请求序号,防止较早发出的请求晚于较新的请求返回,并覆盖更新的数据。

9. deactivateactivatedispose

deactivate 表示 Element 暂时从树中移除:

@override
void deactivate() {
  super.deactivate();
}

暂时移除不一定意味着永久销毁。某些重新挂载过程可能先调用 deactivate,随后调用 activate

@override
void activate() {
  super.activate();
}

当 State 确认不会重新插入树时,才会调用 dispose

@override
void dispose() {
  _controller.dispose();
  _subscription?.cancel();
  super.dispose();
}

必须释放的典型资源包括:

  • AnimationController
  • TextEditingController
  • ScrollController
  • FocusNode
  • StreamSubscription
  • Timer;
  • 手动注册的事件监听器;
  • 支持取消的异步任务。

dispose 之后不能再次使用该 State,也不能通过调用 setState 让它恢复。若资源只在 initState 创建,就应在 dispose 对称释放;若资源由 Widget 参数决定,则还要在 didUpdateWidget 中处理参数切换。


四、BuildContext 到底是什么

1. BuildContext 是 Element 的抽象接口

BuildContext 不是一个全局环境对象,也不是“当前页面”。在 Flutter API 中,它是 Widget 在树中对应 Element 的接口抽象。

因此,下面这些操作本质上都是从当前 Element 出发遍历或查询树:

Theme.of(context)
MediaQuery.of(context)
Localizations.of(context, ...)
Navigator.of(context)
ScaffoldMessenger.of(context)
context.findAncestorWidgetOfExactType<MyWidget>()

查询结果依赖调用位置。相同的 Widget 类,如果插入不同的树位置,使用同一个方法查询到的祖先对象可能完全不同。

2. “向上查找”的方向和范围

例如:

final theme = Theme.of(context);

Flutter 会从当前上下文向祖先方向查找 Theme。它不会向任意后代查找,也不会自动跨越另一个不相关的树。

这解释了一个常见错误:

Widget build(BuildContext context) {
  return Theme(
    data: ThemeData.dark(),
    child: Text(
      Theme.of(context).brightness.toString(),
    ),
  );
}

这里的 context 属于 Theme 的父级位置,因此 Theme.of(context) 不会看到刚刚返回的这个 Theme。要访问新插入的祖先,需要使用更低位置的上下文,例如:

Widget build(BuildContext context) {
  return Theme(
    data: ThemeData.dark(),
    child: Builder(
      builder: (innerContext) {
        return Text(
          Theme.of(innerContext).brightness.toString(),
        );
      },
    ),
  );
}

Builder 创建了一个位于 Theme 子树中的新构建位置,所以 innerContext 能够查到它。

3. BuildContext 不应长期缓存

错误做法:

class _PageState extends State<Page> {
  late BuildContext savedContext;

  @override
  Widget build(BuildContext context) {
    savedContext = context;
    return const SizedBox();
  }
}

BuildContext 绑定的是树中的具体位置。Widget 可能被移动、卸载或重建,之前保存的上下文可能不再有效。尤其是异步回调中,不应无条件使用旧 context:

onPressed: () async {
  await doSomething();

  if (!context.mounted) return;

  Navigator.of(context).pop();
}

在现代 Flutter API 中,BuildContext 提供了 mounted 属性,可用于检查该上下文是否仍挂载。对于 State,也可以使用 State.mounted

更安全的原则是:

  • 只在需要时使用当前 build 方法传入的 context;
  • 异步间隔后检查 context.mountedmounted
  • 不要把 context 放入单例、全局变量或长期业务对象;
  • 不要用 context 代替业务状态容器。

4. BuildContextInheritedWidget

InheritedWidget 是 Flutter 依赖传播的基础机制。一个简化示例:

class AppConfig extends InheritedWidget {
  const AppConfig({
    super.key,
    required this.apiBaseUrl,
    required super.child,
  });

  final String apiBaseUrl;

  static AppConfig of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<AppConfig>();
    assert(result != null, 'AppConfig not found in context');
    return result!;
  }

  @override
  bool updateShouldNotify(AppConfig oldWidget) {
    return apiBaseUrl != oldWidget.apiBaseUrl;
  }
}

使用:

class ApiLabel extends StatelessWidget {
  const ApiLabel({super.key});

  @override
  Widget build(BuildContext context) {
    final config = AppConfig.of(context);
    return Text(config.apiBaseUrl);
  }
}

这里发生了两件事:

  1. ApiLabel 通过 context 查找祖先 AppConfig
  2. dependOnInheritedWidgetOfExactType 建立了依赖关系。

AppConfig 更新且 updateShouldNotify 返回 true 时,依赖它的后代会收到通知并重新构建。

这也是 Provider、许多状态管理库和主题、媒体查询等 Flutter 机制的基础。具体库可能提供 watchread、选择器或异步状态抽象,但底层仍然需要把“状态变化通知哪些界面”划定出来。


五、Key:Widget 身份和状态迁移的控制器

1. Key 的作用不是强制刷新

Key 用来标识 Widget 在同一父节点子树中的身份。它主要影响:

  • 新旧 Widget 如何匹配;
  • Element 和 State 是否复用;
  • 列表重排时状态是否跟随项目移动;
  • 某些特殊场景下 State 是否能够跨位置迁移。

它不是“刷新按钮”,也不是“让 build 必然执行”的开关。

2. 没有 Key 的列表重排反例

假设每一行有独立计数状态:

class CounterRow extends StatefulWidget {
  const CounterRow({
    super.key,
    required this.label,
  });

  final String label;

  @override
  State<CounterRow> createState() => _CounterRowState();
}

class _CounterRowState extends State<CounterRow> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text('${widget.label}: $count'),
      trailing: IconButton(
        icon: const Icon(Icons.add),
        onPressed: () {
          setState(() {
            count++;
          });
        },
      ),
    );
  }
}

父组件先返回:

Column(
  children: const [
    CounterRow(label: 'A'),
    CounterRow(label: 'B'),
  ],
)

用户把第一行加到 3,状态关系是:

位置 0 -> A, count = 3
位置 1 -> B, count = 0

随后父组件把顺序改为:

Column(
  children: const [
    CounterRow(label: 'B'),
    CounterRow(label: 'A'),
  ],
)

如果没有 Key,Flutter 可能按位置复用:

位置 0 的旧 State -> 新的 B
位置 1 的旧 State -> 新的 A

结果可能显示:

B: 3
A: 0

State 没有“跟随 A”,而是跟随了位置。

正确做法是使用稳定身份:

Column(
  children: const [
    CounterRow(
      key: ValueKey('A'),
      label: 'A',
    ),
    CounterRow(
      key: ValueKey('B'),
      label: 'B',
    ),
  ],
)

重排时,Flutter 能根据 Key 找到对应 Element:

A 的 State -> 仍然绑定 A
B 的 State -> 仍然绑定 B

因此结果保持:

B: 0
A: 3

3. ValueKeyObjectKeyUniqueKey

ValueKey

当业务身份可以用稳定值表示时,常用 ValueKey

ValueKey<String>(user.id)

它根据值判断相等性。值必须稳定且能够唯一标识当前父节点下的项目。

ObjectKey

ObjectKey 根据对象身份和相等性语义区分项目,适合对象本身具有明确身份的情况,但需要了解对象是否重写了 ==hashCode

UniqueKey

UniqueKey()

每次创建都不同。它会让 Flutter 把该 Widget 视为新身份,因此通常会导致旧 State 不再复用。

例如:

SomeForm(key: UniqueKey())

可以重置表单内部状态,但如果它在每次 build 中都新建 UniqueKey

@override
Widget build(BuildContext context) {
  return SomeForm(key: UniqueKey());
}

那么每次父组件重建都会销毁并重新创建表单,输入内容、焦点、滚动位置等状态都会丢失。这通常是错误的,而不是“刷新成功”。

4. GlobalKey:跨位置访问和重父级的特殊能力

GlobalKey 在整个应用的 Widget 树中必须保持唯一。它可以用于:

  • 访问对应 State;
  • 访问 BuildContext
  • 在某些条件下把 State 从一个位置重新挂到另一个位置;
  • 保存需要跨父节点位置迁移的状态。

示例:

final formKey = GlobalKey<FormState>();

Form(
  key: formKey,
  child: const TextFormField(),
)

// 触发校验
final valid = formKey.currentState?.validate() ?? false;

GlobalKey 成本和约束都比局部 Key 更高:

  • 必须全局唯一;
  • 不能在 build 中反复创建;
  • 重新挂载可能触发旧位置的 deactivate
  • 使用不当会扩大更新和重父级的影响范围。

应把它声明为长期持有的字段:

class _PageState extends State<Page> {
  final _formKey = GlobalKey<FormState>();
}

而不是:

@override
Widget build(BuildContext context) {
  final key = GlobalKey<FormState>(); // 每次 build 都是新 Key
  return Form(key: key, child: ...);
}

六、更新边界:哪些变化会影响哪些 Widget

“更新边界”是指一次状态变化能够影响到哪一部分 Widget 子树。理解它,需要区分三类边界:

  1. setState 触发的 State 子树边界;
  2. InheritedWidget 依赖通知边界;
  3. Key 和 Widget 匹配决定的身份边界。

1. setState 的影响范围

当某个 State 调用 setState,Flutter 会把该 State 对应的 Element 标记为需要构建。其 build 返回的子树会被重新比较。

例如:

class Parent extends StatefulWidget {
  const Parent({super.key});

  @override
  State<Parent> createState() => _ParentState();
}

class _ParentState extends State<Parent> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$count'),
        const ExpensiveChild(),
      ],
    );
  }
}

count 变化时,Parentbuild 会再次执行,因而会重新返回 ExpensiveChild 的 Widget 配置。但这不表示 ExpensiveChild 一定重新创建 Element 或重新执行所有内部逻辑:

  • 若类型和 Key 匹配,它的 Element 通常复用;
  • ExpensiveChildconst,其 Widget 配置还可能直接复用;
  • 它的 RenderObject 是否需要更新,取决于相关配置是否变化。

因此,“父组件 build 了”不等于“整个子树都被销毁重建”。

2. 过大的 setState 边界

下面这种写法把两个互不相关的状态放在同一个 State 中:

class PageState extends State<Page> {
  int _counter = 0;
  String _query = '';

  // 修改 _counter 也会让包含搜索框的 build 重新执行
}

这不一定是错误,因为 Flutter 的 Widget 重建通常比 RenderObject 重建便宜。但如果页面复杂、状态变化频繁,过大的 State 边界会增加比较和构建工作,也会让状态职责混在一起。

可以把变化频率不同的区域拆成独立 StatefulWidget:

Column(
  children: const [
    CounterSection(),
    SearchSection(),
  ],
)

此时计数变化主要触发 CounterSection 的更新,搜索状态主要触发 SearchSection 的更新。这里的拆分不是为了追求某个固定性能数字,而是为了让状态所有权和更新原因一致。

3. InheritedWidget 的通知边界

InheritedWidget 不会通知所有后代无差别重建。它通知的是通过依赖查询建立依赖关系的后代,并由 updateShouldNotify 决定是否通知:

@override
bool updateShouldNotify(AppConfig oldWidget) {
  return apiBaseUrl != oldWidget.apiBaseUrl;
}

如果配置中包含多个字段,可以通过选择器或更细粒度的 InheritedWidget 减少无关依赖。Provider、Riverpod 等库在此基础上提供了更方便的依赖管理和选择机制,但“谁依赖谁、谁在变化时重建”仍是更新边界问题。

4. RenderObject 更新不是 Element 更新的同义词

Widget 层重新比较后,可能出现以下情况:

Widget build 被再次调用
    ↓
Element 复用
    ↓
RenderObject 配置没有实质变化
    ↓
不需要重新布局或绘制

也可能是:

Widget build 被再次调用
    ↓
Element 复用
    ↓
RenderObject 属性变化
    ↓
标记布局、绘制或合成阶段

所以诊断性能时,不能只看 build 次数。还要区分:

  • Widget/Element 重建;
  • 布局;
  • 绘制;
  • 光栅化;
  • 合成;
  • 平台端纹理或原生视图更新。

七、一个可运行的生命周期与 Key 示例

下面的程序演示:

  • StatefulWidget 如何保存局部状态;
  • 列表重排时 Key 如何让状态跟随项目;
  • didUpdateWidget 如何观察配置变化;
  • dispose 如何释放资源;
  • setState 如何更新界面。

它可以放入一个新的 Flutter 项目的 lib/main.dart 中运行。

import 'package:flutter/material.dart';

void main() {
  runApp(const LifecycleApp());
}

class LifecycleApp extends StatelessWidget {
  const LifecycleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Lifecycle Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const LifecycleHomePage(),
    );
  }
}

class LifecycleHomePage extends StatefulWidget {
  const LifecycleHomePage({super.key});

  @override
  State<LifecycleHomePage> createState() => _LifecycleHomePageState();
}

class _LifecycleHomePageState extends State<LifecycleHomePage> {
  final List<String> _items = ['A', 'B', 'C'];
  bool _useKeys = true;

  void _reverseItems() {
    setState(() {
      _items.setAll(0, _items.reversed);
    });
  }

  void _toggleKeys() {
    setState(() {
      _useKeys = !_useKeys;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('State / Key / Lifecycle'),
        actions: [
          IconButton(
            tooltip: 'Reverse',
            onPressed: _reverseItems,
            icon: const Icon(Icons.swap_vert),
          ),
        ],
      ),
      body: Column(
        children: [
          SwitchListTile(
            title: const Text('为每一行使用 ValueKey'),
            value: _useKeys,
            onChanged: (_) => _toggleKeys(),
          ),
          const Divider(height: 1),
          Expanded(
            child: ListView(
              children: [
                for (final item in _items)
                  CounterRow(
                    key: _useKeys ? ValueKey(item) : null,
                    label: item,
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class CounterRow extends StatefulWidget {
  const CounterRow({
    super.key,
    required this.label,
  });

  final String label;

  @override
  State<CounterRow> createState() => _CounterRowState();
}

class _CounterRowState extends State<CounterRow> {
  int _count = 0;

  @override
  void initState() {
    super.initState();
    debugPrint('initState: ${widget.label}');
  }

  @override
  void didUpdateWidget(covariant CounterRow oldWidget) {
    super.didUpdateWidget(oldWidget);
    debugPrint(
      'didUpdateWidget: ${oldWidget.label} -> ${widget.label}',
    );
  }

  @override
  void dispose() {
    debugPrint('dispose: ${widget.label}');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text('${widget.label}: $_count'),
      trailing: IconButton(
        icon: const Icon(Icons.add),
        onPressed: () {
          setState(() {
            _count++;
          });
        },
      ),
    );
  }
}

运行和观察步骤

前置条件是已安装 Flutter SDK,并且项目能够运行到 Android、iOS、桌面或 Web 设备。执行:

flutter create lifecycle_demo
cd lifecycle_demo
# 将 lib/main.dart 替换为上面的代码
flutter run

观察过程:

  1. 点击 A 的加号,使其变为 A: 2

  2. 点击右上角交换按钮;

  3. 保持“使用 ValueKey”开启;

  4. 预期显示:

    B: 0
    C: 0
    A: 2
    

    A 的状态跟随了 A

  5. 关闭“使用 ValueKey”;

  6. 再次交换列表顺序;

  7. 此时状态可能按位置跟随,而不是按标签跟随。

这个结果不是因为 Flutter 随机处理,而是因为:

  • 使用 Key 时,匹配依据包含每一行的业务身份;
  • 不使用 Key 时,同类型无 Key 子节点更容易按位置复用;
  • CounterRow_count 存在于 State 中,而不是存在于 label 这个普通字段中。

调试日志中还可以看到:

  • 首次插入时调用 initState
  • 同一 State 绑定到新的 Widget 配置时调用 didUpdateWidget
  • 节点真正移除时调用 dispose

debugPrint 只用于调试观察,不应把日志本身当作生命周期规范。具体重建次数还会受到树结构、调试模式、热重载和框架内部优化影响。


八、常见失败表现及其原因

1. 在 build 中重复创建 Controller

错误:

@override
Widget build(BuildContext context) {
  final controller = TextEditingController();
  return TextField(controller: controller);
}

每次 build 都会创建新的 Controller,可能导致:

  • 用户输入被重置;
  • 光标跳动;
  • 旧 Controller 未释放;
  • 状态表现与重建次数相关。

正确做法:

class _EditorState extends State<Editor> {
  late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
}

2. 异步返回后调用已销毁 State 的 setState

失败表现通常是运行时异常,内容会提示 State 已经不再挂载,仍然调用了 setState

错误:

Future<void> _load() async {
  final data = await repository.fetch();
  setState(() {
    _data = data;
  });
}

修正:

Future<void> _load() async {
  final data = await repository.fetch();

  if (!mounted) return;

  setState(() {
    _data = data;
  });
}

如果需要显示错误、结束 loading,也要在每个异步返回路径上检查挂载状态。

3. 把业务状态放在错误的 State 中

例如一个详情页面通过构造参数接收 userId,但只在 initState 中加载一次:

@override
void initState() {
  super.initState();
  _load(widget.userId);
}

如果父组件在原 State 被复用的情况下把 userId1 改为 2initState 不会再次执行,页面可能仍显示用户 1 的数据。

需要在 didUpdateWidget 中处理:

@override
void didUpdateWidget(covariant UserPage oldWidget) {
  super.didUpdateWidget(oldWidget);

  if (oldWidget.userId != widget.userId) {
    _load(widget.userId);
  }
}

或者把“用户数据由 userId 派生”交给更高层的状态管理边界,使页面只订阅当前 userId 对应的状态。

4. 把 UniqueKey 当作普通稳定 Key

错误:

return Editor(key: UniqueKey());

如果这行处于频繁执行的 build 中,每次构建都会产生新身份,导致编辑器状态不断丢失。

如果业务身份是文档 ID,应使用:

return Editor(key: ValueKey(document.id));

如果确实要重置状态,应在明确的用户操作或状态转换时改变 Key,而不是每次 build 都生成。

5. 用错误的 BuildContext 查找祖先

失败表现可能是:

  • Theme.of(context) 读到的不是期望主题;
  • Navigator.of(context) 找不到 Navigator;
  • ScaffoldMessenger.of(context) 找不到 ScaffoldMessenger;
  • Provider 抛出“未找到祖先”的异常。

诊断方法是沿 Widget 树检查查询点和目标祖先之间是否真的存在目标对象。必要时使用 Builder、拆分子 Widget,或把查询放到目标祖先的后代构建位置。


九、生命周期与状态管理方案的边界

局部状态和共享状态并不是两种互斥技术,而是不同的所有权边界。

1. 适合放在 State 中的状态

典型局部状态包括:

  • 当前输入框内容;
  • 当前焦点;
  • 展开/折叠状态;
  • 动画控制器;
  • 一个页面内部的临时 loading 状态;
  • 一个局部组件的选中项。

这些状态通常与某个 Widget 的挂载生命周期一致,放在 State 中最直接。

2. 不适合仅放在 State 中的状态

如果状态需要:

  • 被多个页面共享;
  • 在路由切换后继续存在;
  • 由业务层处理而不是由界面生命周期决定;
  • 支持复杂事件、缓存、错误和重试;
  • 由多个组件以不同粒度订阅;

则仅靠一个页面 State 容易造成:

  • 页面销毁后状态丢失;
  • 网络请求和界面生命周期耦合;
  • 多个组件之间通过回调层层传递;
  • 事件顺序和错误处理不清晰。

这时可以考虑:

  • InheritedWidget:Flutter 原生依赖传播机制;
  • Provider:基于 InheritedWidget 的常用封装;
  • Riverpod:强调可组合依赖、作用域和可测试性;
  • BLoC:以事件和状态流组织业务转换。

无论采用哪种方案,都要明确:

状态存在哪里
谁拥有它
谁可以修改它
谁订阅它
变化通知的边界是什么
组件销毁时哪些工作仍应继续

例如网络请求属于全局缓存层还是页面临时操作,会直接决定请求是否应该在 dispose 时取消。


十、跨平台差异:Widget 生命周期相同,宿主生命周期不同

StatefulWidgetStateBuildContextKey 的核心语义由 Flutter 框架统一提供,在 Android、iOS、Windows、macOS、Linux 和 Web 上基本一致。列表重排是否保留 State,不会因为平台不同而改变。

差异主要出现在宿主环境生命周期和系统能力上:

Android 与 iOS

应用可能进入后台、被系统暂停、被系统杀死后重新启动。Flutter Widget 的 dispose 不应被当作“应用进程即将退出”的可靠通知。

如果需要监听应用前后台状态,应使用 Flutter 提供的应用生命周期相关 API,例如 AppLifecycleListener,而不是只依赖某个页面 State 的 deactivatedispose

桌面平台

窗口可以最小化、隐藏、调整大小或失去焦点。窗口状态不等于某个 Widget 是否挂载,Widget 生命周期和窗口事件需要分开处理。

Web

浏览器标签页可能被切到后台、冻结或直接关闭。网络请求、定时器和页面卸载行为受浏览器调度策略影响,不能假设 dispose 一定及时执行。浏览器窗口尺寸和设备像素比变化也可能触发 MediaQuery 相关依赖更新。

因此,以下两种生命周期必须分开:

Widget 生命周期:
initState -> build -> deactivate/activate -> dispose

应用或宿主生命周期:
前台、后台、暂停、恢复、窗口激活、窗口失焦、浏览器标签页隐藏

前者管理 Widget 资源,后者管理应用与平台交互。混用会造成资源未释放、状态保存不完整或恢复逻辑错误。


十一、如何诊断状态和更新边界问题

1. 先确认 State 是否被复用

在 State 中临时记录:

@override
void initState() {
  super.initState();
  debugPrint('init ${identityHashCode(this)}');
}

@override
void dispose() {
  debugPrint('dispose ${identityHashCode(this)}');
  super.dispose();
}

如果列表重排后 State 的身份仍然存在,说明它被复用了;如果出现新的身份并伴随旧 State 的 dispose,说明 Key 或 Widget 类型改变了更新边界。

2. 检查 Widget 类型和 Key

对于一个出现“状态串行”问题的列表,逐项检查:

父节点是否相同?
项目是否会重排?
每个项目是否有稳定且唯一的 Key?
Key 是否在 build 中被重新创建?
Key 是否只在同一父节点范围内唯一?

3. 检查异步任务返回时的挂载状态

在异步方法中定位所有 await,检查每个 await 后是否可能:

  • 调用 setState
  • 读取 context
  • 使用 Controller;
  • 触发导航;
  • 显示 SnackBar 或 Dialog。

这些操作都可能需要:

if (!mounted) return;

或:

if (!context.mounted) return;

4. 观察重建而非猜测重建

Flutter DevTools 和框架调试选项可以帮助观察 Widget 重建、布局和绘制。诊断时要区分:

  • build 次数高但布局绘制很少;
  • 大量布局;
  • 大量绘制;
  • 异步请求重复;
  • Controller 反复创建;
  • State 反复 dispose。

“重建多”本身不是充分的故障定义,必须继续确认它是否造成了用户可见问题或不必要的工作。


十二、把整个更新过程串起来

考虑如下父子关系:

Parent(
  child: CounterRow(
    key: ValueKey(item.id),
    label: item.name,
  ),
)

当父组件改变 item.name 时,流程可以逐步表示为:

  1. 父组件调用 setState
  2. 父组件对应的 Element 被标记为 dirty;
  3. 下一帧执行父组件的 build
  4. 父组件返回新的 CounterRow Widget;
  5. Flutter 比较旧、新 CounterRow
    • 类型相同;
    • ValueKey(item.id) 相同;
  6. canUpdate 成立;
  7. 原来的 State 被复用;
  8. State 的 widget 引用更新为新配置;
  9. 调用 didUpdateWidget
  10. 执行 build
  11. _count 等局部状态继续保留。

item.id 也改变时:

  1. 类型仍相同;
  2. Key 不同;
  3. 原 State 不再匹配;
  4. 旧 State 进入 deactivate,最终 dispose
  5. 创建并挂载新的 State;
  6. 新 State 执行 initStatedidChangeDependenciesbuild

这就是 Key、State 生命周期和更新边界之间的因果链,而不是三个彼此独立的 API 概念。


十三、核心结论

StatefulWidget 是不可变配置,真正保存可变数据的是 State;State 能否保留,取决于对应 Element 是否在更新中被复用。

BuildContext 是树中某个 Element 的访问接口。它的查询结果取决于调用位置,不能当作全局容器长期保存;异步操作完成后使用 context 或 State 前,应确认仍然挂载。

Key 用于表达 Widget 身份。稳定的业务 Key 能让列表项目重排时状态跟随项目;改变 Key 会主动切断状态复用;UniqueKeyGlobalKey 都有明确代价,不应在 build 中随意创建。

setState 只是在同步修改状态后标记更新,build 会在后续框架调度中执行。initStatedidChangeDependenciesdidUpdateWidgetdeactivateactivatedispose 分别对应不同的生命周期边界,资源创建、监听切换和资源释放必须与这些边界匹配。

当页面出现状态错位、输入丢失、异步异常或重复请求时,优先从以下关系检查:

Widget 身份
→ Element 是否复用
→ State 是否保留
→ BuildContext 是否仍有效
→ setState 的更新范围
→ 异步任务是否跨越生命周期

掌握这条链路后,局部 State、InheritedWidget 以及 Provider、Riverpod、BLoC 等更高层状态管理方案,就可以被理解为不同的状态所有权和更新通知边界,而不再只是互相替换的 API。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。