Dart + Flutter 完整知识体系交互讲解

全景: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);
}
网上大量教程停在 Dart 2 时代——没有健全空安全、每个 case 还要写 break,照抄会编译不过。另外 Flutter SDK 自带 Dart,装了 Flutter 就别再单独装 Dart SDK,两份 dart 命令并存最容易出怪事。
本页以 Dart 3 为基准(健全空安全成为强制,records、patterns、sealed 齐备)。先把 Dart 本身学扎实再进 Flutter——Flutter 里八成的「看不懂」其实是 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 -v
「两份 dart」要掐死在开头:先装独立 Dart SDK、后装 Flutter,两个 bin 都在 PATH 里谁在前谁生效。把独立那份摘掉即可。
编辑器装官方 Dart + Flutter 插件,它会接管 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.yamlname: 而不是文件夹名——改了文件夹名却没改 pubspec,就会得到「找不到入口」。另外包名必须是合法的 Dart 标识符:小写下划线,写成 myApp 或带连字符的 my-app 都会被 dart create 直接拒绝。
只想试一小段语法时连项目都不用建:随便写个 t.dartdart 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 做热重启才会重来
用编辑器插件跑(VS Code 里 F5)比终端手敲更顺:保存即热重载。性能一律看 --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;  // 可变
late 有两种下场(Dart SDK 3.12.2):局部变量声明后从未赋值就用,编译器流分析能证明「必然未赋值」,直接编译错 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 能直接跳过重建,是最便宜的性能习惯。

数字、字符串、布尔三件套配上插值语法,是每天要写几十遍的基本功。

三件套与插值

  • intdouble 都是 num 的子类型;整除用 ~/,普通除法 / 总返回 double。
  • 字符串可用单引号或双引号;插值用 $变量${表达式}
  • 三引号 '''…''' 写多行字符串;相邻字符串字面量自动拼接。
  • 字符串不可变;常用:splitcontainsreplaceAllpadLefttrim

数字的三处边界

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)
浮点是 IEEE 754: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 里一大批「运算符类型不匹配」报错的措辞。数字格式化记住 toStringAsFixedpadLeft 就够日常。

类型默认非空是 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('未知命令');
}
从 JS/Python 迁移最高发的坑:把 if (count)while (list.length) 原样搬过来——在 Dart 里全是编译错误。另外读老代码(Dart 2.x)会看到每个 case 末尾都有 break,那是当年防贯穿的强制要求;Dart 3 写不写都不贯穿,新代码直接省掉即可(空 case 体仍会与下一个 case 共享分支,这是留给多值匹配的老写法)。
等值多分支优先 switch——Dart 的 switch 不限整数,String、枚举、const 对象都能当 case;需要「产出一个值」时改用 switch 表达式(见 03 章),语句形态留给纯副作用。

按次数、按元素、先判断还是先执行——四种循环各有明确的适用场景。

四种循环各管一段

  • 三段式 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-inforEach 只在「回调是个现成的函数引用」(如 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;        // 标签:一次跳出两层
  }
}
一边 for-in 遍历一边增删同一个集合,运行时抛 ConcurrentModificationError——要删元素用 removeWhere,或先 toList() 复制一份再遍历。另外 forEach 的回调里没法 break/continue,回调里写 await 也不会等(forEach 不理会回调返回的 Future,循环体变成并发乱序执行)——需要中途退出或逐个 await,老老实实用 for-in。
遍历集合用 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 [] 是被规范化的同一个实例,一旦可变就会在所有调用之间串味。可变默认集合的正确姿势是参数声明成可空、体内 ??= 给新实例。
参数超过两个、或含 bool 参数时改用命名参数——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 更优雅。
  • 常用:mapwhere(≈filter)、fold/reduceany/everyfirstWhere
  • 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');
}
Map 直接 for (final e in map) 编译不过——它不是 Iterable,必须经 entries/keys/values 遍历。
在 Flutter 的 children 列表里优先用 collection-for:[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 岁」——编译器不会拦你,因为它们之间毫无关系
  • 数据和用它的函数没有任何绑定:把 nameage2 传进同一次调用,编译完全通过。
  • 人一多就得多一套变量。这两件难受,就是类要替你解决的全部。

写成类

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,可以改
不覆写 toStringprint(对象) 只打出 Instance of 'Person',一个字段都看不到,调试时容易白等半天。另一件反直觉的事:字段完全一样的两个实例默认不相等——Person('小明', 12) == Person('小明', 12)false,因为默认比的是「是不是同一个对象」。想按字段比,要覆写 ==hashCode(见「继承 / 抽象 / 多态」卡),或者干脆用 record(03 章)。
刚上手别急着往类上贴 finalconst、初始化列表这些东西。先把「字段 + 构造 + 方法」这三样写顺,写出五六个能跑的类,再回头看下一卡——那一卡讲的全是给同一件事减字数的简写,不是新概念。

上一卡那个 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 里 const widget 能让框架靠「引用相等」直接跳过重建,是最便宜的一条优化(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();
final 字段必须在构造体运行前赋完(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
所有字段都是 final 的类尽量给 const 构造——const 实例会被规范化复用,Flutter 里 const widget 是最便宜的优化;初始化列表里还能放 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()); // true
factory 体内没有 this——写了直接编译错(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 里少数几个「编译器帮不上忙」的坑;
  • 正解是让工具替你写:freezedequatable 生成这两个成员,或直接用 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 方法全是虚方法但没有重载,同名不同参直接编译错。
子类构造转发参数用 super 参数简写: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'
extension 是静态解析:接收者是 dynamic 时直接失效,运行时抛 NoSuchMethodError(Class 'String' has no instance getter 'rev')——从 jsonDecode 拿出来的 dynamic 值要先标注类型再用扩展方法。
mixin 加 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>,等于放弃检查。
Dart 泛型是实化(reified)的:运行时 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:StateErrorTypeErrorRangeError、空断言失败。本该崩出来让你看见,捕获它只是把 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() 原地排序且返回 voidfinal s = list.sort() 拿到的是 null 类型错误;不想动原列表写 [...list]..sort()
  • map/where 返回惰性 Iterable不是 List——直接赋给 List 变量会类型错误,且每次遍历重算;
  • Map 不是 Iterable{} is Iterablefalse),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 兜底、后者换带初值的 foldsort() 是原地排序且返回 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 的分界线

要什么recordclassMap
字段名与类型在编译期固定
拼错字段名编译期报错(运行时得 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]
Map 模式与 List 模式的规则不对称:Map 模式忽略多余的键、部分匹配即可命中;List 模式要求长度精确一致,[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)。
分支只能放单个表达式,需要多条语句就退回 switch 语句或抽成函数;表达式形式没有 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 守卫。
解析动态 JSON 时,一个 if-case 顶一串 is 判断加强转:类型不对、结构不对都自然落进 else,坏数据不用单独防。

把「所有可能的状态」写成编译器数得清的封闭清单,穷尽检查从此替你盯着每一处 switch。

封闭清单与穷尽检查

  • sealed 类的所有直接子类必须在同一库(通常即同一文件)——编译器因此知道全集。
  • 对 sealed 类型做 switch 时,漏掉任一子类会报编译错误。
  • 是建模「有限状态」(加载/成功/失败)的现代标准做法。
  • 配合 records/patterns,大幅取代过去 freezed 的联合类型用途。

它取代了 freezed 的哪一半

  • Dart 3 之前,要表达「加载中 / 成功 / 失败」这种封闭状态集,标准做法是上 freezed 生成联合类型——现在 sealed + record + 模式匹配原生就够了,少一个代码生成器、少一轮 build_runner;
  • freezed 仍然有用的那一半是样板生成copyWithtoJson/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',
};
漏sealed 类隐式 abstract,本身不能实例化;在库外继承会报 can't be extended, implemented, or mixed in outside of its library because it's a sealed class。它的全部价值都建立在「子类清单封闭」上——所以一旦把子类拆到别的库,穷尽性检查立刻失效(穷尽性报错的原文见上一卡)。
新增一个状态子类后,所有相关 switch 会集体报编译错,等于编译器替你列出全部待改点——所以分支里别写 _ 通配,写了就放弃了这层保护。

一组声明「我的类允许你怎么用」的开关,Dart 3 起库作者可以精确控制继承面。

五个开关,控制继承面

  • final:禁止被继承或实现(库外)。
  • base:可被继承但不可被实现,保证子类继承实现。
  • interface:可被实现但不可被继承(当纯接口用)。
  • sealed:隐式 abstract + final,且启用穷尽检查。
  • mixin class:既能 extends 又能 with。设计库 API 时用于约束扩展方式。

五个修饰符各禁掉什么

修饰符库外能 extends库外能 implements典型用途
(无)应用内部代码,绝大多数情况
final不能不能不希望被扩展的具体类
base不能保证子类一定继承到你的实现
interface不能当纯契约用
sealed库外都不能(隐式 abstract)封闭状态集 + 穷尽检查
  • 限制只对「库外」生效:同一个库(通常即同一文件)里这些约束都不拦你,它们是给包的使用者划的线;
  • 约束会传染:父类是 basefinal,子类也必须标 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 {}           // 不可继承/实现
限制只对「库外」生效:同一库里照样能 extends 一个 final class,只是子类自己也得标 base/final/sealed(可通过 analyze)。库外违规的报错形如 The class 'Config' can't be extended outside of its library because it's a final class.
这些修饰符是库作者用来约束外部用法的,应用内部代码通常普通 class 就够。约束会传染:父类是 base/final,子类也必须标 base、final 或 sealed(报错 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 里它对 MainAxisAlignmentTextAlign 这类枚举参数特别顺手,对 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.
很新的语法:需 Dart 3.10+。老项目里看不到很正常,但新代码可放心用。

macros 取消之后,Dart 元编程的现状是 build_runner,方向是 augmentations。

macros 取消后的现状

  • Dart 团队于 2025 年初取消了 macros(因严重拖慢热重载)——不要再等它。
  • 当前代码生成仍靠 build_runnerjson_serializablefreezed 等)。
  • augmentations(增强声明)是元编程的后续方向:允许把类定义拆分到多文件。
  • Dart 3 的语言特性已让你在很多场景不再需要 freezed。
  • 心态:优先用语言原生特性,代码生成按需引入,别一上来全套生成器。

为什么 macros 被砍:热重载与它不可兼得

  • macros 的设想是「编译期跑用户代码来生成代码」。问题在于:热重载要求改一行就能在一秒内把新代码塞进运行中的 VM,而宏意味着每次改动都要重新执行一遍任意用户程序,再重新做一遍类型推断;
  • Dart 团队 2025 年初正式取消该特性,理由正是对分析与热重载的性能影响不可接受——而热重载恰恰是 Dart 存在的理由,这个取舍没有别的答案;
  • 取而代之的方向是 augmentations(增强声明):允许把一个类的定义拆到多个文件、由生成器补齐缺的部分。它不需要在编译期跑用户逻辑,因此对增量编译友好。

现状:能用语言特性解决的,就别上生成器

  • 每引入一个生成器,就多一步 build_runner、多一份 .g.dart 要进版本库或进 .gitignore、多一类「改了源码忘了重新生成」的怪问题;
  • Dart 3 之后,状态建模(sealed + 模式匹配)与多值返回(record)已经不需要生成器——freezed 的用武之地缩小到 copyWith 与 JSON 序列化;
  • 真要用,记住两条freezed 3.x 要求宿主类标 abstract(否则报 Missing concrete implementations…);build_runner 2.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();
}
freezed 3 起被注解的类必须声明为 abstract(或 sealed):写普通 class User with _$User 会编译错 Missing concrete implementationstry making the class abstract(freezed 3.2)。另外 build_runner 2.15 起 --delete-conflicting-outputs 参数已移除,再传只会得到一句 These options have been removed and were ignored
2026 现状:面试或选型若被问「macros 呢」,正确答案是「已取消,走 augmentations + build_runner」。

