面试备战 Flutter 19:Widget 不可变、生命周期与 Build 机制

面试备战 Flutter 19:Widget 不可变、生命周期与 Build 机制

Flutter 面试里,“Widget 为什么不可变”和“为什么 build 会频繁执行”经常连在一起问。表面是在问 API 设计,实际是在考你是否理解 Flutter 的三棵树模型:

Widget      -> 不可变配置
Element     -> 树中位置和生命周期
State       -> StatefulWidget 的可变状态
RenderObject -> 布局、绘制、命中测试

一句话回答:

Widget 设计成 immutable,是为了让 UI 成为某一时刻状态的纯描述。状态变化时不修改旧 Widget,而是生成新 Widget,再由 Element 根据 runtimeTypekey 决定复用还是替换,从而让 build 可以频繁、廉价、可预测地执行。

1. Widget 到底是什么?

Widget 不是屏幕上的 View,也不是最终参与布局绘制的对象。

Widget 是一份配置:

  • Text 描述一段文字应该怎么显示。
  • Container 描述背景、边距、约束、子节点。
  • Scaffold 描述页面骨架。
  • StatefulWidget 描述一个需要绑定 State 的配置入口。

真正持久存在的是 Element。Widget 每次 build 都可能重新创建,但 Element 会尽量复用。

可以这样理解:

Widget  = 配置说明书
Element = 说明书在树上的安装位置
State   = 这个位置关联的可变状态
RenderObject = 真正执行布局和绘制的对象

2. 为什么 Widget 必须不可变?

2.1 让 UI 成为状态的快照

不可变 Widget 表示:这个对象一旦创建,就只代表某一刻的 UI 配置。

当状态变化时,Flutter 不会在原 Widget 上改字段,而是重新调用 build,生成一批新 Widget:

old state -> old widgets
new state -> new widgets

这样 UI 更新就变成一个简单的转换:

State -> Widget description

这非常适合声明式 UI。

2.2 降低 diff 成本

Flutter 更新 Element 时,最核心的匹配规则是:

oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key

如果匹配成功,Element 复用,只更新配置;如果不匹配,旧 Element 被卸载,新 Element 被挂载。

Widget 不可变后,框架不用追踪一个 Widget 内部哪些字段被改过,只需要比较新旧 Widget 配置即可。

2.3 避免共享可变对象带来的混乱

如果 Widget 可变,就会出现很多难以判断的问题:

  • 一个 Widget 被多个地方持有,谁改了它?
  • Element 正在 build 时,Widget 字段被另一个逻辑改了怎么办?
  • 新旧配置边界在哪里?
  • 热重载后状态和配置如何安全恢复?

不可变对象天然没有“半更新”状态,框架和开发者的心智都会简单很多。

2.4 让 build 变得廉价

Flutter 官方 API 文档强调:build 可能每一帧都被调用。

这只有在 Widget 足够轻、可频繁创建的前提下才成立。Widget 不持有昂贵资源、不承载真实布局绘制对象,就可以被频繁丢弃和重建。

真正昂贵的对象,如 State、Element、RenderObject,会被框架尽量复用。

3. StatefulWidget 为什么还能保持状态?

一个常见误解是:StatefulWidget 能变,所以它不是 immutable。

事实正好相反:

StatefulWidget 也是 immutable。能变的是它关联的 State。

典型流程:

父组件 rebuild
-> 创建新的 StatefulWidget 实例
-> Element 判断 type + key 一致
-> 复用原来的 State
-> State.widget 指向新的 Widget 配置
-> 调用 didUpdateWidget
-> 再调用 build

示例:

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

  final String userId;

  
  State<UserHeader> createState() => _UserHeaderState();
}

userId 变化时,不是原来的 UserHeader 被修改,而是父节点创建了一个新的 UserHeader(userId: newId)。如果 key 和类型匹配,_UserHeaderState 会保留下来。

这就是为什么在 State 里访问最新配置要用 widget.userId

4. Widget、Element、State、RenderObject 生命周期

面试里讲 Flutter 生命周期,不能只背 initState -> build -> dispose。更完整的回答应该分层。

层级职责生命周期特点
Widget不可变 UI 配置随 build 频繁创建和丢弃
ElementWidget 在树中的位置mount、update、rebuild、deactivate、unmount
StateStatefulWidget 可变状态initState、didChangeDependencies、didUpdateWidget、build、dispose
RenderObject布局、绘制、命中测试attach、layout、paint、detach

5. State 生命周期逐个讲

createState

框架第一次挂载 StatefulWidget 时调用,用来创建 State。

