Flutter学习笔记——第四章:别怕 build!Flutter 性能优化指南

5 阅读3分钟

作为一个新手,学习的时候,被很多文章告诫,“千万不要在 build 方法里写耗时逻辑,因为它会被疯狂调用。” 这时往往会疑惑,我只是改了一个小小的计数器,为什么感觉整个屏幕都在刷新?为什么我的 print("build...") 在控制台疯狂输出?
如果你也有这样的疑问,那这篇文章可以参考下。

Flutter 新手最常问的三个问题:

  • 为什么 build 会被调用这么多次?
  • setState 是不是很耗性能?
  • 我这个页面会不会被整页重绘?

一、先说结论,安抚心情

先把最重要的话放在前面:

build 被频繁调用是 正常现象
build ≠ 重绘(可能导致重绘,也可能什么都不发生) setState ≠ 重绘整个页面 ≠ 性能差 Flutter 的性能瓶颈 几乎从来不在 build
日志刷屏 ≠ 页面重绘

二、build到底在干啥

1、build本质只干一件事

Widget build(BuildContext context) {
  return ...
}

build 的唯一职责:

👉 根据当前状态,描述 UI 应该长什么样

注意关键词:

  • 描述
  • 配置
  • 声明式

那哪些事儿是build不做的呢?

  • ❌ 画界面
  • ❌ 请求网络
  • ❌ 读文件
  • ❌ 初始化业务数据

2、build ≠ 渲染

Flutter 内部是这样分层的:

Widget  →  Element  →  RenderObject
(配置)   (实例)      (真正绘制)
  • Widget:轻量、不可变、描述 UI
  • Element:Widget 的运行时实例
  • RenderObject:真正参与布局和绘制

👉 build 只是在“生成 Widget 配置”

三、build 为什么会被频繁调用?

1、最常见的触发条件(是Flutter的设计,并非bug)

build 会在这些情况下被调用:

  • ✅ 父组件 rebuild
    • 如果父组件调用了 setState , 它的所有子组件都会跟着重建
    • 即使子组件的状态没有变化,也会重新执行 build 方法
  • ✅自己调用 setState
    • 当你调用 setState(() { ... }) 时,Flutter 会标记当前组件为“脏”状态
    • 框架会在合适的时机调用该组件的 build 方法重新构建
setState(() {
  count++;
});
  • ✅ 屏幕尺寸变化
    • 屏幕旋转
    • 分屏
    • 输入法弹出
  • ✅ 依赖的 InheritedWidget 变化
    • Theme
    • MediaQuery
    • Provider(后面会学习)
  • ✅ 动画和滚动的“副作用”
    • 动画每一帧都会触发状态变化
    • 滚动时,列表项的 build 方法也可能被频繁调用

2、真实场景示例:

反例:容易导致频繁重建的代码

class BadExample extends StatefulWidget {
  @override
  _BadExampleState createState() => _BadExampleState();
}

class _BadExampleState extends State<BadExample> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    print('BadExample build called');
    
    // 问题1:在build中创建新对象
    final user = User(name: '乐乐', age: 8);
    
    // 问题2:在build中执行耗时计算
    final result = _calculateSomething();
    
    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () => setState(() => count++),
          child: Text('Increment'),
        ),
        // 问题3:父组件重建导致子组件重建
        ChildWidget(),
      ],
    );
  }
  
  int _calculateSomething() {
    // 模拟耗时计算
    return 42;
  }
}

class ChildWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('ChildWidget build called');
    return Text('I am a child');
  }
}

上述代码,每次点击按钮都会触发。即使 ChildWidget 没有任何变化,也会跟着重建!

BadExample build called
ChildWidget build called.

四、setState 到底干了什么?

1、setState 的真实作用(非常重要)

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

这行代码 只做了两件事

1️⃣ 修改状态
2️⃣ 标记当前 Element 为 dirty(需要 rebuild)

❗ setState 不会立刻 build
❗ setState 不会同步重绘

Flutter 会在 下一帧 统一处理所有 dirty 的 Element。

2、setState 的“影响范围”

✅只会影响当前 State 对应的子树
setState 是局部更新,无法影响父组件、兄弟组件,也不能跨组件层级传递状态。
不会整个 App 重建,放心。

五、build 很多次,为什么页面不卡?

这算是Flutter的核心优势之一

1️⃣ Flutter 的 diff 机制