异步编程

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);
}
忘记 await 的 Future 出错后没人接:运行时打印 Unhandled exception: 并以非零码退出(exit 255)。而 await 只能写在 async 函数里,否则编译错 The await expression can only be used in an async function.
async 函数体在第一个 await 之前是同步执行的(调用后函数体首行先于调用点下一行打印),所以「调用 async 函数」不等于「稍后才开始」。确实不想等它时,用 dart:asyncunawaited() 显式标明意图。

多个异步任务的编排:并发、超时与错误策略,都有现成 API,但细节与 JS 不完全一样。

并发、超时与错误策略

  • Future.wait([...]) 并发等待多个,全部完成才返回(≈Promise.all)。
  • Future.anyFuture.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 不是取消——网络请求会继续占着连接、写操作会继续写完。真要能取消,得用支持取消的客户端(如 dioCancelToken)或 StreamSubscription.cancel()
  • 这条在「用户退出页面后请求还在跑、回来时 setState 到已 dispose 的 State」里表现为崩溃——修法在 09 章的 mounted 检查。
final results = await Future.wait([
  fetchUser(), fetchPosts(),
]);                                   // 并发

final data = await fetchSlow()
    .timeout(Duration(seconds: 3));   // 超时保护
错误行为与 Promise.all 不同: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,3
单订阅流二次 listen(或 listen 过再 await for)抛 Bad 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,函数体就执行了两遍——生成逻辑昂贵或带副作用时会重复付费。
生成器是「描述」不是「执行」:调用它函数体一行都不跑,开始迭代才跑(先打印 created 才打印函数体内的输出)。要缓存结果或多次消费,先 .toList() 固化。

当事件来自你自己的命令式代码时,用它手工造一条流并控制推送节奏。

手工造一条流

  • 当事件来自命令式代码(按钮、WebSocket、定时器)时,用它构造流。
  • .sink.add(event) 推送;.stream 暴露给消费者;用完 .close()
  • broadcast() 支持多订阅者。
  • 很多状态管理(如 Bloc)底层就是 StreamController 的封装。

生命周期的两个端点都要管

  • 开头:单订阅 controller 在无人监听时会缓冲(listen 之前 add 的值仍全部送达),broadcast 则丢弃——上一张卡那张表的直接后果;
  • 结尾close() 之后再 addBad 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();
close 之后再 add 抛 Bad state: Cannot add event after closing;反过来忘记 close,流就永不结束,await for 的消费者会一直挂着——Flutter 里常见于 dispose 时忘关 controller 造成泄漏。
单订阅 controller 在没有订阅者时会缓冲事件,之后 listen 照样收到(listen 之前 add 的值仍送达);broadcast 则直接丢弃无人监听时的事件。选哪种,先想清楚「早到的事件能不能丢」。

事件循环解决「等」的问题,isolate 解决「算」的问题——CPU 重活要的是真并行。

解决「算」的问题

  • Dart 单线程跑事件循环;Isolate 是独立内存的并行执行单元(≈ Web Worker)。
  • isolate 间不共享内存,只能通过消息(端口)通信——天然无数据竞争。
  • CPU 密集任务(解析大 JSON、图像处理)放进 isolate,避免掉帧。
  • compute(fn, arg) 是开箱即用的简化入口,自动起一个 isolate。

