Skip to content

03 · Flutter 渲染原理与生命周期

【通用进阶】这一章的知识在本项目代码里几乎看不到——项目大量使用 ConsumerWidget(无状态), 你可能写完三个页面都没写过一次 initState。但不学这些,你迟早会卡在 「为什么状态丢了」「为什么列表乱了」「为什么这么卡」这三个问题上。


开篇:前端概念对照

前端概念Flutter 对应差异
Virtual DOM diffElement 树 diff目标相同,机制不同
React 组件卸载dispose()
useEffect(() => {...}, [])initState()
useEffect(() => () => cleanup, [])dispose()
useEffect(() => {...}, [dep])didUpdateWidget(old)
React keyFlutter Key作用机制差异很大
浏览器重排(reflow)layout
浏览器重绘(repaint)paintFlutter 有独立的合成层概念
will-change: transformRepaintBoundary都用于隔离重绘
useMemo / React.memoconst 构造 / 拆分 Widget

一、三棵树:各司其职

02 章讲过概览,这里深入各自职责。

┌─────────────────────────────────────────────────────┐
│  Widget 树                                           │
│  · 不可变配置(immutable)                            │
│  · 每次 build() 都新建                                │
│  · 极其轻量,创建成本 ≈ 创建一个小对象                  │
│  · 生命周期:可能只有一帧                              │
└────────────────────┬────────────────────────────────┘
                     │ Element 持有 Widget
┌────────────────────▼────────────────────────────────┐
│  Element 树                                          │
│  · Widget 的实例化,是可变对象                         │
│  · 持有 State(StatefulWidget 的)                    │
│  · 负责 diff:决定复用还是重建                         │
│  · 生命周期:尽量复用,跨帧存活                        │
└────────────────────┬────────────────────────────────┘
                     │ Element 持有 RenderObject
┌────────────────────▼────────────────────────────────┐
│  RenderObject 树                                     │
│  · 真正的布局(layout)、绘制(paint)、命中测试        │
│  · 创建和重排都很昂贵                                  │
│  · 生命周期:尽量复用                                  │
└─────────────────────────────────────────────────────┘

为什么要分三棵?

一句话:让「描述 UI」变得足够便宜(Widget),让「渲染 UI」保持稳定(RenderObject),中间的 Element 负责牵线。

dart
Widget build(context) => Text('${DateTime.now()}');

这个 build 每秒可能执行 60 次,每次都新建一个 Text Widget 对象。但:

  • Widget 创建:纳秒级,无所谓
  • RenderObject 如果也每帧重建 + 重新布局:会卡

所以 Element 做 diff:新 Widget 的 runtimeTypekey 与旧的相同 → 复用旧的 Element 和 RenderObject,只把新配置写进去。

diff 的核心规则

Element 复用条件(简化版):

dart
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() —— 只调一次

dart
@override
void initState() {
  super.initState();          // 必须先调 super
  _controller = TextEditingController();
  _animationController = AnimationController(...);
  _subscription = someStream.listen(...);
}

前端类比useEffect(() => {...}, []) 的主体部分。

⚠️ 这里不能用 context 做依赖查找Theme.of(context) 等)——此时还没挂载完成。要用的话放 didChangeDependencies()

项目实例 —— lib/ui/home/views/...(典型模式):

dart
@override
void initState() {
  super.initState();
  _loadTheme();     // 触发一次性初始化
}

lib/main.dart:210-213

dart
@override
void initState() {
  super.initState();
  _loadTheme();
}

didChangeDependencies() —— 依赖变化时

当依赖的 InheritedWidget(如 Theme、MediaQuery、Provider)变化时调用。

典型用途:需要 context 的初始化。

dart
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  final tokens = AppTokens.of(context);   // ✅ 这里可以用 context
  // 根据主题做初始化
}

build() —— 会被疯狂调用

这里必须保持纯净、快速

dart
@override
Widget build(BuildContext context) {
  // ❌ 不要在这里做:网络请求、读写文件、复杂计算
  // ❌ 不要在这里 new 大对象(每帧都新建)
  // ✅ 只做:读取状态 + 组装 Widget
  return Text(_name);
}

