03 · Flutter 渲染原理与生命周期
【通用进阶】这一章的知识在本项目代码里几乎看不到——项目大量使用
ConsumerWidget(无状态), 你可能写完三个页面都没写过一次initState。但不学这些,你迟早会卡在 「为什么状态丢了」「为什么列表乱了」「为什么这么卡」这三个问题上。
开篇:前端概念对照
| 前端概念 | Flutter 对应 | 差异 |
|---|---|---|
| Virtual DOM diff | Element 树 diff | 目标相同,机制不同 |
| React 组件卸载 | dispose() | |
useEffect(() => {...}, []) | initState() | |
useEffect(() => () => cleanup, []) | dispose() | |
useEffect(() => {...}, [dep]) | didUpdateWidget(old) | |
React key | Flutter Key | 作用机制差异很大 |
| 浏览器重排(reflow) | layout | |
| 浏览器重绘(repaint) | paint | Flutter 有独立的合成层概念 |
will-change: transform | RepaintBoundary | 都用于隔离重绘 |
useMemo / React.memo | const 构造 / 拆分 Widget |
一、三棵树:各司其职
02 章讲过概览,这里深入各自职责。
┌─────────────────────────────────────────────────────┐
│ Widget 树 │
│ · 不可变配置(immutable) │
│ · 每次 build() 都新建 │
│ · 极其轻量,创建成本 ≈ 创建一个小对象 │
│ · 生命周期:可能只有一帧 │
└────────────────────┬────────────────────────────────┘
│ Element 持有 Widget
┌────────────────────▼────────────────────────────────┐
│ Element 树 │
│ · Widget 的实例化,是可变对象 │
│ · 持有 State(StatefulWidget 的) │
│ · 负责 diff:决定复用还是重建 │
│ · 生命周期:尽量复用,跨帧存活 │
└────────────────────┬────────────────────────────────┘
│ Element 持有 RenderObject
┌────────────────────▼────────────────────────────────┐
│ RenderObject 树 │
│ · 真正的布局(layout)、绘制(paint)、命中测试 │
│ · 创建和重排都很昂贵 │
│ · 生命周期:尽量复用 │
└─────────────────────────────────────────────────────┘为什么要分三棵?
一句话:让「描述 UI」变得足够便宜(Widget),让「渲染 UI」保持稳定(RenderObject),中间的 Element 负责牵线。
Widget build(context) => Text('${DateTime.now()}');这个 build 每秒可能执行 60 次,每次都新建一个 Text Widget 对象。但:
- Widget 创建:纳秒级,无所谓
- RenderObject 如果也每帧重建 + 重新布局:会卡
所以 Element 做 diff:新 Widget 的 runtimeType 和 key 与旧的相同 → 复用旧的 Element 和 RenderObject,只把新配置写进去。
diff 的核心规则
Element 复用条件(简化版):
newWidget.runtimeType == oldWidget.runtimeType && newWidget.key == oldWidget.key这解释了 Key 为什么重要——它给 diff 提供了「身份」判据。详见第三节。
二、State 生命周期全解
StatefulWidget 的 State 对象有完整的生命周期。这是本章最实用的一节。
生命周期图
createState() ← StatefulWidget 创建 State(只一次)
│
▼
initState() ← 初始化(只一次)。订阅流、创建 controller
│
▼
didChangeDependencies() ← 依赖变化(可能多次)。InheritedWidget 变更时
│
▼
build() ← 构建 UI(**可能非常多次**)
│
├── didUpdateWidget(old) ← 父 Widget 重建,配置更新时(在 build 之前)
│ └── build()
│
├── deactivate() ← 从树中临时移除(可能重新插入)
│
└── dispose() ← 永久移除,释放资源(只一次)逐个说明
initState() —— 只调一次
@override
void initState() {
super.initState(); // 必须先调 super
_controller = TextEditingController();
_animationController = AnimationController(...);
_subscription = someStream.listen(...);
}前端类比:useEffect(() => {...}, []) 的主体部分。
⚠️ 这里不能用 context 做依赖查找(Theme.of(context) 等)——此时还没挂载完成。要用的话放 didChangeDependencies()。
项目实例 —— lib/ui/home/views/...(典型模式):
@override
void initState() {
super.initState();
_loadTheme(); // 触发一次性初始化
}lib/main.dart:210-213:
@override
void initState() {
super.initState();
_loadTheme();
}didChangeDependencies() —— 依赖变化时
当依赖的 InheritedWidget(如 Theme、MediaQuery、Provider)变化时调用。
典型用途:需要 context 的初始化。
@override
void didChangeDependencies() {
super.didChangeDependencies();
final tokens = AppTokens.of(context); // ✅ 这里可以用 context
// 根据主题做初始化
}build() —— 会被疯狂调用
这里必须保持纯净、快速:
@override
Widget build(BuildContext context) {
// ❌ 不要在这里做:网络请求、读写文件、复杂计算
// ❌ 不要在这里 new 大对象(每帧都新建)
// ✅ 只做:读取状态 + 组装 Widget
return Text(_name);
}didUpdateWidget(Widget oldWidget) —— 父配置更新
当父 Widget 重建并传入新的配置时调用。注意:State 对象本身被复用,只是配置变了。
@override
void didUpdateWidget(MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.deviceId != widget.deviceId) {
// 参数变了,重新拉数据
_reload(widget.deviceId);
}
}前端类比:useEffect(() => {...}, [props.deviceId])。
⚠️ 不重写这个方法会导致一个经典 bug:父组件换了参数,子组件没反应。因为它只在 initState 里拉了数据,而 initState 只跑一次。
deactivate() —— 临时移除
State 被从树中移除(但可能重新插入,如页面栈变化时)。较少使用。
dispose() —— 释放资源(只一次)
@override
void dispose() {
_controller.dispose(); // TextEditingController
_animationController.dispose(); // AnimationController
_subscription.cancel(); // Stream 订阅
super.dispose(); // 最后调 super
}前端类比:useEffect 返回的清理函数。
⚠️ 忘记 dispose 是内存泄漏的头号原因,尤其是 AnimationController 和 TextEditingController。
⚠️ 经典坑:异步之后 setState
void _loadData() async {
final data = await repository.fetch(); // 假设耗时 3 秒
setState(() => _data = data); // ❌ 危险!
}问题:3 秒后用户可能已经退出这个页面,State 已经 dispose。此时 setState 会抛:
FlutterError: setState() called after dispose()正确写法:
void _loadData() async {
final data = await repository.fetch();
if (!mounted) return; // ✅ 关键检查
setState(() => _data = data);
}mounted 是 State 的属性,表示该 State 是否还在树中。
项目实例:mounted 检查
lib/ui/family/views/family_join_page.dart:34-37:
final granted = await ref
.read(permissionGuardProvider)
.ensureGranted(AppPermission.camera);
if (!granted || !mounted) return; // ✅ 双重检查
setState(() { ... });注意这里检查了两件事:权限是否被授予,以及页面是否还活着。
lib/main.dart:247-257:
Future<void> _runPostPrivacyInit() async {
await runPostPrivacyInit(isExistingInstall: widget.isExistingInstall);
...
if (mounted) setState(() => _postPrivacyInitDone = true); // ✅
}lib/main.dart:228 与 :243:
if (mounted) { ... }
setState(() => _agreedThisSession = true);练习 3.1:观察生命周期
// 粘到 lib/main_playground.dart
class LifecycleDemo extends StatefulWidget {
const LifecycleDemo({super.key});
@override
State<LifecycleDemo> createState() => _LifecycleDemoState();
}
class _LifecycleDemoState extends State<LifecycleDemo> {
int _count = 0;
@override
void initState() {
super.initState();
debugPrint('1. initState');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('2. didChangeDependencies');
}
@override
Widget build(BuildContext context) {
debugPrint('3. build');
return Scaffold(
appBar: AppBar(title: const Text('生命周期')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('count = $_count'),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('+1(观察 build 被调用)'),
),
],
),
),
);
}
@override
void didUpdateWidget(LifecycleDemo oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('4. didUpdateWidget');
}
@override
void deactivate() {
debugPrint('5. deactivate');
super.deactivate();
}
@override
void dispose() {
debugPrint('6. dispose');
super.dispose();
}
}观察要点:
- 首次进入:
initState→didChangeDependencies→build - 点
+1:只有build被调用(印证了 Widget 重建 ≠ State 重建) - 退出页面:
deactivate→dispose
练习 3.2:复现 setState after dispose
class DisposeBugDemo extends StatefulWidget {
const DisposeBugDemo({super.key});
@override
State<DisposeBugDemo> createState() => _DisposeBugDemoState();
}
class _DisposeBugDemoState extends State<DisposeBugDemo> {
String _text = '加载中...';
@override
void initState() {
super.initState();
_loadSlowly();
}
Future<void> _loadSlowly() async {
await Future.delayed(const Duration(seconds: 3));
// ❌ 故意不检查 mounted —— 进入页面后 3 秒内退出,观察报错
setState(() => _text = '加载完成');
}
@override
Widget build(BuildContext context) =>
Scaffold(body: Center(child: Text(_text)));
}观察:进入后立刻返回,等 3 秒,控制台会出现 setState() called after dispose()。
然后修好它:加上 if (!mounted) return;。
自检
- [ ] 能按顺序列出
initState→didChangeDependencies→build→didUpdateWidget→deactivate→dispose - [ ] 知道
initState里不能用context查依赖 - [ ] 每次异步后
setState都记得检查mounted - [ ] 知道
controller必须在dispose里释放 - [ ] 理解「父组件换参数但子组件没反应」是因为没重写
didUpdateWidget
三、Key 体系:为什么列表会「乱」
前端对照
React 的 key 和 Flutter 的 Key 目的一样(给列表项一个稳定身份),但后果不同:
| React | Flutter | |
|---|---|---|
| 作用 | 优化 diff 效率 | 决定 State 是否被复用 |
| 不加 key | 可能渲染错乱(警告) | State 跟着位置走,导致状态错乱 |
Flutter 的后果更严重,因为 State 是挂在 Element 上的,而 Element 复用依赖 key。
四种 Key
| Key | 判据 | 用途 |
|---|---|---|
ValueKey(value) | 值相等(==) | 列表项有稳定 id(最常用) |
ObjectKey(obj) | 对象身份(identity) | 按对象实例区分 |
UniqueKey() | 每次都不同,永不复用 | 强制重建(如动画重放) |
GlobalKey | 全局唯一,可跨树访问 | 获取 State / 测量 Widget(慎用) |
经典 bug:删除列表项后状态错乱
// ❌ 没有 key:删除第一项后,第二项的 State 复用了第一项的 Element
ListView(
children: items.map((item) => EditableItem(item)).toList(),
)假设列表是 [A(已展开), B(未展开)],删掉 A 后:
- 无 key:Element 按位置复用 → 位置 0 的 Element(原本是 A,已展开)现在承载 B → B 变成已展开
- 有 key:Element 按 key 匹配 → B 找到自己的 Element → 状态正确
// ✅ 用 ValueKey 绑定业务 id
ListView(
children: items
.map((item) => EditableItem(key: ValueKey(item.id), item))
.toList(),
)什么时候必须加 key
- 列表项持有 State(展开/收起、输入内容、复选状态、动画)
- 列表会增删改顺序(不只是追加)
- 需要强制重建某个 Widget(
UniqueKey)
什么时候不需要
纯展示、无 State 的列表项,加不加都行(但加上更安全、且能提升 diff 效率)。
GlobalKey:能不用就不用
GlobalKey 可以让你从外部访问一个 Widget 的 State:
final _formKey = GlobalKey<FormState>();
Form(key: _formKey, child: ...);
// 外部调用
_formKey.currentState!.validate();代价:GlobalKey 很贵(全局注册表、禁止某些优化)。只在确实需要跨组件访问 State 时用(典型场景就是 Form 校验)。
项目实例:Key 的隐式使用
本项目列表大量使用 ListView.builder,其返回的 item 通常不需要显式 key(因为数据直接从 items[index] 取,State 由外部 ViewModel 管理而非存在 item 内部)。
但 Tab 保活用了类似机制 —— lib/he_components/src/widgets/he_list_scaffold.dart:79 注释:
/// Tab body 列表,与 [tabs] 一一对应。每个 body 应自管分页(推荐继承 [PagedListBody])。
final List<Widget>? tabBodies;配合 HeKeepAliveTab(he_list_scaffold.dart:38 注释提到)保活各 Tab 的滚动位置与内部 state——本质就是阻止 Element 被销毁,从而保住 State。
练习 3.3:复现状态错乱
class KeyBugDemo extends StatefulWidget {
const KeyBugDemo({super.key});
@override
State<KeyBugDemo> createState() => _KeyBugDemoState();
}
class _KeyBugDemoState extends State<KeyBugDemo> {
final List<String> _items = ['A', 'B', 'C'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Key 演示'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => setState(() => _items.removeAt(0)),
),
],
),
body: Column(
// 1. 先用无 key 版本,点删除,观察颜色是否错位
// 2. 再加 ValueKey(_items[i]),重复操作,观察差异
children: _items
.map((e) => _ColorTile(text: e))
.toList(),
),
);
}
}
class _ColorTile extends StatefulWidget {
const _ColorTile({super.key, required this.text});
final String text;
@override
State<_ColorTile> createState() => _ColorTileState();
}
class _ColorTileState extends State<_ColorTile> {
// 每个 tile 随机一个颜色,并在 State 里保存(模拟「内部状态」)
late final Color _color =
Colors.primaries[DateTime.now().microsecond % Colors.primaries.length];
@override
Widget build(BuildContext context) => Container(
height: 80,
color: _color,
alignment: Alignment.center,
child: Text(widget.text, style: const TextStyle(fontSize: 24)),
);
}观察:无 key 时删除 A,B 会「继承」A 的颜色。加上 ValueKey 后颜色跟着文字走。
自检
- [ ] 知道无 key 时 Element 按位置复用,会导致 State 错位
- [ ] 会给有状态的列表项加
ValueKey(item.id) - [ ] 知道
GlobalKey很贵,不滥用 - [ ] 知道
UniqueKey用于强制重建
四、重建(rebuild)vs 重绘(repaint)vs 重布局(relayout)
这是性能优化的基础认知。三者代价递增:
build() 重建 Widget ← 最便宜
↓ 可能触发
layout 重新布局 ← 中等(需要遍历子树计算尺寸)
↓ 可能触发
paint 重新绘制 ← 较贵(光栅化)
↓ 可能触发
compositing 合成 ← 涉及层树关键认知
build() 被调用 ≠ 界面重新绘制。
Widget build(context) => Text('固定文本');即使 build 每秒调用 60 次,如果产出的 Widget 配置没变,Flutter 会跳过后续所有步骤——因为 RenderObject 发现「配置没变,我不用重新布局和绘制」。
setState() 触发的范围
class _MyState extends State<MyWidget> {
int _count = 0;
void _inc() => setState(() => _count++);
}setState 会把当前 Element 标记为 dirty,下一帧重新 build。
⚠️ 注意:它只标记当前 Widget,但 Flutter 不会自动帮你缩小范围——build 方法体里的所有 Widget 都会重建。
Widget build(context) {
return Column(children: [
ExpensiveWidget(), // ← 也会重建!即使它不依赖 _count
Text('$_count'),
]);
}优化手段一:const 构造
Column(children: [
const ExpensiveWidget(), // ✅ const 的 Widget 不会重建
Text('$_count'),
])原理:const Widget 是编译期常量,全局唯一实例。Element diff 时发现 identical(oldWidget, newWidget) 为 true,直接跳过整个子树。
这是最便宜的优化手段,项目里到处在用:
// lib/ui/core/notifiers/paged_notifier_mixin.dart:67
state = const AsyncValue.loading();// lib/ui/core/widgets/paged_list_body.dart:80
Widget buildLoading(BuildContext context) => const HeListSkeleton();优化手段二:拆 Widget(缩小 rebuild 范围)
// ❌ 整个页面重建
class CounterPage extends StatefulWidget { ... }
// build 里包含整个复杂 UI
// ✅ 把变化的部分拆成独立 Widget
class CounterPage extends StatelessWidget {
Widget build(context) => Column(children: [
const HeaderSection(), // 不变
const ListSection(), // 不变
CounterText(), // ← 只有这里跟着状态变
]);
}前端类比:React 里把变化的 state 下沉到子组件,避免整个组件树 re-render。
优化手段三:RepaintBoundary
RepaintBoundary(
child: MyFrequentlyAnimatingWidget(),
)作用:给子树一个独立的绘制层。子树重绘时不会波及兄弟节点。
前端类比:will-change: transform 让浏览器把元素提升为独立合成层。
什么时候用:
- 有频繁动画的部分(如转圈的 loading、进度条)
- 复杂但静态的部分(防止被邻居的重绘波及)
什么时候不用:不要到处加——每层都有内存开销。Flutter 的很多 Widget 内部已经自带了(如 ListView 的每个 item)。
优化手段四:Riverpod 的 select(详见 08 章)
// 只订阅 totalCount,其他字段变化不重建
final count = ref.watch(provider.select((s) => s.totalCount));练习 3.4:观察 rebuild 范围
class RebuildDemo extends StatefulWidget {
const RebuildDemo({super.key});
@override
State<RebuildDemo> createState() => _RebuildDemoState();
}
class _RebuildDemoState extends State<RebuildDemo> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('重建范围')),
body: Column(
children: [
const _HeavyWidget(), // 加了 const,不会重建
_HeavyWidget2(), // 没加 const,会重建
Text('$_count'),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('+1'),
),
],
),
);
}
}
class _HeavyWidget extends StatelessWidget {
const _HeavyWidget();
@override
Widget build(BuildContext context) {
debugPrint('HeavyWidget build'); // 观察:点 +1 时是否打印
return const SizedBox(height: 60, child: Text('const 组件'));
}
}
class _HeavyWidget2 extends StatelessWidget {
const _HeavyWidget2();
@override
Widget build(BuildContext context) {
debugPrint('HeavyWidget2 build'); // 观察:点 +1 时是否打印
return const SizedBox(height: 60, child: Text('非 const 调用'));
}
}观察:_HeavyWidget(调用处带 const)不会重建,_HeavyWidget2(调用处不带 const)会重建。
注意:const 要加在调用处,不是定义处。
自检
- [ ] 知道
build()被调用不等于界面重绘 - [ ] 会在调用处给不变的子树加
const - [ ] 知道
setState会重建整个build方法体,会用拆 Widget 缩小范围 - [ ] 知道
RepaintBoundary的作用和代价
五、build 方法瘦身原则
三条铁律
① 不在 build 里做副作用
Widget build(context) {
repository.fetchData(); // ❌ 每次重建都发请求
AppLogger.I.d('building'); // ❌ 刷屏
return ...;
}② 不在 build 里 new 大对象 / 做重计算
Widget build(context) {
final list = hugeList.map(expensiveTransform).toList(); // ❌ 每帧算一次
final style = TextStyle(fontSize: 14, ...); // ⚠️ 每帧新建
return ...;
}③ 不嵌套过深
build 方法超过 100 行就该拆了。拆成私有方法或独立 Widget:
// ⚠️ 私有方法:仍然在同一 Element 内,不会缩小 rebuild 范围,但可读性更好
Widget _buildHeader() => ...;
// ✅ 独立 Widget:真正缩小了 rebuild 范围(配合 const 效果更佳)
class _Header extends StatelessWidget { ... }项目实例:拆分范式
lib/ui/core/widgets/paged_list_body.dart:82-109 的 build 保持精简,把复杂度拆到 _buildSuccess(:111)、_wrapCard(:170)等私有方法:
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = RuntimeI18n.ofNonNull(context);
setupErrorListener(ref, l10n);
final header = buildHeader(context, ref);
final list = LoadingStateHandler<PagedState<T>>(
state: watchState(ref),
loadingBuilder: buildLoading,
onErrorRetry: () => refresh(ref),
successBuilder: (data) => _buildSuccess(context, ref, data),
);
if (header == null) return list;
return _wrapCard(context, Column(children: [header, Expanded(child: list)]));
}整个 build 只有十几行——这是值得模仿的范式。
六、本章速查
| 场景 | 做法 |
|---|---|
| 初始化(只一次) | initState() |
| 需要 context 的初始化 | didChangeDependencies() |
| 父传参变化要重新加载 | didUpdateWidget(old) 比较 old.xxx != widget.xxx |
| 释放资源 | dispose()(controller / subscription / animation) |
| 异步后更新 UI | if (!mounted) return; 再 setState |
| 列表项有 State 且会增删 | 加 ValueKey(item.id) |
| 强制重建 | UniqueKey() |
| 跨组件访问 State | GlobalKey(慎用) |
| 减少重建 | 调用处加 const |
| 缩小 rebuild 范围 | 拆成独立 Widget |
| 隔离重绘 | RepaintBoundary |
| 精准订阅 | ref.watch(p.select((s) => s.field))(见 08 章) |