不共享内存换来了什么,又要付什么

  • 换来:没有共享可变状态,就没有数据竞争、不需要锁。Dart 里你永远不会写 mutex——这是它并发模型最大的省心之处;
  • 付出:跨 isolate 只能传消息,对象要被拷贝过去(大对象有实打实的序列化开销);能传的东西也有限制——捕获了 ReceivePortSocket 之类不可发送对象的闭包,直接抛 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);
传给 isolate 的闭包不能捕获不可发送对象:捕获了 ReceivePort、Socket 之类会抛 ArgumentError Illegal argument in isolate message: object is unsendable。另外 Web 平台没有 isolate,compute 在 Web 上退化为在当前 isolate 直接执行,重活照样卡 UI(官方文档)。
纯 Dart 里一行起后台并行: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
// 规则:同步代码执行完 → 清空整个微任务队列 → 才取下一个事件
异步回调永远不会同步执行:Future 已经完成,它的 then/await 也要排微任务,同步代码全部打印完 then 才执行;而 Future(fn) 走的是事件队列(Timer),比所有微任务都晚(本卡示例顺序 1→2→3→4→5)。把任何一个当「立刻执行」用都会得到意外顺序。
长时间的同步计算会同时冻结两条队列——Flutter 掉帧的常见根因。要么分片让出(穿插 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 —— 依赖与资源声明。图片、字体必须在这里登记,否则运行时找不到;改了它 rR 都不够,要停掉重跑;
  • 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')),
        ),
      );
}
热重载(r)只注入新代码并重建 widget 树,不会重新执行 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 的对照

FlutterReact
UI 描述widget 树(Dart 代码)虚拟 DOM(JSX)
真正渲染的东西RenderObject 树,Flutter 自绘浏览器 DOM,交给浏览器
diff 发生在Element 树Fiber 树
列表身份靠Keykey
  • 结构上几乎一一对应,唯一的根本差别是最底层:Flutter 不用平台控件,自己往画布上画每一个像素;
  • 这解释了它的长处(跨端像素级一致、动画不受控件限制)与代价(包体积大、无障碍与平台质感要自己补、文本选择/输入法这类系统能力要重新实现)。
Widget build(BuildContext context) {
  return Center(            // 布局也是 widget
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Text('Hello Flutter'),
    ),
  );
}
「widget 廉价」的前提是它不可变:往 widget 类里塞可变字段是误用(analyzer 会报 must_be_immutable),可变状态属于 State 对象——widget 实例随时会被整个换新。
深层嵌套别堆成一个巨型 build:把子树抽成独立的小 StatelessWidget(而不是返回 Widget 的 helper 方法),可读性更好,框架也能按类型与 const 精准裁剪重建范围。

有没有随时间变化的内部状态,决定了继承 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'));
}
可变数据必须放 State 里而不是 StatefulWidget 的字段上——widget 随父级重建会被整个换新,挂在它身上的「状态」会悄悄丢失,State 对象才会跨重建存活。
IDE 里敲 stlessstful 模板一键生成骨架;日后要升级,用「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'),
      ),
    ]);
  }
}
异步回调里对已销毁的 State 调 setState,抛「setState() called after dispose(): _XxxState#…(lifecycle state: defunct, not mounted)」——调用前先判 mounted,或在 dispose 里取消 Timer 与订阅。
setState 只重建当前 State 的子树:子组件计数时父组件的 build 一次都不会重跑——把易变状态下沉到尽量小的组件里,是最便宜的重建裁剪。

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 所在的那个 buildNavigator.of(context)②在 widget 还没进树时用(构造函数里、initState 里取 Inherited),拿不到位置;
  • ①的标准解法是再包一层 BuilderBuilder 唯一的作用就是「凭空造一个更深的 context」;②的解法是挪到 didChangeDependenciesbuild 里;
  • 顺带解释了另一个直觉:不同位置的 context 不等价。把 context 存进变量跨页面用,或者在异步回调里用一个早已失效的 context,都会出问题——BuildContext 是「位置」,不是「全局句柄」。
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
Navigator.of(context).push(route);
await 之后继续用 context 前先判 context.mounted:widget 被移出树后 mounted 变为 false,此时再拿它查找祖先,操作的是已失效的位置(lint use_build_context_synchronously 拦的就是这个)。
BuildContext 其实就是 Element 本身(Element implements BuildContext),哪个 build 给的 context 就定位哪个 widget——想拿「Scaffold 之下」的东西却只有 Scaffold 之上的 context 时,包一层 Builder 制造更深的 context。

从挂树到销毁的回调顺序,决定初始化、依赖获取与资源清理各自该写在哪。

五个回调的分工

  • 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
  • 记忆线索:initStatedispose 必须成对——在前者里 new 出来、listen 上去的每一样东西,后者里都要有一行对应的清理。这条纪律能挡掉 Flutter 里绝大多数内存泄漏。
@override
void initState() {
  super.initState();
  _controller = AnimationController(vsync: this);
}
@override
void dispose() {
  _controller.dispose();   // 必须释放
  super.dispose();
}
在 initState 里调 Theme.of(context) 这类依赖查找,抛「dependOnInheritedWidgetOfExactType<…>() or dependOnInheritedElement() was called before …initState() completed.」——挪到 didChangeDependencies 或 build 里。
完整顺序:首挂 initState→didChangeDependencies→build;父级传新配置时 didUpdateWidget→build;移除时 dispose。需要 Inherited 数据的初始化放 didChangeDependencies——它首次在 initState 之后就会被调用。

你写的 widget 只是配置,Element 持有状态做 diff,RenderObject 负责布局绘制,三层分工是性能的来源。

配置、实例、渲染

  • Widget 树:你写的不可变配置,频繁重建、廉价。
  • Element 树:widget 的实例化,持有状态与 context,框架在此做 diff/复用。
  • RenderObject 树:真正负责布局、绘制、命中测试,昂贵、尽量复用。
  • 重建 widget ≠ 重建 render object——同类型同 key 的 element 会被复用,性能由此而来。
  • 理解这层,才能解释 key、const、性能优化为什么有效。

三层各自的寿命与代价

是什么寿命代价
Widget不可变配置极短,每次 build 全部重造几乎为零
Elementwidget 在树中的实例,持有 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 不等于重新布局:Element 复用成功时 RenderObject 纹丝不动;反之在 build 里现造 UniqueKey 会让身份每帧都变,State、RenderObject 连同动画与滚动位置全部推倒重来。
「复用还是重建」只有一条判定(Widget.canUpdate):新旧 widget 的 runtimeType 与 key 都相同就原地更新 Element,否则拆掉重建——三棵树的行为都能从这条推出来。

同类型 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(),
);
两个同类型 StatefulWidget 交换位置:不加 key 时 Element 按位置匹配,内部状态留在原位、跟错了新数据(内容互换但状态没跟走);加 ValueKey 后状态正确跟随。
选 key 认数据不认位置:列表项用业务 id 建 ValueKey(item.id);UniqueKey 每次构造都是新身份,写在 build 里等于强制丢弃状态,只该用于「故意重置」。

const widget 在编译期就是同一个实例,重建 diff 时框架看到相同引用直接整棵跳过。

最便宜的一条优化

  • const widget 在编译期就确定,重建时框架直接跳过它(同实例)。
  • 尽量给不变的 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('固定标题'),
