const、final、late:const 是性能开关
如果你写 Flutter 时对 IDE 那句「Prefer const with constant constructors」的提示一直是「知道了,回头再说」,这一章会让你改主意。const 在 Flutter 里不是代码洁癖,它是唯一一个能让框架自动跳过整棵子树的开关——而且它的原理,正好是理解下一卷那三棵树的钥匙。
三个词,三件事
| 关键字 | 什么时候确定值 | Kotlin 里的对应 |
|---|---|---|
final x = … | 运行时第一次赋值,之后不能改 | val |
const x = … | 编译期就算好,写进程序常量池 | const val(但作用大得多) |
late final x; | 声明时不赋,保证用之前会赋 | lateinit var / by lazy |
到这里都还在意料之中。真正的区别在下面这个问题上:同一段 const 表达式写两遍,得到的是一个对象还是两个对象?
规范化:写两遍,只有一个对象
Dart 的编译器对 const 表达式做规范化(canonicalization):整个程序里,所有「类型相同、参数相同」的 const 表达式,共用同一个实例。
把上面那个开关关掉再看一眼:去掉 const 之后,两个内容完全一样的 Text 变成了两个不同的对象。而 Flutter 判断「这个 widget 变没变」用的是 identical(对象身份),不是内容相等。
这个身份,决定了框架要不要重建整棵子树
提前剧透一点第 7 章的内容。Flutter 每一帧同步界面时,对每个孩子都会跑一个叫 updateChild 的方法,它的第一个判断是这样的:
// Flutter 框架源码的意思(简化)
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
if (child != null) {
if (child.widget == newWidget) { // ← 就是这一行
// 新旧是同一个对象 → 什么都不用做,整棵子树原地不动
return child;
}
if (Widget.canUpdate(child.widget, newWidget)) {
child.update(newWidget); // 复用 Element,继续往下递归
return child;
}
…
}
}
第一行那个判断,就是 const 的全部价值所在:
1. 你写了 const Text('课程表')。
2. 编译器把它规范化:每一帧的 build() 返回的都是同一个对象,不是长得一样的新对象。
3. 下一帧父组件重建,走到 updateChild,发现 child.widget == newWidget(同一个引用)。
4. 直接 return——这棵子树的 Element 不更新、build() 不调用、下面几十个孙子节点连碰都不碰。
反过来,不写 const,第 3 步就永远为假,这棵子树每一帧都要重新走一遍完整的同步流程。内容一模一样也没用,Flutter 比的是身份。
这个跳过是递归的——跳过的不是一个节点,是整棵子树。所以 const 该加在尽可能靠上的地方:给一个包着 30 个子节点的 const 容器加上 const,收益是 30 个节点;给最里面那个 Text 加,收益是 1 个。
怎么让自己的 Widget 支持 const
两个条件,缺一不可:
- 所有字段都是
final(Widget 本来就该是不可变的,这个条件你自然会满足)。 - 构造函数标上
const。
class CourseTile extends StatelessWidget {
const CourseTile({super.key, required this.title}); // ← 这个 const
final String title; // ← 必须 final
@override
Widget build(BuildContext context) => Text(title);
}
// 调用侧:只有实参也全是常量时,才能真的写成 const
const CourseTile(title: '高等数学'); // ✓ 字符串字面量是常量
CourseTile(title: course.name); // ✗ 运行时才知道,不能 const
注意第二处:构造函数是 const 的,不代表每次调用都是 const。参数里有任何一个运行时才确定的值,这次调用就是普通调用。所以「给构造函数加 const」是能力,「调用时写 const」才是行使——两件事都要做。
const 会传染(往下)
一旦进入 const 上下文,里面的子表达式不用重复写 const:
// 这样写就够了
const Padding(
padding: EdgeInsets.all(16), // 不用写 const EdgeInsets.all(16)
child: Column( // 不用写 const Column
children: [
Text('课程表'), // 也不用
SizedBox(height: 8),
],
),
)
Dart 的 linter 有一条 unnecessary_const 就是提醒你这个的。反过来,如果最外层不是 const,里面每一个能 const 的都要单独写——这就是你在 Flutter 代码里看到满屏 const 的原因。
什么时候写不了 const(以及怎么绕)
凡是值要在运行时才知道的,都不能 const。三个最常见的场景:
| 写不了 const 的情况 | 怎么办 |
|---|---|
Text(course.name) 依赖数据 | 没办法,本来就该重建;但把它周围不变的部分抽出去 const |
Theme.of(context).colorScheme.primary | 依赖 context,天然不行。用 Theme 的地方就别指望 const |
回调 onTap: () => … | 闭包不是常量。但如果回调是 widget.onTap 这种直接引用,外层仍可能 const |
顺带说个高频误解:const Text('$name') 是不行的——字符串插值里有运行时变量。但 const Text('课程表') 可以,因为它是纯字面量。
在 analysis_options.yaml 里打开这几条,IDE 会用黄色波浪线盯着你:
include: package:flutter_lints/flutter.yaml
linter:
rules:
- prefer_const_constructors
- prefer_const_literals_to_create_immutables
- prefer_const_declarations
- unnecessary_const
然后 dart fix --apply 可以一次性把整个项目能加的 const 全加上。这是接手一个老 Flutter 项目后值得第一天就跑的命令。
这就是 Compose 里那件由编译器代劳的事
这里有一个很干净的对照,理解了它,你对两个框架的认识会同时深一层。
Compose 编译器会给每个可组合函数做可跳过(skippable)判定:如果这次重组时,传进来的所有参数都和上次「相等」(对于稳定类型用 equals,不稳定类型直接判定为不可跳过),那么这个函数体就不执行。这就是为什么你要给数据类打 @Immutable / @Stable,为什么往可组合函数里传 List<T>(不稳定)会导致它每次都重组。
Flutter 做的是同一件事的手动版:
| Compose | Flutter | |
|---|---|---|
| 判据 | 参数 equals 且类型稳定 | identical(oldWidget, newWidget) |
| 谁来保证 | 编译器插件自动插桩 | 你自己写 const |
| 你要做的 | 打 @Immutable、别传不稳定类型 | 加 const、把不变的部分抽成独立 Widget |
| 跳过的粒度 | 那一个可组合函数 | 整棵子树 |
注意最后一行:Flutter 的跳过粒度其实更粗也更狠——命中就是一整棵子树原地不动。代价是它只认身份不认内容,所以只有 const 能触发。
1. const 对象是全局共享的。const Key('a') 在程序任何地方都是同一个对象。这在 99% 的情况下没问题,但如果你用可变的东西做 const(Dart 不允许,所以基本不会发生),就会共享出问题。
2. 别为了 const 而扭曲代码结构。见过有人把 Text(title) 改成 const Text('') 再想办法塞值——不值得。const 是白捡的收益,不是要付代价去追的目标。加不了就算了。
3. 热重载时 const 子树可能不刷新。你改了一个 const widget 里的文字,热重载后界面没变——因为它被规范化成了同一个实例。按大写 R 热重启一次就好。知道这一条,能省下你半小时的困惑。
final、const、late 之外:var 和 dynamic 什么时候用
顺手把变量声明的选择讲全,因为它影响你每一行代码的风格:
| 写法 | 含义 | 建议 |
|---|---|---|
final x = ... | 只赋一次,类型推断 | 默认就用它——Flutter 里绝大多数局部变量都该是 final |
const x = ... | 编译期常量 | 值在编译期已知、且是 Widget/样式时优先用 |
var x = ... | 会重新赋值,类型推断 | 确实要改的循环变量、累加器才用 |
int x = ...(显式类型) | 写死类型 | 推断不出或想强调类型时用,字段声明常用 |
dynamic x | 关闭类型检查 | 尽量别用——它让你失去空安全和补全,多半你想要的是 Object? |
一个和 Kotlin 不同的默认习惯:Dart/Flutter 社区强烈倾向 final,官方 lint 有 prefer_final_locals。理由和 Kotlin 推 val 一样——不可变的东西更好推理。写 Flutter 时你的手应该默认敲 final,只有真要重新赋值才改 var。
你从网络拿到的 JSON 解析成 Map<String, dynamic>——那个 dynamic 值意味着 json['age'] 拿出来编译期不检查类型,你 json['age'] + 1 哪怕 age 其实是字符串也能编译,跑起来才崩。这就是第 22 章要用代码生成把 JSON 尽快转成强类型对象的原因——让 dynamic 的作用范围缩到最小,一拿到就转成 Course,之后全程享受类型安全。见到 dynamic 在业务代码里流动,就是个该收口的信号。
同一件事:Compose 的「参数没变就跳过重组」和 Flutter 的「同一个对象就跳过重建」,解决的是同一个问题——怎么在一次全量重描述里,安全地少干一点活。
不同的是谁干:那边编译器帮你算,代价是你要理解稳定性推断这套隐式规则;这边你自己写 const,规则简单粗暴(就看身份),代价是要靠 linter 帮你盯着。
动作项:打开那四条 lint 规则,跑一次 dart fix --apply。这是这一章唯一需要你今天就做的事。
这一章的一句话
Flutter 的 const 不是代码风格而是性能开关:编译器把相同的 const 表达式规范化成同一个实例,于是下一帧 identical(old, new) 成立,整棵子树连 build 都不进——它是 Compose 里那套「参数没变就跳过重组」的手动版本,只是判据从「内容相等」换成了「就是同一个对象」。
到这里,卷 I 结束——语言过完了,直觉认领完了。接下来是这本书的主星卷。我们从一个听起来有点冒犯的问题开始:你写的那些 Widget,其实根本不是界面。