全景:Dart 的定位与现状
钻进语法之前先回答三个问题:Dart 解决什么问题、今天它用在哪里、和邻近技术怎么选。后面每一章都是这张地图的放大。
Dart 的立身之本是为 UI 而生:这门语言今天几乎与 Flutter 绑定——AOT/JIT 双引擎带来「开发时热重载、发布时原生性能」,健全空安全把空指针挡在编译期。
今天它用在哪
- Flutter 一套代码跨 6 端:iOS、Android、Web、Windows、macOS、Linux;Google Pay、BMW 车主应用、闲鱼都是它写的;
- 双引擎是开发体验的来源:JIT 给开发期亚秒级热重载(改完保留应用状态),AOT 给发布期原生机器码。
和邻居怎么选
- vs Kotlin/KMP:Flutter 自绘引擎保证像素级跨端一致、交付快;KMP 共享逻辑、保留原生 UI 手感——有原生团队做精品选 KMP,小团队全端交付选 Flutter;
- vs React Native:RN 映射原生控件、Web 团队上手快,Flutter 渲染性能更可控。
// Dart 3 的气质:records + patterns
(String, int) fetchUser() => ('Alice', 30);
void main() {
final (name, age) = fetchUser(); // 解构多返回值
final tag = switch (age) { // switch 表达式 + 模式匹配
< 18 => 'minor',
>= 60 => 'senior',
_ => 'adult',
};
print(name + ': ' + tag);
}case 还要写 break,照抄会编译不过。另外 Flutter SDK 自带 Dart,装了 Flutter 就别再单独装 Dart SDK,两份 dart 命令并存最容易出怪事。上手:先把 Dart 跑起来,再把 Flutter 跑起来
在钻进语法之前,先让工具链在你自己的机器上转起来:装哪一份 SDK、三条命令跑出第一个纯 Dart 程序、再把 Flutter 的「改一行、一秒看到变化」跑通。本页 02–04 章讲 Dart 语言、05–12 章讲 Flutter,接缝在异步——跳过 04 章去读 10 章一定会卡住。本章所有数字都在 Dart SDK 3.12.2 上得到,你照着敲应该看到同一量级。
上手第一步是想清楚装哪一份 SDK——两者是包含关系,装错顺序会留下两份 dart 命令。
两份 SDK 的关系:包含,不是并列
- Flutter SDK 内含一份完整的 Dart SDK(在
flutter/bin/cache/dart-sdk/下)。要做 Flutter 只装 Flutter 就够,别再单独装 Dart;只写命令行工具或服务端,装独立 Dart SDK 体积小得多。 - 判断你实际在用哪一份,别看
dart --version,看flutter --version——它会同时打印 Flutter 版本和它自带的 Dart 版本,这两个数字才是配套的。
装完先跑 flutter doctor
- 它是一张体检表,逐项检查 SDK、Android toolchain、Xcode、编辑器插件与可用设备;
-v会打印每一项的具体路径,排查「找错了 SDK」时最有用; - 不必追求全绿:只做 Web 与桌面时,Android/Xcode 那两项红着不影响。第一个红叉通常是 Android 许可证没接受,跑
flutter doctor --android-licenses一路 y 即可。
# 只做 Flutter:装 Flutter 即可,它自带 Dart
flutter --version
# Flutter 3.x • channel stable
# Tools • Dart 3.x • DevTools 2.x ← 这两个版本是配套的
# 只写 CLI / 服务端 / 包:装独立 Dart SDK 就够
dart --version
# Dart SDK version: 3.12.2 (stable) on "windows_x64" ← 本页基准
# 装完先体检,-v 会打印每一项的实际路径
flutter doctor -vbin 都在 PATH 里谁在前谁生效。把独立那份摘掉即可。flutter run、热重载与 DevTools;渠道保持 stable。Flutter 项目结构复杂、启动要连设备,学语言的阶段别急着开 Flutter——一个纯 Dart 控制台项目三条命令就能跑,改一行看一次结果只要半秒,是练 02–04 章语法最快的环境。
生成的目录,三个文件要认识
bin/hello.dart——入口,含main()。只有bin/下的文件能被dart run直接执行;pubspec.yaml——声明依赖与 SDK 版本约束;pubspec.lock锁定实际解析到的版本,应用要提交它,库不提交;lib/放代码主体(包内引用写import 'package:hello/hello.dart'而不是相对路径),test/下以_test.dart结尾的文件会被dart test自动发现。
# 造项目并跑通
dart create -t console hello
cd hello
dart run # Hello world: 42!
dart test # All tests passed!
# bin/hello.dart —— 模板生成的入口
import 'package:hello/hello.dart' as hello;
void main(List<String> arguments) {
print('Hello world: ${hello.calculate()}!');
}dart run 不带参数时跑的是 bin/<包名>.dart,包名来自 pubspec.yaml 的 name: 而不是文件夹名——改了文件夹名却没改 pubspec,就会得到「找不到入口」。另外包名必须是合法的 Dart 标识符:小写下划线,写成 myApp 或带连字符的 my-app 都会被 dart create 直接拒绝。t.dart,dart run t.dart 直接跑。本页 02–04 章的每段示例都能这样验证——把「读到一条规则就去跑一遍」变成习惯,比反复读第二遍有效得多。这里只要跑通一次「改一行代码,一秒内看到变化」——这个循环是后面所有练习的载体。
四步跑起来
flutter create my_app生成一个能跑的计数器示例,flutter devices列出可用设备(Chrome 与桌面开箱就有,也不需要 Android 工具链);cd my_app && flutter run,或用-d chrome直接指定设备;- 跑起来后终端处于交互状态:
r热重载、R热重启、q退出、v打开 DevTools; - 改
lib/main.dart里任意一段文案,存盘,按r——界面变了,而计数器的数字还在。这一条就是热重载与重启的全部区别。
r 与 R:一个保状态,一个清状态
r热重载把改过的代码注入正在运行的 VM 再重建 widget 树,State 全部保留;R热重启丢掉整个状态从main()重来。- 什么时候必须用
R:改了main()、全局/静态变量的初值、类的形状、initState里的逻辑——这些热重载不会重新执行,你会看到「代码明明改了却没效果」。 - 改了
pubspec.yaml或原生侧代码,连R都不够,必须停掉重跑。
# 生成并跑通
flutter create my_app
cd my_app
flutter devices # 看看有哪些设备可用
flutter run -d chrome # Web 目标最省事,不需要 Android 工具链
# 跑起来后,终端是交互的:
# r 热重载 —— 保留 State(计数器数字不变)
# R 热重启 —— 从 main() 重来,状态清空
# v 打开 DevTools q 退出
# 改 pubspec / 原生配置后,r 和 R 都不够,要停掉重跑initState,也不会重跑已经执行过的顶层变量初始化。于是「改了初始化逻辑却没反应」几乎总是这一条——按 R 做热重启才会重来。--release。Dart 语言核心
写好 Flutter 之前,先写好 Dart。类型系统、null safety、类与面向对象。
var、final、const、late 四个关键词各管一件事:可变、只赋一次、编译期定死、延迟初始化。
四个关键词,四件事
var类型推断但可重新赋值;final运行时常量,只赋一次;const编译期常量。- 优先用
final——大多数变量赋值后不再改变,习惯比 JS 的 let 更收敛。 const对象会被规范化(同值共享同一实例),对 Flutter widget 性能很关键。late延迟初始化:声明非空但稍后赋值,编译器能证明必然未赋值时直接编译报错,证明不了(如类字段)则运行时抛 LateInitializationError。dynamic关闭类型检查(慎用);Object?是所有类型的顶;想推断就用var/final。
const 为什么是 Flutter 里最便宜的优化
「const 对象会被规范化」这句话很抽象,一行 identical 就能看穿:
identical(const Point(1), const Point(1)) → true 同一个实例 identical( Point(1), Point(1)) → false 两个实例
- 规范化(canonicalization)发生在编译期:值相同的 const 表达式在整个程序里只留一份实例;
- Flutter 的 widget 比较靠
==,而框架对 const widget 有一条捷径——引用相同就直接判定「没变」,整棵子树跳过重建。所以给不含变量的 widget 加const,收益是白捡的; - 反过来也说明了限制:只要构造参数里有一个运行时值,整个
const就用不了——这就是为什么 Flutter 代码里 const 常常只能加在叶子节点上。
final name = 'Charles'; // 推断为 String,不可重新赋值
const pi = 3.14159; // 编译期常量
late final Database db; // 稍后初始化一次
var count = 0; count = 1; // 可变The late local variable 'x' is definitely unassigned at this point.(诊断码 definitely_unassigned_late_local_variable);类字段这种证明不了的,才在运行时抛 LateInitializationError: Field 'x' has not been initialized.——注意抛出的异常类型名是 LateError,而消息里写的是 LateInitializationError,按类型 catch 时别拿消息当类名。别指望编译器兜住所有 late 的坑:只要控制流分析证明不了,检查就滑到运行时。final,确实要重赋值再降级为 var;能在编译期定死的值用 const——const 对象会被规范化,Flutter 里 const widget 能直接跳过重建,是最便宜的性能习惯。数字、字符串、布尔三件套配上插值语法,是每天要写几十遍的基本功。
三件套与插值
int与double都是num的子类型;整除用~/,普通除法/总返回 double。- 字符串可用单引号或双引号;插值用
$变量或${表达式}。 - 三引号
'''…'''写多行字符串;相邻字符串字面量自动拼接。 - 字符串不可变;常用:
split、contains、replaceAll、padLeft、trim。
数字的三处边界
9223372036854775807 + 1 → -9223372036854775808 静默环绕,不抛异常 (0.1 + 0.2) → 0.30000000000000004 (0.1 + 0.2).toStringAsFixed(2) → "0.30" 只是显示层修饰 int.tryParse("x") → null int.parse("x") 则抛 FormatException
- int 溢出是环绕不是报错——在 VM 上 int 是 64 位补码;金额、计数器做累乘时别指望它替你报警;
- 编译到 Web 时
int由 JS 的 double 承载,安全整数只有 2^53,同一段代码在 VM 与 Web 上会得到不同结果,这是 Flutter Web 的经典疑难; - 解析外部输入一律用
tryParse配??兜底,把parse留给「格式已经由你自己保证」的场合。
字符串的长度有三个答案
final s = 'a👍中'; s.length → 4 UTF-16 码元数(👍 占两个) s.runes.length → 3 Unicode 码点数 utf8.encode(s).length → 8 UTF-8 字节数 s.codeUnitAt(1) → 55357 取到的是半个代理对,不是一个字符
String在 Dart 里是 UTF-16 码元序列,length与下标都按码元算——这是所有「emoji 截断成乱码」「按长度切字符串切坏了」的根源;- 要按「人看到的字符」处理,标准库没有现成方案,得用
characters包的字素簇(grapheme cluster)API,Flutter 项目里它是默认依赖; - 要按字节算(存储、网络),先
utf8.encode——字节数是编码的属性,不是字符串的属性。
final name = 'Charles', n = 3;
print('Hi $name, you have ${n * 2} points');
final big = '''
多行
文本''';
print(7 ~/ 2); // 3(整除)
print(7 / 2); // 3.5(/ 总返回 double)0.1 + 0.2 得到 0.30000000000000004,金额别用 double 存。另外 1 == 1.0 为 true;而编译到 Web 时 int 由 JS number 承载、安全整数只有 2^53,VM 上才是真 64 位。'$a $b'——+ 两侧必须都是 String,'a' + 1 直接编译错(The argument type 'int' can't be assigned to the parameter type 'String'.)。报错说的是「实参类型」而不是「赋给变量」,因为 + 在 Dart 里是 String.operator+(String other) 这个普通方法——运算符只是方法的语法糖,这一条解释了 Dart 里一大批「运算符类型不匹配」报错的措辞。数字格式化记住 toStringAsFixed 与 padLeft 就够日常。类型默认非空是 Dart 类型系统的地基,?、??、?.、! 四件工具决定了你和 null 打交道的日常姿势。
四件工具,四种姿势
String不能为 null;String?才可空。这是 Dart 类型系统的根基。?.空安全调用;??空合并取默认;??=为空才赋值。!断言非空(你向编译器担保),错了会运行时崩溃——能不用就不用。- 类型提升:
if (x != null) { x.foo }中 x 自动收窄为非空。 - 对照 TS 的 strictNullChecks,但 Dart 默认开启且更彻底。
「健全」到底健全在哪
- Dart 的空安全是 sound(健全)的:编译器一旦把某个表达式定为非空类型,运行时就保证它不可能是 null——不是「大概率不是」,而是类型系统与运行时联合担保;
- 这带来一个 TS 没有的红利:编译器可以据此优化掉判空检查,非空类型不必在每次访问前再测一遍。健全性在这里换来的是实打实的性能,不只是心理安慰;
- 代价是没有后门:TS 里
strictNullChecks可以逐文件开关、any随时逃逸,Dart 的空安全是全语言强制的,混着写旧代码的过渡期比 TS 痛苦得多——这也是 Dart 2 教程今天全部作废的原因。
类型提升失效的四种情形
「判过空就能当非空用」只在编译器能证明中间没人改过它时成立。下列四种都不提升:
- 公共字段(
String? s;)—— 别处随时可能改它,见本卡 pitfall 里的两行报错; - 非 final 的私有字段 —— 同理;只有
final的私有字段(Dart 3.2 起)才提升; - getter —— 每次访问都是一次函数调用,返回值可以不同;
- 顶层/静态可变变量,以及被闭包捕获后又在别处赋值的局部变量。
统一解法只有一个:final s = 那个表达式; 拷进局部变量再判。养成这个习惯,可以省掉九成的 !。
String? maybe;
print(maybe?.length); // null,不崩
print(maybe ?? 'default'); // 'default'
maybe ??= 'init'; // 仅当为 null 时赋值
final len = maybe!.length; // 断言非空(确信时才用)! 赌输的报错原文:Null check operator used on a null value。另一个高频坑:类型提升只对局部变量生效——公共字段 if (s != null) print(s.length) 编译不过,输出是两行:主报错 The property 'length' can't be unconditionally accessed because the receiver can be 'null'.,下面才跟一行上下文提示 's' refers to a public property so it couldn't be promoted.——真正解释原因的是第二行,只读第一行会以为自己没判空、于是去加 !,把编译期检查换成运行时崩溃。正解是 final s = this.s; 拷到局部再判;Dart 3.2 起私有 final 字段(如 _s)可以直接提升(3.12.2 同一段代码换成 _s 后无任何诊断)。if (x != null) 判空让类型提升、再 ?? 给默认、再 ?. 链式跳过——! 永远是最后手段,它把编译期检查换成了运行时赌博。条件必须是真正的 bool、switch 不再贯穿——Dart 的分支比 JS 严格,也比 JS 省心。
三件事:bool、不贯穿、多值
if / else if / else链与 JS 一致,从上往下命中第一个成立分支。- 条件必须是
bool:Dart 没有 truthy/falsy,if (0)、if (str)、if (list.length)全是编译错误——写显式判断if (list.isNotEmpty)、if (x != null)。 - switch 语句不贯穿:Dart 3 起非空 case 执行完自动跳出,无需
break;多值命中同分支用逻辑或模式case a || b。 - 兜底分支写
default:或通配模式_,二选一。 - 这是语句形态;switch 作表达式产出值、配模式解构是进阶——见本页 03 章「Switch 表达式」与「if-case 语句」。
「没有 truthy」不是龟毛,是省 bug
- JS 里
if (list.length)、if (str)能跑,但0、""、NaN全是假值——「空字符串」和「没有这个值」被混成一件事,是 JS 最高发的一类 bug; - Dart 直接在类型层面堵死:条件位置只接受
bool,报Conditions must have a static type of 'bool'.(诊断码non_bool_condition); - 于是你被迫写出意图:
list.isNotEmpty(我关心空不空)、x != null(我关心有没有)、str.isNotEmpty——读代码的人不用再猜你想表达哪一个。
final score = 72;
if (score >= 90) {
print('优秀');
} else if (score >= 60) {
print('及格');
} else {
print('不及格');
}
// if (score) 编译错误:条件必须是 bool
const cmd = 'stop';
switch (cmd) {
case 'open':
print('启动'); // Dart 3:执行完自动跳出,无需 break
case 'stop' || 'halt': // 逻辑或模式:多值匹配同一分支
print('停止');
default:
print('未知命令');
}if (count)、while (list.length) 原样搬过来——在 Dart 里全是编译错误。另外读老代码(Dart 2.x)会看到每个 case 末尾都有 break,那是当年防贯穿的强制要求;Dart 3 写不写都不贯穿,新代码直接省掉即可(空 case 体仍会与下一个 case 共享分支,这是留给多值匹配的老写法)。按次数、按元素、先判断还是先执行——四种循环各有明确的适用场景。
四种循环各管一段
- 三段式
for (var i = 0; i < n; i++)管「按次数」,初始化、条件、步进各司其职。 for-in遍历一切Iterable,日常遍历集合首选(Map 需经entries,见「集合」卡)。while先判断后执行(可能一次不跑);do-while先执行后判断(至少跑一次)。break结束整个循环,continue跳过本轮;嵌套跳出外层给循环起标签break outer;。
forEach 是陷阱最多的那一个
- 回调里
break/continue直接编译不过——它们是语句层面的控制流,而回调是一个函数; - 回调里写
await不会等:forEach的签名是void Function(E),它不理会回调返回的 Future,于是所有迭代同时开跑、乱序完成,而外层已经往下走了。这一条造成的「数据偶尔丢一条」极难查; - 返回值是
void,链不起来; - 结论:默认写
for-in。forEach只在「回调是个现成的函数引用」(如list.forEach(print))时才更短,此外没有理由用它。
惰性:Iterable 不遍历就不计算
final lazy = [1,2,3].map((e) { print('计算 $e'); return e*2; }); // 此处一行输出都没有 lazy.first; // 只打印「计算 1」——算了一个就停
map/where/expand返回的是惰性 Iterable,它描述「怎么算」而不是「算好的结果」;- 好处是省:只要
.first就只算一个;坏处是每次遍历都重算一遍,副作用会重复发生、昂贵计算会重复付费; - 要多次消费、要
length、要下标,就.toList()固化——「用两次以上就固化」是条够用的经验法则。
for (var i = 0; i < 3; i++) {
print(i); // 0 1 2
}
final langs = ['Dart', 'Swift', 'Kotlin'];
for (final lang in langs) { // 遍历集合首选
if (lang == 'Swift') continue; // 跳过本轮
print(lang);
}
var retry = 0;
while (retry < 3) { retry++; } // 先判断,可能一次不跑
do { retry--; } while (retry > 0); // 至少执行一次
final grid = [[1, 2], [3, -4]];
outer: for (final row in grid) {
for (final v in row) {
if (v < 0) break outer; // 标签:一次跳出两层
}
}ConcurrentModificationError——要删元素用 removeWhere,或先 toList() 复制一份再遍历。另外 forEach 的回调里没法 break/continue,回调里写 await 也不会等(forEach 不理会回调返回的 Future,循环体变成并发乱序执行)——需要中途退出或逐个 await,老老实实用 for-in。list.indexed 配记录解构:for (final (i, v) in list.indexed)(Dart 3 起,可用)。位置可选、命名可选、required 与默认值——Dart 的参数系统是读懂 Flutter 构造函数的前提。
三种参数形态
- 箭头函数
=> expr是单表达式函数的简写。 - 位置可选参数用方括号
[ ];命名参数用花括号{ },调用时写参数名。 - 命名参数默认可空;加
required强制传入——Flutter widget 构造函数大量使用。 - 函数是一等公民:可作参数、返回值、存入变量(与 JS 一致)。
- 命名参数让调用处自文档化,是 Flutter API 的标志性风格。
为什么 Flutter 的 API 长成那样
- Flutter 的 widget 构造函数动辄十几个参数,如果是位置参数,调用处会变成一串无法阅读的字面量;命名参数让每个实参自带标签,调用处即文档;
- 命名参数默认可空、加
required才强制——于是「可选配置一大堆、必填只有一两个」这种 UI 组件的典型形状能被自然表达; - 配合尾随逗号(trailing comma):给参数列表最后一项加逗号,
dart format就会把每个参数各占一行竖排。Flutter 代码里到处是尾随逗号,不是手抖,是在指挥格式化器。
int add(int a, int b) => a + b;
String greet(String name, {String greeting = 'Hi', required int age}) =>
'$greeting $name ($age)';
greet('Charles', age: 21); // 用默认 greeting
greet('Charles', greeting: '你好', age: 21);{int x = someVar} 与裸 = [] 报的是同一句 The default value of an optional parameter must be constant.(诊断码 non_constant_default_value)。于是很多人顺手改成 = const [] 让它过编译——这才是真陷阱:编译过了,函数体里 add 时运行时抛 Unsupported operation: Cannot add to an unmodifiable list,而且 const [] 是被规范化的同一个实例,一旦可变就会在所有调用之间串味。可变默认集合的正确姿势是参数声明成可空、体内 ??= 给新实例。resize(width: 100, keepRatio: true) 比 resize(100, true) 可读得多,这正是 Flutter API 风格的来源。List、Set、Map 三大件加上 spread 与 collection-if/for,Dart 的集合字面量几乎是一门小型 DSL。
三大件与字面量 DSL
List(有序可重复)、Set(无序唯一)、Map(键值对)。- Map 不是 Iterable:for-in 遍历键值对要经
entries(只要键或值用keys/values),再取entry.key/entry.value。 - 展开操作符
...与空安全展开...?合并集合。 - collection-if 与 collection-for 可在字面量里直接做条件/循环——比 JSX 更优雅。
- 常用:
map、where(≈filter)、fold/reduce、any/every、firstWhere。 List.generate(n, (i)=>…)快速构造;惰性求值返回Iterable,必要时.toList()。
空花括号是 Map 不是 Set
({}).runtimeType → _Map<dynamic, dynamic>
(<int>{}).runtimeType → _Set<int>- 历史原因:
{}在 Set 字面量出现之前就已经是 Map 了,只能保持兼容——要空 Set 必须写类型参数<int>{}或Set<int>(); - 而
{1, 2}有元素时能推断出是 Set,{'a': 1}有冒号能推断出是 Map——只有空的时候有歧义。
字面量里的四件小工具
[...a, ...b] 展开 → [1, 2, 3]
[...?maybe, 9] 空安全展开 → [9] (maybe 为 null 时展开成空)
[for (x in a) if (x.isEven) x*10] collection-if/for → [20]
{1, 1, 2} Set 去重 → {1, 2}...?是最被低估的一个:可空列表要合并时,它省掉一整个if (maybe != null)分支;- collection-for 与 collection-if 可以嵌套、可以混用,在 Flutter 的
children:里比.map().toList()干净——后者还得记着补toList(),忘了就是类型错误。
final nums = [1, 2, 3];
final more = [0, ...nums, if (true) 4, for (var n in nums) n * 10];
// [0, 1, 2, 3, 4, 10, 20, 30]
final evens = nums.where((n) => n.isEven).toList();
final ages = {'Alice': 30, 'Bob': 25};
for (final e in ages.entries) { // Map 遍历要经 entries
final name = e.key, age = e.value;
print('$name is $age');
}for (final e in map) 编译不过——它不是 Iterable,必须经 entries/keys/values 遍历。[for (final it in items) Text(it)] 比 items.map(...).toList() 更直观,还能与 collection-if 混用做条件渲染。先别看语法,看它解决什么问题:一个「人」有名字、有年龄、还能自我介绍。不用类的话,这三样只能各自散着放,谁也管不住谁。类就是把「一组数据」和「针对这组数据的行为」装进同一个盒子。
不用类的写法,两个人就开始难受
String name = '小明'; int age = 12; String intro(String n, int a) => '$n,$a 岁'; // 每次调用都要重新把数据接一遍 // 来了第二个人,只能再来一套变量 String name2 = '小红'; int age2 = 9; intro(name, age2); // 「小明,9 岁」——编译器不会拦你,因为它们之间毫无关系
- 数据和用它的函数没有任何绑定:把
name和age2传进同一次调用,编译完全通过。 - 人一多就得多一套变量。这两件难受,就是类要替你解决的全部。
写成类
class Person { String name; // 字段:这个盒子里装什么数据 int age; Person(String n, int a) { // 构造函数:造盒子的时候怎么填 name = n; age = a; } String intro() => '$name,$age 岁'; // 方法:只管自己的字段,不用再传参 } final a = Person('小明', 12); // 实例:按 Person 这张图纸造出来的一个具体对象 final b = Person('小红', 9); // 另一个实例,两套数据互不干扰 a.intro(); // 小明,12 岁 b.age; // 9
- 类是图纸,实例是照图纸造出来的东西。
Person只写一次,实例可以有一万个,每个自带一份字段。 - 方法体里的
name指的是当前这个实例的 name——所以a.intro()和b.intro()结果不同。这就是「数据和行为绑在一起」的全部含义,没有更玄的东西。 - Dart 里
new可以省,写Person(...)就是创建实例。
再加两样,日常就够用了
class Person { String name; int age; Person(this.name, this.age); // 上面那段赋值样板的简写,下一卡细讲 bool get isAdult => age >= 18; // getter:像字段一样读的计算值 @override String toString() => 'Person($name, $age)'; // print 这个对象时用它 } print(Person('小明', 12)); // Person(小明, 12),而不是 Instance of 'Person'
get声明的是取值时才算的属性,调用处不写括号——p.isAdult。toString是每个类都从 Object 继承来的方法,@override表示「我要换掉父类的版本」(继承那张卡会展开)。
class Person {
String name;
int age;
Person(this.name, this.age);
String intro() => '$name,$age 岁';
bool get isAdult => age >= 18;
}
final p = Person('小明', 12);
p.intro(); // 小明,12 岁
p.isAdult; // false
p.age = 13; // 字段没写 final,可以改toString 时 print(对象) 只打出 Instance of 'Person',一个字段都看不到,调试时容易白等半天。另一件反直觉的事:字段完全一样的两个实例默认不相等——Person('小明', 12) == Person('小明', 12) 是 false,因为默认比的是「是不是同一个对象」。想按字段比,要覆写 == 与 hashCode(见「继承 / 抽象 / 多态」卡),或者干脆用 record(03 章)。final、const、初始化列表这些东西。先把「字段 + 构造 + 方法」这三样写顺,写出五六个能跑的类,再回头看下一卡——那一卡讲的全是给同一件事减字数的简写,不是新概念。上一卡那个 Person(String n, int a) { name = n; age = a; } 全是样板:参数名和字段名一个个对着抄。这一卡的每样东西都是在给这段样板减字数——先看减到什么样,再看为什么可以这样减。
一步一步减
// ① 原样:参数接进来,再一个个赋给字段 class Point { double x, y; Point(double a, double b) { x = a; y = b; } } // ② this.x 简写:「这个参数直接赋给同名字段」,最常用的一条 class Point { double x, y; Point(this.x, this.y); // 没有函数体了,一个分号收尾 } // ③ 需要另一种造法时加命名构造,名字随便起 class Point { double x, y; Point(this.x, this.y); Point.origin() { x = 0; y = 0; } // 用法:Point.origin() }
- 到这一步为止,日常写类够了。下面两样是遇到具体需求才用得上的。
需求一:字段是 final,就得用初始化列表
class Point { final double x, y; // final:造好之后不许改 Point(this.x, this.y); // 这样还行 Point.origin() { x = 0; } // 编译错:构造体里已经不能赋 final 字段 Point.origin() : x = 0, y = 0; // 对:冒号之后这一段叫「初始化列表」 }
- 初始化列表在构造体之前跑,所以它是 final 字段唯一的赋值时机之一。要拿参数算一下再赋值也用它:
Point.polar(double r) : x = r, y = 0;。 - 为什么 final 字段之后就不能赋?因为构造体开始执行时对象已经「成形」了——本卡 pitfall 有报错原文和完整解释。
需求二:想让实例成为编译期常量,就得 const 构造
class Point { final double x, y; // 前提:所有字段都是 final const Point(this.x, this.y); // 构造函数前面加 const } const p1 = Point(3, 4); const p2 = Point(3, 4); identical(p1, p2); // true——同样的参数编译期只留一个实例
- 这不是可选的讲究:Flutter 里
constwidget 能让框架靠「引用相等」直接跳过重建,是最便宜的一条优化(05 章「const 构造与性能」)。 - 判据很简单:所有字段都是 final,就顺手给它一个 const 构造,用不用是调用方的事。
三个初始化窗口,各有各的时机
class P { final int x, y; P(this.x, int raw) // ① 形参直接赋字段 : y = raw * 2, // ② 初始化列表:构造体之前跑 assert(raw > 0) { // 这里还不能用 this print(x + y); // ③ 构造体:对象已成形,可用 this } }
- 顺序固定:初始化列表 → 父类构造 → 自己的构造体。final 字段必须在前两步内赋完。
assert放在初始化列表里做参数校验,只在 debug 模式生效、release 里被完全剔除——零成本的契约检查,Flutter 框架源码里到处是。- 子类往父类转发参数写
Circle(super.r);(Dart 2.17 起)比: super(r)少一层样板。
class Point {
final double x, y;
const Point(this.x, this.y); // 主构造 + const
Point.origin() : x = 0, y = 0; // 命名构造 + 初始化列表
double get dist2 => (x * x + y * y); // getter:距离平方
}
const p = Point(3, 4);
final o = Point.origin();this.x、初始化列表或声明处默认值)——在构造体里写 x = 1 同时报两条:All final variables must be initialized, but 'x' isn't. 与 'x' can't be used as a setter because it's final.。两条其实说的是同一件事的两面:构造体开始执行时对象已经「成形」了,final 字段此刻必须已有值、也不再有 setter 可用——所以初始化的窗口只有 this.x 参数、初始化列表与声明处默认值这三处。同理,初始化列表执行时对象尚未成形,那里也还不能用 this。assert 做参数校验。factory 把「怎么拿到实例」从「怎么初始化实例」中拆出来,是单例与解析器的标准载体。
factory 管「怎么拿到」
factory构造函数不一定创建新实例,可返回缓存对象或子类实例。- 经典用途:单例、对象池、根据参数返回不同子类、从 JSON 解析。
- 与普通构造的区别:factory 体内可有逻辑、可 return。
- 实现单例:私有构造
_internal()+ 静态实例 + factory 返回它。
缓存型 factory 第二次不再创建
class Cache { static final Map<String, Cache> _pool = {}; Cache._(this.k) { print('真的创建了 $k'); } factory Cache(String k) => _pool.putIfAbsent(k, () => Cache._(k)); } Cache('a'); Cache('a'); // 输出只有一行「真的创建了 a」 // identical(c1, c2) → true
- 这是 factory 最有价值的形态:调用方写法完全不变(还是
Cache('a')),是否复用是类自己的实现细节; - 同一套骨架换个
return就是别的模式:返回子类实例=多态工厂,返回唯一静态实例=单例,从 Map 造对象=fromJson。
class Logger {
static final Logger _i = Logger._internal();
factory Logger() => _i; // 永远返回同一实例
Logger._internal();
void log(String m) => print('[LOG] $m');
}
identical(Logger(), Logger()); // truethis——写了直接编译错(Invalid reference to 'this' expression.,诊断码 invalid_reference_to_this)。原因就在 factory 的定义里:它不隐式创建实例,只负责 return 一个,所以进入函数体时根本没有「当前对象」可指——真正的字段初始化仍要交给它调用的生成式构造。另外单例的 static final 是惰性的:首次访问才创建,不是程序启动就建好。fromJson 这类「从数据造对象」的入口用 factory 语义最贴切——既能做校验,也能按数据返回不同子类或缓存实例。继承的入口不是术语,是两个类里出现了一模一样的代码。顺着这件事往下推,abstract 和多态都会自己长出来。
① 先看那段重复
class Circle { double r; Circle(this.r); double area() => 3.14159 * r * r; String describe() => '面积 ' + area().toString(); // 这一行 } class Square { double a; Square(this.a); double area() => a * a; String describe() => '面积 ' + area().toString(); // 和上面一模一样 }
describe()重复了,而且它只用到 area()、根本不关心面积怎么算。「公共的行为 + 各自不同的一小步」——这个形状就是继承的用武之地。
② 把公共部分提上去,不同的那一步留空
abstract class Shape { double area(); // 抽象方法:只有签名没有方法体 String describe() => '面积 ' + area().toString(); // 公共实现,只写一次 } class Circle extends Shape { final double r; Circle(this.r); @override // 标注「我在覆写父类的方法」 double area() => 3.14159 * r * r; } class Square extends Shape { final double a; Square(this.a); @override double area() => a * a; }
abstract class不能实例化:写Shape()直接编译错——「形状」本来就不是一个具体东西,它只是「圆和方共同那部分」的名字。- 子类必须实现所有抽象方法,漏一个就编译错。这是 abstract 的全部作用:把「每个子类都得自己回答」这件事写进类型里。
- 父类已有的实现想换掉就
@override重写,想在原有基础上加就先调一次super.方法();构造函数往父类传参写: super(...),Dart 2.17 起可以直接写Circle(super.r)。
③ 多态:一段不提子类名字的代码
final List<Shape> shapes = [Circle(1), Square(2), Circle(3)]; for (final s in shapes) { print(s.describe()); // 每个都跑自己那份 area() }
- 这段代码一次都没提 Circle 和 Square。以后加一个
Triangle extends Shape,这里一个字都不用改——这才是继承换来的东西,比「省了两行重复代码」值钱得多。 - Dart 的实例方法全都是虚方法,不用像 C++ 那样写
virtual:变量的静态类型是Shape,跑起来找的是实际那个对象的实现。 - 但 Dart 没有方法重载:同名不同参数直接编译错。要多种入口就用命名参数或命名构造。
== 与 hashCode 必须成对
class P { final int x; P(this.x); @override bool operator ==(Object o) => o is P && o.x == x; } // 故意不覆写 hashCode {P(1)}.contains(P(1)) → false 「明明相等却查不到」 {P(1): 'a'}[P(1)] → null P(1).hashCode == P(1).hashCode → false
- 原因:
Set/Map先按 hashCode 找桶,再在桶内用 == 比。哈希不等,根本走不到 == 那一步; - 而分析器一句话都不会说——这段代码
dart analyze完全干净。这是 Dart 里少数几个「编译器帮不上忙」的坑; - 正解是让工具替你写:
freezed或equatable生成这两个成员,或直接用 Dart 3 的 record(记录的 == 与 hashCode 按字段自动成立,见 03 章)。
abstract class Shape {
double area(); // 抽象方法
String describe() => '面积 ${area()}';
}
class Circle extends Shape {
final double r;
Circle(this.r);
@override
double area() => 3.14159 * r * r;
}== 却不覆写 hashCode,对象放进 Set/Map 就会「明明相等却查不到」——两者必须成对覆写。另外 Dart 方法全是虚方法但没有重载,同名不同参直接编译错。Circle(super.r);(Dart 2.17 起,可用)——省掉 : super(r) 一层样板。这三个常被排在一起讲,其实它们各堵一个不同的缺口:我想要求「谁都得有这几个方法」、我想把同一段实现塞进几个不相干的类、我想给改不了源码的类加个方法。一个一个来。
① 只要「必须长这个样子」→ implements
class Duck { void quack() => print('嘎'); void swim() => print('游'); } class RobotDuck implements Duck { // 借 Duck 的「形状」,实现一行都不继承 @override void quack() => print('滴'); @override void swim() => print('划'); }
- Dart 里每个类都自动带一个同名接口,没有单独的
interface关键字:想当接口用的类,直接implements它。 - 和
extends的分界很干净:implements只借形状,父类写好的方法体一行都不给你,全部成员都得自己实现,漏一个就编译错;extends连实现一起继承,但只能一个,而implements可以写好几个。
② 同一段实现要塞进几个不相干的类 → mixin
mixin Swimmer { int laps = 0; // mixin 可以带字段 void swim() { laps++; print('游第 ' + laps.toString() + ' 圈'); } } class Duck extends Bird with Swimmer {} // 鸭会游 class Fish extends Animal with Swimmer {} // 鱼也会游,但鱼不是鸟
- 「会游泳」这件事横跨了两条继承链,而 Dart 只能单继承——
with就是为这种情况准备的:把方法和字段整段混进来,不占用你唯一的那个 extends 名额。 - 多个混在一起写
with A, B,同名成员后写的赢。这是 mixin 唯一需要记的规则。
③ 想给别人的类加方法 → extension
extension StringX on String { String get reversed => split('').reversed.join(); } 'abc'.reversed; // 'cba'——String 是内置类型,源码你改不了
- 它不修改原类,只是让编译器把
'abc'.reversed翻译成一次普通函数调用。给第三方类型、内置类型加便利写法,这是唯一的正路。
extension 是静态解析——这决定了它的全部脾气
- 扩展方法在编译期按静态类型选中,不进虚方法表。所以接收者一旦是
dynamic,编译器无从选择,运行时直接NoSuchMethodError(本卡 pitfall 有报错原文); - 推论一:扩展方法不能被覆写、不参与多态。同名的实例方法永远赢过扩展方法——库作者以后给类加了个同名方法,你的扩展会无声地失效;
- 推论二:必须 import 才生效。扩展不在类型上,跟着库走——「同事那边能跑我这边报错」十有八九是少了一行 import;
- 因此 extension 适合给类型加纯粹的便利写法(
'abc'.capitalize()),不适合承载业务多态——那是接口和 mixin 的活。
四条路怎么挑
要连实现一起继承(复用「是什么」) → extends(只能一个) 只要求长成某个样子(复用「形状」) → implements(可以多个) 同一段实现塞进几个不相干的类 → with mixin(可以多个) 给改不了源码的类加便利方法 → extension
mixin Swimmer { void swim() => print('游'); }
class Duck with Swimmer {}
extension StringX on String {
String get reversed => split('').reversed.join();
}
'abc'.reversed; // 'cba'Class 'String' has no instance getter 'rev')——从 jsonDecode 拿出来的 dynamic 值要先标注类型再用扩展方法。on 子句限定宿主类型后,体内就能调 super 的成员;extension 记得起名字——冲突时才能用 show/hide 或显式包装 StringX('abc') 消歧。类型参数让集合与工具类既复用又保住类型安全,Dart 的泛型还带运行时信息。
类型参数与约束
<T>让类/方法对任意类型工作且保持类型安全(与 TS 泛型相通)。- 约束:
<T extends num>限定 T 必须是 num 子类型。 - 集合本身就是泛型:
List<int>、Map<String, User>。 - 返回带泛型的 Future:
Future<List<User>>。
实化泛型:Dart 与 Java/TS 的根本分歧
final xs = <int>[1, 2]; xs is List<int> → true 类型参数在运行时真的存在 xs.runtimeType → List<int> xs is List<num> → true 协变:List<int> 也是 List<num>
- Java 的泛型在编译后被擦除成
List,TS 的类型信息运行时干脆不存在——两者都做不到上面第一行; - 实化的直接好处:
is判断、jsonDecode之后的类型校验、依赖注入按类型查找都能正常工作,不必额外传一个Class<T>参数; - 代价是协变留下的运行时洞:
List<int>可以当List<num>用,于是add(1.5)能过编译、在运行时才抛type 'double' is not a subtype of type 'int' of 'value'(本卡 pitfall)。把可变集合当协变类型传出去,是这类崩溃的唯一来源——只读场合传Iterable<num>就安全了。
T firstOr<T>(List<T> xs, T fallback) =>
xs.isEmpty ? fallback : xs.first;
class Box<T> {
final T value;
Box(this.value);
}
final b = Box<int>(42);List<int> 能赋给 List<num>,但经它 add(1.5) 运行时抛 type 'double' is not a subtype of type 'int' of 'value'——编译器放行、运行时兜底。另外写 List 不带类型参数等于 List<dynamic>,等于放弃检查。xs is List<int> 真能判断、runtimeType 打印出 List<int>——这一点与 Java/TS 的类型擦除完全不同。Dart 2.17 之后枚举就是带字段与方法的受限类,足以取代一批「常量加 map」的旧写法。
枚举即受限的类
- 现代 Dart 枚举不只是常量,可声明字段、构造函数和方法。
- 适合表达带数据的固定取值集合(状态、配置项)。
.values拿到全部;.name拿名字;.index拿序号。- 搭配 switch 表达式做穷尽匹配特别顺手。
它取代了什么
enum Plan { free(0), pro(30); final int price; const Plan(this.price); bool get paid => price > 0; } Plan.pro.price → 30 Plan.pro.paid → true Plan.values → [Plan.free, Plan.pro] Plan.values.byName('free') → Plan.free
- 旧写法是「一个枚举 + 一个
Map<Plan, int>常量表 + 一个 switch 函数」,三处分散、加成员时容易漏改; - 现在数据与行为跟成员绑在一起,加一个成员编译器会逼你把构造参数补齐;
- 再配 03 章的 switch 表达式做穷尽匹配,「新增枚举值忘了处理」也会变成编译错误——两条合起来才是完整的收益。
enum Plan {
free(0), pro(9), team(29);
const Plan(this.price);
final int price;
bool get isPaid => price > 0;
}
Plan.pro.price; // 9 ; Plan.values; // 全部byName 找不到会抛(Invalid argument (name): No enum value with that name: "x");持久化别存 .index——枚举成员一换顺序旧数据全部错位,存 .name 稳得多。Plan.values.byName('pro'),拿名字用 .name——比 toString().split('.') 的老写法干净得多;注意枚举的构造函数必须是 const。on 按类型、catch 拿对象与堆栈、finally 收尾,三者组合出 Dart 的错误处理骨架。
on / catch / finally
on 类型按异常类型捕获;catch (e, st)拿到异常对象和堆栈。finally无论是否异常都执行(释放资源)。- 可
throw任意对象,但约定抛Exception/Error的子类。 rethrow在 catch 内重新抛出原异常,保留堆栈。
Exception 与 Error 是两类东西,别一起吞
Exception—— 预期内的失败:网络断了、文件不存在、输入格式不对。应该捕获并处理;Error—— 程序 bug:StateError、TypeError、RangeError、空断言失败。本该崩出来让你看见,捕获它只是把 bug 藏起来,换来一个更晚、更难查的现场;- 而裸
catch (e)两类通吃(StateError照样被捕获)——所以「catch 一切并打印日志」这种写法会悄悄吞掉真 bug; - 习惯:按类型
on XxxException catch (e)捕你认得的,其余让它上抛。真需要顶层兜底(比如上报崩溃日志),在最外层写一次,并且记得rethrow。
rethrow 与 throw e 的区别是可见的
rethrow保留原始堆栈——栈顶仍指向最初抛出的那一行;throw e是一次全新的抛出,堆栈从当前行重算,最初的现场就此丢失;- 排查线上问题时,这个差别决定了你看到的是「真正出错的那一行」还是「某个 catch 块的那一行」——后者等于没有线索。
try {
final n = int.parse('abc');
} on FormatException catch (e) {
print('格式错误: ${e.message}');
} catch (e, st) {
print('其他: $e'); rethrow;
} finally {
print('收尾');
}catch (e) 连 Error 一起吞——StateError 也会被捕获,而 Error 系(TypeError、StateError 等)代表程序 bug,本该让它崩出来暴露问题;按类型 on 捕才是常态。Dart 也没有受检异常,函数抛不抛全看文档。catch (e, st);捕到但处理不了就 rethrow——它保留原始堆栈,比 throw e 重新抛(堆栈从当前行重算)更利于排查。Dart 没有 public/private 关键字,可见性由下划线与库边界决定。
下划线与库边界
- 以
_开头的标识符是「库级私有」(不是类私有)——同文件可访问。 import 'x.dart' as p;加前缀;show/hide选择性导入。- 包导入:
import 'package:flutter/material.dart';。 export重新导出,做聚合入口(barrel 文件)。
「库级私有」到底有多宽
// 同一个文件里 class Cache { static final Map<String, Cache> _pool = {}; } class Other { int n() => Cache._pool.length; } // 合法,照样读得到
_的边界是库(默认=一个文件),不是类。同文件里的任何代码都能读你的_field;part文件更宽:它与主文件共享同一个库作用域,私有形同虚设——代码生成产物(.g.dart)正是靠这一点访问你的私有字段的;- 要真正隔离,唯一办法是拆成独立文件;包对外发布时再用
lib/src/目录约定——放在src/下的文件不该被包外 import,pub 的 lint 会提醒。
import 'dart:math' as math;
import 'package:flutter/material.dart' show Color, Colors;
class _Private {} // 库私有
math.max(1, 2); // 带前缀使用_ 是「库级」私有而非类私有——同一文件里别的类照样能读你的 _field;part 文件更是与主文件共享整个库作用域,私有形同虚设。想要真正隔离,拆成独立文件(库)。as 前缀最省心;对外发布的包用 export 聚合出一个入口文件(barrel),使用方只需 import 一处。把 dart:core / async / convert / math / io 常用 API 压成一屏——按库分组,每个函数配一个最小示例调用,右侧注释给用途 / 结果;标 ★ 的本页另有专章详解。
这一屏里最容易踩的四条
firstWhere无匹配、reduce遇空集合都抛Bad state: No element——前者补orElse:,后者换带初值的fold;sort()原地排序且返回void:final s = list.sort()拿到的是 null 类型错误;不想动原列表写[...list]..sort();map/where返回惰性Iterable,不是 List——直接赋给List变量会类型错误,且每次遍历重算;Map不是Iterable({} is Iterable为false),for-in必须走entries/keys/values。
// —— dart:core · String ——
'a,b,c'.split(',') // 拆成 List → [a, b, c]
'hello'.substring(1, 3) // 截取 [start, end) → el
' hi '.trim() // 去首尾空白 → hi
'a-b'.replaceAll('-', '/') // 全局替换 → a/b
'abc'.contains('b') // 是否含子串 → true
'file.dart'.startsWith('f') // 前缀判断 → true
'7'.padLeft(3, '0') // 左补齐 → 007
'go'.toUpperCase() // 转大写 → GO
'abcb'.indexOf('b') // 定位,找不到 → -1
// —— dart:core · num / int / double ——
3.9.toInt() // 截断取整 → 3
3.14159.toStringAsFixed(2) // 定小数位 → "3.14"
(-5).abs() // 绝对值 → 5
3.6.round() // 四舍五入 → 4
12.clamp(0, 10) // 夹到区间 → 10
int.parse('42') // 解析,失败抛异常 → 42
int.tryParse('x') // 解析,失败 → null
// —— dart:core · List / Iterable ——
[1, 2, 3].map((n) => n * 10) // 惰性变换 → (10, 20, 30)
list.where((n) => n.isEven) // 惰性筛选 → Iterable
list.expand((x) => x) // 展开嵌套并摊平
list.take(2) // 前 n 个(惰性)
list.fold(0, (a, b) => a + b) // 带初值归约 → 和
list.reduce((a, b) => a + b) // 无初值归约
list.sort() // 原地排序(可传 cmp)
['a', 'b'].join('-') // 拼接成串 → a-b
list.firstWhere((e) => e > 0) // 首个匹配(无则抛)
list.any((e) => e.isOdd) // 任一满足 → bool
list.every((e) => e > 0) // 全部满足 → bool
list.contains(2) // 是否包含 → bool
// —— dart:core · Map ——
map.entries // (key, value) 视图
map.keys // 键视图 Iterable
map.values // 值视图 Iterable
map.forEach((k, v) {}) // 遍历键值对
map.containsKey('k') // 是否含键 → bool
map.putIfAbsent('k', () => 0) // 缺失才插入并返回
map.update('k', (v) => v + 1) // 就地更新值
// —— dart:async(★ 见 04 章「异步编程」) ——
Future<String> // 单个异步值 ★
Stream<int> // 异步事件序列 ★
// —— dart:convert ——
jsonEncode({'a': 1}) // 对象 → JSON 串
jsonDecode('{"a":1}') // 解析 → Map / List
utf8.encode('hi') // 串 → 字节 List
utf8.decode(bytes) // 字节 → 串
// —— dart:math ——
max(3, 7) // 取大 → 7
min(3, 7) // 取小 → 3
sqrt(9) // 平方根 → 3.0
pow(2, 10) // 幂 → 1024
pi // 常量 π ≈ 3.1416
Random().nextInt(6) // 随机整数 [0, 6)
// —— dart:io(★ 见 10 / 12 章,仅非 Web) ——
File('a.txt').readAsString() // 读文件 → Future ★
Directory('.').list() // 列目录 → Stream ★
Platform.isAndroid // 平台判断 → bool ★firstWhere 无匹配、reduce 遇空集合都抛 Bad state: No element——前者给 orElse 兜底、后者换带初值的 fold;sort() 是原地排序且返回 void,想要不动原列表就 [...list]..sort()。where / map / expand 返回惰性 Iterable——不遍历就不计算;需要索引、length 或多次遍历时记得 .toList() 固化。标 ★ 的库本页另有专章:async 见 04 章、io 见 10 / 12 章。Dart 3 现代特性
records、patterns、sealed classes、dot shorthands——Dart 3 让你少写一半样板。
不想为了返回两个值就专门定义一个类,records 就是为此设计的轻量聚合。
轻量多值聚合
- 无需定义类即可打包多个值:
(1, 'a', true)。 - 位置字段用
.$1 .$2访问;命名字段用名字访问。 - 最常见用途:函数返回多个值(替代 out 参数 / 临时类 / Map)。
- 完全类型安全,且自动有
==与hashCode——比 Map 强得多。 - 可用
typedef给记录类型起名,提升可读性。
它和 class / Map 的分界线
| 要什么 | record | class | Map |
|---|---|---|---|
| 字段名与类型在编译期固定 | 是 | 是 | 否 |
| 拼错字段名编译期报错 | 是 | 是 | 否(运行时得 null) |
| == / hashCode 按值自动成立 | 是 | 要自己写 | 否(按引用) |
| 能挂方法、能演化 | 否 | 是 | 否 |
| 写起来的成本 | 零 | 一整个声明 | 零 |
- 选择规则很干脆:临时装两三个值、就地拆开用 → record;要挂方法、要长期演化、要出现在公开 API 里 → class;Map 只用于「键在运行时才知道」的真动态数据;
- 那条「== 与 hashCode 自动成立」尤其值钱——它正好补上了 02 章「继承」卡里那个「覆写 == 忘了 hashCode」的坑。
两条一定会撞一次的规则
(1).runtimeType → int 只是加了括号的整数 (1,).runtimeType → (int) 尾逗号才让它成为记录 final r = (1, name: 'x', 2); r.$2 → 2 位置编号只数位置字段,name 不占号
- 单元素记录的尾逗号是语法上必需的消歧手段,忘了不会报错、只会得到一个普通值——错得很安静;
- 混合字段时
$1 $2跳过命名字段计数。只要有超过两个位置字段就该改用命名字段,$3这种写法在代码评审里没人读得懂。
(String, int) parse(String s) => (s, s.length);
final (text, len) = parse('hi'); // 解构
typedef User = ({String name, int age});
User u = (name: 'Charles', age: 21);
print(u.name); // 命名字段访问(1,) 才是记录,(1) 只是加了括号的 int(runtimeType 就是 int)。混合字段时位置编号只数位置字段:(1, name: 'x', 2) 里 $2 取到的是 2,不会数到 name。final (:name, :age) = u;——变量名与字段名一致时不必写两遍。records 适合装两三个临时值;一旦需要方法或要长期演化,就升级成 class。Dart 3 把「判断形状」和「拆出数据」合并成一个动作,类型检查加取值一步完成。
匹配与解构合成一个动作
- 模式可出现在变量声明、for、switch、if-case 中。
- 支持解构记录、List、Map、对象,并绑定到变量。
- List 模式可用
...匹配剩余元素;可加when守卫条件。 - 对照 JS 解构,但 Dart 还能同时做类型判断与穷尽检查。
List 模式与 Map 模式的规则不对称
switch ([1,2,3]) { [int _, int _] => … } → 不匹配(长度必须精确相等)
switch ([1,2,3]) { [int _, ...] => … } → 匹配 (... 放宽长度)
switch ({'a':1,'b':2}) { {'a': int _} => … } → 匹配 (Map 天然是部分匹配)- 不对称是有道理的:List 的长度是它的一部分语义(「恰好三个元素」是有意义的判断),而 Map 通常是「我只关心其中几个键」;
- 实务后果:解析 JSON 对象用 Map 模式很顺手——多出来的字段自动忽略、少了就不匹配、类型不对也不匹配,一个
if-case顶掉一整串手写校验; - 但拿 List 模式解析 JSON 数组时要记得写
...,否则来一条多余数据整个分支就静默落空。
final json = {'name': 'C', 'age': 21};
if (json case {'name': String n, 'age': int a}) {
print('$n is $a'); // 校验+解构一步完成
}
final [first, ...rest] = [1, 2, 3]; // first=1, rest=[2,3][1,2,3] 匹配不上 [int a, int b],要加 ... 才放宽。(a, b) = (b, a) 一行完成交换(输出 2 1)。对象与命名字段解构支持 :final name 简写,变量名同字段名时少写一半。switch 从语句升级成能直接返回值的表达式,是 Dart 3 里日常使用频率最高的新语法。
从语句升级成表达式
switch现在可作表达式直接赋值/返回,分支用=>,逗号分隔。_作通配(default);关系/逻辑模式可写>= 90这类条件。- 对 enum/sealed 类型可省略
_,由编译器强制穷尽。 - 在 Flutter
build里按状态返回不同 widget 极其顺手。 - 下方 build 示例用到了
sealed类与对象模式解构(Loading()、Data(:final items))——这两者见本页「Sealed 密封类」(3.5)。
分支自上而下取第一个命中
String grade(int s) => switch (s) { >= 60 => 'B', >= 90 => 'A', // 永远到不了这里 _ => 'C', }; grade(95) → 'B'
- 关系模式必须从严到宽排。这和
if / else if链是同一条规则,但 switch 的竖排形式会让人误以为它像查表一样「找最匹配的那个」; - 编译器不会警告——95 同时满足两条,取第一条完全合法。这是本章最容易在代码评审里溜过去的一个 bug;
- 防御手段:把边界从大到小写,或干脆写成互不重叠的区间(
>= 90、>= 60 && < 90)——后者啰嗦但读代码的人不必在脑子里模拟顺序。
穷尽检查:什么时候能省掉 _
sealed / enum 类型 → 可以省 _,漏分支直接编译错: The type 'Result' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Err()'. int / String 等开放类型 → 必须写 _,漏了同样是编译错: The type 'int' isn't exhaustively matched … pattern 'int()'.
- 区别在于编译器数不数得清全集:sealed 的子类被限制在同一个库里、enum 的成员是写死的,所以它能数清;
int有无穷多个值,只能靠你兜底; - 这条性质正是 sealed 建模状态的全部价值所在——新增一个状态子类,所有没处理它的 switch 立刻变成编译错误,编译器替你做穷举检查。
String grade(int s) => switch (s) {
>= 90 => 'A',
>= 60 => 'B',
_ => 'F',
};
// 在 build 中按状态切 widget
Widget body(AsyncState st) => switch (st) {
Loading() => const CircularProgressIndicator(),
Error(:final msg) => Text(msg),
Data(:final items) => ListView(children: items),
};>= 60 写在 >= 90 前面,95 分也只会得 B——关系模式要从严到宽排。对 int/String 这类开放类型漏写 _ 直接编译错 The type 'int' isn't exhaustively matched by the switch cases(analyze)。case 也没有 break,天然不存在 fall-through。只关心一种形状时不必写完整 switch,一个 if 就能同时完成匹配与解构。
单分支的匹配与解构
if (value case Pattern) { … }—— 匹配成功才进入分支并解构。- 适合「只关心一种情况」的解构,比完整 switch 更轻。
- 可与
when守卫组合,做更细的条件。 - 常用于安全解析未知结构的 JSON / 动态数据。
它最实用的场合:解析不可信的动态数据
final data = jsonDecode(text); // 类型是 dynamic if (data case {'user': {'name': String name, 'age': int age}}) { // 走到这里时 name 是 String、age 是 int,已经校验过了 } else { // 结构不对、类型不对、字段缺失 —— 全部落到这里 }
- 一个模式同时完成了四件事:判断是不是 Map、判断嵌套结构、判断每个字段的类型、把值绑到变量上;
- 换成手写要四五层
is判断加类型转换,而且漏一层就是运行时崩溃。这是 Dart 3 对日常代码改善最大的一处; - 配
when守卫还能加业务条件:if (data case {'age': int age} when age >= 18)。
Object data = (200, 'OK');
if (data case (int code, String msg) when code == 200) {
print('成功: $msg');
}case 后的裸标识符是常量模式:引用一个非常量变量直接编译错 The expression of a constant pattern must be a valid constant.;关系模式 == z 的操作数同样必须是常量(The relational pattern expression must be a constant.)。想和运行期值比较,只能写进 when 守卫。is 判断加强转:类型不对、结构不对都自然落进 else,坏数据不用单独防。把「所有可能的状态」写成编译器数得清的封闭清单,穷尽检查从此替你盯着每一处 switch。
封闭清单与穷尽检查
sealed类的所有直接子类必须在同一库(通常即同一文件)——编译器因此知道全集。- 对 sealed 类型做 switch 时,漏掉任一子类会报编译错误。
- 是建模「有限状态」(加载/成功/失败)的现代标准做法。
- 配合 records/patterns,大幅取代过去
freezed的联合类型用途。
它取代了 freezed 的哪一半
- Dart 3 之前,要表达「加载中 / 成功 / 失败」这种封闭状态集,标准做法是上
freezed生成联合类型——现在 sealed + record + 模式匹配原生就够了,少一个代码生成器、少一轮 build_runner; - freezed 仍然有用的那一半是样板生成:
copyWith、toJson/fromJson、深比较。sealed 一个都不给你; - 所以现实的选择是:状态建模用语言原生的 sealed,数据类的样板才请 freezed——而不是像以前那样一上来全套生成。
sealed class Result<T> {}
class Ok<T> extends Result<T> { final T data; Ok(this.data); }
class Err<T> extends Result<T> { final String msg; Err(this.msg); }
String show(Result<int> r) => switch (r) { // 漏分支即报错
Ok(:final data) => '值 $data',
Err(:final msg) => '错 $msg',
};can't be extended, implemented, or mixed in outside of its library because it's a sealed class。它的全部价值都建立在「子类清单封闭」上——所以一旦把子类拆到别的库,穷尽性检查立刻失效(穷尽性报错的原文见上一卡)。_ 通配,写了就放弃了这层保护。一组声明「我的类允许你怎么用」的开关,Dart 3 起库作者可以精确控制继承面。
五个开关,控制继承面
final:禁止被继承或实现(库外)。base:可被继承但不可被实现,保证子类继承实现。interface:可被实现但不可被继承(当纯接口用)。sealed:隐式 abstract + final,且启用穷尽检查。mixin class:既能 extends 又能 with。设计库 API 时用于约束扩展方式。
五个修饰符各禁掉什么
| 修饰符 | 库外能 extends | 库外能 implements | 典型用途 |
|---|---|---|---|
| (无) | 能 | 能 | 应用内部代码,绝大多数情况 |
final | 不能 | 不能 | 不希望被扩展的具体类 |
base | 能 | 不能 | 保证子类一定继承到你的实现 |
interface | 不能 | 能 | 当纯契约用 |
sealed | 库外都不能(隐式 abstract) | 封闭状态集 + 穷尽检查 | |
- 限制只对「库外」生效:同一个库(通常即同一文件)里这些约束都不拦你,它们是给包的使用者划的线;
- 约束会传染:父类是
base或final,子类也必须标base/final/sealed,报The type 'D' must be 'base', 'final' or 'sealed' because the supertype 'B' is 'base'.——否则「不可实现」这条保证会从子类漏掉; - 应用内部代码基本不需要这些。它们是库作者的工具,用在自己项目里多半只是给未来的自己添堵。
interface class Repository {} // 只能 implements
base class Service {} // 只能 extends
final class Config {} // 不可继承/实现The class 'Config' can't be extended outside of its library because it's a final class.。The type 'D' must be 'base', 'final' or 'sealed')。上下文已经知道类型时,就不必再把类型名抄一遍。
上下文已知就省掉类型名
- 目标类型已知时,枚举值/静态成员/命名构造可写
.value省略类型名。 - 例:
Status.running→.running。但Colors.blue写不成.blue——简写只查上下文类型自身的静态成员,参数类型是Color,而blue定义在Colors上。 - Flutter 里给
mainAxisAlignment:这类参数赋值时尤其省字。 - Dart 3.11 进一步完善了 IDE 对它的补全与提示支持。
- 本质是借鉴 Swift 的隐式成员表达式,少打字、更聚焦。
它只看「上下文类型自身」的静态成员
参数类型是 Status → 写 .running 可以(Status.running)
参数类型是 Color → 写 .blue 不行,报 dot_shorthand_undefined_member
(blue 定义在 Colors 上,不在 Color 上)
var s = .running; 不行,报 A dot shorthand can't be
used where there is no context type.- 规则一句话:编译器拿上下文类型当命名空间去查静态成员,查不到就报错,绝不去别的类里找;
- 所以在 Flutter 里它对
MainAxisAlignment、TextAlign这类枚举参数特别顺手,对Colors/Icons这类「常量集合类」则完全用不上; - 需要 Dart 3.10+。老项目里看不到很正常,但它是纯语法糖,加上去不会影响任何行为,可以放心逐步用。
enum Status { none, running, stopped }
Status s = .running; // 等价 Status.running
// 已知参数类型时
int port = .parse('8080'); // int.parse(...)Color 时写 .blue 找不到 Colors.blue(报 dot_shorthand_undefined_member)。没有上下文类型也不行:var s = .running; 报 A dot shorthand can't be used where there is no context type.。macros 取消之后,Dart 元编程的现状是 build_runner,方向是 augmentations。
macros 取消后的现状
- Dart 团队于 2025 年初取消了 macros(因严重拖慢热重载)——不要再等它。
- 当前代码生成仍靠
build_runner(json_serializable、freezed等)。 augmentations(增强声明)是元编程的后续方向:允许把类定义拆分到多文件。- Dart 3 的语言特性已让你在很多场景不再需要 freezed。
- 心态:优先用语言原生特性,代码生成按需引入,别一上来全套生成器。
为什么 macros 被砍:热重载与它不可兼得
- macros 的设想是「编译期跑用户代码来生成代码」。问题在于:热重载要求改一行就能在一秒内把新代码塞进运行中的 VM,而宏意味着每次改动都要重新执行一遍任意用户程序,再重新做一遍类型推断;
- Dart 团队 2025 年初正式取消该特性,理由正是对分析与热重载的性能影响不可接受——而热重载恰恰是 Dart 存在的理由,这个取舍没有别的答案;
- 取而代之的方向是 augmentations(增强声明):允许把一个类的定义拆到多个文件、由生成器补齐缺的部分。它不需要在编译期跑用户逻辑,因此对增量编译友好。
现状:能用语言特性解决的,就别上生成器
- 每引入一个生成器,就多一步
build_runner、多一份.g.dart要进版本库或进.gitignore、多一类「改了源码忘了重新生成」的怪问题; - Dart 3 之后,状态建模(sealed + 模式匹配)与多值返回(record)已经不需要生成器——freezed 的用武之地缩小到
copyWith与 JSON 序列化; - 真要用,记住两条
freezed3.x 要求宿主类标abstract(否则报Missing concrete implementations…);build_runner2.15 起--delete-conflicting-outputs参数已被移除,还传只会得到一句These options have been removed and were ignored——网上教程里那条命令现在是空转。
// 现状:注解 + build_runner(如 freezed 数据类)
@freezed
abstract class User with _$User {
const factory User({required String name, int? age}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// $ dart run build_runner build ← 生成 user.freezed.dart / user.g.dart
// 方向:augmentations(预览)——把类「增强」到另一个文件
augment class User {
String get shout => name.toUpperCase();
}abstract(或 sealed):写普通 class User with _$User 会编译错 Missing concrete implementations…try making the class abstract(freezed 3.2)。另外 build_runner 2.15 起 --delete-conflicting-outputs 参数已移除,再传只会得到一句 These options have been removed and were ignored。异步编程
Future、Stream、isolate 与事件循环——UI 流畅的根基。
Future 把「稍后才有的值」变成一个可以 await 的普通变量,异步代码从此长得像同步。
把「稍后的值」变成普通变量
Future<T>代表「未来某刻的一个值」,≈ JS 的 Promise。async标记函数返回 Future;await等待其完成并取值。- 未捕获的 await 异常会向上抛——用 try/catch 包裹。
await不阻塞线程,只是挂起当前异步流,UI 继续响应。
await 不阻塞线程——这句话的准确含义
Future<void> f() async { print('A'); // 同步执行 await Future.value(); print('C'); // 恢复后执行 } f(); print('B'); 实际输出:A → B → C
- async 函数体在遇到第一个
await之前是彻底同步的——所以「调用一个 async 函数」不等于「稍后才开始」,它已经跑了一段; - 碰到
await时,函数把「剩下的部分」登记成一个回调然后立即返回,控制权还给调用者(打印 B),等值就绪后再从事件循环回来接着跑(打印 C); - 推论一:await 挂起的是这一条异步流,不是线程。UI 线程回去继续处理手势与渲染,这就是「不卡界面」的全部机制;
- 推论二:如果 await 之前那段是重计算,它照样卡 UI——async 关键字救不了同步阻塞,那是 isolate 的活。
Future<String> fetchName() async {
await Future.delayed(Duration(seconds: 1));
return 'Charles';
}
void main() async {
final name = await fetchName();
print(name);
}Unhandled exception: 并以非零码退出(exit 255)。而 await 只能写在 async 函数里,否则编译错 The await expression can only be used in an async function.。await 之前是同步执行的(调用后函数体首行先于调用点下一行打印),所以「调用 async 函数」不等于「稍后才开始」。确实不想等它时,用 dart:async 的 unawaited() 显式标明意图。多个异步任务的编排:并发、超时与错误策略,都有现成 API,但细节与 JS 不完全一样。
并发、超时与错误策略
Future.wait([...])并发等待多个,全部完成才返回(≈Promise.all)。Future.any、Future.delayed、.timeout(...)控制时序。- 回调式:
.then()/.catchError()/.whenComplete()——但 async/await 更清晰。 Future.value/Future.error构造即时完成的 future。
Future.wait 是真并发
三个任务分别耗时 100 / 200 / 300 ms: await Future.wait([...]) 总耗时 309 ms ← 约等于最慢的那个,不是 600
- 之所以能并发,是因为这三个 Future 在传进 wait 之前就已经启动了——Dart 的 Future 是「已经在跑的任务」,不是 JS 里也不是 Rust 里那种惰性对象;
- 推论:
await a; await b;是串行(300+200),Future.wait([a, b])才是并发。这两行长得像,耗时差一倍,是性能问题的高发地; - 类型不同的少数几个 future 用记录扩展
(f1, f2).wait,解构后各自保留精确类型(int/String),比Future.wait返回List<dynamic>再强转干净得多。
两处与 JS 直觉相反的行为
① Future.wait 默认 eagerError: false
任务 A 在 100 ms 就失败了
→ 305 ms 才收到这个错误(等全部完成),
且成功任务的结果全部丢失
② .timeout 只让 await 提前抛,不取消底层操作
50 ms 超时抛 TimeoutException
→ 200 ms 时那个任务照样跑完了(done == true)- ①与
Promise.all的「立即 reject」不同。要快速失败传eagerError: true;要「谁都别丢」用Future.wait配每个任务自己的catchError,或改用Future.any; - ②意味着 timeout 不是取消——网络请求会继续占着连接、写操作会继续写完。真要能取消,得用支持取消的客户端(如
dio的CancelToken)或StreamSubscription.cancel(); - 这条在「用户退出页面后请求还在跑、回来时 setState 到已 dispose 的 State」里表现为崩溃——修法在 09 章的
mounted检查。
final results = await Future.wait([
fetchUser(), fetchPosts(),
]); // 并发
final data = await fetchSlow()
.timeout(Duration(seconds: 3)); // 超时保护Future.wait 默认 eagerError: false,先失败的错误要等全部完成才抛出,且成功任务的结果全部丢失(100ms 就失败的错误约 300ms 才收到)。.timeout 只是让 await 提前抛 TimeoutException,底层操作仍会继续跑完,并不会被取消。Future.wait 是真并发:总耗时约等于最慢的那个(100/200/300ms 三任务约 304ms 完成)。等固定几个不同类型的 future 时,用记录扩展 (f1, f2).wait,解构后各自保留精确类型(Dart 3,)。一个值用 Future,一串值用 Stream,Flutter 的事件与数据推送都建在它上面。
一串值的抽象
Stream<T>= 「会陆续到来的一串值」,≈ 可监听的事件源。- 单订阅流(默认,只能 listen 一次)vs 广播流
.asBroadcastStream()。 - 用
await for在 async 函数里逐个消费;或.listen(cb)。 - Stream 也支持
map/where/take等变换。 - Flutter 中
StreamBuilder直接把流绑定到 UI。
单订阅 vs 广播:选错的代价
| 单订阅(默认) | 广播 broadcast | |
|---|---|---|
| 能 listen 几次 | 1 次,第二次抛 Bad state: Stream has already been listened to. | 任意多次 |
| 没人监听时 add 的事件 | 缓冲,listen 之后照样送达 | 直接丢弃 |
| 适合 | 一份数据一个消费者:文件、HTTP 响应体 | 事件广播:按钮、WebSocket、全局状态 |
- 选择只看一个问题:早到的事件能不能丢。能丢就 broadcast,不能丢就单订阅;
Stream.fromIterable是个例外——SDK 明确允许它被多次 listen、每个订阅者独立从头迭代(不抛错),别拿它当单订阅的例子来验证,会得到错误结论。
Stream<int> ticks() async* { // 异步生成器
for (var i = 1; i <= 3; i++) {
await Future.delayed(Duration(seconds: 1));
yield i;
}
}
await for (final t in ticks()) print(t); // 1,2,3Bad state: Stream has already been listened to.(async* 生成的流与 StreamController 的流均如此)。但 Stream.fromIterable 是例外:SDK 明确允许多次 listen、每个订阅者独立迭代(不抛错),别拿它当单订阅的反面教材。await for 适合顺序消费、能用普通 try/catch 包错误;.listen 返回 StreamSubscription,可 pause/resume/cancel——需要控制订阅生命周期时选它。用普通函数的写法惰性生产 Iterable 和 Stream:yield 一个值,yield* 委托一整段。
yield 与 yield*
sync*+yield惰性产生Iterable(按需计算)。async*+yield产生Stream。yield*委托给另一个序列,把其元素逐个产出。- 适合无限序列、分页拉取、逐步计算等场景。
生成器是「描述」,不是「执行」
Iterable<int> gen() sync* { print('body'); yield 1; yield 2; } final it = gen(); print('created'); // 此时还没有打印 body it.first; // 打印 body it.first; // 又打印一次 body
- 调用生成器函数一行函数体都不跑,只造出一个 Iterable;开始迭代才跑,而且每次重新迭代都从头重跑;
- 后果一:函数体里的副作用(打日志、发请求、写文件)会按迭代次数重复发生;后果二:昂贵计算重复付费;
- 解法一律是
.toList()固化一次。判据:只要这个 Iterable 会被用第二次,就先固化; async*同理,但它产出的是 Stream,「重新迭代」表现为「重新 listen」——而单订阅流根本不允许重新 listen,于是这个坑在 async* 上反而不容易踩到。
Iterable<int> naturals() sync* {
var i = 0;
while (true) yield i++; // 惰性,无限
}
naturals().take(3).toList(); // [0, 1, 2]sync* 返回的 Iterable 取两次 .first,函数体就执行了两遍——生成逻辑昂贵或带副作用时会重复付费。.toList() 固化。当事件来自你自己的命令式代码时,用它手工造一条流并控制推送节奏。
手工造一条流
- 当事件来自命令式代码(按钮、WebSocket、定时器)时,用它构造流。
.sink.add(event)推送;.stream暴露给消费者;用完.close()。broadcast()支持多订阅者。- 很多状态管理(如 Bloc)底层就是 StreamController 的封装。
生命周期的两个端点都要管
- 开头:单订阅 controller 在无人监听时会缓冲(listen 之前 add 的值仍全部送达),broadcast 则丢弃——上一张卡那张表的直接后果;
- 结尾:
close()之后再add抛Bad state: Cannot add event after closing; - 忘了 close 更糟:流永不结束,
await for的消费者永远挂着、监听回调持有的对象永远不被回收。Flutter 里这是最常见的一类内存泄漏——凡是在initState里建的 controller 与订阅,都必须在dispose里关掉; - 写法上养成配对:
final _c = StreamController<T>();紧跟着就把_c.close()写进dispose,别等功能写完再回来补。
final ctrl = StreamController<int>();
ctrl.stream.listen((v) => print('收到 $v'));
ctrl.sink.add(1);
ctrl.sink.add(2);
await ctrl.close();Bad state: Cannot add event after closing;反过来忘记 close,流就永不结束,await for 的消费者会一直挂着——Flutter 里常见于 dispose 时忘关 controller 造成泄漏。事件循环解决「等」的问题,isolate 解决「算」的问题——CPU 重活要的是真并行。
解决「算」的问题
- Dart 单线程跑事件循环;
Isolate是独立内存的并行执行单元(≈ Web Worker)。 - isolate 间不共享内存,只能通过消息(端口)通信——天然无数据竞争。
- CPU 密集任务(解析大 JSON、图像处理)放进 isolate,避免掉帧。
compute(fn, arg)是开箱即用的简化入口,自动起一个 isolate。
不共享内存换来了什么,又要付什么
- 换来:没有共享可变状态,就没有数据竞争、不需要锁。Dart 里你永远不会写 mutex——这是它并发模型最大的省心之处;
- 付出:跨 isolate 只能传消息,对象要被拷贝过去(大对象有实打实的序列化开销);能传的东西也有限制——捕获了
ReceivePort、Socket之类不可发送对象的闭包,直接抛Illegal argument in isolate message: object is unsendable; - 现代写法是一行:
final r = await Isolate.run(() => heavy());(可用,isolate 里抛的异常会原样传回 await 处、能正常 catch)。Flutter 的compute如今就是它的封装; - 判据:任务是「等」(网络、文件、定时)就用 async/await,是「算」(解析大 JSON、图像处理、加解密)才上 isolate。用错方向不会更快,只会多一次拷贝。
Web 上没有 isolate
- 编译到 Web 时
compute退化成在当前 isolate 直接执行——重活照样卡 UI,而且不会有任何报错或警告; - 所以「在移动端好好的、上了 Flutter Web 就卡死」有一类根因就在这;
- Web 上要真并行只能用 Web Worker,Dart 侧目前没有透明的等价物——只能从算法上把任务切碎、分帧执行。
int heavy(int n) => List.generate(n, (i) => i).reduce((a, b) => a + b);
// 在后台 isolate 跑,UI 不卡
final sum = await compute(heavy, 100000000);ReceivePort、Socket 之类会抛 ArgumentError Illegal argument in isolate message: object is unsendable。另外 Web 平台没有 isolate,compute 在 Web 上退化为在当前 isolate 直接执行,重活照样卡 UI(官方文档)。final r = await Isolate.run(() => heavy());,isolate 里抛的异常也会原样传回 await 处、可以正常 catch;Flutter 的 compute 如今就是它的封装。异步代码的执行顺序不是玄学:两条队列、一条规则,看懂就能定位大多数时序 bug。
两条队列,一条规则
- Dart 有两个队列:微任务队列(优先)和事件队列。
scheduleMicrotask/ 已完成的 Future 回调进微任务队列。- I/O、计时器走事件队列;
await等待的事件(timer/IO)也在事件队列,事件到达后其后的续体(continuation)经微任务恢复。 - 微任务全清空后才处理下一个事件——与 JS 的 microtask/macrotask 模型相通。
- 实务:别在微任务里写死循环,会饿死事件队列让 UI 卡死。
把顺序跑出来(输出就是 1→2→3→4→5)
print('1 同步'); Future.microtask(() => print('3 微任务')); Future(() => print('5 事件队列')); Future.value().then((_) => print('4 已完成 Future 的 then')); print('2 同步');
- 同步代码先全部跑完(1、2)——哪怕那个 Future 已经完成了,它的
then也不会同步执行; - 然后清空整个微任务队列(3、4);微任务里再产生的微任务还会在这一轮被清掉;
- 最后才轮到事件队列的第一项(5)。
Future(fn)与Timer走的都是事件队列,因此比所有微任务都晚; - 实务推论:别在微任务里写循环产生微任务——事件队列(含 UI 帧、手势、IO)会被饿死,表现为界面完全无响应而 CPU 跑满。
import 'dart:async';
void main() {
print('1 同步代码');
Future(() => print('5 事件队列:Future(...) 走 Timer'));
scheduleMicrotask(() => print('3 微任务'));
Future.microtask(() => print('4 微任务,排在 3 之后'));
print('2 同步代码先全部跑完');
}
// 打印顺序:1 → 2 → 3 → 4 → 5
// 规则:同步代码执行完 → 清空整个微任务队列 → 才取下一个事件then/await 也要排微任务,同步代码全部打印完 then 才执行;而 Future(fn) 走的是事件队列(Timer),比所有微任务都晚(本卡示例顺序 1→2→3→4→5)。把任何一个当「立刻执行」用都会得到意外顺序。await Future.delayed),要么丢给 isolate;async 关键字本身救不了同步阻塞。Flutter 核心理念
一切皆 Widget。Stateless/Stateful、生命周期、三棵树与 Key。
一条 flutter create 生成可运行工程,main 里一次 runApp 挂上整棵 widget 树。
从 create 到 runApp
- 装好 Flutter SDK 后跑
flutter doctor自检环境(Android Studio/Xcode、设备),再用flutter create my_app生成模板工程。 - 入口是
main(),其中调用一次runApp(根 widget)把整棵 widget 树挂到屏幕——根通常是MaterialApp。 flutter run跑到模拟器/真机;改代码按r热重载,秒级看到效果。- 目录:
lib/放 Dart 代码(lib/main.dart是入口),pubspec.yaml声明依赖与资源,android/ ios/ web/是各端壳工程。
runApp 那一行到底做了什么
void main() { runApp(const MyApp()); }
runApp把你给的 widget 接到渲染树的根上,并启动整条流水线:构建 → 布局 → 绘制 → 合成,之后每一帧都由框架驱动;- 它只该被调用一次。热重启(
R)会重新走一遍main(),热重载(r)则不会——这正是 01 章那张卡里「改main()必须按 R」的原因; - 根 widget 通常是
MaterialApp:它一口气提供了导航器(Navigator)、主题(Theme)、本地化与方向信息。很多「Navigator.of(context)找不到」「Theme.of拿到默认值」的报错,根因就是某个 widget 不在MaterialApp之下。
目录里哪些该动、哪些别动
lib/—— 你的全部 Dart 代码,入口是lib/main.dart。日常 99% 的改动都在这里;pubspec.yaml—— 依赖与资源声明。图片、字体必须在这里登记,否则运行时找不到;改了它r和R都不够,要停掉重跑;test/——flutter test的地盘,见 13 章;android/ ios/ web/ windows/…—— 各端壳工程。平时不要动,只有配权限、改包名、加原生依赖时才进去;它们由flutter create生成,也可以用flutter create --platforms=…事后补;build/、.dart_tool/—— 产物与缓存,永远进.gitignore;构建出怪问题时flutter clean就是删它们。
# 1. 建工程并运行
$ flutter create my_app
$ cd my_app && flutter run
// 2. lib/main.dart:最小可运行应用
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
home: Scaffold(
body: const Center(child: Text('Hello Flutter')),
),
);
}main(),也不会重置已有 State——改了 main、全局初始化或 initState 看不到效果时,按大写 R 热重启,别急着怀疑代码写错。MaterialApp 提供路由、主题、本地化等全局设施,本章后续都在这个根之下展开;主题细节见「主题 Theme」(7.11),多页面跳转见「导航与路由」章。界面、布局、间距、手势甚至主题,在 Flutter 里全是 widget,用组合而非继承拼出 UI。
组合而非继承
- Widget 是 UI 的不可变配置描述,不是真正渲染的对象。
- 界面通过组合小 widget 构建(而非继承大组件)——与 React/Vue 组件思路一致。
- padding、对齐、手势、主题……都是 widget,用「包一层」表达。
- Widget 很廉价:框架频繁重建它们,真正昂贵的是底层 RenderObject。
「widget 是配置不是对象」这句话的三个后果
- 它可以被极其频繁地创建:一个 widget 只是一小撮字段,
build每帧重跑、造出成百上千个 widget 实例都不是问题——真正昂贵的是它背后的 RenderObject,而那一层框架会尽量复用(见「三棵树」卡); - 它必须是不可变的:所有字段都是
final。想改界面不是去改 widget,而是造一个新的让框架去 diff——这与 React 的「不要直接改 state」是同一条纪律; - 「包一层」成了唯一的表达手段:要留白就包
Padding,要居中就包Center,要能点就包GestureDetector。好处是正交、可任意组合;代价是嵌套深、可读性靠格式化撑着——这就是 Flutter 代码那种「金字塔缩进」观感的来源,也是尾随逗号必须写的原因。
和 React / Vue 的对照
| Flutter | React | |
|---|---|---|
| UI 描述 | widget 树(Dart 代码) | 虚拟 DOM(JSX) |
| 真正渲染的东西 | RenderObject 树,Flutter 自绘 | 浏览器 DOM,交给浏览器 |
| diff 发生在 | Element 树 | Fiber 树 |
| 列表身份靠 | Key | key |
- 结构上几乎一一对应,唯一的根本差别是最底层:Flutter 不用平台控件,自己往画布上画每一个像素;
- 这解释了它的长处(跨端像素级一致、动画不受控件限制)与代价(包体积大、无障碍与平台质感要自己补、文本选择/输入法这类系统能力要重新实现)。
Widget build(BuildContext context) {
return Center( // 布局也是 widget
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Hello Flutter'),
),
);
}有没有随时间变化的内部状态,决定了继承 StatelessWidget 还是 StatefulWidget。
有没有内部可变状态
StatelessWidget:只依赖传入参数,无内部可变状态,给定输入→固定输出。StatefulWidget:有随时间变化的内部状态,需要重建 UI。- Stateful 由两部分组成:不可变的 Widget + 可变的
State对象。 - 经验法则:能用 Stateless 就用 Stateless;状态尽量上提或交给状态管理。
为什么 Stateful 要拆成两个类
class Counter extends StatefulWidget { … } // 不可变,会被反复丢弃重建 class _CounterState extends State<Counter> { … } // 可变,跨重建存活
- 拆开的理由就是「widget 不可变」这条纪律:状态必须活得比 widget 长,所以不能放在 widget 里;
- State 对象由 Element 持有,只要 widget 的类型与 key 不变,同一个 State 就一直被复用——你的计数器、滚动位置、动画进度因此能跨重建保留;
- 反过来,类型或 key 一变,旧 State 就被
dispose、新的重新initState,状态清零。列表里的「状态串台」和「状态莫名清空」都是这条规则的两面(见「Key 的作用」卡); - 在 State 里访问 widget 的参数写
widget.xxx——它总是指向最新那个 widget 实例,所以 State 里不要缓存构造参数。
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int count = 0;
@override
Widget build(BuildContext c) =>
TextButton(onPressed: () => setState(() => count++),
child: Text('$count'));
}stless/stful 模板一键生成骨架;日后要升级,用「Convert to StatefulWidget」重构自动完成两段式改写。setState 把当前 State 标脏,下一帧框架重跑 build,用新旧配置的 diff 做最小更新。
标脏,下一帧重建
- 在
setState(() {...})里修改状态,框架会重新调用build。 - 只标记当前 State 子树为脏,框架做最小化的 diff 与重绘。
- 别在 build 里调 setState:对自身调用会被框架静默忽略(不抛错),在 build 中牵连其他 widget 则抛「setState() or markNeedsBuild() called during build.」;别在异步回调里对已 dispose 的 State 调它。
- setState 是最基础的状态机制——先吃透它,再考虑 Provider/Riverpod。
它不是「立刻重绘」,是「登记一下」
setState做两件事:跑你给的回调(改字段),然后把这个 Element 标记为脏。build 不会同步发生,要等到下一帧;- 所以在一个函数里连着调三次
setState,只会重建一次——不必为了性能去合并它们; - 也所以
setState之后立刻读取「界面上的」尺寸、位置是拿不到新值的,那要等布局完成(WidgetsBinding.instance.addPostFrameCallback); - 回调里其实可以什么都不写(
setState(() {}))——字段在外面改也一样生效。但约定俗成把修改写进回调,是为了让读代码的人一眼看到「哪些字段是界面依赖的」。
三种用错的方式,报错各不相同
| 怎么错的 | 会发生什么 |
|---|---|
在自己的 build 里对自己调 | 静默忽略(不抛错)——最难查的一种 |
在 build 里牵连别的 widget 重建 | 抛 setState() or markNeedsBuild() called during build. |
| 异步回调里对已销毁的 State 调 | 抛 setState() called after dispose(): _XxxState#…(lifecycle state: defunct, not mounted) |
- 第三种是最高频的:
await一个网络请求,用户中途退出页面,回来时 State 已经没了。标准写法是await之后先判if (!mounted) return;; - 根治则是在
dispose里取消订阅与定时器——判mounted只是让它不崩,任务本身还在跑(04 章「.timeout 不取消底层操作」是同一回事)。
class _CounterState extends State<Counter> {
int _n = 0;
@override
Widget build(BuildContext context) { // setState 后整个 build 重跑
return Column(children: [
const Text('计数器'), // const:同一实例,重建时被跳过
Text('$_n'), // 依赖状态,每次重建生成新配置
ElevatedButton(
onPressed: () => setState(() => _n++), // 标脏 → 下一帧重建
child: const Text('+1'),
),
]);
}
}mounted,或在 dispose 里取消 Timer 与订阅。context 是 widget 在 Element 树中位置的句柄,Theme、MediaQuery、Navigator 都靠它向上查找。
树中位置的句柄
context是当前 widget 在 Element 树中位置的引用。- 靠它向上查找祖先:
Theme.of(context)、MediaQuery.of(context)、Navigator.of(context)。 - 常见坑:在 widget 还没插入树时用 context(如构造函数里)会失败。
.of(context)本质是沿树向上找对应的 InheritedWidget。
.of(context) 是一次向上查找
Theme.of(context)的本质是:从 context 这个位置出发,沿 Element 树往上走,找最近的那个Theme。找到就返回,找不到就报错或给默认值;- 「往上」这个方向决定了两条常见故障:①在提供者同一层或上面取,一定取不到——最典型的是在
MaterialApp所在的那个build里Navigator.of(context);②在 widget 还没进树时用(构造函数里、initState里取 Inherited),拿不到位置; - ①的标准解法是再包一层
Builder:Builder唯一的作用就是「凭空造一个更深的 context」;②的解法是挪到didChangeDependencies或build里; - 顺带解释了另一个直觉:不同位置的 context 不等价。把 context 存进变量跨页面用,或者在异步回调里用一个早已失效的 context,都会出问题——
BuildContext是「位置」,不是「全局句柄」。
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
Navigator.of(context).push(route);context.mounted:widget 被移出树后 mounted 变为 false,此时再拿它查找祖先,操作的是已失效的位置(lint use_build_context_synchronously 拦的就是这个)。从挂树到销毁的回调顺序,决定初始化、依赖获取与资源清理各自该写在哪。
五个回调的分工
initState:创建后调一次,做订阅、控制器初始化(不能用 context 取 Inherited)。didChangeDependencies:依赖的 Inherited 变化时调用(首次在 initState 后)。build:每次重建都调,必须纯粹、无副作用。didUpdateWidget:父级重建传入新配置时调,用于对比新旧 widget。dispose:销毁时调,务必释放控制器、取消订阅,防内存泄漏。
各写什么,一张表说清
| 回调 | 调用时机 | 该写什么 | 禁忌 |
|---|---|---|---|
initState | 创建后一次 | 控制器初始化、订阅、一次性请求 | 不能用 context 取 Inherited |
didChangeDependencies | 依赖的 Inherited 变化时(首次在 initState 后) | 依赖 Theme/MediaQuery 的初始化 | 会被调多次,别放一次性副作用 |
build | 每次重建 | 只描述 UI | 必须纯粹:不发请求、不改状态 |
didUpdateWidget | 父级传入新配置时 | 对比 oldWidget,按需重订阅 | 别忘了先 super |
dispose | 销毁时 | 取消订阅、释放控制器 | 之后不能再 setState |
- 记忆线索:
initState与dispose必须成对——在前者里 new 出来、listen 上去的每一样东西,后者里都要有一行对应的清理。这条纪律能挡掉 Flutter 里绝大多数内存泄漏。
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
}
@override
void dispose() {
_controller.dispose(); // 必须释放
super.dispose();
}Theme.of(context) 这类依赖查找,抛「dependOnInheritedWidgetOfExactType<…>() or dependOnInheritedElement() was called before …initState() completed.」——挪到 didChangeDependencies 或 build 里。你写的 widget 只是配置,Element 持有状态做 diff,RenderObject 负责布局绘制,三层分工是性能的来源。
配置、实例、渲染
- Widget 树:你写的不可变配置,频繁重建、廉价。
- Element 树:widget 的实例化,持有状态与 context,框架在此做 diff/复用。
- RenderObject 树:真正负责布局、绘制、命中测试,昂贵、尽量复用。
- 重建 widget ≠ 重建 render object——同类型同 key 的 element 会被复用,性能由此而来。
- 理解这层,才能解释 key、const、性能优化为什么有效。
三层各自的寿命与代价
| 树 | 是什么 | 寿命 | 代价 |
|---|---|---|---|
| Widget | 不可变配置 | 极短,每次 build 全部重造 | 几乎为零 |
| Element | widget 在树中的实例,持有 State 与 context | 长,只要类型与 key 不变就复用 | 中等(diff 发生在这一层) |
| RenderObject | 真正负责布局、绘制、命中测试 | 最长,尽力复用 | 昂贵,重新布局/绘制才是性能开销 |
- 关键结论:「重建 widget」≠「重新布局绘制」。build 跑一万次,只要 Element 复用、RenderObject 的输入没变,屏幕上什么都不用重做;
- 这一层分工把三个看似无关的优化统一起来了:
const让框架用引用相等立刻判定「没变」;Key决定 Element 能不能被认出来复用;把setState下沉到小组件则是缩小标脏的范围; - 调试时 DevTools 的 Widget Inspector 看的是 widget/element 树,Layout Explorer 看的是 RenderObject 的约束与尺寸——找不到「为什么这么大」时要看后者。
// 经典实验:交换两个「颜色存在 State 里」的方块
List<Widget> tiles = [
StatefulColorBox(key: UniqueKey()),
StatefulColorBox(key: UniqueKey()),
];
void swap() => setState(() => tiles.insert(0, tiles.removeAt(1)));
// 有 key:Element(连同 State/RenderObject)认出自己的 widget 并跟随 → 颜色互换
// 无 key:按位置匹配到「同类型」,旧 Element 原地复用 → 颜色纹丝不动
// 结论:widget 只是廉价配置;真正「活着」的是 Element 与 RenderObject同类型 widget 在列表中增删重排时,key 是框架分辨谁还是谁的凭证。
让框架认出谁是谁
- 当 widget 列表的顺序或身份会变时,key 让框架认出「谁是谁」。
ValueKey(按值)、ObjectKey(按对象)、UniqueKey(每次都唯一)。GlobalKey全局唯一,可跨树访问 State / context(开销大,慎用)。- 典型 bug:可重排的 StatefulWidget 列表不加 key,状态会「串台」。
- 类比 React/Vue 列表的
key—— 同一思想。
没有 key 时框架怎么配对
- 默认规则:同一位置、同一类型的 widget 就认为是同一个,复用它的 Element 与 State;
- 于是列表重排时会出问题——第 1 项和第 2 项都是
TodoItem,交换后框架按「位置 + 类型」配对,State 留在原位没有跟着走,表现为勾选状态、动画进度、输入框内容串台; - 加了 key,配对就改成按 key 找,Element 与 State 跟着数据走;
- 判据:只要列表里的
StatefulWidget会被增删或重排,就必须给 key;纯展示的StatelessWidget列表则可以不给(没有 State 可串)。
四种 Key 各用在哪
| Key | 身份取自 | 典型场合 |
|---|---|---|
ValueKey(id) | 一个值 | 列表首选,用业务 id |
ObjectKey(obj) | 对象身份 | 没有稳定 id、但对象本身唯一 |
UniqueKey() | 每次都新 | 故意让状态重置(如重播动画) |
GlobalKey() | 全局唯一 | 跨树访问 State/context,开销大 |
UniqueKey()用错方向的后果最反直觉:拿它当列表 key,每次 build 都是新 key,于是每次都判定「全是新元素」——State 全丢、动画全断、性能反而更差;GlobalKey常被滥用成「拿到别处 State 的后门」。它有实打实的开销(全局注册表),而且同一个 GlobalKey 不能同时挂在两处,重排时容易撞车——需要跨组件通信应优先用状态管理(08 章)。
ListView(
children: items
.map((it) => TodoTile(key: ValueKey(it.id), item: it))
.toList(),
);ValueKey(item.id);UniqueKey 每次构造都是新身份,写在 build 里等于强制丢弃状态,只该用于「故意重置」。const widget 在编译期就是同一个实例,重建 diff 时框架看到相同引用直接整棵跳过。
最便宜的一条优化
constwidget 在编译期就确定,重建时框架直接跳过它(同实例)。- 尽量给不变的 widget 加
const——几乎零成本的提速。 - 开启 lint
prefer_const_constructors让 IDE 自动提示。 - 这是 Flutter 性能优化里「投入最低、回报最稳」的一条。
为什么它有效:一条引用比较
- 框架 diff 时先比新旧 widget:引用相同就直接判定「没变」,整棵子树跳过。而 02 章验证过
identical(const Point(1), const Point(1))为true——const 表达式在编译期被规范化成同一个实例; - 于是
const Text('提交')在每次 build 里都是同一个对象,框架连比字段都省了; - 开 lint
prefer_const_constructors,IDE 会把所有能加的地方标出来,dart fix --apply可以一键补全(12 章那张分析器卡); - 限制也很明确:构造参数里只要有一个运行时值,整个
const就不成立。所以实践中 const 大多加在叶子节点(图标、固定文案、SizedBox)上——但这些恰恰是数量最多的那些。
// 加 const:重建时被复用,不再 rebuild
const SizedBox(height: 16),
const Text('固定标题'),布局系统
约束向下、尺寸向上、父级定位。Row/Column、Flex、Stack 与滚动。
Flutter 布局只有一趟遍历:父级把约束传下去,子级把尺寸报上来,父级决定位置——这三句话是本章所有 widget 的总纲。
一趟遍历,三句话
- 父 widget 给子 widget 传约束(最小/最大宽高)。
- 子 widget 在约束内决定自己的尺寸,向上回报给父级。
- 父级再决定子级的位置。三句话解释了几乎所有布局行为。
- 「为什么我的 Container 没有变大/居中?」90% 是没理解约束传递。
- 调试利器:
debugPaintSizeEnabled = true或 DevTools 的 Layout Explorer。
「我的 Container 为什么没变大」
- 症状:给
Container设了width: 200,结果它铺满了整个屏幕,或者干脆是子级的大小; - 原因永远是父级传下来的约束:如果父级给的是「紧约束」(min == max,比如
Center之外的很多容器直接把自己的尺寸压下来),子级没有选择余地,你写的width会被忽略; - 所以解法不是改子级,而是在中间插一个放宽约束的父级——
Center、Align、UnconstrainedBox都是把紧约束变成松约束的工具; - 这条规律的另一半是「约束里没有位置信息」:子级只知道自己能多大,永远不知道自己在哪,位置完全由父级说了算。想「让自己居中」的写法在 Flutter 里不存在,只能让父级去
Center。
两个极端约束,解释一半的报错
| 约束 | 谁会传下来 | 典型报错 |
|---|---|---|
| 无界(infinite) | 滚动容器的主轴、Row 的横轴 | RenderBox was not laid out / 「有无限高度的 Column」 |
| 紧(tight) | 被 Expanded 包住的子级、铺满型父级 | 「我设的 width 不生效」 |
- 无界约束的意思是「你想多高都行」——这时子级不能说「我要和父级一样高」(没有「一样」可言),于是
Expanded、double.infinity、CrossAxisAlignment.stretch在这个方向上全部失效; - 把这两行记住,Flutter 里绝大多数布局报错都能自己定位:先问「这个方向上父级给的是无界还是紧约束」;
- 看不出来就打开
debugPaintSizeEnabled = true,或用 DevTools 的 Layout Explorer——它会把每一层收到的约束和报上去的尺寸直接标在图上。
// LayoutBuilder 能亲眼看到父级传下来的约束
LayoutBuilder(builder: (context, constraints) {
debugPrint('$constraints'); // BoxConstraints(0.0<=w<=390.0, …)
return const Placeholder();
});
// 子级的「想要」必须服从约束——宽 50 在 Expanded 里被无视
Row(children: [
Expanded( // 下发紧约束:宽 = 全部剩余空间
child: SizedBox(width: 50, // ← 无效!紧约束下子级没有话语权
child: ColoredBox(color: Colors.red)),
),
]);SizedBox(width: 50) 放进 Expanded,最终宽被紧约束拉成整行 800——调 width/height 不生效时,先查父级下发的约束是不是紧的,而不是怀疑 widget 坏了。定位、留白、装饰、定尺寸,处理单个子级的日常需求全靠这几个容器。
单子级的日常需求
Container是组合体:内外边距、装饰、约束、对齐,一个顶多个。Padding加内边距;Center/Align控制子级对齐。SizedBox固定尺寸,或当作间距(SizedBox(height: 16))。DecoratedBox/BoxDecoration做圆角、边框、渐变、阴影。
Container 是组合体——知道它等于什么才好取舍
- 一个
Container内部按需组合出Padding+Align+DecoratedBox+ConstrainedBox+Transform:你传了哪几个参数,它就只装配哪几层; - 所以「只加内边距」时直接写
Padding比Container(padding:)更直白,但性能上没有区别——Container 不会造出你没要的层; - 真正的取舍是可读性:参数超过两三个时
Container更紧凑,只有一个诉求时专用 widget 更能表达意图; - 一个常见困惑:不带任何参数的
Container()会尽可能大(有子级时包裹子级,无子级时占满)——想要「零尺寸占位」用SizedBox.shrink()。
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: const Text('卡片'),
)alignment 又变回撑满(800×600)。「Container 怎么变大/变小了」多半是撞上了这三条规则。Padding、只要尺寸就用 SizedBox(还能 const),语义更清楚、层级更省。Row 与 Column 就是 Flutter 的 flexbox:一根主轴、一根交叉轴,加起来覆盖大部分线性布局。
主轴与交叉轴
Row横向、Column纵向排列子级(≈ CSS flex-direction)。mainAxisAlignment控制主轴分布;crossAxisAlignment控制交叉轴。mainAxisSize决定占满还是包裹内容。- 子级溢出会显示黄黑条警告——用 Expanded/Flexible 或可滚动容器解决。
那条黄黑警告条的读法
- 出现「A RenderFlex overflowed by N pixels」时,N 就是超出的量,方向由 Row/Column 决定;
- 三种修法对应三种意图:让某个子级让步→包
Expanded/Flexible;让内容可滚→换成ListView或包SingleChildScrollView;让内容换行→换成Wrap; - 最容易忽略的一种情形:
Row里放长文本。文本在横向是无界的,一定溢出——正解是把Text包进Expanded(这样它拿到有界约束就会自己换行),而不是去调字号; mainAxisSize: MainAxisSize.min让 Row/Column 包裹内容而不是占满主轴——弹窗、按钮内部的图标+文字组合几乎都要它。
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [Text('左'), Icon(Icons.star), Text('右')],
)mainAxisSize: MainAxisSize.min 让 Row/Column 只包裹内容而不占满主轴——对话框按钮排、行内标签组常用。主轴上多出来的空间怎么分,由这三个 widget 说了算。
主轴剩余空间怎么分
Expanded占满剩余空间;多个 Expanded 按flex比例分配。Flexible允许占用剩余空间但不强制占满(fit: FlexFit.loose)。Spacer是弹性空白,等价于不带 child 的 Expanded。- 只能放在 Row/Column/Flex 内部,否则报错。
Expanded 与 Flexible 差在一个字段
Expanded ≡ Flexible(fit: FlexFit.tight, child: …) 必须占满分到的份额 Flexible ≡ Flexible(fit: FlexFit.loose, child: …) 最多占这么多,可以更小
- 差别只有在子级本身比分到的空间小时才看得出来:
Expanded会把子级拉大到占满,Flexible让它保持自己的尺寸; - 所以「按钮组要等宽」用
Expanded,「文字尽量占但别拉伸」用Flexible; flex:是比例不是像素:两个Expanded(flex: 2)和Expanded(flex: 1)按 2:1 分剩余空间——是剩余,不是总宽,这一点很容易算错;Spacer()就是Expanded(child: SizedBox()),用来把两侧的东西推开,比MainAxisAlignment.spaceBetween更灵活(可以只在某两项之间推)。
Row(children: const [
Expanded(flex: 2, child: ColoredBox(color: Colors.red)),
Expanded(flex: 1, child: ColoredBox(color: Colors.blue)),
]) // 红:蓝 = 2:1Expanded = Flexible(fit: FlexFit.tight)。要「必须占满」用 Expanded,要「最多这么大、内容小就收缩」用 Flexible。需要重叠与像素级定位时,Stack 就是布局系统里的 absolute。
布局系统里的 absolute
Stack让子级互相重叠,后面的盖在前面。Positioned用 top/left/right/bottom 精确定位(类似 CSS absolute)。- 未 Positioned 的子级按
alignment对齐。 - 用
Positioned.fill让子级铺满;常配合背景图、徽标、悬浮按钮。
Stack 的尺寸由谁决定
- Stack 的大小只由「没有 Positioned 的那些子级」撑起来——被
Positioned包住的子级完全不参与; - 于是最经典的一个坑:所有子级都
Positioned了,Stack 会缩成约束允许的最小尺寸,看起来「什么都没显示」。解法是留一个不带 Positioned 的子级当底板,或给 Stack 一个明确尺寸; Positioned只能直接放在Stack里,隔一层都不行——中间插了个Padding就会报错;Positioned.fill等价于四边都设 0,用来铺满;alignment管的是那些没 Positioned 的子级怎么对齐。
Stack(children: [
const FlutterLogo(size: 120),
Positioned(
right: 0, top: 0,
child: Container(width: 14, height: 14,
decoration: const BoxDecoration(
color: Colors.red, shape: BoxShape.circle)),
),
])clipBehavior: Clip.hardEdge,把角标定位到边界外会被裁掉,需要露出时设 Clip.none。Stack 自身的尺寸只由未定位子级决定。Positioned(left: 10, top: 20) 包 30×30 子级,最终矩形正是 (10, 20, 40, 50)——坐标系原点在 Stack 左上角。想改子级能拿到的约束,就用这组「约束改写器」。
改写传给子级的约束
ConstrainedBox施加最小/最大尺寸约束。AspectRatio维持宽高比(如 16:9 视频框)。FractionallySizedBox按父级尺寸的百分比设定大小。IntrinsicHeight/Width按内容固有尺寸对齐(开销较大,慎用)。
ConstrainedBox 为什么经常「不生效」
- 它做的是收窄父级传来的约束,而不是覆盖。如果父级给的是紧约束(min == max),收窄之后还是那个值——你写的 maxWidth 一点作用都没有;
- 这就是「约束模型」那一卡的直接推论:子级永远只能在父级给的范围内活动。要真正突破,得先用
Center/Align/UnconstrainedBox把紧约束松开; IntrinsicHeight/IntrinsicWidth是另一类:它们要先额外问一遍子级的固有尺寸,等于多跑一趟布局,官方文档明确标注开销较大——列表项里逐个用它是性能杀手,能用固定尺寸或Expanded就别用它。
AspectRatio(
aspectRatio: 16 / 9,
child: ColoredBox(color: Colors.black),
)AspectRatio(16/9) 在宽 320 下得 320×180,FractionallySizedBox(0.5, 0.25) 在 800×600 里得 400×150。滚动容器给子级的主轴空间是无限的——这一句解释了本卡和下一卡的所有报错。
无限主轴与懒加载
SingleChildScrollView让任意内容可滚动(适合短内容)。ListView(children: [...])一次性构建全部——仅用于少量固定项。- 长列表必须用
ListView.builder:按需懒构建可见项,省内存。 ListView.separated自动加分隔线;itemCount控制数量。
为什么 ListView 里塞 Column 会炸
- 滚动容器给子级的主轴约束是无限的(「你想多高都行,超出的部分我来滚」);
- 于是在
ListView里放Column再放Expanded,就是要求「在无限高度里占满剩余」——无解,直接报错; - 同理
ListView里嵌ListView也会炸。修法有三:给内层shrinkWrap: true+physics: NeverScrollableScrollPhysics()(简单但放弃了懒加载,长列表不能用)、改用CustomScrollView+ 多个 Sliver(正解,见下面的 Sliver 卡)、或干脆把数据拍平成一个列表。
三个构造函数的分界线
| 写法 | 什么时候构建子级 | 用在哪 |
|---|---|---|
ListView(children: [...]) | 一次性全建 | 项数少且固定(十几项以内) |
ListView.builder | 只建可见的(外加少量缓存) | 长列表、数据来自网络的列表——默认选它 |
ListView.separated | 同 builder,另加分隔线 | 需要分割线的列表 |
- 判据很简单:项数不由你写死,就用 builder。一千项的
ListView(children:)会在首帧构建一千个 widget 并全部布局,直接卡住; itemCount别忘了传——不传的话 builder 会被认为是无限列表,配合shrinkWrap会挂死。
ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) => ListTile(
title: Text(items[i].title),
),
)Expanded 包住即可(无异常)。shrinkWrap: true 也能过(800×40),但它会把全部子项布局出来求总高,长列表等于放弃懒加载。itemExtent(或 prototypeItem)能显著提升滚动性能——viewport 不用逐项测量就能算出总高和跳转位置。网格 = 列表 + 一条「怎么切列」的 delegate。
列表 + 一条切列规则
GridView.builder+SliverGridDelegateWithFixedCrossAxisCount固定列数。...WithMaxCrossAxisExtent按最大宽度自适应列数(响应式)。childAspectRatio控制每个格子的宽高比。- 同样优先用 builder 版本做懒加载。
两个 delegate,对应两种设计诉求
| delegate | 行为 | 什么时候用 |
|---|---|---|
…WithFixedCrossAxisCount | 列数固定,格子宽度随屏幕变 | 设计稿写死「一行三个」 |
…WithMaxCrossAxisExtent | 格子最大宽度固定,列数随屏幕变 | 响应式:手机两列、平板五列自动来 |
- 做多端适配时后者几乎总是更好——不必自己写断点,宽屏自然多排几列;
childAspectRatio是宽÷高,容易记反;格子里内容溢出时先怀疑它。要按内容高度自适应,固定网格做不到,得上SliverGrid的其他 delegate 或第三方瀑布流;- 和 ListView 一样,优先
.builder。
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8),
itemCount: 20,
itemBuilder: (c, i) => Card(child: Center(child: Text('$i'))),
)childAspectRatio 是「宽/高」,且宽是按列分完后的实际宽——格子高度由比例算死,内容(如换行文字)不会把格子撑高,网格里的溢出黄条多半源于此。要不等高的瀑布流得用第三方包(如 flutter_staggered_grid_view)。SliverGridDelegateWithMaxCrossAxisExtent(给每格最大宽度、自动算列数),天然响应式,比自己按屏宽算列数省事。当一页的滚动由「折叠头 + 列表 + 网格」这类异质段落拼成时,就到了 Sliver 的地盘。
把一屏滚动拆成几段
- Sliver 是「可滚动区域的一段」,由
CustomScrollView把多段组合起来。 SliverAppBar做可折叠/吸顶的标题栏(floating/pinned/snap)。SliverList/SliverGrid/SliverToBoxAdapter拼出复杂滚动页。- 当一个页面需要「头图折叠 + 列表 + 网格」混排时,就该上 Sliver。
什么时候必须上 Sliver
- 判据:同一个滚动视图里有异质的段落——头图会折叠、中间是列表、下面接一个网格。用嵌套 ListView 实现这种页面,要么报错要么丢掉懒加载;
CustomScrollView提供一个滚动位置,把若干 Sliver 拼成一条完整的滚动内容——每段各自懒加载,性能与单个 ListView 一致;- 常用几件:
SliverAppBar(floating上滑即现 /pinned吸顶 /snap吸附)、SliverList、SliverGrid、SliverToBoxAdapter(把一个普通 widget 塞进 Sliver 世界); - 报「A RenderViewport expected a child of type RenderSliver」就是把普通 widget 直接放进了
CustomScrollView——包一层SliverToBoxAdapter即可。
CustomScrollView(slivers: [
const SliverAppBar(expandedHeight: 180, pinned: true,
flexibleSpace: FlexibleSpaceBar(title: Text('标题'))),
SliverList.builder(itemCount: 30,
itemBuilder: (c, i) => ListTile(title: Text('行 $i'))),
])slivers: 列表里只能放 sliver 协议的 widget——普通 box widget(Container、Text)必须用 SliverToBoxAdapter 包一层,直接塞会报 RenderObject 类型不匹配;反过来 sliver 也不能放进普通布局。SliverList.builder(当前 SDK 可用)比老写法 SliverChildBuilderDelegate 简洁得多。布局不只服从父级约束,还要感知设备:刘海、状态栏、手势条与屏幕尺寸。
感知设备与可用空间
SafeArea自动避开刘海、状态栏、底部手势条。MediaQuery.of(context)获取屏幕尺寸、方向、文字缩放、亮度等。LayoutBuilder拿到父级约束,按可用宽度切换布局(响应式核心)。- Flutter 较新版本可用
MediaQuery.sizeOf(context)精准订阅单个属性,减少重建。
三者的作用域完全不同
| 回答什么问题 | 作用范围 | |
|---|---|---|
SafeArea | 哪些区域被刘海/状态栏/手势条占了 | 包住的那棵子树 |
MediaQuery.of | 整个窗口多大、什么方向、字体缩放多少 | 全局(不是父级给的空间) |
LayoutBuilder | 我的父级给了我多少空间 | 当前位置 |
- 最常见的误用是拿
MediaQuery的宽度当自己可用的宽度——侧边栏里、对话框里、分屏时,这两个数字完全不同。做响应式布局要用LayoutBuilder; MediaQuery.of(context)会订阅整个 MediaQueryData,键盘一弹、字号一改就重建;新版本用MediaQuery.sizeOf(context)只订阅需要的那一项,能显著减少无谓重建;SafeArea别一律套在最外层:全屏背景图要顶到刘海下面,只有内容区才需要避让——正确做法是把它包在内容上,而不是整页上。
LayoutBuilder(builder: (context, constraints) {
return constraints.maxWidth > 600
? const WideLayout()
: const NarrowLayout();
})MediaQuery.of(context).size 会让 widget 在任何媒体属性变化(键盘弹出、旋转、亮度切换)时都重建;MediaQuery.sizeOf(context)(当前 SDK 可用)只订阅尺寸一项,优先用后者。响应式管尺寸变化,自适应管平台与输入方式——两件事分开设计。
尺寸与平台是两件事
- 响应式:随尺寸连续调整(断点切换列数、间距)。
- 自适应:随平台/输入方式调整(鼠标 vs 触摸、Material vs Cupertino)。
- 工具:
LayoutBuilder、MediaQuery、Flex、断点常量、Wrap(自动换行)。 - 桌面/Web 还需考虑:键盘、悬停、可调窗口、更大信息密度。
两条轴,别混着设计
- 响应式(responsive)= 随尺寸连续变化:断点切列数、调间距、侧边栏收起来。工具是
LayoutBuilder+ 一组断点常量; - 自适应(adaptive)= 随平台与输入方式变化:鼠标要 hover 反馈、触摸要更大的点击区、桌面有右键菜单与键盘快捷键、iOS 用 Cupertino 观感;
- 混着做的典型错误是「按宽度猜平台」——用窗口宽度判断「这是桌面」,然后给了鼠标交互;结果平板横屏、手机分屏、桌面窄窗口全部误判。要判平台用
Theme.of(context).platform或defaultTargetPlatform,要判尺寸用约束; - 桌面/Web 还有一批容易漏的:窗口可以被拖到很窄(布局不能假设下限)、需要键盘导航与焦点管理、信息密度期望更高(照搬手机布局会显得空旷)。
Wrap(spacing: 8, runSpacing: 8, children: [
for (final tag in tags) Chip(label: Text(tag)),
]) // 空间不够自动换行Platform.isAndroid 区分「手机还是桌面」是双重错误:dart:io 的 Platform 在 Web 上直接抛错(要先判 kIsWeb),而且平板也是 Android——自适应应该看窗口尺寸与输入方式,而不是操作系统。常用 UI 组件
Material 3、Scaffold、文本、按钮、表单、反馈与主题。
Flutter 内置 Material 与 Cupertino 两套完整设计语言,绝大多数应用直接用 Material 3 即可。
两套设计语言
material.dart提供 Android/通用风格;cupertino.dart提供 iOS 风格。- Flutter 默认推 Material 3(Material You),通过
useMaterial3: true(新版默认开启)。 - 多数 App 全平台用 Material 即可;追求 iOS 原生质感才混用 Cupertino。
MaterialApp/CupertinoApp是各自的根 widget。
「混用」比想象中麻烦
- 常见诉求是「iOS 上用 Cupertino、Android 上用 Material」。真做起来要处理三件事:两套 widget、两套主题体系、两套导航转场——工作量接近写两遍 UI;
- 更现实的做法是全平台统一 Material,只在少数几个平台习惯差异大的地方按平台分叉:返回手势、日期/时间选择器、对话框按钮顺序、滚动回弹;
- Flutter 其实已经替你做了一部分:
MaterialPageRoute在 iOS 上自动用侧滑返回的转场、Switch.adaptive/CircularProgressIndicator.adaptive这类.adaptive构造会按平台换观感; - 判据:你的用户会不会拿它跟系统原生应用比。工具类、企业内部应用统一 Material 完全没问题;面向 iOS 大众市场的消费级产品才值得为 Cupertino 付这份成本。
// 同一个开关的两套写法
Switch(value: on, onChanged: toggle); // Material
CupertinoSwitch(value: on, onChanged: toggle); // iOS 质感
// 官方自适应工厂:iOS/macOS 上自动渲染成 Cupertino 样式
Switch.adaptive(value: on, onChanged: toggle);
// 或手动按平台分支
import 'dart:io' show Platform;
final button = Platform.isIOS
? CupertinoButton(onPressed: submit, child: const Text('确定'))
: FilledButton(onPressed: submit, child: const Text('确定'));dart:io 的 Platform 在 Web 上不可用,直接访问会抛异常;跨端做平台分支应改用 defaultTargetPlatform 或 Theme.of(context).platform。.adaptive 工厂(如 Switch.adaptive,可用),比手写平台分支省事。Scaffold 把标题栏、主体、抽屉、悬浮按钮等页面槽位一次配齐,是 Material 页面的标准骨架。
页面骨架的槽位
Scaffold提供标准页面结构,是大多数页面的容器。- 常用槽位:
appBar、body、floatingActionButton、drawer、bottomNavigationBar。 AppBar含title、actions、leading,自动适配主题色。Scaffold.of(context)可打开 Drawer;SnackBar 走ScaffoldMessenger(见本章「反馈」卡)。
SnackBar 为什么不能用 Scaffold.of
- 老教程写
Scaffold.of(context).showSnackBar(...),现在会告诉你该用ScaffoldMessenger。原因值得知道:Scaffold.of找的是「当前这个 Scaffold」,页面一 pop,SnackBar 跟着消失; ScaffoldMessenger挂在更上层(通常在MaterialApp之下),SnackBar 因此能跨页面存活——「保存成功」的提示在你返回上一页之后仍然看得见;- 另一个高频报错
Scaffold.of() called with a context that does not contain a Scaffold:你在创建 Scaffold 的那个 build 里取它——同一层取不到(05 章 BuildContext 卡讲的方向问题),包一层Builder即可。
Scaffold(
appBar: AppBar(title: const Text('主页'),
actions: const [Icon(Icons.search)]),
body: const Center(child: Text('内容')),
floatingActionButton: FloatingActionButton(
onPressed: () {}, child: const Icon(Icons.add)),
)Scaffold.of(context) 会报「Scaffold.of() called with a context that does not contain a Scaffold」——用 Builder 拿一个位于 Scaffold 之下的新 context 再调。drawer 或位于可返回路由时,AppBar 的 leading 会自动放上抽屉图标或返回箭头,无需手写。Text 负责单一样式文本,Text.rich 负责一段内多样式,排版尽量取自主题。
单样式与多样式
Text显示文字;style: TextStyle(...)控制字号/字重/颜色/行高。- 用主题排版:
Theme.of(context).textTheme.titleLarge保证全局一致。 RichText/Text.rich+TextSpan实现一段内多样式。maxLines+overflow: TextOverflow.ellipsis处理超长文本。
别硬编码字号:走 textTheme
Theme.of(context).textTheme.titleLarge这类取法有三个好处:全局统一、跟随主题亮暗自动换色、跟随系统字体缩放;- 最后一条是无障碍的硬要求——用户在系统里调大字体后,硬编码
fontSize: 14的文字纹丝不动,而周围跟随主题的部分会变大,界面直接错位; - 要在主题基础上微调用
copyWith:textTheme.bodyMedium?.copyWith(color: …),而不是从头 new 一个 TextStyle; - 超长文本一定要配
maxLines+overflow: TextOverflow.ellipsis——否则在窄屏或大字号下直接溢出,就是 06 章那条黄黑警告。
Text('标题',
style: Theme.of(context).textTheme.titleLarge),
Text.rich(TextSpan(children: [
const TextSpan(text: '普通 '),
TextSpan(text: '高亮',
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold)),
]))maxLines + overflow: TextOverflow.ellipsis 不够:文本必须先有有界宽度,所以放在 Row 里要裹 Expanded/Flexible,否则先撞上横向溢出(06 章那张卡讲了溢出本身)。titleLarge、bodyMedium 等)而不是手写数字,需要微调时在档位上 copyWith,换主题字体时全局联动。五种按钮对应不同的视觉强调层级,按操作的重要程度选用。
五级视觉强调
- Material 3 层级:
FilledButton(主操作)→ElevatedButton→OutlinedButton→TextButton。 IconButton纯图标;FloatingActionButton页面主操作。onPressed: null会让按钮变灰禁用——常用enabled ? fn : null。style: ...styleFrom(...)定制颜色、内边距、形状。
选哪一个,看这一屏里它排第几
| 按钮 | 强调级别 | 一屏里的数量 |
|---|---|---|
FilledButton | 最高,主操作 | 最多一个 |
ElevatedButton | 次高(M3 里定位有点尴尬) | 少用 |
OutlinedButton | 中,次要操作 | 一两个 |
TextButton | 低,取消/跳过 | 随意 |
IconButton | 工具栏、密集操作 | 随意 |
onPressed: null就是禁用——这是 Flutter 的统一约定,不需要额外的enabled参数。写法是onPressed: canSubmit ? _submit : null;- 由此引出一个高频 bug:把
onPressed: () {}(空函数)当禁用,按钮看起来是可点的、点了没反应,用户只会以为应用卡了; - 统一定制用主题里的
filledButtonTheme等,而不是每处写styleFrom——后者改一次要改几十处。
FilledButton(onPressed: () {}, child: const Text('确认')),
OutlinedButton(onPressed: () {}, child: const Text('取消')),
IconButton(onPressed: () {}, icon: const Icon(Icons.delete)),RaisedButton/FlatButton/OutlineButton 在 Flutter 2.0 前后弃用、3.0 正式移除,对应换成 Elevated/Text/OutlinedButton;样式也从 color: 等散参数改成了 styleFrom(...)。另外 onPressed: null 是「禁用变灰」而非「无动作」。FilledButton;次要动作 OutlinedButton;低强调(对话框里的「取消」)用 TextButton;ElevatedButton 留给需要浮起与背景区分的场景。本地资源图、网络图与内置图标字体覆盖大部分图像需求。
本地图、网络图与图标
Image.asset('a.png')用本地资源(需在 pubspec 声明 assets)。Image.network(url)加载网络图,配loadingBuilder/errorBuilder。- 生产环境用
cached_network_image做磁盘缓存。 Icon(Icons.xxx)用内置图标字体;BoxFit控制缩放裁剪方式。
Image.network 不该进生产
- 它只有内存缓存,没有磁盘缓存:应用一重启,所有图重新下载;列表滚出去再滚回来也可能重新下载;
- 它默认没有占位与错误处理:网慢时是一片空白,失败时是一个报错图标——都需要你自己写
loadingBuilder/errorBuilder; - 生产环境用
cached_network_image:磁盘缓存 + 占位 + 失败重试一次到位。这是少数几个「几乎每个项目都要装」的包; - 本地图别忘了在
pubspec.yaml的assets:下登记——没登记的资源在运行时才报错,而且改完 pubspec 必须停掉重跑(01 章热重载卡)。
Image.network(url,
fit: BoxFit.cover,
errorBuilder: (c, e, s) => const Icon(Icons.broken_image)),Image.network 一律拿到 400,别在 flutter test 里验证网络图;Image.asset 的资源必须先在 pubspec.yaml 的 assets: 列表声明,否则运行时加载失败。errorBuilder 兜底;列表里的大图设 cacheWidth/cacheHeight 按显示尺寸解码,能显著降低内存。TextField 配合控制器与装饰能完成绝大多数输入场景。
输入、控制器与焦点
TextField接收输入;controller: TextEditingController()读写文本。decoration: InputDecoration(...)配标签、提示、图标、边框。onChanged实时回调;keyboardType/obscureText(密码)按需设置。- 控制器要 dispose,否则泄漏;焦点用
FocusNode管理。
控制器与焦点都必须 dispose
late final _ctrl = TextEditingController(); late final _focus = FocusNode(); @override void dispose() { _ctrl.dispose(); // 两个都要 _focus.dispose(); super.dispose(); }
- 它们都是
ChangeNotifier,持有监听者引用;不 dispose 就是确定的内存泄漏,页面进出几十次后会明显吃内存; - 这正是 05 章那条纪律的具体形态:
initState里 new 出来的,dispose里必须有一行对应; - 另一个坑:用
controller.text = x直接改文本会把光标弹到开头——要保住光标得同时设selection,或者干脆用value =一次性给全新的TextEditingValue; - 只读文本不要用
TextField(enabled: false)(灰得看不清),用readOnly: true。
final ctrl = TextEditingController();
TextField(
controller: ctrl,
decoration: const InputDecoration(
labelText: '邮箱', prefixIcon: Icon(Icons.email)),
)TextEditingController 在 State 里创建、在 dispose() 里释放;只需要拿最终值时也可以不用控制器,直接 onChanged/onSubmitted 存变量。Form 用一个 GlobalKey 统管所有 TextFormField 的校验与保存。
GlobalKey 统管校验
Form用GlobalKey<FormState>统一管理子字段。TextFormField的validator返回错误文案或 null(通过)。_formKey.currentState!.validate()触发全表单校验。onSaved/AutovalidateMode控制保存与自动校验时机。
三种校验时机,体验差很多
autovalidateMode | 什么时候标红 | 体验 |
|---|---|---|
disabled(默认) | 只有你手动 validate() 时 | 提交后才知道错——最差 |
always | 每次重建都校验 | 一进页面全是红,很显眼 |
onUserInteraction | 用户碰过这个字段之后才校验 | 推荐 |
validator的契约是:校验通过返回null,不通过返回错误文案。返回空字符串会占位但不显示文字,容易误以为「没生效」;_formKey.currentState!.validate()返回bool,提交前必须先看它;save()触发所有onSaved;- 异步校验(查用户名是否重复)
validator做不了——它是同步的。要么在提交时单独发请求,要么把结果存进 State 再让 validator 读。
final _formKey = GlobalKey<FormState>();
Form(key: _formKey, child: TextFormField(
validator: (v) =>
(v == null || v.isEmpty) ? '必填' : null,
));
if (_formKey.currentState!.validate()) { /* 提交 */ }GlobalKey<FormState> 不要在 build 方法里创建——每次重建都生成新 key 会让表单状态丢失,应作为 State 的字段只建一次。null 即通过、返回字符串就是错误文案;提交前 validate() 一次收齐全部错误,配 AutovalidateMode.onUserInteraction 可以边输边校验。选择类控件全是受控组件:值存在你手里,控件只负责展示与回调。
受控组件
- 这些是受控组件:自身不存状态,靠
value+onChanged回写。 Switch/Checkbox布尔;Radio单选组;Slider连续值。- Material 3 新增
DropdownMenu(带搜索补全)替代旧DropdownButton。 - 在 StatefulWidget 里用 setState 更新值,或交给状态管理。
「点了没反应」几乎总是同一个原因
- 这些控件自己不存状态:显示什么完全由你传的
value决定,点击只是调一下onChanged; - 所以只写
value: _on而onChanged里忘了setState,开关就纹丝不动——它已经通知你了,是你没重建; - 而
onChanged: null是禁用(同按钮的约定),不是「什么都不做」——控件会变灰; - 这套「受控」设计的好处是界面永远是状态的函数,不会出现「控件显示的和数据不一致」;代价是每个控件都要配一段 setState,这也正是状态管理框架要解决的问题(08 章)。
bool on = false;
Switch(value: on, onChanged: (v) => setState(() => on = v)),
Slider(value: vol, onChanged: (v) => setState(() => vol = v)),Radio 的 groupValue/onChanged 自 3.32 起已弃用,新写法是在外层套 RadioGroup 统一管理选中值;旧写法能编译但有弃用警告。onChanged 里回写并 setState;给 onChanged 传 null 控件呈禁用态,这也是做只读展示的惯用手法。Card、ListTile、Chip 这些现成组件能拼出大多数列表与信息卡界面。
现成的信息组件
Card带圆角阴影的容器,承载分组内容。ListTile标准列表项:leading/title/subtitle/trailing+onTap。Chip系列(ActionChip/FilterChip/ChoiceChip)做标签与筛选。Divider/VerticalDivider分隔;ListTileTheme统一样式。
先找现成的,再考虑自己拼
ListTile一个顶一大段布局代码:leading / title / subtitle / trailing 四个槽位加上点击态、内边距、最小高度、无障碍语义全都给好了,还自动跟随主题;- 自己用
Row+Column拼出来的「列表项」通常会漏掉后三样,在大字号或读屏软件下露馅; Card在 Material 3 里默认是低阴影 + 圆角,不再是老版那种明显的浮起——想要边框感用Card.outlined;- 要统一改所有
ListTile的样式,用ListTileTheme包一层,而不是每处传参。
Card(child: ListTile(
leading: const Icon(Icons.person),
title: const Text('Charles'),
subtitle: const Text('软件工程'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
))margin: EdgeInsets.zero。三种反馈通道各有定位:模态确认用 Dialog,选项面板用 BottomSheet,轻提示用 SnackBar。
三种反馈通道
showDialog弹模态对话框(AlertDialog/SimpleDialog)。showModalBottomSheet从底部滑出面板,承载选项或表单。ScaffoldMessenger.of(context).showSnackBar(...)弹出底部轻提示。showDialog/showModalBottomSheet返回Future,可 await 用户的选择结果;showSnackBar返回的是控制器,等它关闭要用.closed。
选哪一种:看要不要打断用户
| 模态 | 返回值 | 用在 | |
|---|---|---|---|
showDialog | 是,必须响应 | await 拿到用户选择 | 删除确认、必须做的选择 |
showModalBottomSheet | 是,但可下滑关闭 | await 拿到结果 | 选项面板、表单、分享菜单 |
showSnackBar | 否,不打断 | 返回控制器,等关闭用 .closed | 「已保存」「已删除 + 撤销」 |
- 前两个返回的是
Future<T?>:用户点了叉或点了外面,拿到的是null——这个分支必须处理,忘了就是空指针; - 三者都在异步之后回来,所以用之前先判
mounted(05 章 setState 卡); - 不要用 Dialog 做「操作成功」提示——它强制用户多点一次。成功用 SnackBar,失败且需要用户处理才用 Dialog。
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('删除?'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(c, true), child: const Text('删除')),
],
),
);context.mounted,否则可能用到已销毁的 context——这是 use_build_context_synchronously 这条 lint 盯的场景。await showDialog 配 Navigator.pop(c, value) 能拿到用户选择;关闭弹层统一走 Navigator.pop(对话框自己的 context, 结果)——builder 回调给的 c 才是弹层的 context。一颗种子色生成整套 ColorScheme,亮暗两套主题交给 themeMode 切换。
一颗种子色生成整套
- Material 3 用单一种子色生成整套协调配色:
ColorScheme.fromSeed(seedColor: ...)。 ThemeData集中定义颜色、排版、组件样式;全局一处改,全局生效。MaterialApp的theme+darkTheme+themeMode支持亮暗切换。- 组件内取色用
Theme.of(context).colorScheme.primary而非硬编码。
ColorScheme.fromSeed 到底给了你什么
- 输入一个种子色,M3 的算法生成一整套语义化配色:
primary/onPrimary/primaryContainer/surface/error… 每一对「色 + on色」都保证了对比度; - 关键收益是无障碍:只要你用
colorScheme.onPrimary在colorScheme.primary上写字,对比度就是达标的——不必自己算; - 亮暗两套只需把
brightness换一下再生成一次,MaterialApp的theme+darkTheme+themeMode负责切换; - 硬编码
Colors.blue会在暗色模式下当场露馅——这是暗色模式适配里最常见、也最容易避免的一类问题。取色一律走Theme.of(context).colorScheme.*。
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
darkTheme: ThemeData.dark(useMaterial3: true),
themeMode: ThemeMode.system,
)Colors.xxx 的组件在暗色模式下不会跟着变色,是暗色适配最常见的返工点——取色一律走 Theme.of(context).colorScheme。ColorScheme.fromSeed(seedColor: 同一颗种子, brightness: Brightness.dark) 生成,两套配色出自同源,比直接 ThemeData.dark() 更协调。GestureDetector 提供裸手势识别,InkWell 在此之上加 Material 水波反馈。
裸手势与水波反馈
GestureDetector捕获 tap/doubleTap/longPress/拖拽/缩放等手势。InkWell/InkResponse在点击时显示 Material 水波纹(需在Material之上)。onTap: null让它失效;可包裹任意 widget 让其可点击。- 复杂手势冲突用
GestureArena机制仲裁;多数场景直接用现成 widget 即可。
两个高频「点不到」的原因
- 透明区域收不到点击:
GestureDetector默认只在子级绘制过的像素上响应。包一个没有背景色的Container,中间的空白点不动——加behavior: HitTestBehavior.opaque即可; - InkWell 的水波看不见:水波是画在
Material层上的,如果 InkWell 上面盖了一个带背景色的Container,波纹会被遮住。正解是把颜色交给Material(color: …),或者用Ink代替Container; - 选择:要 Material 反馈就用
InkWell(列表项、按钮化的区域),只要识别手势不要视觉反馈才用GestureDetector(拖拽、缩放、自定义交互); - 手势冲突(外层滚动 vs 内层拖拽)由 GestureArena 仲裁,一般不用管;真要干预时看
RawGestureDetector与各识别器的gestureSettings。
InkWell(
onTap: () => print('tapped'),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.all(12), child: Text('点我')),
)Material 祖先上——包在带背景色的 Container 里时水波会被盖住看不见,把颜色交给 Ink 或 Material 来画;GestureDetector 默认命中测试是 deferToChild,child 的空白区域点不中,需要时设 behavior: HitTestBehavior.opaque。状态管理
从 setState 到 Riverpod。先吃透底层机制,再选框架。
状态只服务于当前组件时,setState 就是正确答案,不需要任何框架。
够用就别升级
- 组件自己的、不需要共享的状态,直接 setState 即可——不要过度设计。
- 适用:开关、当前 tab、表单临时输入、动画状态。
- 局限:状态无法跨 widget 共享,深层传递会变成「prop 钻取」。
- 判断标准:状态只服务于当前组件 → setState 足矣。
先问三个问题再决定要不要上框架
- 这个状态有几个组件要读? 一个 →
setState,到此为止; - 它们的距离有多远? 父子或隔一层 → 状态提升 + 回调;隔了很多层或跨页面 → 才需要 InheritedWidget 之上的方案;
- 它是异步的吗? 要处理加载中/成功/失败三态、要缓存、要去重请求 → 这才是 Riverpod / Bloc 真正的战场;
- 反模式:项目第一天就搭一整套 Riverpod,然后用它管「这个对话框开没开」。作用域小的状态放进全局容器,等于把局部问题变成全局问题——调试时你得在全局状态里找一个只有一个组件关心的布尔值。
bool expanded = false;
IconButton(
onPressed: () => setState(() => expanded = !expanded),
icon: Icon(expanded ? Icons.expand_less : Icons.expand_more),
)build 里调 setState:那会立刻触发下一次 build,直接死循环。(异步回调里调用前判 mounted 的理由见 05 章。)共享状态上移到最近公共祖先,数据向下传参、事件向上回调。
props down / callback up
- 当多个组件需要同一状态:把它提升到最近公共父级。
- 父级通过构造参数把数据向下传,通过回调函数让子级向上通知。
- 这就是经典的 props down / callback up 数据流——Flutter 用回调函数把子级的事件通知给父级。
- 先用纯参数传递把数据流理清,是上任何状态框架前的必修课。
它的极限在哪:prop 钻取
- 状态提升到公共祖先之后,数据要逐层往下传。中间那些层明明不关心这个数据,却必须声明参数、原样转发——这就是 prop drilling;
- 代价不只是啰嗦:中间每一层都要跟着重建,而且加一个字段要改沿途所有构造函数;
- 判据:穿过三层以上、或中间层完全不使用,就该换 InheritedWidget(或它的封装)了;
- 但别跳过这一步——先把数据流用纯参数理清楚,再决定哪一段值得用框架抄近路。上来就用框架的项目,最后往往说不清「这个值是谁改的」。
// 父:持有状态,传值 + 传回调
Child(count: count, onInc: () => setState(() => count++));
// 子:只接收与通知,自身无状态
class Child extends StatelessWidget {
final int count; final VoidCallback onInc;
const Child({super.key, required this.count, required this.onInc});
}任意深度的子级直接拿到祖先数据并自动订阅更新,这是 of(context) 与各状态框架共同的地基。
共享状态的物理原理
InheritedWidget让子树高效访问祖先数据,无需逐层传参。Theme、MediaQuery、Navigator等都是它的封装——这是「.of(context)」的本质。- 数据变化时,只通知依赖了它的子级重建,精准高效。
- 几乎所有状态管理框架(Provider/Riverpod)底层都构建在它之上。
- 理解它,你就理解了 Flutter 共享状态的物理原理,不再是「黑魔法」。
它凭什么做到「精准通知」
- 子级调
context.dependOnInheritedWidgetOfExactType<T>()(也就是各种.of(context)内部干的事)时,框架把这个 Element 登记为该 InheritedWidget 的依赖者; - 数据变化时,框架调
updateShouldNotify判断要不要通知,然后只标记登记过的那些 Element 为脏——没读过它的子树一动不动; - 所以它是 O(依赖者数量) 而不是 O(子树大小),这就是「无需逐层传参又不会全树重建」的全部秘密;
- 知道这一层之后,两件事不再是黑魔法:①
Theme.of(context)会让当前 widget 订阅主题变化(所以在不需要订阅的地方用Theme.of(context, listen: false)式的读法更省);②所有状态管理框架都是在它之上加语法糖——Provider 几乎就是它的直接封装,Riverpod 则把「provider 的声明」搬出了 widget 树。
class CounterScope extends InheritedWidget {
const CounterScope(
{super.key, required this.count, required super.child});
final int count;
static CounterScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<CounterScope>()!;
@override // 返回 true 时,才通知依赖它的子级重建
bool updateShouldNotify(CounterScope old) => count != old.count;
}
// 子树任意深度:拿数据 + 自动订阅,这就是 .of(context) 的全部秘密
final count = CounterScope.of(context).count;updateShouldNotify 返回 false 时依赖组件完全不重建——写死 true 会让每次父级重建殃及所有依赖者;且比较发生在「换上新 widget」时,只 mutate 内部对象不换实例,谁也收不到通知。getInheritedWidgetOfExactType。一个可监听的值加一个局部重建的 builder,纯内置零依赖的最小响应式方案。
零依赖的最小方案
ValueNotifier<T>持有一个值,变更时通知监听者(.value = x)。ValueListenableBuilder仅在该值变化时局部重建对应 UI。ChangeNotifier+ListenableBuilder适合更复杂的可监听对象。- 纯 Flutter 内置、零依赖,小型应用或局部场景非常够用。
比 setState 强在哪:重建范围
setState → 重建整个 State 的 build ValueListenableBuilder → 只重建 builder 里那一小块
- 所以在一个复杂页面里,把「频繁变的那一小块」用
ValueListenableBuilder包起来,比整页setState省得多; ValueNotifier适合单个值;一个对象里有多个字段要通知就用ChangeNotifier+notifyListeners(),配ListenableBuilder;- 它们也是
ChangeNotifier,同样必须dispose; - 一个容易忽略的坑:
ValueNotifier用==判断值变没变。把List原地add之后再赋给自己,通知不会发出——必须赋一个新列表。这与「widget 不可变」是同一条纪律。
final counter = ValueNotifier<int>(0);
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (c, value, _) => Text('$value'),
);
counter.value++; // 触发上面局部重建把对象放进树里、子级按类型取用,Provider 是读懂大量存量项目的钥匙。
InheritedWidget 的友好封装
Provider把对象注入 widget 树,子级用context.watch/read取用。ChangeNotifierProvider+notifyListeners()是经典组合。- 理解 Provider 有助于看懂大量存量项目与教程。
- 新项目里,它在很大程度上被 Riverpod 取代(同作者的进化版)。
watch / read / select 别用混
| 写法 | 行为 | 用在 |
|---|---|---|
context.watch<T>() | 订阅,变了就重建 | build 里 |
context.read<T>() | 只读一次,不订阅 | 回调里(onPressed 等) |
context.select<T, R>() | 只订阅对象里的某个字段 | 对象大而你只关心一个字段 |
- 两个方向都会出错:在
build里用read→ 数据变了界面不更新;在回调里用watch→ 直接报错(回调不在 build 阶段,没法登记依赖); select是最常被忘记的那个,也是最能省重建的:一个有二十个字段的 model,你只显示其中一个,watch会让你跟着另外十九个一起重建;- 新项目建议直接看 Riverpod,但存量项目与大量教程都是 Provider——这一卡的价值主要在读懂它们。
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const App(),
);
final cart = context.watch<CartModel>(); // 订阅变化context.watch<T>() 在 build 里订阅、context.read<T>() 在回调里一次性读、context.select 只订阅字段切片以减少重建。把 provider 从 widget 树解耦成全局声明,编译期检查、不依赖 BuildContext。
把 provider 搬出 widget 树
- Riverpod 不依赖 BuildContext,解决了 Provider 的若干痛点,是当前社区主流之一。
- 用
@riverpod注解 + 代码生成声明 provider;ref.watch订阅、ref.read一次性读。 Notifier/AsyncNotifier管理同步/异步状态,天然适配加载/错误态。- 支持自动依赖追踪、缓存、销毁(autoDispose),可组合 provider。
- 但:它解决的是「跨组件共享 + 异步 + 可测试」,不是「我有个开关要切」。
它相对 Provider 解决了什么
| Provider 的痛点 | Riverpod 的做法 |
|---|---|
取不到就运行时抛 ProviderNotFoundException | provider 是全局声明的顶层变量,编译期就知道存不存在 |
依赖 BuildContext,widget 外拿不到 | 用 ref,不需要 context,测试里可直接读 |
| 同一类型只能有一个 | 按变量区分,同类型可以有任意多个 |
| 异步态要自己拼 | AsyncValue 内建加载/数据/错误三态 |
AsyncValue那一条在 Dart 3 下特别顺手:它本身就是个封闭的三态联合,配switch模式匹配渲染 UI,漏一个状态编译器会报错(03 章 sealed 卡);ref.watch订阅、ref.read一次性读——与 Provider 的 watch/read 是同一条纪律,用混的后果也一样;autoDispose让 provider 在没人用时自动销毁,避免「切走了还在跑」;@riverpod注解 + 代码生成能省掉一大半样板,但也把build_runner拉进了项目(03 章);- 它解决的是「跨组件共享 + 异步 + 可测试」。只是「我有个开关要切」的话,
setState更合适。
@riverpod
class Counter extends _$Counter {
@override int build() => 0;
void inc() => state++;
}
// UI 中
final count = ref.watch(counterProvider);
ref.read(counterProvider.notifier).inc();ProviderScope,任何 ref 取值都会直接报错;注解写法还依赖 build_runner 跑代码生成,忘了跑会提示 _$Counter 不存在。事件进、状态出的单向数据流,用样板代码换可预测性与可测试性。
事件进,状态出
Cubit暴露方法直接发新状态;Bloc用「事件 → 状态」更严格的单向流。- 强调可预测性、可测试性、清晰的状态转移——大型/团队项目常见。
- 配合 sealed class 建模状态,穷尽 switch 渲染 UI,非常契合 Dart 3。
- 学习曲线比 Riverpod 陡,样板更多;按团队规范与项目规模选择。
用样板换什么
- 换来可追溯:每一次状态变化都由一个具名事件触发,配
BlocObserver可以把「什么事件导致了什么状态」全部打进日志——线上问题复盘时这是实打实的价值; - 换来可测试:Bloc 是纯粹的「事件流 → 状态流」,不碰 widget、不碰 context,用
bloc_test断言「给这串事件,应得这串状态」,不需要跑 UI; - 换来强约定:十个人的团队里,状态怎么改只有一条路——这在大项目里比灵活性更值钱;
- 代价是样板:一个功能要写事件类、状态类、Bloc 三份。Cubit 是简化版(直接暴露方法、省掉事件类),大多数场景 Cubit 就够;
- 与 Dart 3 契合得很好:状态用
sealed class建模,UI 用switch表达式穷尽渲染——新增一个状态,所有没处理它的地方直接编译错。
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void inc() => emit(state + 1);
}
BlocBuilder<CounterCubit, int>(
builder: (c, count) => Text('$count'));看状态的作用域、是否异步、团队约定三个维度,从最简单的方案开始按需升级。
三个维度,从简单开始
- 局部、临时:setState / ValueNotifier。
- 小范围共享:状态提升 + 回调,或 InheritedWidget。
- 跨页面 + 异步 + 可测试:Riverpod(社区现代默认之一)。
- 大型、强约定、团队:Bloc。
- 原则:从简单开始,被真实复杂度推着升级,而不是预先套最重的方案。
一张表对上号
| 状态的样子 | 方案 |
|---|---|
| 只服务当前组件(开关、当前 tab) | setState |
| 当前组件里某一小块频繁变 | ValueNotifier + ValueListenableBuilder |
| 父子或隔一层共享 | 状态提升 + 回调 |
| 穿透多层、跨页面 | Riverpod(或存量项目的 Provider) |
| 再加上异步三态、缓存、去重 | Riverpod(AsyncNotifier) |
| 大团队、强约定、要可追溯 | Bloc |
- 原则:被真实复杂度推着升级,而不是预先套最重的方案。一个方案能撑到痛为止,痛点会告诉你下一步该换什么;
- 反过来也要警惕另一个极端:整个应用只用
setState+ 全局单例——能跑,但没人说得清某个值是谁改的,测试无从下手; - 混用是正常的:全局用 Riverpod、组件内部用
setState是绝大多数成熟项目的实际形态,不必强求统一。
// 同一个计数器,三个复杂度档位
// ① setState —— 局部、零依赖,先用它
onPressed: () => setState(() => _n++),
// ② ValueNotifier —— 轻量共享,仍是纯 Flutter 内置
final n = ValueNotifier(0);
n.value++; // ValueListenableBuilder 处局部重建
// ③ Riverpod —— 跨页面 + 异步 + 需要测试时才升级
@riverpod
class Counter extends _$Counter {
@override int build() => 0;
void inc() => state++;
}数据与网络
FutureBuilder、HTTP、JSON 序列化、本地存储与分层架构。
把 Future 或 Stream 的「加载中/有数据/出错」三态直接映射成 UI 分支,是最轻量的异步绑定方式。
三态映射成 UI 分支
FutureBuilder监听一个 Future,按「未完成/有数据/出错」渲染不同 UI。StreamBuilder同理监听流,持续更新。- 用
snapshot.connectionState与snapshot.hasError/snapshot.data分支。 - 坑:别在 build 里直接调接口创建 Future(每次重建都重新请求)——先在 initState/状态层创建好。
那个「每次重建都重新请求」的坑,机制在这
// 错:build 每跑一次就造一个新 Future Widget build(ctx) => FutureBuilder(future: api.fetch(), builder: …); // 对:Future 在 State 里创建一次 late final _f = api.fetch(); Widget build(ctx) => FutureBuilder(future: _f, builder: …);
- 原因在 04 章讲过:Dart 的 Future 是「已经在跑的任务」,写下
api.fetch()的那一刻请求就发出去了。放在build里,每次重建就是一次新请求; - 而
build会因为各种原因重跑——父级重建、主题变化、键盘弹出、屏幕旋转。症状是「接口被调了十几次」,很多人会误以为是状态管理的问题; - 这也是 05 章「
build必须纯粹」那条纪律的最典型违反案例; - 真正需要「参数变了就重新请求」时,用
didUpdateWidget判断参数变化后重建 Future,或者直接用 Riverpod 的FutureProvider.family(它内建了按参数缓存)。
三态一个都不能省
snapshot.hasError必须处理——漏掉它的表现是「转圈转到天荒地老」,因为出错时hasData是 false,你的代码就一直显示加载中;connectionState有四个值,日常只需分waiting与其余;但StreamBuilder要额外注意active(流还在推、已有数据);- 顺序要对:先判 error,再判 waiting,最后才用 data——反过来写会在出错时先落进加载分支;
- 页面稍微复杂一点,就该把这三态搬到状态层用
AsyncValue(08 章)统一处理,而不是每个FutureBuilder里抄一遍分支。
FutureBuilder<User>(
future: _userFuture, // 提前创建好
builder: (c, snap) {
if (snap.connectionState != ConnectionState.done) {
return const CircularProgressIndicator();
}
if (snap.hasError) return Text('出错: ${snap.error}');
return Text(snap.data!.name);
},
)connectionState == done 不代表成功——请求失败同样是 done,跳过 hasError 直接 snap.data! 会在出错时抛空断言异常,把一次网络错误变成崩溃。FutureBuilder,持续更新的数据(WebSocket、数据库 watch())用 StreamBuilder;分支按「先查 hasError、再查加载中、最后取 data」的顺序写最不容易漏。网络层从 http 包起步,规模上来后换 dio 补上拦截器与统一错误处理。
从 http 到 dio
http包做基础 GET/POST,返回Response,手动解析response.body。dio提供拦截器、超时、取消、上传下载、统一错误处理——生产首选。- 网络调用务必 try/catch,并区分超时、4xx、5xx、无网络。
- 把网络逻辑收进 Repository 层,UI 不直接碰 HTTP。
什么时候该从 http 换到 dio
| 需求 | http | dio |
|---|---|---|
| 发个 GET/POST | 够用 | 够用 |
| 统一加 token / 统一处理 401 | 自己包一层 | 拦截器 |
| 超时、重试 | 手写 | 内建 |
| 取消请求 | 做不到 | CancelToken |
| 上传下载进度 | 麻烦 | 内建 |
- 「取消」那一行最值钱:04 章验证过
.timeout不取消底层操作——用户切走页面时,只有真正能取消的客户端才会释放连接、停止回调; - 无论用哪个,网络调用一律 try/catch,并且区分开超时、4xx、5xx、无网络——把它们混成一句「网络错误」,用户和你自己都无法判断下一步该干什么;
- 网络逻辑收进 Repository 层(本章最后一卡),UI 里不应该出现任何 HTTP 相关的类型。
final res = await http.get(Uri.parse('https://api.x.com/users/1'));
if (res.statusCode == 200) {
final user = User.fromJson(jsonDecode(res.body));
}http 包在 404/500 时不会抛异常,只是照常返回 Response,必须自己判断 statusCode;忘写 await 则请求照发,但你既拿不到结果也捕不到异常。http 包就够;要拦截器、全局超时、请求取消、上传进度时再换 dio,别一开始就背上重依赖。dart:convert 内置 jsonDecode/jsonEncode,但对象映射要自己写——Dart 没有运行时反射可用。
jsonDecode 之后靠自己
jsonDecode把字符串转成Map<String, dynamic>;jsonEncode反向。- 在模型类里写
fromJson(Map)工厂构造与toJson()方法。 - 用 Dart 3 的模式匹配可更安全地校验 + 解构 JSON。
- 小项目、字段少时,手写最直接、零依赖、易调试。
为什么 Dart 不能像 Java 那样自动映射
- Dart 没有可用的运行时反射:
dart:mirrors在 Flutter 里被禁用,因为 AOT 编译要做 tree shaking——能反射就意味着任何类都可能被用到,无法裁剪; - 这是 12 章「AOT 让产物只有 6.36 MB」那件事的代价的另一面:体积与启动速度换掉了反射;
- 所以映射只有两条路:手写(本卡)或代码生成(下一卡)。没有第三条;
- 手写时用 Dart 3 的模式匹配最稳(03 章 if-case 卡):一个 Map 模式同时完成结构校验、类型校验与解构,比一串
as String安全得多——后者遇到 null 或类型不符直接崩在解析处,而且报错不告诉你是哪个字段。
class User {
final String name; final int age;
User({required this.name, required this.age});
factory User.fromJson(Map<String, dynamic> j) =>
User(name: j['name'] as String, age: j['age'] as int);
Map<String, dynamic> toJson() => {'name': name, 'age': age};
}as List<String> 会在运行时抛「type 'List<dynamic>' is not a subtype of type 'List<String>' in type cast」;没写 toJson() 的对象传给 jsonEncode 则抛 JsonUnsupportedObjectError。List<dynamic>,转具体类型用 .cast<String>() 或 List<String>.from(...);数字要留意 1 解码成 int、1.5 解码成 double,两者都可能出现的字段声明为 num。模型一多,手写映射的出错率很快超过配置生成器的成本,交给 json_serializable。
模型一多就交给生成器
- 字段多、模型多时,手写易错——用
json_serializable自动生成。 - 给类加
@JsonSerializable(),运行dart run build_runner build生成.g.dart。 freezed还能同时生成不可变类 + copyWith + ==(但 Dart 3 已覆盖部分场景)。- 生成物
.g.dart用part挂在源文件上,改字段后需重跑生成器。
成本与收益的交叉点
- 收益:字段改名、增删时,生成器保证
fromJson/toJson同步更新——手写时这里是最高发的 bug(改了字段忘了改映射,编译不报错,运行时静默丢数据); - 成本:多一个
build_runner步骤、多一批.g.dart文件、全量构建变慢、CI 里要多一步生成; - 交叉点大概在「模型超过五六个、或字段超过十个」。一个只有三个字段的响应体,手写更快也更好读;
- 改完源文件必须重跑生成器,否则用的还是旧的
.g.dart——开发时挂build_runner watch可以免掉这一步; - 生成物进不进版本库看团队约定:进则拉下来就能编、CI 更快;不进则版本库干净、但每个人和 CI 都要先跑一次生成。
@JsonSerializable()
class User {
final String name; final int age;
User({required this.name, required this.age});
factory User.fromJson(Map<String, dynamic> j) => _$UserFromJson(j);
Map<String, dynamic> toJson() => _$UserToJson(this);
}.g.dart 还是旧的,轻则编译报错,重则序列化悄悄丢字段。开发期挂一个 watch 就能免掉(12 章)。@JsonKey(name: 'user_name') 映射;模型嵌套时给外层加 @JsonSerializable(explicitToJson: true),否则 toJson() 返回的 Map 里嵌的是子对象本身而非它的 Map。shared_preferences 统一封装各平台的键值存储,适合存主题、开关这类零散偏好。
键值偏好
shared_preferences存小型键值(主题、token、开关、上次选择)。- 异步 API:
await SharedPreferences.getInstance()后读写。 - 不要用它存大量数据或结构化数据——那是数据库的活。
- 敏感信息(token)应用
flutter_secure_storage加密存储。
三条边界,越界就该换方案
- 数据量:它在各平台底层是
SharedPreferences/NSUserDefaults/文件,整个存储会被一次性读进内存——塞几千条记录会拖慢启动; - 结构化查询:它只有 key→(bool/int/double/String/List<String>)。把对象
jsonEncode成字符串塞进去能跑,但没法查询、没法部分更新——这就是该上数据库的信号; - 敏感信息:它不加密。token、密码、密钥要用
flutter_secure_storage(底层走 Keychain / Keystore); - API 是异步的(
await SharedPreferences.getInstance()),所以要在启动时预热,否则第一次读会让界面闪一下默认值。
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('darkMode', true);
final dark = prefs.getBool('darkMode') ?? false;flutter_secure_storage;写入是异步的,关键数据要 await setter 完成再离开页面。getInstance() 取一次实例存进单例或 Provider,之后的 get 都是同步读缓存;只支持 bool/int/double/String/List<String> 五种类型,存对象要先 jsonEncode 成字符串。结构化数据交给数据库:SQL 系(sqflite/drift)与对象存储系(Hive/Isar)各有适用面。
SQL 系与对象系
sqflite:原生 SQLite,写 SQL,控制力强但样板多。drift:基于 SQLite 的类型安全 ORM(代码生成 + 响应式查询)。Isar/Hive:NoSQL 风格、高性能、API 简洁,适合对象存储。- 维护状态需留意:
Isar长期停更、社区自行维护分叉,新项目更稳妥的选择是drift或维护活跃的方案。 - 选型看:是否需要复杂查询/关系(→drift/sqflite)还是简单快存(→Hive/Isar)。
四个候选,两条路线
| 方案 | 路线 | 特点 | 代价 |
|---|---|---|---|
sqflite | SQL | 原生 SQLite,控制力最强 | 手写 SQL 与映射,样板多 |
drift | SQL | 类型安全 ORM + 响应式查询 | 代码生成 |
Hive | 对象 | API 极简、快 | 复杂查询与关系表达不了 |
Isar | 对象 | 性能好、查询能力强 | 长期停更,社区分叉维护 |
- 判据是「要不要复杂查询与关系」:要 → drift(新项目里它基本是默认答案);不要、只是按 key 存取对象 → Hive;
- 选型时务必看维护状态:Isar 的情况就是活例子——性能再好,主仓库停更之后每次 Flutter 升级都可能变成你的问题。pub.dev 上先看最近一次发版时间和 issue 处理速度,再看 likes;
- 无论选哪个,都要从第一天就想好迁移(schema migration):用户手机上的旧数据不会消失,加一个字段就要考虑老库怎么升级。
// sqflite:手写 SQL,控制力最强
final db = await openDatabase('app.db', version: 1,
onCreate: (db, v) => db.execute(
'CREATE TABLE todos(id INTEGER PRIMARY KEY, title TEXT)'));
await db.insert('todos', {'title': '学 Flutter'});
final rows = await db.query('todos', where: 'id = ?', whereArgs: [1]);
// drift:类型安全 + 响应式查询(代码生成)
Stream<List<Todo>> watchAll() => select(todos).watch();
// Hive:NoSQL 对象快存,无 SQL
final box = await Hive.openBox<String>('settings');
box.put('theme', 'dark');onUpgrade、drift 的 MigrationStrategy),老用户升级后查询直接报错;开发期删 App 重装能糊弄过去,线上没有这条退路。drift:类型安全、迁移方案成熟、watch() 返回的 Stream 可直接喂给 StreamBuilder;只有要手写复杂 SQL 或追求极简依赖时才退回 sqflite。分层的核心是单向依赖:UI 只认状态层,状态层只认 Repository,底下的数据源可随时替换。
单向依赖
- Repository 封装「数据从哪来」(网络或本地),对上只暴露领域方法。
- UI → 状态层(Riverpod/Bloc)→ Repository → 数据源(API / DB),单向依赖。
- 好处:可替换数据源、可 mock 测试、UI 不关心网络细节。
- 别一上来就上完整 Clean Architecture——按项目复杂度逐步分层。
Repository 真正买到的是什么
- 可替换:「先读缓存、没有再请求网络、拿到后写回缓存」这套逻辑属于 Repository。UI 与状态层完全不知道数据从哪来,你可以随时改策略而不动上层一行;
- 可测试:状态层依赖的是 Repository 这个接口,测试时塞一个假的进去,不需要网络、不需要数据库、跑得飞快。这是分层最实在的回报;
- 可定位:网络出问题只看 data 层,界面出问题只看 presentation 层——分层的本质是把「出了问题该去哪找」变成确定的;
- 但别一上来就上完整 Clean Architecture:三个人的项目里搞四层加 UseCase,每加一个字段要改六个文件。先 UI + Repository 两层,等 domain 逻辑真的复杂到需要独立表达时再拆。
class UserRepository {
final ApiClient api; final UserDao dao;
UserRepository(this.api, this.dao);
Future<User> getUser(int id) async {
final cached = await dao.find(id);
return cached ?? await api.fetchUser(id);
}
}jsonDecode、拼 URL;数据转换与错误归一必须收在仓库内,DTO 不该泄漏到 UI 层。动画与绘制
隐式动画起步,显式动画控场,CustomPainter 自由绘制。
会 setState 就会做动画:把属性一改,补间的事交给 AnimatedXxx。
改属性,自动补间
AnimatedContainer/AnimatedOpacity/AnimatedPadding等:你改属性,它自动补间。- 只需提供
duration与(可选)curve,无需手写控制器。 AnimatedSwitcher在子 widget 切换时自动做进出动画。- 实现 80% 的日常动画需求——优先选它,简单稳妥。
为什么它能「自动」:新旧值 diff
AnimatedContainer是个StatefulWidget,它在didUpdateWidget里比较新旧属性,发现变了就自己起一个控制器从旧值补到新值;- 所以触发它的方式就是普通的
setState——「会 setState 就会做动画」不是修辞,是它的实现方式决定的; - 推论:第一次 build 不会有动画(没有「旧值」可补),需要入场动画得用
TweenAnimationBuilder或显式动画; AnimatedSwitcher是同一思路的另一形态——它比较的是子 widget 的类型与 key,所以切换两个都是Text的子级时必须给不同的 key,否则它认为没变、没有动画。这是这一卡最高发的一个坑。
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
width: expanded ? 200 : 100,
color: expanded ? Colors.blue : Colors.grey,
) // 改 expanded 即平滑过渡TweenAnimationBuilder 补位——它是「万能隐式动画」,同样只要 duration 不要控制器。隐式动画管不到的时间轴——暂停、倒放、循环、打断——都归 AnimationController。
拿回时间轴
AnimationController控制时间轴(forward/reverse/repeat),需vsync。Tween(begin, end).animate(controller)把 0~1 映射到目标范围。AnimatedBuilder/AnimatedWidget监听并局部重建动画部分。- State 需
with SingleTickerProviderStateMixin提供 vsync,并在 dispose 释放控制器。
vsync 与 dispose:两个必须做的动作
class _S extends State<X> with SingleTickerProviderStateMixin { late final _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 300)); @override void dispose() { _c.dispose(); super.dispose(); } }
vsync的作用是省电:它让控制器只在这个 widget 可见时才接收帧回调——页面被盖住或滚出屏幕时动画自动停,不白烧 CPU;- 一个控制器用
SingleTickerProviderStateMixin,多个用TickerProviderStateMixin——用错会在运行时报错提示你换; - 忘记
dispose的后果是「页面关了动画还在跑」:控制器持续请求帧回调,还持有 State 引用,是最典型的一类泄漏。这仍然是 05 章那条「initState 与 dispose 成对」的纪律; - 什么时候必须用显式:要暂停、倒放、循环、被手势打断、多段编排——隐式动画一个都做不到。
late final _c = AnimationController(
vsync: this, duration: const Duration(seconds: 1))..repeat();
AnimatedBuilder(
animation: _c,
builder: (ctx, child) => Transform.rotate(
angle: _c.value * 6.28, child: child),
child: const FlutterLogo(),
)dispose() 里先 _c.dispose() 再 super.dispose()。Interval 切时间片就能做出依次入场的 stagger 效果;单 Ticker 用 SingleTickerProviderStateMixin,多个 controller 才换 TickerProviderStateMixin。两个页面、同一个 tag,Flutter 自动补出中间的飞行轨迹。
同 tag 自动飞行
- 两个页面里给同一
tag的Hero包裹同一元素,跳转时自动「飞」过去。 - 常用于:列表缩略图 → 详情大图。
- tag 必须唯一且两端一致;包裹的内容尺寸可不同。
- 几乎零成本就能得到精致的转场质感。
tag 唯一,是「同一屏内唯一」
- 规则:同一时刻的同一个路由里,一个 tag 只能出现一次,否则运行时直接报错;
- 于是列表页最容易踩:每一项都包了 Hero 而 tag 写死成同一个字符串——正解是用业务 id(
tag: 'photo_' + item.id); - 两端的
tag必须完全相等(==),内容尺寸可以不同——Flutter 会补出中间的形变; - 飞行途中显示的是 Hero 的子树,所以子树里别放依赖
context祖先的东西(比如Theme.of取到的值在飞行层可能不同),必要时用flightShuttleBuilder自己指定飞行中显示什么。
// 列表页
Hero(tag: 'avatar-$id', child: Image.network(url)),
// 详情页(相同 tag)
Hero(tag: 'avatar-$id', child: Image.network(url)),'avatar-$id' 这类带数据 id 的动态 tag。且 Hero 只在 PageRoute 转场时触发,showDialog 弹层默认不会飞。Material:飞行途中 widget 脱离原页面的 Material 环境,Text 会出现黄色双下划线的「无 Material」样式。(真机转场体验,按文档写)显式动画的视觉端:把一个 Animation 接到透明度、位移、缩放、旋转上。
把 Animation 接到视觉属性
FadeTransition/SlideTransition/ScaleTransition/RotationTransition。- 接收一个
Animation,把它应用到对应视觉属性,比手写 AnimatedBuilder 简洁。 - 可叠加组合多种 transition 做复合动画。
Curves决定节奏:easeInOut、elasticOut、bounceOut等。
它们比 AnimatedBuilder 好在哪
FadeTransition这类组件只重建自己那一层,而且大多直接作用在 RenderObject 或图层上——不会让child重新 build;- 手写
AnimatedBuilder时如果把 child 也放进 builder 里,每一帧都会重建整棵子树;正确写法是把不变的部分传给child:参数,builder 里只包一层; Curves决定节奏:easeInOut是安全默认,elasticOut/bounceOut有弹性但容易滥用;UI 反馈类动画时长控制在 200–300 ms,超过 400 ms 用户会觉得卡;- 多个 transition 可以嵌套叠加(淡入 + 位移),共用同一个 controller,用
Interval曲线错开时间段就能做出编排效果。
class _FadeInState extends State<FadeIn>
with SingleTickerProviderStateMixin {
late final _c = AnimationController(
vsync: this, duration: const Duration(milliseconds: 600))
..forward();
late final _fade = CurvedAnimation(parent: _c, curve: Curves.easeInOut);
@override
Widget build(BuildContext context) => FadeTransition(
opacity: _fade, // 接收 Animation<double>
child: SlideTransition( // 可叠加组合成复合动画
position: Tween(begin: const Offset(0, .2), end: Offset.zero)
.animate(_fade),
child: widget.child,
),
);
@override
void dispose() { _c.dispose(); super.dispose(); }
}SlideTransition 的 position 单位不是像素,是「自身尺寸的倍数」:Offset(0, .2) 表示向下偏移自身高度的 20%,想按像素位移要用 Transform.translate 或 AnimatedBuilder 手写。Animation(显式)——读代码时看到后缀就知道谁在管时间轴。当组合 widget 表达不了你要的图形时,拿起 Canvas 自己画。
直接拿 Canvas 画
- 继承
CustomPainter,在paint(canvas, size)里用 Canvas API 绘制。 canvas.drawCircle/drawPath/drawLine,Paint控制颜色、描边、填充。shouldRepaint决定是否需要重绘,影响性能。- 用于内置 widget 表达不了的视觉:自定义图表、进度环、手写板、波纹。
shouldRepaint 写错就是持续掉帧
- 它的契约是:返回
true表示需要重绘。图省事写=> true,意味着每一帧都重绘——静态图形也在烧 GPU; - 正确写法是比较影响绘制的字段:
oldDelegate.progress != progress; - 另一半是把绘制隔离出去:给频繁重绘的 painter 包一层
RepaintBoundary,否则它会连累同一图层里的邻居一起重绘(12 章性能卡); - 性能上还有两条:
Paint对象在paint外面创建(别每帧 new);路径能复用就复用——Path的构建成本不低; - 什么时候才该用它:组合现成 widget 表达不了的图形——自定义图表、进度环、手写板、波形。能用 widget 拼出来的,别画。
class RingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final p = Paint()..color = Colors.blue
..style = PaintingStyle.stroke..strokeWidth = 8;
canvas.drawCircle(size.center(Offset.zero), 40, p);
}
@override
bool shouldRepaint(_) => false;
}shouldRepaint 一律返回 false 会导致参数变了画面不刷新,一律返回 true 又可能帧帧重绘——正确写法是对比字段:old.progress != progress。另外 CustomPaint 不给 child 也不给 size 时默认尺寸为零,「画了但看不见」多半是这个原因。paint 收到的 size 算,别写死像素——同一个 painter 放进任何布局都能自适应;画文字用 TextPainter 先 layout 再 paint。工程化与工具链
SDK、依赖、调试、测试、架构、国际化、平台交互与性能优化。
Flutter 与 Dart SDK 成对发布,渠道选择决定新特性到手的速度与稳定性。
渠道与配套版本
- 渠道:
stable(生产用)、beta、main(最新但不稳)。 - 每个 Flutter 版本捆绑对应 Dart 版本(如 Flutter 3.38 ↔ Dart 3.10)。
flutter --version查看;flutter upgrade升级;flutter channel切换。- 团队用 FVM(Flutter Version Management)锁定版本,避免「我这能跑你那不行」。
为什么团队要锁版本
- Flutter 与 Dart SDK 成对发布:每个 Flutter 版本捆绑一个确定的 Dart 版本,
flutter --version会把两个一起打印(本页语言部分以 Dart SDK 3.12.2 为基准); - 于是「我这能跑你那不行」几乎总是版本差异:新 SDK 的语言特性在旧 SDK 上编不过、lint 规则集不同、生成器版本要求不同;
- 解法是 FVM(Flutter Version Management):把版本写进项目配置,每个人和 CI 用的都是同一份 SDK——效果等同于 Node 世界的
.nvmrc; - 渠道就选
stable。beta/main 的意义是提前试新特性,代价是随时可能遇到未修的回归——不要拿产品项目去承担这个风险。
$ flutter --version # Flutter 3.x · Dart 3.x 成对捆绑
$ flutter channel # 列出 stable / beta / main
$ flutter channel stable
$ flutter upgrade # 升级当前渠道到最新
# 团队协作:FVM 按项目锁定版本
$ dart pub global activate fvm
$ fvm use 3.32.0 # 写入 .fvmrc,提交给全员共用
$ fvm flutter run # 之后的 flutter 命令都走 fvm 前缀flutter upgrade 会连 Dart 一起跳版本,可能引发依赖解析失败或行为变化;升级放在迭代初而不是交付前夜,升完先整体跑一遍测试。environment.sdk 写清 Dart 下限,再用 FVM 把 Flutter 版本钉进仓库(.fvmrc 提交给全员),CI 与本地读同一份配置才能杜绝「我这能跑你那不行」。Dart 的静态分析器不是可选装饰品——本页大量「报错」都是它给的。先学会读它的输出,后面每一章的坑你都能在按下运行键之前就看见。
三个命令,各管一件事
dart analyze—— 类型错误与 lint 一起报。它比dart run更早、更全:run 只会在执行到那一行时才炸,analyze 一次扫全项目;dart fix --dry-run—— 列出「能自动修的」lint 与条数,确认后dart fix --apply一键改完。升级 SDK 后清理过时写法特别省事;dart format—— 官方唯一格式化风格,没有配置项也就没有争论。CI 里用dart format --output=none --set-exit-if-changed .,格式不对就让流水线红。
报错怎么读:三段式(输出)
error - bad.dart:4:11 - The property 'length' can't be
unconditionally accessed because the receiver can be
'null'. Try making the access conditional (using '?.')
or adding a null check to the target ('!').
- unchecked_use_of_nullable_value- 位置
文件:行:列;说明一句话讲清违反了什么,且几乎总带一句Try …给出修法;末尾那个下划线名字是诊断码; - 诊断码才是你该拿去搜的东西——它稳定、唯一、可直接查官方 diagnostic 文档,而说明文字会随版本改措辞(本页 02 章那六条报错原文就是这么校对出来的);
- 严重度分三档:
error编译不过、warning很可能是 bug、info是风格建议。CI 想把 info 也当红灯,加--fatal-infos。
规则从哪来:analysis_options.yaml
dart create生成的模板默认写着include: package:lints/recommended.yaml;Flutter 项目则默认flutter_lints,多一批 widget 相关规则;- 想更严就换成
package:lints/core.yaml之外再手动linter: rules:加规则;想让某个目录免检,用analyzer: exclude:(生成的代码通常要排除); - 单行豁免写
// ignore: 诊断码,整文件豁免写// ignore_for_file: 诊断码——用诊断码而不是描述文字。
# 一次扫全项目,比 run 早得多
dart analyze
# error - bin/bad.dart:4:11 - The property 'length' can't be
# unconditionally accessed … - unchecked_use_of_nullable_value
# 看看有多少能自动修
dart fix --dry-run
# bin/fixme.dart
# unnecessary_new - 1 fix
dart fix --apply
# CI 里卡格式
dart format --output=none --set-exit-if-changed .dart analyze 的结果可能不一致:IDE 用的是常驻的分析服务器,改了 analysis_options.yaml 或切了 SDK 后需要重启它才会生效(VS Code 里执行 Dart: Restart Analysis Server)。以命令行的输出为准。另外 dart fix 只修「有自动修复方案」的诊断,剩下的它一句都不会提——别把 --dry-run 报的条数当成问题总数。dart analyze && dart test 放进提交前钩子或 CI 第一步。Dart 的分析器和编译器是同一套前端,所以 analyze 干净基本等于编得过——这一点和「lint 只是建议」的语言很不一样,值得依赖。pubspec.yaml 是项目清单,依赖、资源、字体与 SDK 约束都从这里声明。
项目清单
pubspec.yaml声明依赖、资源(assets)、字体、SDK 约束。flutter pub add 包名添加;dependenciesvsdev_dependencies(仅开发期)。- 版本约束
^1.2.0表示兼容的最新;pubspec.lock锁定确切版本。 - 去
pub.dev看包的「likes / pub points / 下载量」与维护状态再引入。
引入一个包之前先看四件事
- 最近发版时间——比 likes 重要得多。半年没动的包,下次 Flutter 升级时可能就是你的问题(本页 10 章 Isar 是活例子);
- issue 与 PR 的处理速度——看维护者还在不在;
- 它拉进来多少传递依赖——一个小工具包带进十几个依赖是常见的,日后版本冲突就从这里来;
- 能不能自己写——只用到一个函数的包,抄那三十行进项目往往比长期承担依赖更划算;
- 版本约束
^1.2.0表示「兼容的最新」(>=1.2.0 <2.0.0);pubspec.lock应用要提交、库不提交——这与 02 章纯 Dart 项目那条规则一致。
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
go_router: ^14.0.0
flutter:
assets:
- assets/images/pubspec.lock——^ 约束只保证主版本兼容,不提交 lock 的话 CI 与同事可能解析到不同的小版本。dart pub add collection 会自动往 dependencies 写入 collection: ^1.19.1 这样的约束行并立即解析,比手改 YAML 再 pub get 少一步也不易错;开发期工具(build_runner、lints)放 dev_dependencies。秒级看到改动是 Flutter 的招牌体验,前提是分清哪些改动 reload 吃不下。
分清哪些改动 reload 吃不下
- Hot Reload:注入改动并重建 widget 树,保留当前状态——秒级,最常用。
- Hot Restart:重启应用,丢弃所有状态,但不重装。
- 改了
main()、全局变量初始化、枚举、initState逻辑时,常需 hot restart。 - 这是 Flutter 开发体验的核心优势之一,善用能极大提速。
四类改动必须 Hot Restart
| 改了什么 | r 有效? | 为什么 |
|---|---|---|
build 里的 UI | 有效 | 重建 widget 树就能看到 |
initState 的逻辑 | 无效 | 它不会被重新调用 |
main() / 顶层变量初始值 | 无效 | 已经执行过了 |
| 枚举、类的字段增删 | 无效 | 已有实例的形状对不上 |
pubspec.yaml、原生代码 | 连 R 都不够 | 要重新构建,停掉重跑 |
- 这张表就是 01 章那条经验的完整版。「代码明明改了却没效果」时,先照表对一遍,再去怀疑别的;
- 还有一类容易忽略:热重载会保留旧状态,于是你改的初始化逻辑虽然生效了,界面上显示的还是改动前那次初始化留下的数据——看起来同样像「没生效」。
# flutter run 运行中的终端快捷键
r # Hot Reload:注入新代码并重建 widget 树,State 保留(秒级)
R # Hot Restart:重新执行 main(),所有状态清零(但不重装 App)
p # 显示布局网格(debugPaintSizeEnabled)
q # 退出
// 这些改动 hot reload 不生效,需要大写 R:
void main() => runApp(const App()); // main() 本身的改动
int total = 100; // 全局/static 初始化器:旧值仍在内存
enum Status { idle, busy } // 枚举成员增删、类签名级变化initState、全局与 static 初始化器——改这些逻辑后「看起来没生效」是旧值还在内存里,不是代码写错了。r;怀疑旧状态污染就直接 R 清零;连 R 都救不了的(原生代码、插件、pubspec 资源声明)才需要重跑 flutter run。DevTools 是浏览器里的一站式排障台:先用数据定位,再动手改代码。
先用数据定位
- Widget Inspector:可视化 widget 树、查看约束、定位布局问题。
- Performance:抓帧、找掉帧(jank)、看 build/raster 耗时。
- Memory:排查泄漏(常见于忘记 dispose 控制器/订阅)。
- CPU Profiler / Network:定位热点函数与网络请求。
- 配合
debugPrint、assert、flutter analyze形成完整排错流程。
四个面板,各回答一个问题
| 面板 | 回答 | 典型用法 |
|---|---|---|
| Widget Inspector | 「这个东西为什么这么大/在这个位置」 | 看 widget 树 + Layout Explorer 看约束 |
| Performance | 「为什么卡」 | 抓帧,看 build 慢还是 raster 慢 |
| Memory | 「为什么越用越占内存」 | 页面进出几次看实例数涨不涨 |
| Network / CPU | 「请求发了几次/热点在哪」 | 抓重复请求、找热点函数 |
- 「build 慢还是 raster 慢」是性能排查的第一个分叉:build 慢是 Dart 侧的问题(重建范围太大、build 里做重活),raster 慢是 GPU 侧的问题(图层太多、阴影/模糊/裁剪太贵)——两者的修法完全不同;
- Memory 面板最实用的一招不是看曲线,而是反复进出同一个页面,看某个类的实例数会不会一直涨——涨了就是忘了 dispose;
- 务必在 profile 模式下测性能:debug 模式跑的是 JIT 且开满断言(本章双引擎卡),数据没有参考价值。
$ flutter run # 控制台会打印 DevTools 链接,点开即用
$ dart devtools # 或独立启动,再 attach 到运行中的应用
// 代码侧配合的调试开关
import 'package:flutter/rendering.dart';
debugPaintSizeEnabled = true; // 给所有盒子描边,看清布局
debugPrint('长日志分块输出,避免被系统截断');
# 面板速查:
# Inspector 点选 widget → 看约束/尺寸 Performance 抓帧定位 jank
# Memory 对比快照查泄漏 Network 请求时序flutter run --profile 再连 DevTools。下一张卡讲的三层测试全都建在同一个包上:package:test。flutter_test 和 integration_test 只是在它之上加了 WidgetTester 和设备驱动——test / group / expect / setUp 这套 API 三层通用。先把底座讲清楚,上面那两层才好理解。
装与跑
- 它不在 SDK 里,要写进
pubspec.yaml的dev_dependencies(test: ^1.25.0);纯 Dart 项目跑dart test,Flutter 项目跑flutter test(后者会自动带上flutter_test); - 约定:测试放
test/目录,文件名必须以_test.dart结尾——不符合的文件不会被发现,且不报错; - 输出是一行滚动的计数器
00:00 +3 -1: ...:加号是通过、减号是失败、波浪号是跳过,末尾All tests passed!或Some tests failed.并列出失败用例名,失败时退出码 1; - 常用开关:
-n 名字片段只跑匹配的、-t 标签按tags:筛、-j控并发、-r换报告格式。用例上可以直接挂skip: '原因'、timeout:、tags:。
expect 的第二个参数是 matcher,不是值
- Dart 的
==对List/Map是引用比较:['a'] == ['a']为false。所以expect(结果, 期望列表)要能过,靠的不是==,而是expect把裸值自动包成equals(...)matcher——equals对集合是递归深比较; - 失败信息因此很具体,比较
['a','b']与['a','b','c']得到:Expected: ['a', 'b', 'c']/Actual: ['a', 'b']/Which: at location [2] is ['a', 'b'] which shorter than expected——直接点到第几个元素; - 常用 matcher:
equals、isA<T>()、isNull/isNotNull、contains、hasLength、closeTo(值, 容差)(浮点,closeTo(0.3, 1e-9)接住0.1 + 0.2)、throwsA(isA<StateError>()); - 断言抛错时要传函数而不是调用结果:
expect(() => avg([]), throwsA(...)),写成expect(avg([]), ...)会先把异常抛在expect外面。
异步:忘了 await 会安静地测错东西
- 用例回调写成
async后await即可。漏掉 await 时断言拿到的是Future本身——expect(slow(), isA<Future<int>>())通过,这条用例本身就是那个坑的证据:你以为在比 7,其实在比一个 Future 对象; - 不想写
async时用completionmatcher:expect(slow(), completion(equals(7)))——它会等 Future 落定再比,通过; - 测
Stream用emitsInOrder:expect(ticks(), emitsInOrder([1, 2, emitsDone])),按序匹配并检查流正常结束; - 测异步错误:
expect(Future.error(StateError('x')), throwsA(isA<StateError>()))——throwsA对 Future 同样有效,不必额外包 try/catch。
// pubspec.yaml
// dev_dependencies:
// test: ^1.25.0 ← 不在 SDK 里,要显式加
// test/split_test.dart ← 文件名必须以 _test.dart 结尾
import 'package:test/test.dart';
import 'package:demo/split.dart';
void main() {
group('split', () {
late List<String> log;
setUp(() => log = []); // 每条用例前
tearDown(() => log.clear()); // 每条用例后
test('按逗号切分', () {
expect(split('a,b,c', ','), equals(['a', 'b', 'c']));
});
test('== 是引用比较,matcher 才比内容', () {
expect(['a'] == ['a'], isFalse); // Dart 的 List == 比引用
expect(['a'], equals(['a'])); // equals 递归比内容
});
test('抛错要传函数', () {
expect(() => avg([]), throwsA(isA<StateError>()));
});
test('浮点用 closeTo', () {
expect(0.1 + 0.2, closeTo(0.3, 1e-9));
});
});
// —— 异步三种写法 ——
test('漏了 await', () {
expect(slow(), isA<Future<int>>()); // ❌ 通过了——比的是 Future
});
test('async + await', () async {
expect(await slow(), 7); // ✅
});
test('或者用 completion', () {
expect(slow(), completion(equals(7))); // ✅ 不写 async 也行
});
test('Stream', () {
expect(ticks(), emitsInOrder([1, 2, emitsDone]));
});
test('跳过', () {}, skip: '等接口定下来');
test('慢用例', () {}, tags: 'slow',
timeout: Timeout(Duration(seconds: 2)));
}
// $ dart test → 00:00 +4 -1: Some tests failed. (退出码 1)
// $ dart test -n '切分' → 只跑名字匹配的
// $ dart test -t slow → 只跑打了 slow 标签的_test.dart 结尾,整个文件一条都不会跑,而且没有任何提示。把 split_tests.dart(多了个 s)放进 test/,dart test 会正常退出、显示 All tests passed!——因为它一条都没找到。看计数器里那个 +N 对不对,比看有没有红色可靠。另一个:
expect 在异步用例里如果写在 await 之后、而用例又提前返回了,断言可能落在用例结束之后执行——表现同样是「失败没有归属到这条用例」。凡是回调里有异步,确保用例函数是 async 且每个 Future 都被 await。group 可以嵌套,setUp 按层从外到内依次执行——外层建公共环境、内层建本组特有的,比每条用例复制一遍干净。setUpAll / tearDownAll 是整组只跑一次的版本,留给「起一个测试服务器」这类重量级准备。用例名建议写成结论(
'空输入返回空列表'),因为失败汇总里 Dart 打印的正是 组名: 用例名 这一串——它应当直接说明坏了什么。金字塔原则落到 Flutter:大量单元测试打底,widget 测试验交互,少量集成测试兜底。
金字塔落到 Flutter
- 单元测试:纯 Dart 逻辑(
test包),快、多写。 - Widget 测试:
flutter_test的WidgetTester渲染并交互单个组件。 - 集成测试:
integration_test在真机/模拟器跑完整流程。 - 金字塔原则:大量单元 + 适量 widget + 少量集成。
mockito/mocktail做 mock;Repository 分层让逻辑更易测。
三层的成本与回报
| 层 | 跑一次 | 能发现 | 该写多少 |
|---|---|---|---|
单元(test) | 毫秒 | 逻辑错误 | 最多 |
Widget(flutter_test) | 百毫秒 | 渲染与交互问题 | 适量 |
集成(integration_test) | 秒~分钟,要设备 | 端到端流程断裂 | 少量关键路径 |
- Widget 测试是 Flutter 的甜点:
WidgetTester在纯 Dart 环境里渲染真实 widget、真实布局,能tap/enterText/断言,却不需要模拟器——比别的 UI 框架便宜得多; - 记住两个高频 API:
await tester.pumpAndSettle()(等动画与异步全部稳定)、find.byType/byKey/text。忘了 pump 是新手最常见的失败原因——你 tap 完就断言,界面还没重建; - 可测性来自分层:Repository 抽象让你能塞假数据源(10 章),逻辑不在 widget 里就能用最便宜的单元测试覆盖。
testWidgets('点击自增', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('1'), findsOneWidget);
});flutter test 跑在 headless 测试环境:真实 HTTP 默认被替换成返回 400 的假客户端,平台通道没有原生端响应——依赖网络或插件的逻辑要 mock,别写成依赖真实环境的 widget 测试。tester.pump() 只推进一帧,等动画与异步收尾用 pumpAndSettle();widget 构建期抛出的异常用 tester.takeException() 取出来断言。结构服务于定位与隔离:目标是「改一个功能只碰一个目录」。
改一个功能只碰一个目录
- 小项目按类型分(
widgets/、models/)即可;中大项目用 feature-first(按功能分包)。 - 典型分层:
presentation(UI+状态)/domain(实体+用例)/data(仓库+数据源)。 - 依赖方向单向向内(UI 依赖 domain,domain 不依赖 UI)。
- 别过度工程:架构是为复杂度服务的,从简单结构起步,随项目成长再分层。
按类型分 vs 按功能分
按类型(type-first) 按功能(feature-first) lib/models/ lib/features/order/ lib/widgets/ ├ data/ lib/pages/ ├ domain/ lib/services/ └ presentation/ 改一个功能要开四个目录 改一个功能只开一个目录
- 小项目(十几个文件)按类型分完全够用,一眼看得完;
- 文件数量一上来,按类型分的代价就显现了:一个功能的代码散落在四五个目录里,改动要横跨;而新人接手时也看不出「这个应用有哪些功能」;
- feature-first 的额外好处是删功能很干净——整个目录删掉即可,不会到处留下孤儿文件;
- 依赖方向单向向内(presentation → domain ← data)是分层的关键约束,不是目录名。目录分对了但 domain 里 import 了 widget,分层就白做了。
lib/
├─ main.dart # 入口:只做启动与全局装配
├─ core/ # 跨功能共享:路由、主题、工具
│ ├─ router.dart
│ └─ theme.dart
└─ features/ # feature-first:按业务功能分包
└─ cart/
├─ presentation/ # 页面、widget、状态(Notifier)
├─ domain/ # 实体与用例:纯 Dart,零 Flutter 依赖
└─ data/ # Repository 实现、API/DB 数据源screens/、widgets/ 在功能变多后互相乱引用,改一个功能要横跨多个目录;更致命的是反向依赖(data 层 import UI),一旦出现分层就名存实亡。build_runner 是各家生成器的统一执行器,json_serializable、freezed、riverpod_generator 都靠它跑。
各家生成器的统一执行器
dart run build_runner build生成一次(冲突输出 2.7 起默认自动删除)。build_runner watch监听文件变化持续生成。- 生成文件(
.g.dart/.freezed.dart)一般纳入版本控制或按团队约定。 - 代价:拖慢全量构建。按需引入,别把所有东西都交给生成器。
三条经验
--delete-conflicting-outputs已被移除:build_runner2.15 起再传只会得到一句These options have been removed and were ignored——网上教程里那条命令现在是空转,冲突输出默认就会被处理;- 开发时挂
watch:dart run build_runner watch监听文件变化持续生成,省掉「改完忘了生成」这类怪问题; - 它会显著拖慢全量构建,而且生成器越多越慢。所以 03 章那条原则值得重复:能用语言特性解决的就别上生成器——Dart 3 之后 sealed + record 已经顶掉了 freezed 的一半用途;
- 生成物进不进版本库按团队约定,但必须统一:一半人提交一半人不提交,会产生无穷无尽的无意义 diff。
// 1. 源文件:part 挂上生成物,注解标记生成目标
import 'package:json_annotation/json_annotation.dart';
part 'todo.g.dart';
@JsonSerializable()
class Todo {
final String title;
Todo(this.title);
factory Todo.fromJson(Map<String, dynamic> j) => _$TodoFromJson(j);
}
# 2. 跑生成器
$ dart run build_runner build
$ dart run build_runner watch # 监听改动持续生成part 'todo.g.dart'; 这一行,生成的代码挂不上源文件,编译时报一串 _$TodoFromJson 未定义——先检查 part 行与实际文件名是否逐字一致。dart run build_runner watch 让生成物随存盘自动更新;-d/--delete-conflicting-outputs 已成历史:build_runner 2.7 起冲突输出默认自动删除,现版本再传只会得到一句 removed 警告。官方流水线:.arb 文件存各语言文案,gen-l10n 生成类型安全的访问类。
arb + gen-l10n
- 开启
flutter_localizations与intl,在MaterialApp配localizationsDelegates。 - 用
.arb文件存各语言文案,工具生成类型安全的访问类。 - 用
AppLocalizations.of(context).xxx取文案,支持复数、占位、日期/数字本地化。 - 中文 App 也建议从一开始就走 i18n 结构,后期加语言零成本。
为什么中文应用也建议从一开始就走 i18n
- 成本几乎为零:只是把文案从散落各处的字面量集中到一个
.arb文件,取用时写AppLocalizations.of(context).xxx; - 后期加语言才是真正的零成本:加一个
app_en.arb即可,不用回头翻遍整个代码库找中文字符串——而后者的工作量往往比预想大一个数量级; - 顺带白得几样东西:复数规则、占位符、日期与数字的本地化格式(
intl提供),这些自己写很容易出错; - 另一个常被忽略的收益:文案集中之后,改一句话不需要改代码文件——非开发人员也能参与。
# l10n.yaml —— 打开官方生成流水线
arb-dir: lib/l10n
template-arb-file: app_en.arb
// lib/l10n/app_zh.arb(每种语言一个文件)
{ "hello": "你好 {name}", "@hello": { "placeholders": { "name": {} } } }
// 接入与使用:类型安全,占位/复数/日期自动处理
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
);
Text(AppLocalizations.of(context)!.hello('Charles'));AppLocalizations.of(context) 要在 MaterialApp 之下的 context 里调用,之上拿不到;改了 .arb 没生效时先重跑 flutter gen-l10n(或重启 flutter run),热重载不保证触发重新生成。三条路调原生:MethodChannel 手写消息,Pigeon 生成类型安全桥,FFI 直连 C。
三条路调原生
MethodChannel在 Dart 与原生(Kotlin/Swift)间传消息调原生 API。Pigeon用 schema 生成类型安全的桥接代码,比手写 channel 更稳。dart:ffi直接调 C/C++ 动态库,做高性能或复用现成 native 库。- 2026 路线图:Swift Package Manager 成 iOS 插件默认,并探索 Dart 直调 Swift/Kotlin。
三条路怎么选
| 方式 | 类型安全 | 适合 |
|---|---|---|
MethodChannel | 否,手写字符串方法名 + 动态参数 | 只调一两个原生 API |
Pigeon | 是,用 schema 生成两侧代码 | 接口多、要长期维护 |
dart:ffi | 是 | 直接调 C/C++ 动态库,或要高性能 |
MethodChannel的代价是两侧对不上时没有任何编译期检查:方法名打错、参数类型不一致,全部要等到运行时才发现,而且报错很难读;- Pigeon 就是为了解决这一点:写一份 Dart 的接口声明,生成 Dart 与 Kotlin/Swift 两侧的类型安全代码——接口一多就该上它;
dart:ffi走的是另一条路,不经过平台通道、没有消息序列化开销,适合复用现成的 C 库(图像、加解密、音视频);- 2026 路线图上还有两件事在推进:iOS 插件默认改用 Swift Package Manager,以及探索 Dart 直调 Swift/Kotlin——方向都是把中间那层胶水代码去掉。
// Dart 端:通过命名通道调原生
static const _ch = MethodChannel('app/battery');
final int level = await _ch.invokeMethod('getBatteryLevel');
// Android 端(Kotlin):同名通道注册处理器
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "app/battery")
.setMethodCallHandler { call, result ->
if (call.method == "getBatteryLevel") result.success(getBattery())
else result.notImplemented()
}
// 更稳的选择:Pigeon 用 schema 生成双端类型安全的桥接代码MissingPluginException,排错先对拼写。dart:ffi。一套 Dart 代码编到六个平台,但平台差异要在代码与依赖两层显式处理。
一套代码,六个平台
- 同一代码库可编译到移动、Web、桌面(Windows/macOS/Linux)。
- 平台差异用
Platform.isIOS或kIsWeb、defaultTargetPlatform判断。 - 不可在 Web 用的库用条件导入(
import ... if (dart.library.html) ...)隔离。 - Web 需关注:首屏体积、SEO 局限、URL 路由(用 go_router)。
平台差异要在两层处理
- 代码层:
kIsWeb判 Web、defaultTargetPlatform判平台。注意Platform.isIOS在 Web 上会直接抛异常(dart:io在 Web 上不可用)——所以判断顺序必须是先kIsWeb再Platform.*; - 依赖层:用不了的库要用条件导入隔离(
import 'stub.dart' if (dart.library.io) 'io_impl.dart';),否则编 Web 时直接编不过; - Web 还有三件必须单独考虑的事:首屏体积(Flutter Web 的起步包不小)、SEO 基本没有(内容画在 canvas 上)、URL 路由要用
go_router才能让地址栏正常工作; - 还有 04 章那条:Web 上没有 isolate,
compute退化成同步执行——移动端不卡的重活,到 Web 上会卡死。
$ flutter build appbundle # Android(Google Play 要求 aab)
$ flutter build ipa # iOS(需 macOS + Xcode)
$ flutter build web --wasm # Web(WasmGC 运行时)
$ flutter build windows # 桌面还有 macos / linux
// 代码里判断平台
if (kIsWeb) { /* Web 专属 */ }
else if (Platform.isIOS) { /* iOS 专属 */ }
// 条件导入:Web 与 IO 各给一份实现
import 'stub.dart'
if (dart.library.js_interop) 'web_impl.dart'
if (dart.library.io) 'io_impl.dart';dart:io 在 Web 上不可用,含它的代码编译 Web 目标会直接报错——判断平台先用 kIsWeb 或 defaultTargetPlatform(来自 foundation,全平台可用),而不是无条件 import dart:io。全景章说 Dart 靠双引擎拿到「开发时热重载、发布时原生性能」。这句话很容易当成营销词滑过去——本机跑一次,它就变成两个你能记住的数字。
同一段代码,两种跑法(Dart 3.12.2)
循环 2 亿次做浮点除法累加:
dart run bench.dart 内部计时 634 ms 进程总耗时 1.20 s
dart compile exe 后直跑 内部计时 212 ms 进程总耗时 0.32 s
↑ AOT 快约 3 倍- 差距来自编译时机:JIT 要一边跑一边观察类型、生成并替换代码,热点函数需要若干轮才优化到位;AOT 在构建期就把整棵调用图编成机器码,没有预热期。进程总耗时差得更多,多出来的是 VM 启动与解析源码;
- 另一个数字是产物体积:只
print一行的 hello world,dart compile exe出来 6.36 MB——大头是被静态链接进去的 Dart 运行时,所以体积几乎不随小程序增长。这也解释了 Flutter「空项目就有十几 MB」的观感:起步价是运行时加渲染引擎。
为什么两种模式必须共存
开发期要 JIT:热重载的本质是「把改过的代码塞进正在运行的 VM 再重建 UI」,只有能在运行时接受新代码的 JIT 才做得到。发布期要 AOT:上面那 3 倍差距若落在每帧 16.7 ms 的渲染预算里就是掉帧,何况 iOS 平台规则本就禁止运行时生成可执行代码。所以双引擎不是给你选的,是两个阶段各用一个——也因此性能永远要按 release 模式衡量(12 章)。
// bench.dart —— 拿它自己量一次
void main() {
final sw = Stopwatch()..start();
var s = 0.0;
for (var i = 1; i < 200000000; i++) { s += 1.0 / i; }
print('${sw.elapsedMilliseconds}ms');
}
# JIT:直接跑源码
dart run bin/bench.dart # 634ms
# AOT:先编成原生可执行文件
dart compile exe bin/bench.dart -o bench.exe
./bench.exe # 212ms —— 快约 3 倍dart run 的耗时去评估「Dart 快不快」,更别拿它去和别的语言的编译产物比——你量的是 JIT 预热而不是稳态性能。同理,Flutter 里所有性能观察都必须在 release/profile 模式下做:debug 模式跑的是 JIT 且开着一堆断言与检查,卡顿是必然的,拿它调优会把你引向完全错误的方向。dart compile 还有别的目标:aot-snapshot(要配 dartaotruntime 跑,体积小得多)、js(编到 JavaScript)、wasm。要分发单文件命令行工具就用 exe——用户机器上不需要装任何 Dart 运行时,这是 Dart 写 CLI 相对 Python/Node 的实在优势。性能优化的主线只有一条:减少每帧要 build 与 raster 的工作量。
减少每帧的工作量
- 尽量
constwidget;长列表用.builder;拆分 widget 缩小重建范围。 RepaintBoundary隔离频繁重绘区域,避免连累周围。- build 方法保持轻量:别在里面做网络/重计算/创建 Future。
- 重 CPU 任务丢
compute/isolate;图片用合适分辨率与缓存。 - DevTools Performance 用数据定位,别凭感觉「优化」。
先分清是 build 慢还是 raster 慢
| 症状 | 方向 | 手段 |
|---|---|---|
| build 慢(Dart 侧) | 缩小重建范围 | const、拆小组件、.builder、把 setState 下沉、select/ValueListenableBuilder |
| raster 慢(GPU 侧) | 减少图层与昂贵效果 | RepaintBoundary 隔离、少用阴影/模糊/saveLayer、图片用合适分辨率 |
| 整体卡顿 | 把重活挪走 | compute/Isolate.run(04 章) |
- 这一整章的优化手段其实都指向 05 章的三棵树:
const让 diff 提前短路、Key让 Element 能复用、RepaintBoundary让 RenderObject 的重绘不互相连累; - 务必用 DevTools 的数据定位,别凭感觉优化——凭感觉最常见的浪费是到处加
const却没发现真正的瓶颈是一张没缩放的大图; - 并且必须在 profile / release 模式下测(01 章双引擎卡)。这一条重复三次都不算多,它是新手最常犯的方法论错误。
// 1. 长列表懒构建:只 build 可见项
ListView.builder(
itemCount: items.length,
itemBuilder: (c, i) => ItemTile(item: items[i]),
);
// 2. RepaintBoundary:把频繁重绘的区域隔离成独立图层
RepaintBoundary(child: SpinningLogo()),
// 3. const + 拆小 widget:缩小 setState 的重建半径
const Header(),
// 4. 重计算丢给 isolate,别卡 UI 线程(一帧只有 16ms)
final data = await compute(parseBigJson, raw);compute 的入参与结果都要跨 isolate 拷贝,塞巨大对象时拷贝开销可能盖过计算收益;RepaintBoundary 也别滥撒——每个都是一块独立图层的内存开销。flutter run --profile 连 DevTools 找到掉帧点再动手;最便宜的三板斧是 const 构造、ListView.builder 与拆小 setState 的影响面。release 构建、混淆、签名、瘦身是上架前的固定动作,每一步都有不可逆的坑。
上架前的固定动作
flutter build apk/appbundle/ipa/web --release出生产包。--obfuscate --split-debug-info混淆 Dart 代码、分离符号。- 签名:Android keystore / iOS 证书与描述文件,妥善保管。
- 瘦身:
--split-per-abi、按需资源、tree-shaking 图标、移除无用依赖。
每一步都有不可逆的坑
- 签名密钥丢了=这个应用再也无法更新(Android keystore、iOS 证书都是如此)。必须离线备份并且不要提交进版本库——这是本卡唯一一条丢了就无法补救的;
- 混淆要配
--split-debug-info并保存符号文件:--obfuscate之后线上崩溃日志全是乱码,只有对应版本的符号文件才能还原。符号文件按版本归档,丢了那个版本的崩溃就永远读不懂了; - 瘦身:
--split-per-abi(Android 按架构分包)、按需资源、图标 tree-shaking、清掉没用的依赖。起步体积大是 Flutter 的固有代价(01 章讲过运行时是静态链接进去的); - 别把密钥写进 Dart 代码:AOT 产物可以被逆向,字符串照样能捞出来。凡是不能泄漏的东西,都应该放在服务端。
$ flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols
# 混淆 Dart 符号 + 把还原表存到 build/symbols(解析崩溃要用)
# android/key.properties —— 签名密钥配置(务必加入 .gitignore)
storeFile=../upload-keystore.jks
storePassword='******'
keyAlias=upload
# build.gradle 的 signingConfigs 读取它给 release 签名
# 线上崩溃:还原混淆后的堆栈
$ flutter symbolize -i stack.txt -d build/symbolskey.properties 与 .jks 绝不能进 git,泄漏等于交出签名权。--split-debug-info 的符号目录按版本归档保存——线上崩溃堆栈全靠它还原;上 Google Play 用 appbundle,商店会按设备下发瘦身后的安装包。从这里到精通:路线图
地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。
Dart/Flutter 的学习曲线前平后陡:写个 Demo 很快,写出架构清晰、状态可控的完整 App 才是分水岭。按下面的顺序走,每一步都以「能交付的东西」收尾。
动手项目(难度递进)
- ① Dart CLI 小工具:纯 Dart 写一个文件批量重命名或 JSON 转 CSV 工具,用
dart compile exe交付单二进制——练语言本身与 pub 工具链,不碰 UI。 - ② Flutter 计数器 → Todo 应用:从官方模板改起,做一个带增删改查、滑动删除与列表动画的 Todo——练 Widget 树、setState 与布局系统。
- ③ 完整 App:Riverpod 状态管理 + 本地存储(drift 或 shared_preferences)+ 网络请求 + go_router 多页面路由的完整应用——练架构分层与异步状态(加载/错误/空态)。
- ④ 发布:把 ③ 上架 Google Play / App Store(图标、签名、混淆、CI 一条龙),或把可复用逻辑抽成包发到 pub.dev——练交付闭环与版本维护。
书与资料(按阶段)
- 入门:官方 Language Tour(dart.dev)过语言;Flutter 官方 Codelab "Write your first Flutter app" 上手 UI。
- 系统:Flutter 官方文档的 Cookbook 按场景给可运行示例;《Flutter in Action》适合系统补一遍框架思维。
- 日常:pub.dev 选包先看分数、下载量与维护状态;官方 YouTube 的 Widget of the Week 每集 2 分钟补 Widget 地图。