const 挡得住父级重建,挡不住依赖变化:父组件 setState 时 const 子组件的 build 被跳过;但它依赖的 InheritedWidget(如主题)一变,const 组件照样重建。
const 的门槛是全部构造参数都是编译期常量:一处用了运行时值整条就 const 不了——把「真正不变的部分」拆成独立的 const 子组件,比原地硬补 const 更有效。

布局系统

约束向下、尺寸向上、父级定位。Row/Column、Flex、Stack 与滚动。

Flutter 布局只有一趟遍历:父级把约束传下去,子级把尺寸报上来,父级决定位置——这三句话是本章所有 widget 的总纲。

一趟遍历,三句话

  • 父 widget 给子 widget 传约束(最小/最大宽高)。
  • 子 widget 在约束内决定自己的尺寸,向上回报给父级。
  • 父级再决定子级的位置。三句话解释了几乎所有布局行为。
  • 「为什么我的 Container 没有变大/居中?」90% 是没理解约束传递。
  • 调试利器:debugPaintSizeEnabled = true 或 DevTools 的 Layout Explorer。

「我的 Container 为什么没变大」

  • 症状:给 Container 设了 width: 200,结果它铺满了整个屏幕,或者干脆是子级的大小;
  • 原因永远是父级传下来的约束:如果父级给的是「紧约束」(min == max,比如 Center 之外的很多容器直接把自己的尺寸压下来),子级没有选择余地,你写的 width 会被忽略;
  • 所以解法不是改子级,而是在中间插一个放宽约束的父级——CenterAlignUnconstrainedBox 都是把紧约束变成松约束的工具;
  • 这条规律的另一半是「约束里没有位置信息」:子级只知道自己能多大,永远不知道自己在哪,位置完全由父级说了算。想「让自己居中」的写法在 Flutter 里不存在,只能让父级去 Center

两个极端约束,解释一半的报错

约束谁会传下来典型报错
无界(infinite)滚动容器的主轴Row 的横轴RenderBox was not laid out / 「有无限高度的 Column」
紧(tight)Expanded 包住的子级、铺满型父级「我设的 width 不生效」
  • 无界约束的意思是「你想多高都行」——这时子级不能说「我要和父级一样高」(没有「一样」可言),于是 Expandeddouble.infinityCrossAxisAlignment.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你传了哪几个参数,它就只装配哪几层
  • 所以「只加内边距」时直接写 PaddingContainer(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('卡片'),
)
Container 的尺寸规则容易让人困惑(均为):无 child 时尽量撑满(Center 松约束下也是 800×600);有 child 时包住 child(30×20);但一旦设了 alignment 又变回撑满(800×600)。「Container 怎么变大/变小了」多半是撞上了这三条规则。
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('右')],
)
子级总宽超出就溢出:800 宽屏放两个 500 宽子级,报「A RenderFlex overflowed by 200 pixels on the right.」(debug 下显示黄黑条)。长文本要用 Expanded/Flexible 包住才能换行或省略,直接放 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:1
只能作 Row/Column/Flex 的直接子级:放进 Center 报「Incorrect use of ParentDataWidget. The ParentDataWidget Expanded(flex: 1) wants to apply ParentData of type FlexParentData…」并提示「Typically, Expanded widgets are placed directly inside Flex widgets.」——中间隔一层 Padding/Container 也一样报。
记法:Expanded = 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)),
  ),
])
Positioned 与 Expanded 同理,必须是 Stack 的直接子级;且 Stack 默认 clipBehavior: Clip.hardEdge,把角标定位到边界外会被裁掉,需要露出时设 Clip.none。Stack 自身的尺寸只由未定位子级决定。
Positioned 给了 left+right(或 top+bottom)就等于定了宽(高):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),
)
ConstrainedBox 的约束要与父级约束合并,父级的紧约束永远赢——在紧约束环境里再包 ConstrainedBox 设尺寸没有任何效果(与 Expanded 里设 width 无效是同一个机制)。IntrinsicHeight/Width 要多跑一趟布局,别放进长列表的每一项。
这一组的本质都是「改写传给子级的约束」: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),
  ),
)
Column 直接套 ListView 报「Vertical viewport was given unbounded height.」——滚动方向上必须有有限高度,用 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 一致;
  • 常用几件:SliverAppBarfloating 上滑即现 / pinned 吸顶 / snap 吸附)、SliverListSliverGridSliverToBoxAdapter(把一个普通 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 也不能放进普通布局。
普通 ListView/GridView 内部就是 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 可用)只订阅尺寸一项,优先用后者。
响应式分支看「父级给我多宽」用 LayoutBuilder,看「整块屏幕多大」才用 MediaQuery——组件被复用到半屏面板里时,两者的答案完全不同。

响应式管尺寸变化,自适应管平台与输入方式——两件事分开设计。

尺寸与平台是两件事

  • 响应式:随尺寸连续调整(断点切换列数、间距)。
  • 自适应:随平台/输入方式调整(鼠标 vs 触摸、Material vs Cupertino)。
  • 工具:LayoutBuilderMediaQueryFlex、断点常量、Wrap(自动换行)。
  • 桌面/Web 还需考虑:键盘、悬停、可调窗口、更大信息密度。

两条轴,别混着设计

  • 响应式(responsive)= 随尺寸连续变化:断点切列数、调间距、侧边栏收起来。工具是 LayoutBuilder + 一组断点常量;
  • 自适应(adaptive)= 随平台与输入方式变化:鼠标要 hover 反馈、触摸要更大的点击区、桌面有右键菜单与键盘快捷键、iOS 用 Cupertino 观感;
  • 混着做的典型错误是「按宽度猜平台」——用窗口宽度判断「这是桌面」,然后给了鼠标交互;结果平板横屏、手机分屏、桌面窄窗口全部误判。要判平台用 Theme.of(context).platformdefaultTargetPlatform,要判尺寸用约束
  • 桌面/Web 还有一批容易漏的:窗口可以被拖到很窄(布局不能假设下限)、需要键盘导航与焦点管理信息密度期望更高(照搬手机布局会显得空旷)。
Wrap(spacing: 8, runSpacing: 8, children: [
  for (final tag in tags) Chip(label: Text(tag)),
]) // 空间不够自动换行
Platform.isAndroid 区分「手机还是桌面」是双重错误:dart:io 的 Platform 在 Web 上直接抛错(要先判 kIsWeb),而且平板也是 Android——自适应应该看窗口尺寸与输入方式,而不是操作系统。
断点别散落在各个 build 里——集中定义成常量(Material 3 的参考断点:紧凑 <600、中等 <840、扩展 ≥840),全 app 统一判断。

常用 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:ioPlatform 在 Web 上不可用,直接访问会抛异常;跨端做平台分支应改用 defaultTargetPlatformTheme.of(context).platform
选型:全平台统一体验就全用 Material;只想让个别控件带 iOS 质感时优先 .adaptive 工厂(如 Switch.adaptive,可用),比手写平台分支省事。

Scaffold 把标题栏、主体、抽屉、悬浮按钮等页面槽位一次配齐,是 Material 页面的标准骨架。

页面骨架的槽位

  • Scaffold 提供标准页面结构,是大多数页面的容器。
  • 常用槽位:appBarbodyfloatingActionButtondrawerbottomNavigationBar
  • AppBartitleactionsleading,自动适配主题色。
  • 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 的那个 build 的 context 上调 Scaffold.of(context) 会报「Scaffold.of() called with a context that does not contain a Scaffold」——用 Builder 拿一个位于 Scaffold 之下的新 context 再调。