didUpdateWidget(Widget oldWidget) —— 父配置更新

当父 Widget 重建并传入新的配置时调用。注意:State 对象本身被复用,只是配置变了。

dart
@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() —— 释放资源(只一次)

dart
@override
void dispose() {
  _controller.dispose();          // TextEditingController
  _animationController.dispose(); // AnimationController
  _subscription.cancel();         // Stream 订阅
  super.dispose();                // 最后调 super
}

前端类比useEffect 返回的清理函数。

⚠️ 忘记 dispose 是内存泄漏的头号原因,尤其是 AnimationControllerTextEditingController

⚠️ 经典坑:异步之后 setState

dart
void _loadData() async {
  final data = await repository.fetch();   // 假设耗时 3 秒
  setState(() => _data = data);            // ❌ 危险!
}

问题:3 秒后用户可能已经退出这个页面,State 已经 dispose。此时 setState 会抛:

FlutterError: setState() called after dispose()

正确写法

dart
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

dart
final granted = await ref
    .read(permissionGuardProvider)
    .ensureGranted(AppPermission.camera);
if (!granted || !mounted) return;     // ✅ 双重检查
setState(() { ... });

注意这里检查了两件事:权限是否被授予,以及页面是否还活着。

lib/main.dart:247-257

dart
Future<void> _runPostPrivacyInit() async {
  await runPostPrivacyInit(isExistingInstall: widget.isExistingInstall);
  ...
  if (mounted) setState(() => _postPrivacyInitDone = true);   // ✅
}

lib/main.dart:228:243

dart
if (mounted) { ... }
setState(() => _agreedThisSession = true);

练习 3.1:观察生命周期

dart
// 粘到 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();
  }
}