每次 build:

  • 新 Widget 树
  • 对比旧 Widget 树
  • 只更新不同的 Element / RenderObject

👉 和 React 的 Virtual DOM 思想相似,但却更激进。

2️⃣ Widget 很便宜(非常便宜)

  • Widget 只是普通 Dart 对象
  • 创建成本很低
  • Flutter 鼓励你 多build,少存状态(很重要的一点,请谨记)

3️⃣ Widget 树 vs Element 树

  • Widget 树:每秒可以重建几千个,只是内存里的轻量配置对象。
  • Element 树:它是“管家”,会对比新旧 Widget。如果 Widget 类型和 Key 没变,它就复用,只更新属性。
  • RenderObject 树:这才是真正的“重活”,涉及布局计算和像素绘制。只要 Element 觉得 Widget 没本质变化,RenderObject 就不会动。

六、真正危险从不是build本身,而是build里面做了什么

❌ 错误示例 1:在 build 里请求网络

Widget build(BuildContext context) {
  fetchData(); // ❌
  return ...
}

👉 结果:

  • 每次 rebuild 都请求网络,可能一秒几十次
  • 浪费大量网络资源,数据不停刷新

❌ 错误示例 2:在 build 里初始化数据

Widget build(BuildContext context) {
  list = loadFromDb(); // ❌
  return ...
}

👉 结果:

  • 每次 rebuild 都读取数据库,IO读取拥堵,APP卡爆炸

✅正确示例:使用initState

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

记忆点:

build = 描述 UI
initState = 初始化一次

七、如何避免不必要的重建(性能优化)

1️⃣ 拆分Widget(最重要)

❌ 一个巨大的 build:

Widget build(BuildContext context) {
  return Column(
    children: [
      Header(),
      Body(count),
      Footer(),
    ],
  );
}

✅ 拆成小组件:

class Header extends StatelessWidget { ... }
class Footer extends StatelessWidget { ... }

👉 Flutter 优化的第一法则:拆组件

2️⃣ const Widget 的意义

  • 对于不变的 Widget,使用 const 关键字, 编译期间创建。
  • Flutter 会复用同一个 Widget 实例,避免build时重建。
class GoodExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const Text('Hello'); // 使用const
  }
}

对比示例

//代码核心节选
Column(
  children: [
    const Text('标题'),
    Text('计数:$count'),
  ],
)
  • const Text('标题')仍会走 build 流程,但由于配置恒定, Flutter 可以直接复用 Widget 实例,跳过 diff 成本,几乎没有性能开销。
  • Text('计数:$count'):随状态变化

👉 这是 Flutter 性能优化的第一步

3️⃣ 局部 Stateful,而不是整页 Stateful

❌ 整页 Stateful

class Page extends StatefulWidget { ... }

✅ 只让需要变化的部分 Stateful

class Counter extends StatefulWidget { ... }

4️⃣ 使用 StatefulBuilder 限制重建(rebuild)范围

问题:当父组件重建时,所有子组件都会跟着重建,即使子组件的状态没有变化。
解决方案

  • StatefulBuilder:为局部状态创建独立的重建范围
  • Builder:为特定部分创建独立的构建上下文
    实际案例对比: 假设我们有一个页面,里面有一个很重的“背景组件”和一个简单的“计数器”。

❌ 糟糕的做法:全页刷新

点击按钮时,整个 ComplexBackground 都会重新 build,即便它根本没变。

class MyPage extends StatefulWidget {
  @override
  _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    print("整个页面被重建了!"); // 每次点击都会打印
    return Scaffold(
      body: Column(
        children: [
          const ComplexBackground(), 
          Text('计数器: $_counter'),
          ElevatedButton(
            onPressed: () => setState(() => _counter++), 
            child: const Text('增加'),
          ),
        ],
      ),
    );
  }
}

✅ 优化的做法:使用 StatefulBuilder

通过 StatefulBuilder 把计数器逻辑“包”起来。此时,点击按钮只会触发 builder 闭包内的代码。

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    int localCounter = 0; // 定义在 StatefulBuilder 外

    return StatefulBuilder(
      builder: (context, setState) {
        return Column(
          children: [
            Text('局部计数器: $localCounter'),
            ElevatedButton(
              onPressed: () {
                setState(() => localCounter++);
              },
              child: const Text('局部增加'),
            ),
          ],
        );
      },
    );
  }
}