一个「页面」配一个 Scaffold,别在页面内部再嵌套 Scaffold;配置了 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 的文字纹丝不动,而周围跟随主题的部分会变大,界面直接错位;
  • 要在主题基础上微调用 copyWithtextTheme.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)),
]))
长长 Text 想优雅截断,光给 maxLines + overflow: TextOverflow.ellipsis 不够:文本必须先有有界宽度,所以放在 Row 里要裹 Expanded/Flexible,否则先撞上横向溢出(06 章那张卡讲了溢出本身)。
字号字重优先取主题的语义档位(titleLargebodyMedium 等)而不是手写数字,需要微调时在档位上 copyWith,换主题字体时全局联动。

五种按钮对应不同的视觉强调层级,按操作的重要程度选用。

五级视觉强调

  • Material 3 层级:FilledButton(主操作)→ElevatedButtonOutlinedButtonTextButton
  • 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;低强调(对话框里的「取消」)用 TextButtonElevatedButton 留给需要浮起与背景区分的场景。

本地资源图、网络图与内置图标字体覆盖大部分图像需求。

本地图、网络图与图标

  • 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.yamlassets: 下登记——没登记的资源在运行时才报错,而且改完 pubspec 必须停掉重跑(01 章热重载卡)。
Image.network(url,
  fit: BoxFit.cover,
  errorBuilder: (c, e, s) => const Icon(Icons.broken_image)),
widget 测试环境默认拦截 HTTP、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)),
)
TextField 没有 Material 祖先直接报「No Material widget found. TextField widgets require a Material widget ancestor within the closest LookupBoundary.」——包在 Scaffold/Material 下即可,写 widget 测试时同样要包。
TextEditingController 在 State 里创建、在 dispose() 里释放;只需要拿最终值时也可以不用控制器,直接 onChanged/onSubmitted 存变量。

Form 用一个 GlobalKey 统管所有 TextFormField 的校验与保存。

GlobalKey 统管校验

  • FormGlobalKey<FormState> 统一管理子字段。
  • TextFormFieldvalidator 返回错误文案或 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 的字段只建一次。
validator 返回 null 即通过、返回字符串就是错误文案;提交前 validate() 一次收齐全部错误,配 AutovalidateMode.onUserInteraction 可以边输边校验。

选择类控件全是受控组件:值存在你手里,控件只负责展示与回调。

受控组件

  • 这些是受控组件:自身不存状态,靠 value + onChanged 回写。
  • Switch/Checkbox 布尔;Radio 单选组;Slider 连续值。
  • Material 3 新增 DropdownMenu(带搜索补全)替代旧 DropdownButton
  • 在 StatefulWidget 里用 setState 更新值,或交给状态管理。

「点了没反应」几乎总是同一个原因

  • 这些控件自己不存状态:显示什么完全由你传的 value 决定,点击只是调一下 onChanged
  • 所以只写 value: _ononChanged 里忘了 setState,开关就纹丝不动——它已经通知你了,是你没重建;
  • onChanged: null禁用(同按钮的约定),不是「什么都不做」——控件会变灰;
  • 这套「受控」设计的好处是界面永远是状态的函数,不会出现「控件显示的和数据不一致」;代价是每个控件都要配一段 setState,这也正是状态管理框架要解决的问题(08 章)。
bool on = false;
Switch(value: on, onChanged: (v) => setState(() => on = v)),
Slider(value: vol, onChanged: (v) => setState(() => vol = v)),
(dart analyze,Flutter 3.38):RadiogroupValue/onChanged 自 3.32 起已弃用,新写法是在外层套 RadioGroup 统一管理选中值;旧写法能编译但有弃用警告。
把当前值存进 State 或状态管理,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: () {},
))
Card 默认自带 4px 外边距(margin),嵌套使用或与外层 Padding 叠加时会出现意料外的空隙——需要贴边就显式传 margin: EdgeInsets.zero
Card + ListTile 是列表页的黄金组合:ListTile 自带 onTap 水波、图标与文本的标准间距,比手写 Row + InkWell 省一大截样板。

三种反馈通道各有定位:模态确认用 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('删除')),
    ],
  ),
);
await 一个异步操作之后再用 context 弹窗(如请求完成后 showDialog),要先判 context.mounted,否则可能用到已销毁的 context——这是 use_build_context_synchronously 这条 lint 盯的场景。
await showDialogNavigator.pop(c, value) 能拿到用户选择;关闭弹层统一走 Navigator.pop(对话框自己的 context, 结果)——builder 回调给的 c 才是弹层的 context。

一颗种子色生成整套 ColorScheme,亮暗两套主题交给 themeMode 切换。

一颗种子色生成整套

  • Material 3 用单一种子色生成整套协调配色:ColorScheme.fromSeed(seedColor: ...)
  • ThemeData 集中定义颜色、排版、组件样式;全局一处改,全局生效。
  • MaterialApptheme + darkTheme + themeMode 支持亮暗切换。
  • 组件内取色用 Theme.of(context).colorScheme.primary 而非硬编码。

ColorScheme.fromSeed 到底给了你什么

  • 输入一个种子色,M3 的算法生成一整套语义化配色primary/onPrimary/primaryContainer/surface/error… 每一对「色 + on色」都保证了对比度
  • 关键收益是无障碍:只要你用 colorScheme.onPrimarycolorScheme.primary 上写字,对比度就是达标的——不必自己算;
  • 亮暗两套只需把 brightness 换一下再生成一次,MaterialApptheme + 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('点我')),
)
InkWell 的水波画在最近的 Material 祖先上——包在带背景色的 Container 里时水波会被盖住看不见,把颜色交给 InkMaterial 来画;GestureDetector 默认命中测试是 deferToChild,child 的空白区域点不中,需要时设 behavior: HitTestBehavior.opaque
Material 界面里可点击的东西优先 InkWell(自带按压反馈与无障碍语义);只有需要拖拽、缩放等复杂手势或非 Material 场景才下沉到 GestureDetector。

状态管理

从 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),
)
直接改字段不调 setState,数据变了界面不动——改完必须让框架知道。反过来也别在 build 里调 setState:那会立刻触发下一次 build,直接死循环。(异步回调里调用前判 mounted 的理由见 05 章。)
setState 的回调必须是同步的:传 async 函数会被框架断言拒绝——正确姿势是先 await 拿到结果,再用一个同步的 setState 更新字段。

共享状态上移到最近公共祖先,数据向下传参、事件向上回调。

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});
}
提升过头是反模式:状态挂得越高,setState 波及的子树越大(重建范围就是持有状态的那个 State 的子树)——状态放在「够用的最低层级」,真正共享的才上移。
别急着上高级状态框架:先把这张卡的「传值 + 回调」模式写顺,再碰 Riverpod——跳过这一步常导致「会用工具但不懂数据流」。

任意深度的子级直接拿到祖先数据并自动订阅更新,这是 of(context) 与各状态框架共同的地基。

共享状态的物理原理

  • InheritedWidget 让子树高效访问祖先数据,无需逐层传参。
  • ThemeMediaQueryNavigator 等都是它的封装——这是「.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 内部对象不换实例,谁也收不到通知。
dependOn 查找靠 Element 上的哈希表,O(1) 完成,放心在 build 里每次都调;它同时完成「取值+订阅」两件事,只想取值不想订阅用 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++;  // 触发上面局部重建
ValueNotifier 按相等性判重:设回相同值不通知,往持有的 List 里 add 也不通知(引用没变)——要么换新实例,要么改用 ChangeNotifier 手动 notifyListeners;State 里创建的 notifier 记得在 dispose 里释放。
ValueListenableBuilder 只重跑自己的 builder,外层组件的 build 一次都不多跑——用它包住页面里高频变化的小区域;builder 内不变的大子树还能通过 child 参数摘出去。