观察要点

  1. 首次进入:initStatedidChangeDependenciesbuild
  2. +1:只有 build 被调用(印证了 Widget 重建 ≠ State 重建
  3. 退出页面:deactivatedispose

练习 3.2:复现 setState after dispose

dart
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;

自检

  • [ ] 能按顺序列出 initStatedidChangeDependenciesbuilddidUpdateWidgetdeactivatedispose
  • [ ] 知道 initState 里不能用 context 查依赖
  • [ ] 每次异步后 setState 都记得检查 mounted
  • [ ] 知道 controller 必须在 dispose 里释放
  • [ ] 理解「父组件换参数但子组件没反应」是因为没重写 didUpdateWidget

三、Key 体系:为什么列表会「乱」

前端对照

React 的 key 和 Flutter 的 Key 目的一样(给列表项一个稳定身份),但后果不同

ReactFlutter
作用优化 diff 效率决定 State 是否被复用
不加 key可能渲染错乱(警告)State 跟着位置走,导致状态错乱

Flutter 的后果更严重,因为 State 是挂在 Element 上的,而 Element 复用依赖 key。

四种 Key

Key判据用途
ValueKey(value)值相等(==列表项有稳定 id(最常用)
ObjectKey(obj)对象身份(identity)按对象实例区分
UniqueKey()每次都不同,永不复用强制重建(如动画重放)
GlobalKey全局唯一,可跨树访问获取 State / 测量 Widget(慎用)

经典 bug:删除列表项后状态错乱

dart
// ❌ 没有 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 → 状态正确
dart
// ✅ 用 ValueKey 绑定业务 id
ListView(
  children: items
      .map((item) => EditableItem(key: ValueKey(item.id), item))
      .toList(),
)

什么时候必须加 key

  1. 列表项持有 State(展开/收起、输入内容、复选状态、动画)
  2. 列表会增删改顺序(不只是追加)
  3. 需要强制重建某个 Widget(UniqueKey

什么时候不需要

纯展示、无 State 的列表项,加不加都行(但加上更安全、且能提升 diff 效率)。

GlobalKey:能不用就不用

GlobalKey 可以让你从外部访问一个 Widget 的 State:

dart
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 注释:

dart
/// Tab body 列表,与 [tabs] 一一对应。每个 body 应自管分页(推荐继承 [PagedListBody])。
final List<Widget>? tabBodies;

配合 HeKeepAliveTabhe_list_scaffold.dart:38 注释提到)保活各 Tab 的滚动位置与内部 state——本质就是阻止 Element 被销毁,从而保住 State

练习 3.3:复现状态错乱

dart
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() 被调用 ≠ 界面重新绘制

dart
Widget build(context) => Text('固定文本');

即使 build 每秒调用 60 次,如果产出的 Widget 配置没变,Flutter 会跳过后续所有步骤——因为 RenderObject 发现「配置没变,我不用重新布局和绘制」。

setState() 触发的范围

dart
class _MyState extends State<MyWidget> {
  int _count = 0;
  void _inc() => setState(() => _count++);
}

setState 会把当前 Element 标记为 dirty,下一帧重新 build

⚠️ 注意:它只标记当前 Widget,但 Flutter 不会自动帮你缩小范围——build 方法体里的所有 Widget 都会重建。

dart
Widget build(context) {
  return Column(children: [
    ExpensiveWidget(),          // ← 也会重建!即使它不依赖 _count
    Text('$_count'),
  ]);
}

优化手段一:const 构造

dart
Column(children: [
  const ExpensiveWidget(),      // ✅ const 的 Widget 不会重建
  Text('$_count'),
])

原理const Widget 是编译期常量,全局唯一实例。Element diff 时发现 identical(oldWidget, newWidget) 为 true,直接跳过整个子树

这是最便宜的优化手段,项目里到处在用:

dart
// lib/ui/core/notifiers/paged_notifier_mixin.dart:67
state = const AsyncValue.loading();
dart
// lib/ui/core/widgets/paged_list_body.dart:80
Widget buildLoading(BuildContext context) => const HeListSkeleton();

优化手段二:拆 Widget(缩小 rebuild 范围)

dart
// ❌ 整个页面重建
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

dart
RepaintBoundary(
  child: MyFrequentlyAnimatingWidget(),
)

作用:给子树一个独立的绘制层。子树重绘时不会波及兄弟节点

前端类比will-change: transform 让浏览器把元素提升为独立合成层。

什么时候用

  • 有频繁动画的部分(如转圈的 loading、进度条)
  • 复杂但静态的部分(防止被邻居的重绘波及)

什么时候不用:不要到处加——每层都有内存开销。Flutter 的很多 Widget 内部已经自带了(如 ListView 的每个 item)。

优化手段四:Riverpod 的 select(详见 08 章)

dart
// 只订阅 totalCount,其他字段变化不重建
final count = ref.watch(provider.select((s) => s.totalCount));

练习 3.4:观察 rebuild 范围

dart
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 里做副作用

dart
Widget build(context) {
  repository.fetchData();        // ❌ 每次重建都发请求
  AppLogger.I.d('building');     // ❌ 刷屏
  return ...;
}

② 不在 build 里 new 大对象 / 做重计算

dart
Widget build(context) {
  final list = hugeList.map(expensiveTransform).toList();   // ❌ 每帧算一次
  final style = TextStyle(fontSize: 14, ...);                // ⚠️ 每帧新建
  return ...;
}

③ 不嵌套过深

build 方法超过 100 行就该拆了。拆成私有方法或独立 Widget:

dart
// ⚠️ 私有方法:仍然在同一 Element 内,不会缩小 rebuild 范围,但可读性更好
Widget _buildHeader() => ...;

// ✅ 独立 Widget:真正缩小了 rebuild 范围(配合 const 效果更佳)
class _Header extends StatelessWidget { ... }

项目实例:拆分范式

lib/ui/core/widgets/paged_list_body.dart:82-109build 保持精简,把复杂度拆到 _buildSuccess:111)、_wrapCard:170)等私有方法:

dart
@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)
异步后更新 UIif (!mounted) return;setState
列表项有 State 且会增删ValueKey(item.id)
强制重建UniqueKey()
跨组件访问 StateGlobalKey(慎用)
减少重建调用处加 const
缩小 rebuild 范围拆成独立 Widget
隔离重绘RepaintBoundary
精准订阅ref.watch(p.select((s) => s.field))(见 08 章)

下一步

04 · Flutter 布局与滚动进阶