注意:虽然 StatefulBuilder 很好用,但如果你的局部状态逻辑变得很复杂,代码会变成“嵌套地狱”。这时候,将其抽离成一个独立的 StatefulWidget 类才是最高级的做法。

5️⃣合理使用 Key

问题:先搞懂为什么要在列表中用key?
答案: 假设你有一个列表 [A, B, C],你删除了 A。

❌ 如果没有 Key:

  1. Flutter 发现第一个位置现在的 Widget 是 B。
  2. 它对比旧的第一个位置(原先是 A),发现类型一样(都是同一个类),且都没有 Key。
  3. 它误以为:“A 没消失,只是内容变成了 B”。
  4. 结果:它强行修改 A 的状态来匹配 B。如果你的组件带状态(比如输入框里的文字、滚动位置),你会发现删了 A,结果 B 的文字消失了,或者状态全乱了。

✅ 如果有 Key:

  1. Flutter 看到新的列表第一个是 Key('B')
  2. 它去旧树里找,发现 Key('B') 原来在第二个位置。
  3. 它直接把旧的 Element 移动到新位置。
  4. 状态被完美保留,且不需要重新创建底层的渲染对象。
  5. 性能提升:销毁和创建 RenderObject 是昂贵的(涉及内存分配、布局计算、绘制)。移动(Move)一个 Element 的开销远小于重建(Rebuild)

场景 | 是否需要 Key | 推荐 Key 类型 | | ------------------------ | ------------ | ------------------- | | | 动态列表(增删、排序、过滤) | ✅ 必须 | ValueKey (用数据 ID) | | 跨树移动组件(如动画切换) | ✅ 需要 | GlobalKey |

请看使用范例:
✅ 正确示范:

// 使用数据自带的唯一 ID 作为 Key
return ListView(
  children: items.map((i) => MyItem(key: ValueKey(i.id))).toList(),
);

❌ 错误示范:

// 禁止在 build 方法里生成随机 Key!
// 每次 build 都会导致 key 不匹配,从而强制整个列表销毁重建。
return ListView(
  children: items.map((i) => MyItem(key: UniqueKey())).toList(), 
);

Key 的本质是:
帮助 Flutter 在新旧 Widget 树中正确匹配 Element,而不是“为了性能而加”

6️⃣ 使用 const 集合

对于不变的列表或映射,使用 const 修饰

// 好的做法
final items = const ['A', 'B', 'C'];

// 坏的做法
final items = ['A', 'B', 'C']; // 每次build都会创建新列表

7️⃣ 缓存复杂计算

  • 将耗时操作移到 initState 中
class GoodExample2 extends StatefulWidget {
  const GoodExample2({super.key});

  @override
  State<GoodExample2> createState() => _GoodExample2State();
}

class _GoodExample2State extends State<GoodExample2> {
  late Future<String> _userDataFuture; //定义变量

  @override
  void initState() {
    super.initState();
    // 只在组件初始化时执行一次
    _userDataFuture = fetchUserData();
  }

  Future<String> fetchUserData() async {
    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 2));
    return "乐乐";
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: _userDataFuture, // 使用存储的 Future
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }
        return Text('User: ${snapshot.data}');
      },
    );
  }
}

8️⃣ 使用 RepaintBoundary

  • 对于复杂的、不常变化的 UI 部分,使用 RepaintBoundary 隔离渲染,但容易滥用。
  • 避免整个屏幕重绘
  • RepaintBoundary 会增加 Layer 数量
  • 滥用可能导致内存上涨
  • 只适用于复杂、静态、不常变化的区域

九、日志很吓人,但性能完全OK的例子

ListView.builder(
  itemCount: 1000,
  itemBuilder: (context, index) {
    print('build item $index');
    return ListTile(title: Text('$index'));
  },
);

你会看到疯狂的日志输出。

👉 但是:

  • ListView 是懒加载
  • 屏幕外的 item 根本没 build
  • 滚动时只 build 新出现的项

十、记忆点总结

  • build 是 描述 UI
  • build ≠ 渲染
  • setState = 标记 dirty
  • Widget 性能开销很便宜
  • 真正贵的是 I/O 和计算
  • 拆 Widget 是性能优化的王道
  • 业务逻辑写在 build 外,UI 描述写在 build 内。
  • 不知道怎么优化?先加个 const,再拆个子组件。