把对象放进树里、子级按类型取用,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>(); // 订阅变化
两个经典误用:在事件回调里用 watch——它只允许在 build 中调用,否则抛错;上层忘包对应 Provider,子级取用时抛 ProviderNotFoundException。
取用三件套按场景分工: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 的做法
取不到就运行时ProviderNotFoundExceptionprovider 是全局声明的顶层变量,编译期就知道存不存在
依赖 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 不存在。
选型心法:局部状态 setState → 跨页面/异步/需测试才上 Riverpod。先有真实痛点,再引入它。

事件进、状态出的单向数据流,用样板代码换可预测性与可测试性。

事件进,状态出

  • 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'));
emit 按相等性判重:mutate 旧 state 后 emit 同一个对象,界面不会刷新——每次都要发不可变的新实例(copyWith 配 Equatable 是标准解法)。
从 Cubit 起步:方法直接 emit 新状态、样板最少;状态类用 sealed class 建模,UI 里 switch 穷尽分支,漏一个编译器当场提醒——等事件复杂了再升级成 Bloc。

看状态的作用域、是否异步、团队约定三个维度,从最简单的方案开始按需升级。

三个维度,从简单开始

  • 局部、临时:setState / ValueNotifier。
  • 小范围共享:状态提升 + 回调,或 InheritedWidget。
  • 跨页面 + 异步 + 可测试:Riverpod(社区现代默认之一)。
  • 大型、强约定、团队:Bloc。
  • 原则:从简单开始,被真实复杂度推着升级,而不是预先套最重的方案。

一张表对上号

状态的样子方案
只服务当前组件(开关、当前 tab)setState
当前组件里某一小块频繁变ValueNotifier + ValueListenableBuilder
父子或隔一层共享状态提升 + 回调
穿透多层、跨页面Riverpod(或存量项目的 Provider)
再加上异步三态、缓存、去重RiverpodAsyncNotifier
大团队、强约定、要可追溯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++;
}
两个方向都会出错:给一个开关上 Bloc,样板远超业务;用 setState 硬扛全局状态,掉进层层传参的泥潭。更糟的是同一项目并存多套全家桶——框架是工具不是信仰。
一张表记住:局部临时→setState;轻量共享→ValueNotifier 或 InheritedWidget;跨页面+异步+要测试→Riverpod;大团队强约定→Bloc。同一项目页面内 setState、全局用一套框架,混搭是常态。

导航与路由

Navigator 命令式基础,go_router 声明式现代方案。

页面就是一个栈:push 压入新页,pop 弹回上一页。

页面就是一个栈

  • 页面是一个Navigator.push 入栈、Navigator.pop 出栈。
  • MaterialPageRoute 提供平台默认转场动画。
  • pop 可携带返回值;push 返回 Future,await 即可拿到。
  • 适合简单 App 与局部跳转;深链接/Web 场景能力有限。

push 返回的 Future,什么时候完成

  • Navigator.push 返回 Future<T?>,它在被推入的那个页面 pop 时才完成,值就是 pop 的第二个参数;
  • 所以「跳到选择页、选完回来拿结果」是一行 await,不需要回调、不需要全局状态——这是 Navigator 1.0 最好用的地方
  • 用户按系统返回键或 iOS 侧滑返回时,拿到的是 null这个分支必须处理
  • await 之后照例先判 mounted 再用 context——页面可能已经不在了。
final result = await Navigator.push(context,
  MaterialPageRoute(builder: (c) => const DetailPage()),
);
// 在 DetailPage 中:
Navigator.pop(context, '返回值');
在根路由上调 Navigator.pop 不报错,但会把最后一页也弹掉、留下空栈黑屏——先用 canPop 判断(根路由返回 false),或改用 maybePop(根路由时不弹、返回 false)。
Navigator.of(context) 与静态便捷方法等价;页面里嵌套了 Navigator(如带底栏的分栏导航)时,用 Navigator.of(context, rootNavigator: true) 才能把全屏页推到最外层栈。

构造参数把数据传进去,pop 的第二个参数把结果带回来。

构造参数进,pop 带回

  • 传参:直接把数据作为目标页 widget 的构造参数(类型安全,推荐)。
  • 返回值Navigator.pop(context, value),调用方 await 接收。
  • 避免用字符串 arguments 传强类型数据,易出错且无类型检查。
  • 需要返回结果的页面(如选择器、确认弹层),这是标准模式。

为什么别用字符串 arguments

// 推荐:类型安全,改了字段编译器会告诉你
Navigator.push(context, MaterialPageRoute(
  builder: (_) => DetailPage(user: user)));

// 不推荐:类型全丢,取的时候要强转
Navigator.pushNamed(context, '/detail', arguments: user);
final user = ModalRoute.of(context)!.settings.arguments as User;
  • 第二种写法把编译期检查全部换成了运行时强转:改了参数类型、传错了对象、忘了传,全都要等到运行时崩溃才知道;
  • 但命名路由与 Web URL 有天然联系——真需要 URL 同步时不该用 arguments 硬撑,而该上 go_router:它用路径参数 /user/:id 表达,再配代码生成拿回类型安全。
// 传入
Navigator.push(context, MaterialPageRoute(
  builder: (c) => EditPage(user: user)));
// 返回
final updated = await Navigator.push<User>(context, route);
if (updated != null) setState(() => user = updated);
用户按系统返回键或手势返回时不会经过你的 pop(value),await 拿到的是 null——返回值必须做判空处理,别假设一定有结果。
给 push 标注泛型如 Navigator.push<User>,返回值就有静态类型;await push 配目标页 Navigator.pop(context, value) 能原样拿到 value。

把路径到页面的映射集中在一张路由表里,跳转只认名字。

集中注册的路由表

  • MaterialApp(routes: {...}) 注册路径到 builder 的映射。
  • Navigator.pushNamed(context, '/detail') 按名字跳转。
  • 集中管理利于维护,但强类型传参较弱、嵌套/深链支持不足。
  • 中大型或需要 Web URL 同步的项目,建议直接上 go_router。

它的三个天花板

  • 传参没有类型——只能走 arguments,见上一张卡;
  • 嵌套路由支持弱:底部导航栏各 tab 要保留自己的页面栈时,用命名路由拼会很别扭;
  • 深链接与浏览器前进后退要自己处理:Web 上刷新页面、直接输入 URL、点浏览器返回,都需要额外工作;
  • 结论很直接:只有小 App、只跳几个固定页面时才用它。中大型或要跑 Web 的项目,从第一天就上 go_router——中途迁移的成本远高于一开始就用。
MaterialApp(routes: {
  '/': (c) => const HomePage(),
  '/settings': (c) => const SettingsPage(),
});
Navigator.pushNamed(context, '/settings');
pushNamed 一个未注册的名字直接抛「Could not find a generator for route RouteSettings("/nope", null) in the _WidgetsAppState.」,报错原文还给出了查找顺序:home → routes 表 → onGenerateRoute → onUnknownRoute。
routes 表覆盖不到的动态路径(如 /user/42)交给 onGenerateRoute 统一解析并返回 PageRoute;onUnknownRoute 可做 404 兜底页。

把页面栈变成应用状态的函数,URL 与界面自动保持同步。

页面栈是状态的函数

  • 声明式:页面栈由应用状态决定,而非命令式 push/pop。
  • 解决 Web 地址栏同步、深链接、复杂返回栈等问题。
  • 原始 API(Router/RouterDelegate/RouteInformationParser)较繁琐。
  • 实践中很少直接手写,而是用封装好的库(go_router)——但理解其思想很有用。