不要在这里读 context,通常也不要做业务初始化。

initState

State 插入树后调用一次。

适合:

  • 创建 AnimationController
  • 创建 TextEditingController
  • 订阅 Stream。
  • 启动只依赖 widget 初始参数的任务。

示例:


void initState() {
  super.initState();
  controller = AnimationController(vsync: this);
}

注意:initState 里可以读 widget,但不要用 context.dependOnInheritedWidgetOfExactType 订阅依赖。需要依赖 InheritedWidget 时放到 didChangeDependencies

didChangeDependencies

initState 后立即调用一次;之后如果依赖的 InheritedWidget 变化,还会再次调用。

适合:

  • 依赖 Theme.of(context)Localizations.of(context)MediaQuery.of(context) 的初始化。
  • 依赖 Provider / InheritedWidget 的订阅型逻辑。

build

把当前状态描述成 Widget。

build 必须满足:

  • 快。
  • 可重复。
  • 无副作用。
  • 不创建需要手动释放的长期资源。

错误示例:


Widget build(BuildContext context) {
  fetchUser(); // 错:每次 build 都可能触发请求
  return const SizedBox();
}

didUpdateWidget

父组件 rebuild 后,如果新旧 Widget 类型和 key 一致,Element 会复用同一个 State,并调用 didUpdateWidget

适合处理“配置参数变化”:


void didUpdateWidget(covariant UserHeader oldWidget) {
  super.didUpdateWidget(oldWidget);

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

deactivate

State 从树上移除时调用。它不一定代表销毁,因为 GlobalKey reparent 时可能先 deactivate,再重新插入树。

通常不要在这里释放最终资源,最终释放放到 dispose

dispose

State 永久销毁时调用。

必须释放:

  • Controller。
  • FocusNode。
  • StreamSubscription。
  • Timer。
  • AnimationController。

void dispose() {
  subscription.cancel();
  controller.dispose();
  super.dispose();
}

6. 为什么 build 会频繁执行?

build 频繁执行是 Flutter 的设计预期,不是异常。

常见触发来源:

  • setState
  • 父组件 rebuild。
  • InheritedWidget 变化,比如 Theme、MediaQuery、Localizations。
  • Provider/Riverpod 等状态变化。
  • 动画每帧 tick。
  • didUpdateWidget 后框架调度 build。
  • 热重载。

官方文档对 build 的要求可以概括为:

假设 build 随时可能被调用,包括每一帧。

所以不能把 build 当成“只执行一次的初始化函数”。

7. build 频繁执行为什么还能快?

因为 Flutter 区分了“配置重建”和“真实对象复用”。

Widget rebuild 很频繁
Element 尽量复用
State 尽量复用
RenderObject 尽量复用
Layer 在合适情况下复用

举例:

setState(() {
  count++;
});

它不是把整棵 App 树全部销毁重建,而是:

  1. 当前 Element 标记 dirty。
  2. 下一帧 BuildOwner 统一处理 dirty elements。
  3. 调用对应 build。
  4. 新 Widget 和旧 Element 匹配。
  5. 能复用的 Element/RenderObject 继续复用。

8. build 里不能做什么?

不要做 CPU 重计算

final parsed = jsonDecode(hugeJson);

大 JSON 解析、加解密、图片处理应该放到 computeIsolate.run

不要创建长期对象

final controller = TextEditingController();

这会导致每次 build 创建新对象,而且旧对象可能没有释放。

不要发网络请求

repository.fetch();

父组件 rebuild、键盘弹出、主题变化都可能导致重复请求。

不要注册监听

stream.listen(...);

这会造成重复订阅和内存泄漏。

9. 面试怎么回答?

可以这样答:

Widget 是不可变的 UI 配置,不是真正的 View。Flutter 把 Widget 设计成 immutable,是为了让 UI 成为某一刻状态的纯描述。状态变化后,框架重新 build 新 Widget,再由 Element 根据 runtimeTypekey 判断复用还是替换。这样 build 可以频繁执行,但真正昂贵的 State、Element、RenderObject 会被复用。

StatefulWidget 自己也不可变,变化的是 State。父组件传入新参数后,如果类型和 key 一致,原 State 会保留,State.widget 指向新配置,并触发 didUpdateWidget

build 可能每帧执行,所以必须无副作用、轻量、可重复。初始化放 initState,依赖 InheritedWidget 的逻辑放 didChangeDependencies,参数变化处理放 didUpdateWidget,资源释放放 dispose,CPU 重活放 Isolate。

参考资料