为什么要有这么一套

  • 命令式 push/pop 的根本问题是:页面栈的真相只存在于框架内部,你没法从应用状态推出「现在应该显示什么」;
  • 而 Web 要求反过来:URL 是真相——用户直接输入一个地址、按浏览器返回、分享链接给别人,界面都必须能从 URL 恢复出来;
  • Navigator 2.0 的思路是把整个页面栈变成一个由应用状态计算出来的列表:状态变 → 重新算出栈 → 框架 diff 出该 push/pop 谁。这与 widget 的声明式思路完全一致;
  • 但原始 API 极其繁琐RouterDelegate + RouteInformationParser + BackButtonDispatcher,一个最简单的应用要写上百行)——所以实践中几乎没人手写,理解思想、使用 go_router 是正确姿势。
// Navigator 2.0 原始 API 骨架:页面栈是「状态的函数」,实践中很少手写
class AppRouterDelegate extends RouterDelegate<Uri>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<Uri> {
  @override
  Widget build(BuildContext context) => Navigator(
        key: navigatorKey,
        pages: [
          const MaterialPage(child: HomePage()),   // 页面栈由应用状态推导
          if (loggedIn) const MaterialPage(child: ProfilePage()),
        ],
        onDidRemovePage: (page) { /* 出栈时回写应用状态 */ },
      );
  // setNewRoutePath / currentConfiguration 负责与 URL 双向同步
}

// 接入:MaterialApp.router 挂上 delegate + parser(GoRouter 见下一卡)
MaterialApp.router(
  routerDelegate: AppRouterDelegate(),
  routeInformationParser: AppRouteParser(),
);
(dart analyze):老接口 onPopPage 已弃用,换成 onDidRemovePage——网上大量 Navigator 2.0 教程还是旧写法;手写 delegate 时漏掉出栈回写状态,会导致返回键与 URL/状态不同步。
抓住两个角色就够了:RouteInformationParser 把 URL 解析成应用状态,RouterDelegate 把状态渲染成 Navigator 的 pages 列表;日常项目直接用 go_router 这类封装,读懂本卡是为了看懂它们在做什么。

官方维护的声明式路由包,深链、Web URL、路由守卫一次到位。

声明式路由的事实标准

  • 用路径字符串声明路由树,自动处理 Web URL、深链接、浏览器前进后退。
  • context.go('/x') 替换栈、context.push('/x') 压栈。
  • redirect 做登录守卫;ShellRoute 实现带底部导航的持久外壳。
  • 支持路径参数 /user/:id、查询参数、类型安全跳转(配合代码生成)。
  • 是目前 Flutter 路由的事实标准选择之一。

四件事一次到位

能力写法解决什么
路径参数/user/:idWeb URL 与深链接
登录守卫redirect:未登录统一跳登录页
持久外壳ShellRoute底部导航栏切 tab 时外壳不重建
栈语义context.go vs context.push替换 vs 压栈
  • gopush 的区别是最常搞混的一处go('/x') 是「导航到」,会按路由树重建整个栈(返回键回到父路径);push('/x') 是「压一层上去」(返回键回到刚才那页)。选错的表现是返回键行为诡异
  • redirect 是纯函数,每次导航都会跑——里面别做异步请求,登录态要预先放在可同步读到的地方(配合 refreshListenable 在登录态变化时重算);
  • 它由 Flutter 官方维护,是目前事实标准;配 go_router_builder 做代码生成还能把跳转也变成类型安全的。
final router = GoRouter(routes: [
  GoRoute(path: '/', builder: (c, s) => const HomePage()),
  GoRoute(path: '/user/:id', builder: (c, s) =>
    UserPage(id: s.pathParameters['id']!)),
]);
context.go('/user/42');
context.go 不是压栈:它按新路径重建整个栈,返回键行为与 push 不同;从深链直接进入的页面栈上可能没有「上一页」,返回逻辑要按 go 的语义设计,混用 go/push 时尤其容易出乎意料。
若你用过 Vue Router / React Router,go_router 的声明式心智模型可直接迁移过来,上手会很快。

数据与网络

FutureBuilder、HTTP、JSON 序列化、本地存储与分层架构。

把 Future 或 Stream 的「加载中/有数据/出错」三态直接映射成 UI 分支,是最轻量的异步绑定方式。

三态映射成 UI 分支

  • FutureBuilder 监听一个 Future,按「未完成/有数据/出错」渲染不同 UI。
  • StreamBuilder 同理监听流,持续更新。
  • snapshot.connectionStatesnapshot.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

需求httpdio
发个 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
JSON 数组解码后是 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.dartpart 挂在源文件上,改字段后需重跑生成器。

成本与收益的交叉点

  • 收益:字段改名、增删时,生成器保证 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);
}
改改了字段忘记重跑 build_runner 是这里最高频的坑——.g.dart 还是旧的,轻则编译报错,重则序列化悄悄丢字段。开发期挂一个 watch 就能免掉(12 章)。
JSON 字段名与 Dart 命名不一致时用 @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;
它是明文落盘的,token、密码放这等于裸奔,敏感数据用 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)。

四个候选,两条路线

方案路线特点代价
sqfliteSQL原生 SQLite,控制力最强手写 SQL 与映射,样板多
driftSQL类型安全 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');
改表结构没写迁移(sqflite 的 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);
  }
}
最常见的走样是「转发层」:Repository 只把 API 方法原样代理一遍,UI 里照旧在 jsonDecode、拼 URL;数据转换与错误归一必须收在仓库内,DTO 不该泄漏到 UI 层。
给 Repository 抽接口、在装配处注入实现(构造注入或 Riverpod 覆盖),测试时换成内存假实现,整条业务逻辑就能离线跑通。

动画与绘制

隐式动画起步,显式动画控场,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 即平滑过渡
隐式动画补间的是「旧值到新值」:AnimatedContainer 宽 100→200,走到 150/300ms 时恰为 150(默认线性);但 widget 首次插入树时没有旧值、不会播放入场动画——要「出现即动画」得用显式动画或 TweenAnimationBuilder。另外 AnimatedSwitcher 切换同类型子级必须换 key:无 key 时新旧 Text 不做过渡(树上只有 1 个),带不同 ValueKey 才有交叉淡入(过渡中 2 个并存)。
没有现成 AnimatedXxx 的属性(自定义 double、颜色映射)用 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 不是「可能泄漏」而是必炸:flutter test 中移除未 dispose 的 controller,报「_LeakyState#360b3(ticker active) was disposed with an active Ticker.」并明示「Tickers used by AnimationControllers should be disposed by calling dispose() on the AnimationController itself. Otherwise, the ticker will leak.」——在 dispose() 里先 _c.dispose()super.dispose()
多个动画共用一个 controller,用 Interval 切时间片就能做出依次入场的 stagger 效果;单 Ticker 用 SingleTickerProviderStateMixin,多个 controller 才换 TickerProviderStateMixin

两个页面、同一个 tag,Flutter 自动补出中间的飞行轨迹。

同 tag 自动飞行

  • 两个页面里给同一 tagHero 包裹同一元素,跳转时自动「飞」过去。
  • 常用于:列表缩略图 → 详情大图。
  • 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)),
同一路由里出现两个相同 tag 的 Hero,转场一开始就会报错——列表页务必用 'avatar-$id' 这类带数据 id 的动态 tag。且 Hero 只在 PageRoute 转场时触发,showDialog 弹层默认不会飞。
给 Hero 的 child 包一层 Material:飞行途中 widget 脱离原页面的 Material 环境,Text 会出现黄色双下划线的「无 Material」样式。(真机转场体验,按文档写)

显式动画的视觉端:把一个 Animation 接到透明度、位移、缩放、旋转上。

把 Animation 接到视觉属性

  • FadeTransition/SlideTransition/ScaleTransition/RotationTransition
  • 接收一个 Animation,把它应用到对应视觉属性,比手写 AnimatedBuilder 简洁。
  • 可叠加组合多种 transition 做复合动画。
  • Curves 决定节奏:easeInOutelasticOutbounceOut 等。

它们比 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 手写。
命名记法:AnimatedXxx 自带控制器(隐式),XxxTransition 要你喂 Animation(显式)——读代码时看到后缀就知道谁在管时间轴。

当组合 widget 表达不了你要的图形时,拿起 Canvas 自己画。

直接拿 Canvas 画

  • 继承 CustomPainter,在 paint(canvas, size) 里用 Canvas API 绘制。
  • canvas.drawCircle/drawPath/drawLinePaint 控制颜色、描边、填充。
  • 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(生产用)、betamain(最新但不稳)。
  • 每个 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 一起跳版本,可能引发依赖解析失败或行为变化;升级放在迭代初而不是交付前夜,升完先整体跑一遍测试。
在 pubspec 的 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 .
IDE 里看到的红线和 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 包名 添加;dependencies vs dev_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/
YAML 缩进错一格整个文件就解析失败;应用项目务必提交 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 } // 枚举成员增删、类签名级变化
Hot reload 重跑 build 但不重跑 initState、全局与 static 初始化器——改这些逻辑后「看起来没生效」是旧值还在内存里,不是代码写错了。
改 UI 细节按 r;怀疑旧状态污染就直接 R 清零;连 R 都救不了的(原生代码、插件、pubspec 资源声明)才需要重跑 flutter run

DevTools 是浏览器里的一站式排障台:先用数据定位,再动手改代码。

先用数据定位

  • Widget Inspector:可视化 widget 树、查看约束、定位布局问题。
  • Performance:抓帧、找掉帧(jank)、看 build/raster 耗时。
  • Memory:排查泄漏(常见于忘记 dispose 控制器/订阅)。
  • CPU Profiler / Network:定位热点函数与网络请求。
  • 配合 debugPrintassertflutter 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     请求时序
别在 debug 构建里量性能——debug 模式没有编译优化,数据严重失真;测性能一律 flutter run --profile 再连 DevTools。
布局不对先开 Inspector 点选 widget 看真实约束与尺寸,比盯代码猜快得多;掉帧看 Performance 分清是 build 慢还是 raster 慢,两边的优化手段完全不同。

下一张卡讲的三层测试全都建在同一个包上package:testflutter_testintegration_test 只是在它之上加了 WidgetTester 和设备驱动——test / group / expect / setUp 这套 API 三层通用。先把底座讲清楚,上面那两层才好理解。

装与跑

  • 不在 SDK 里,要写进 pubspec.yamldev_dependenciestest: ^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:equalsisA<T>()isNull / isNotNullcontainshasLengthcloseTo(值, 容差)(浮点,closeTo(0.3, 1e-9) 接住 0.1 + 0.2)、throwsA(isA<StateError>())
  • 断言抛错时要传函数而不是调用结果expect(() => avg([]), throwsA(...)),写成 expect(avg([]), ...) 会先把异常抛在 expect 外面。

异步:忘了 await 会安静地测错东西

  • 用例回调写成 asyncawait 即可。漏掉 await 时断言拿到的是 Future 本身——expect(slow(), isA<Future<int>>()) 通过,这条用例本身就是那个坑的证据:你以为在比 7,其实在比一个 Future 对象;
  • 不想写 async 时用 completion matcher:expect(slow(), completion(equals(7)))——它会等 Future 落定再比,通过;
  • StreamemitsInOrderexpect(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_testWidgetTester 渲染并交互单个组件。
  • 集成测试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),一旦出现分层就名存实亡。
检验分包合理性的土办法:假想删掉一个 feature 目录,其余代码应当只有路由注册处受影响;domain 层保持纯 Dart(不 import Flutter),普通单元测试即可全覆盖。

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_runner 2.15 起再传只会得到一句 These options have been removed and were ignored——网上教程里那条命令现在是空转,冲突输出默认就会被处理;
  • 开发时挂 watchdart 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_localizationsintl,在 MaterialApplocalizationsDelegates
  • .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),热重载不保证触发重新生成。
把 template-arb-file 指定的模板文件当唯一事实源,其他语言缺 key 时生成器会提示未翻译条目;带参数的文案用占位符交给 intl 处理,别在 Dart 里拼字符串。

三条路调原生: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 生成双端类型安全的桥接代码
MethodChannel 走标准编解码,只支持基础类型与 List/Map,自定义对象要先拆成 Map;两端通道名或方法名拼写不一致时 Dart 端抛 MissingPluginException,排错先对拼写。
选型口诀:临时调一两个原生 API 用 MethodChannel;接口多、参数复杂用 Pigeon 生成双端代码免手写序列化;对接 C/C++/Rust 库用 dart:ffi

一套 Dart 代码编到六个平台,但平台差异要在代码与依赖两层显式处理。

一套代码,六个平台

  • 同一代码库可编译到移动、Web、桌面(Windows/macOS/Linux)。
  • 平台差异用 Platform.isIOSkIsWebdefaultTargetPlatform 判断。
  • 不可在 Web 用的库用条件导入import ... if (dart.library.html) ...)隔离。
  • Web 需关注:首屏体积、SEO 局限、URL 路由(用 go_router)。

平台差异要在两层处理

  • 代码层kIsWeb 判 Web、defaultTargetPlatform 判平台。注意 Platform.isIOS 在 Web 上会直接抛异常dart:io 在 Web 上不可用)——所以判断顺序必须是kIsWebPlatform.*
  • 依赖层:用不了的库要用条件导入隔离(import 'stub.dart' if (dart.library.io) 'io_impl.dart';),否则编 Web 时直接编不过;
  • Web 还有三件必须单独考虑的事:首屏体积(Flutter Web 的起步包不小)、SEO 基本没有(内容画在 canvas 上)、URL 路由要用 go_router 才能让地址栏正常工作;
  • 还有 04 章那条:Web 上没有 isolatecompute 退化成同步执行——移动端不卡的重活,到 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 目标会直接报错——判断平台先用 kIsWebdefaultTargetPlatform(来自 foundation,全平台可用),而不是无条件 import dart:io
选包先看 pub.dev 的平台支持标签:纯 Dart 包全平台通吃,带原生代码的插件未必支持 Web/桌面;跨端分歧用条件导入隔离,别指望运行时兜底。

全景章说 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 的工作量。

减少每帧的工作量

  • 尽量 const widget;长列表用 .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/symbols
keystore 丢失就无法再给老应用签发更新(除非当初开启了 Play 应用签名托管);key.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 地图。
最常见的学习误区是「只会抄 widget、不懂约束模型」:Demo 都能跑,一改布局就 overflow 束手无策——卡住时回布局章把「约束向下、尺寸向上」重走一遍再动手。同理别跳过纯 Dart 阶段直接背 Flutter 模板:空安全、异步、集合这些语言基础决定你读懂报错的速度。
一条自测标准:给你一个公开 REST API(比如天气),你能否在一天内交付一个带 Riverpod 状态管理、加载/错误态处理与本地缓存的 Flutter 应用,并全程用热重载调通每个页面——能做到,这一页就毕业了。