Flutter 基础体系 · 第 27/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Dart Record 与模式匹配:解构、switch、封闭建模和返回值
Dart 3 引入了两组相互配合的语言能力:
- Record:把多个值组合成一个匿名、不可变的数据结构;
- 模式匹配(pattern matching):按照数据的结构和类型进行判断,并在判断成功时提取内部值。
它们共同解决了几类常见问题:
// 以前通常需要定义一个专门的类,或者通过 List<dynamic> 传递多个返回值。
final result = calculatePosition();
// 使用 Record,可以直接返回两个有明确类型的值。
final (x, y) = result;
模式匹配则进一步允许我们把“判断类型、判断字段、提取字段、计算返回值”写在同一个结构中:
final text = switch (state) {
Loading() => '加载中',
Loaded(:final data) => '数据:$data',
Failed(:final message) => '失败:$message',
};
这不是字符串比较或运行时反射,而是 Dart 分析器和编译器理解的静态语言结构。
1. Record 是什么
Record 是一种匿名的、不可变的、结构化数据类型。它可以包含:
- 按位置访问的字段,称为 positional fields;
- 按名称访问的字段,称为 named fields;
- 两者的组合。
1.1 位置字段
final point = (3, 4);
print(point.$1); // 3
print(point.$2); // 4
point 的静态类型是:
(int, int)
位置字段使用 $1、$2 等编号访问。编号从 $1 开始,而不是 $0。
如果字段类型不同,可以直接表达:
final user = ('Ada', 37);
// 类型为 (String, int)
print(user.$1); // Ada
print(user.$2); // 37
Record 字段是不可变的,因此下面的代码不能通过编译:
final point = (3, 4);
// point.$1 = 5; // 编译错误
这里的 final 只表示变量不能重新指向其他 Record;即使变量声明为 var,Record 本身的字段也不能修改。
1.2 命名字段
命名字段写在大括号中:
final point = (x: 3, y: 4);
print(point.x); // 3
print(point.y); // 4
它的类型是:
({int x, int y})
命名字段的名称属于 Record 的类型信息。下面两个类型不是同一个类型:
({int x, int y})
({int latitude, int longitude})
即使它们的字段数量和字段类型完全相同,字段名不同也会导致类型不同。
1.3 位置字段与命名字段可以组合
final response = (200, body: 'OK');
print(response.$1); // 200
print(response.body); // OK
对应的类型是:
(int, {String body})
位置字段和命名字段的语法位置不同,因此下面的代码不是同一种 Record:
final a = (200, 'OK'); // (int, String)
final b = (200, body: 'OK'); // (int, {String body})
1.4 Record 的结构化类型与相等性
Record 不需要预先声明类名。只要两个 Record 的结构和字段类型兼容,它们就可以使用相同的 Record 类型。
Record 还具有结构化的相等性:
final p1 = (1, 2);
final p2 = (1, 2);
print(p1 == p2); // true
命名字段也参与相等性判断:
final a = (x: 1, y: 2);
final b = (x: 1, y: 2);
final c = (x: 2, y: 1);
print(a == b); // true
print(a == c); // false
这使 Record 适合作为临时值、函数返回值或简单的复合键。但它并不适合承载复杂领域行为:
// 适合表示临时计算结果
(int, int) minMax(List<int> values) {
// ...
return (0, 0);
}
如果数据需要校验规则、方法、文档语义、继承层次或长期作为公共 API,命名类通常比匿名 Record 更清晰。
2. Record 作为函数返回值
函数可以直接返回 Record 类型:
({int min, int max}) minMax(List<int> values) {
if (values.isEmpty) {
throw ArgumentError('values 不能为空');
}
var min = values.first;
var max = values.first;
for (final value in values.skip(1)) {
if (value < min) min = value;
if (value > max) max = value;
}
return (min: min, max: max);
}
调用时可以通过字段名访问:
void main() {
final result = minMax([7, 2, 9, 4]);
print(result.min); // 2
print(result.max); // 9
}
这个函数的契约明确说明了返回值包含 min 和 max,调用者不需要记忆 $1 和 $2 分别代表什么。
也可以使用位置 Record:
(int min, int max) minMaxByPosition(List<int> values) {
if (values.isEmpty) {
throw ArgumentError('values 不能为空');
}
var min = values.first;
var max = values.first;
for (final value in values.skip(1)) {
if (value < min) min = value;
if (value > max) max = value;
}
return (min, max);
}
这里的 (int min, int max) 中,min 和 max 是文档化的字段注释名称,但它们仍然是位置字段,调用时应使用 $1 和 $2:
final result = minMaxByPosition([7, 2, 9, 4]);
print(result.$1); // 2
print(result.$2); // 9
如果需要按名称访问,必须使用命名字段:
({int min, int max}) minMaxByName(List<int> values) {
// ...
return (min: 2, max: 9);
}
2.1 类型推断与显式 Record 类型
下面的变量类型会被推断为 ({String name, int age}):
final user = (name: 'Ada', age: 37);
也可以显式声明类型:
final ({String name, int age}) user = (
name: 'Ada',
age: 37,
);
显式声明的价值主要在于:
- 让公共函数的输入和输出契约更加清楚;
- 让编译器检查字段类型;
- 避免因为字段名或位置结构错误而在后续代码中暴露问题。
例如:
({String name, int age}) createUser() {
return (
name: 'Ada',
age: 37,
);
}
下面的返回值不能匹配该类型:
// return ('Ada', 37); // 这是 (String, int),不是命名字段 Record
3. 什么是模式
模式是一个用于匹配值的结构。模式可以同时完成两件事:
- 判断一个值是否符合某种结构;
- 从匹配成功的值中提取变量。
例如:
final (x, y) = (3, 4);
print(x); // 3
print(y); // 4
(x, y) 不是一个普通的 Record 表达式,而是一个Record 模式:
- 右侧
(3, 4)是 Record 表达式; - 左侧
(x, y)是用于解构的模式; - 匹配成功后,
x绑定为3,y绑定为4。
因此,Record 和模式在语法上可能相似,但角色不同:
final value = (3, 4); // Record 表达式
final (x, y) = value; // Record 模式
3.1 解构位置 Record
final point = (3, 4);
final (x, y) = point;
print(x); // 3
print(y); // 4
等价于显式访问:
final point = (3, 4);
final x = point.$1;
final y = point.$2;
但模式的优势是可以和判断、类型检查、switch 同时使用。
3.2 解构命名 Record
final point = (x: 3, y: 4);
final (:x, :y) = point;
print(x); // 3
print(y); // 4
:x 是命名字段的简写,含义接近:
(x: var x, y: var y)
为了让初学者更容易辨认绑定关系,也可以写成完整形式:
final (x: var x, y: var y) = point;
注意,命名字段的模式必须匹配字段名称:
final point = (x: 3, y: 4);
// final (:latitude, :longitude) = point;
// 编译错误:Record 中没有 latitude 和 longitude 字段
3.3 忽略不需要的字段
使用下划线可以忽略字段:
final point = (x: 3, y: 4);
final (:x, y: _) = point;
print(x); // 3
在同一个模式中,多个 _ 不会相互冲突,因为它们都表示“不绑定这个值”:
final (first, _, _) = (1, 2, 3);
3.4 类型模式与变量模式
变量模式可以进一步声明类型:
final value = (name: 'Ada', age: 37);
final (name: String, age: int) = value;
这里的模式要求:
name必须是String;age必须是int;- 匹配成功后分别绑定局部变量。
也可以使用对象模式,对类实例进行类型匹配和字段提取:
class User {
final String name;
final int age;
const User(this.name, this.age);
}
void printUser(Object value) {
if (value case User(name: var name, age: var age)) {
print('$name, $age');
}
}
User(name: var name, age: var age) 同时完成了:
- 判断
value是否是User; - 读取
name和agegetter; - 将读取结果绑定到局部变量。
这里依赖的是对象的 getter,而不是直接访问内部字段。因此字段通常需要通过公开 getter 暴露。
4. 可反驳模式与不可反驳模式
理解模式必须区分两个概念。
4.1 不可反驳模式
**不可反驳模式(irrefutable pattern)**是指:在该语法位置,模式必须能够匹配输入值;如果无法保证,就不能使用。
变量声明通常要求模式不可反驳:
final (x, y) = (1, 2);
因为右侧静态类型是 (int, int),它必然具有两个位置字段,所以这个解构是安全的。
如果输入是 Object:
Object value = (1, 2);
// final (x, y) = value;
// 通常无法通过静态检查,因为 Object 不保证是 Record
需要先判断类型:
if (value case (int x, int y)) {
print(x + y);
}
这里 if (value case pattern) 允许模式匹配失败,因此使用的是可反驳模式。
4.2 可反驳模式
**可反驳模式(refutable pattern)**可能匹配失败,常见于:
if-case;switch的某个 case;while-case;- 带具体常量、类型或结构限制的模式。
Object value = (1, 2);
if (value case (int x, int y)) {
print(x + y);
} else {
print('value 不是两个整数构成的 Record');
}
执行过程是:
- 读取
value; - 检查它是否是包含两个位置字段的 Record;
- 检查两个字段是否都是
int; - 全部成功后,才在
if分支中绑定x和y; - 任一步失败,都进入
else。
这种作用域限制很重要:模式中绑定的变量只在匹配成功的分支内可用。
5. 在赋值中使用模式
模式不仅可以声明新变量,还可以给已经存在的变量赋值:
var x = 0;
var y = 0;
(x, y) = (10, 20);
print(x); // 10
print(y); // 20
这与下面的代码不同:
final (x, y) = (10, 20);
后者声明了新的局部变量;前者更新已有变量。
赋值模式要求左侧变量已经存在:
var x = 0;
// (x, y) = (10, 20);
// 编译错误:y 尚未声明
赋值模式适合在已有状态变量之间交换或批量更新:
var left = 1;
var right = 2;
(left, right) = (right, left);
print(left); // 2
print(right); // 1
模式并没有绕过类型检查。若变量类型不兼容,赋值仍会失败:
int count = 0;
// (count) = ('not an int'); // 编译错误
6. switch 语句:根据模式执行分支
传统 switch 通常使用常量或枚举值:
switch (command) {
case 'start':
print('启动');
case 'stop':
print('停止');
}
Dart 3 的 switch 语句可以使用模式:
void describe(Object value) {
switch (value) {
case int n when n > 0:
print('正整数:$n');
case int n:
print('非正整数:$n');
case String text:
print('字符串:$text');
default:
print('其他类型');
}
}
匹配过程按 case 从上到下进行:
- 如果
value是int且n > 0,匹配第一个 case; - 否则,如果
value是int,匹配第二个 case; - 否则,如果
value是String,匹配第三个 case; - 其余值由
default处理。
when 是守卫条件(guard)。它只有在模式本身匹配成功后才会计算。
因此:
case int n when n > 0:
不是先把任意值与 0 比较,而是先确认值是 int,绑定 n,再计算 n > 0。
6.1 case 的顺序影响结果
下面的第二个 case 永远不会处理正整数:
switch (value) {
case int n:
print('整数:$n');
case int n when n > 0:
print('正整数:$n'); // 不可达或会产生分析器警告
}
因为所有 int 已经在第一个 case 中被匹配。更具体的模式应该放在更一般的模式之前:
switch (value) {
case int n when n > 0:
print('正整数:$n');
case int n:
print('其他整数:$n');
}
这不是代码风格问题,而是模式匹配的控制流规则:匹配成功后,后续 case 不再参与。
7. switch 表达式:根据模式产生返回值
switch 语句负责执行分支,switch 表达式负责产生一个值:
final label = switch (value) {
int n when n > 0 => '正整数:$n',
int n => '整数:$n',
String text => '字符串:$text',
_ => '其他类型',
};
每个分支使用 => 返回表达式,整个 switch 表达式的值就是第一个匹配分支的结果。
它特别适合实现“输入状态到输出值”的纯转换:
String formatValue(Object value) {
return switch (value) {
int n => '整数 $n',
double n => '小数 $n',
String text => '文本 $text',
_ => '未知值',
};
}
也可以直接返回 Record:
({String kind, Object value}) normalize(Object input) {
return switch (input) {
int value => (kind: 'int', value: value),
double value => (kind: 'double', value: value),
String value => (kind: 'string', value: value),
_ => (kind: 'other', value: input),
};
}
调用:
void main() {
final result = normalize(42);
print(result.kind); // int
print(result.value); // 42
}
这里有两层返回值:
switch表达式根据input选择一个分支;- 被选中的分支构造并返回一个命名 Record。
如果某个分支没有返回值,表达式就不完整:
// final text = switch (value) {
// int n => '整数 $n',
// };
// 可能存在无法匹配的值,因此编译器要求补充覆盖范围。
通常可以使用 _ 处理剩余情况:
final text = switch (value) {
int n => '整数 $n',
_ => '其他',
};
_ 是通配模式,不绑定变量。
8. Record 与模式的完整结合
下面通过一个可运行的例子,把 Record、解构、守卫和 switch 表达式放在一起。
typedef Point = ({double x, double y});
({String region, double distance}) classifyPoint(Point point) {
return switch (point) {
(x: 0, y: 0) => (
region: 'origin',
distance: 0,
),
(x: var x, y: var y) when x.abs() + y.abs() <= 10 => (
region: 'near',
distance: x.abs() + y.abs(),
),
(x: var x, y: var y) => (
region: 'far',
distance: x.abs() + y.abs(),
),
};
}
void main() {
final point = (x: 3.0, y: 4.0);
final (region: region, distance: distance) = classifyPoint(point);
print(region); // near
print(distance); // 7.0
}
逐步分析 classifyPoint:
Point是类型别名,等价于({double x, double y});- 函数参数
point保证具有x和y两个double命名字段; - 第一个模式
(x: 0, y: 0)只匹配原点; - 第二个模式提取
x、y,然后由when检查曼哈顿距离是否不超过10; - 第三个模式仍然提取
x、y,作为其余所有合法 Point 的兜底; - 每个分支都返回相同结构的
({String region, double distance}); - 调用处再次使用命名 Record 模式解构返回值。
这里的 distance 使用的是曼哈顿距离:
其中:
- 是点的横坐标;
- 是点的纵坐标;
- 和 是绝对值;
near的条件是 。
例如 (3, 4) 的距离是:
因此匹配第二个 case。
如果使用欧氏距离,则应改成:
这属于业务规则变化,而不是模式匹配机制变化。模式只负责结构匹配和条件分支。
9. 列表模式、映射模式与 Record 模式的区别
模式并不限于 Record。
9.1 列表模式
final values = [10, 20, 30];
if (values case [var first, var second, var third]) {
print(first); // 10
print(second); // 20
print(third); // 30
}
这个模式要求列表正好有三个元素。
可以使用剩余元素模式:
if (values case [var first, ...var rest]) {
print(first); // 10
print(rest); // [20, 30]
}
列表模式适合处理结构已知的列表。它不等同于普通索引访问:
final first = values[0];
索引访问如果下标越界会抛出异常;列表模式在 if-case 中则只是匹配失败。
9.2 映射模式
final json = <String, Object?>{
'name': 'Ada',
'age': 37,
};
if (json case {
'name': String name,
'age': int age,
}) {
print('$name, $age');
}
映射模式检查指定键和值的类型。它通常适合解析已经经过基本边界检查的 JSON-like 数据。
映射模式并不自动保证映射只包含这些键:
final json = <String, Object?>{
'name': 'Ada',
'age': 37,
'extra': true,
};
上面的值仍然可以匹配只声明 name 和 age 的映射模式,因为未声明的额外键不会因此自动导致失败。
如果需要严格验证输入,应显式检查允许的键集合,或者先使用专门的 JSON 校验和数据转换逻辑。模式匹配不能替代完整的输入验证。
10. 封闭建模:sealed class 为什么重要
Record 适合表达“一个值中包含哪些数据”;但当一个状态有多个互斥变体时,通常需要类层次结构。
例如网络请求可能有三种状态:
sealed class LoadState {
const LoadState();
}
final class Loading extends LoadState {
const Loading();
}
final class Loaded extends LoadState {
final List<String> data;
const Loaded(this.data);
}
final class Failed extends LoadState {
final Object error;
final String message;
const Failed(this.error, this.message);
}
这里的 sealed 表示 LoadState 是一个封闭的父类型:分析器可以知道它允许哪些直接子类型。Loading、Loaded 和 Failed 使用 final,表示它们不会继续产生子类。
这使得 switch 可以进行穷尽性检查:
String renderState(LoadState state) {
return switch (state) {
Loading() => '加载中',
Loaded(data: var data) => '已加载 ${data.length} 项',
Failed(message: var message) => '加载失败:$message',
};
}
此处没有 _,仍然可以通过分析,因为三个直接子类型已经覆盖了 LoadState 的所有可能变体。
10.1 穷尽性是如何成立的
对 LoadState 进行匹配时,可以把可能值集合抽象为:
三个模式覆盖的集合分别是:
因为:
所以 switch 是穷尽的。
如果漏掉 Failed:
String renderState(LoadState state) {
return switch (state) {
Loading() => '加载中',
Loaded(data: var data) => '已加载 ${data.length} 项',
};
}
分析器会报告 switch 可能无法匹配所有 LoadState。这通常是编译错误,而不是等到用户运行到该路径时才发现。
10.2 Nullable 类型会额外引入 null
如果表达式类型是 LoadState?,可能值集合变成:
原来的三个 case 只覆盖前三项,因此还需要处理 null:
String renderNullableState(LoadState? state) {
return switch (state) {
null => '没有状态',
Loading() => '加载中',
Loaded(data: var data) => '已加载 ${data.length} 项',
Failed(message: var message) => '加载失败:$message',
};
}
或者使用通配模式:
String renderNullableState(LoadState? state) {
return switch (state) {
Loading() => '加载中',
Loaded(data: var data) => '已加载 ${data.length} 项',
Failed(message: var message) => '加载失败:$message',
_ => '没有状态',
};
}
显式写出 null 通常更能表达业务意图;_ 则适合确实不关心剩余类型的场景。
10.3 sealed 不等于运行时防篡改
sealed 主要提供静态建模和穷尽性分析。它不能把外部输入自动变成安全的状态对象,也不能阻止不安全的 dynamic 使用:
dynamic value = getExternalValue();
// dynamic 会削弱静态类型检查,不能把它当成可靠的 LoadState。
正确做法是先在边界处解析和校验外部数据,再构造 LoadState:
LoadState parseState(Map<String, Object?> json) {
final status = json['status'];
return switch (status) {
'loading' => const Loading(),
'loaded' => Loaded(
(json['data'] as List<Object?>).cast<String>(),
),
'failed' => Failed(
json['error'] ?? 'unknown error',
json['message'] as String? ?? 'unknown failure',
),
_ => throw FormatException('未知状态:$status'),
};
}
示例中的强制类型转换仍可能抛出 TypeError 或 FormatException。生产代码应根据实际协议进一步验证字段是否存在、类型是否正确以及列表元素是否合法。
11. 使用 sealed class 建模互斥结果,并返回 Record
可以把“成功或失败”的结果定义为封闭层次,再用 switch 表达式统一转换成 UI 或业务层需要的返回值。
sealed class Result<T> {
const Result();
}
final class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
final class Failure<T> extends Result<T> {
final Object error;
final String message;
const Failure(this.error, this.message);
}
({bool ok, String text}) describeResult<T>(Result<T> result) {
return switch (result) {
Success(value: var value) => (
ok: true,
text: '成功:$value',
),
Failure(message: var message) => (
ok: false,
text: '失败:$message',
),
};
}
void main() {
final Result<int> result = const Success(42);
final (:ok, :text) = describeResult(result);
print(ok); // true
print(text); // 成功:42
}
数据流可以概括为:
flowchart LR
A[Result<T>] --> B{switch 模式匹配}
B -->|Success| C[提取 value]
B -->|Failure| D[提取 message]
C --> E[返回 ok/text Record]
D --> E
关键点是:
Result<T>表示一个封闭的状态集合;Success和Failure是互斥变体;- switch 负责识别变体并提取字段;
- Record 负责把转换后的多个结果一次返回;
- 调用者可以再次解构 Record。
这种组合比返回 List<Object?> 更安全:
// 不推荐:字段位置和类型都依赖约定。
List<Object?> describeBad<T>(Result<T> result) {
return [true, '成功'];
}
List<Object?> 的问题是调用者必须自己记住:
- 第一个元素是什么;
- 第二个元素是什么;
- 每个元素实际是什么类型;
- 失败分支是否仍然返回同样结构。
命名 Record 会把这些信息直接放入静态类型中。
12. 模式匹配与 Flutter 状态渲染
Flutter Widget 通常根据状态返回不同的 Widget。模式匹配适合处理这种“一个状态对应一个 UI 结果”的逻辑:
import 'package:flutter/material.dart';
sealed class ScreenState {
const ScreenState();
}
final class ScreenLoading extends ScreenState {
const ScreenLoading();
}
final class ScreenData extends ScreenState {
final String title;
const ScreenData(this.title);
}
final class ScreenError extends ScreenState {
final String message;
const ScreenError(this.message);
}
class ResultView extends StatelessWidget {
final ScreenState state;
const ResultView({
super.key,
required this.state,
});
@override
Widget build(BuildContext context) {
return switch (state) {
ScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
ScreenData(title: var title) => Center(
child: Text(title),
),
ScreenError(message: var message) => Center(
child: Text(
message,
style: const TextStyle(color: Colors.red),
),
),
};
}
}
这个 build 方法的生命周期与普通 StatelessWidget 完全相同:
- Flutter 调用
build; - switch 根据当前
state选择一个模式; - 返回对应的 Widget;
- 状态对象变化并触发重建时,switch 重新计算。
模式匹配不会自动触发 Flutter 重建,也不会提供状态管理、异步调度或缓存。它只负责在当前调用中进行静态可检查的分支选择。
如果 ScreenState 新增了 ScreenEmpty:
final class ScreenEmpty extends ScreenState {
const ScreenEmpty();
}
所有对 ScreenState 进行穷尽 switch 的代码都需要补充对应分支。这是封闭建模的主要收益:状态扩展会显式暴露受影响的位置。
13. switch 语句与 switch 表达式的选择
两者都支持模式,但目的不同。
使用 switch 语句
当分支主要产生副作用时:
void logState(LoadState state) {
switch (state) {
case Loading():
print('loading');
case Loaded(data: var data):
print('loaded: ${data.length}');
case Failed(message: var message):
print('failed: $message');
}
}
这里的结果不是一个值,而是打印日志。
使用 switch 表达式
当每个分支都应产生一个统一类型的结果时:
String stateLabel(LoadState state) {
return switch (state) {
Loading() => 'loading',
Loaded() => 'loaded',
Failed() => 'failed',
};
}
switch 表达式要求每个分支返回可合并为同一静态类型的值。下面的代码会产生类型问题:
// final value = switch (state) {
// Loading() => 'loading',
// Loaded() => 1,
// };
一个分支返回 String,另一个返回 int。虽然 Dart 可以在某些上下文中推断出更宽的类型,但这种写法通常会损失调用者需要的明确契约。
14. 逻辑模式与守卫条件
模式可以组合逻辑条件。
14.1 when 守卫
String classifyNumber(int value) {
return switch (value) {
int n when n < 0 => '负数',
0 => '零',
int n when n > 0 => '正数',
};
}
对 int 来说:
n < 0覆盖负数;- 常量
0覆盖零; n > 0覆盖正数。
三个集合的并集覆盖所有整数。
14.2 逻辑或模式的绑定约束
可以使用逻辑或模式表达多个形状共享一个分支,但两侧必须提供兼容的变量绑定:
String describe(Object value) {
return switch (value) {
int n || double n => '数字:$n',
String text => '文本:$text',
_ => '其他',
};
}
int n || double n 的两侧都绑定名为 n 的变量,因此后续表达式可以使用 n。
如果只有一侧绑定变量,另一侧没有对应绑定,模式后的代码就无法确定变量是否存在,分析器会拒绝这种写法。逻辑或模式不是简单的布尔 ||,它还必须满足变量环境一致性。
15. 常见误解与失败表现
15.1 Record 不是 JSON
Record 是 Dart 语言中的值类型,不是 JSON 对象:
final user = (name: 'Ada', age: 37);
不能直接假设它可以被 jsonEncode 自动序列化为:
{"name":"Ada","age":37}
跨进程、网络或持久化边界时,应显式转换:
Map<String, Object> toJson(({String name, int age}) user) {
return {
'name': user.name,
'age': user.age,
};
}
这也适用于 Flutter 的平台通道。Android、iOS、桌面和 Web 之间传递的是平台通道支持的数据类型,而不是 Dart Record 的语言级结构。应在 Dart 侧转换成 Map、List、String、num、bool 或 null 等协议数据。
15.2 Record 没有命名类的语义身份
以下两个 Record 类型,即使字段结构相同,也不代表一个具有业务名称的领域实体:
({String name, int age})
({String name, int age})
它们在结构上兼容,但无法像 User 类那样自然地承载:
validateAge();copyWith();- 领域不变量;
- 构造函数校验;
- 对外稳定的 API 身份。
Record 更适合“返回两个值”“局部组合值”“短生命周期中间结果”。
15.3 模式匹配不是异常处理
final value = <String, Object?>{'age': 'not-an-int'};
if (value case {'age': int age}) {
print(age);
} else {
print('模式不匹配');
}
这里模式不匹配只会进入 else,不会自动抛出异常。
但如果代码使用强制转换:
final age = value['age'] as int;
则会在运行时抛出类型错误。模式匹配可以减少部分不安全转换,但不能让外部数据自动变得可信。
15.4 default 或 _ 会隐藏新变体
下面的写法可以通过编译:
String label(LoadState state) {
return switch (state) {
Loading() => 'loading',
_ => '其他',
};
}
但它把 Loaded、Failed 以及未来新增的状态都合并成了“其他”。如果业务上每种状态都需要独立处理,应该显式写出所有变体,让编译器在模型扩展时提醒你。
15.5 case 顺序错误会导致分支不可达
switch (value) {
case Object():
print('对象');
case String text:
print(text);
}
String 是 Object 的子类型,第一个模式已经覆盖了它。更具体的模式必须放在更一般的模式之前。
16. 泛型、可空值和静态类型边界
Record 可以包含可空字段:
final user = (name: 'Ada', nickname: null);
// 类型为 ({String name, Null nickname})
如果显式要求字段可空:
final ({String name, String? nickname}) user = (
name: 'Ada',
nickname: null,
);
解构后,nickname 仍然是 String?:
final (:name, :nickname) = user;
print(name); // String
print(nickname); // String?
需要在使用前进行空值提升或提供默认值:
final displayName = nickname ?? name;
泛型 Record 也可以作为函数返回值:
(T value, bool found) findFirst<T>(
Iterable<T> values,
bool Function(T value) test,
) {
for (final value in values) {
if (test(value)) {
return (value, true);
}
}
throw StateError('没有找到匹配值');
}
实际工程中,如果“未找到”是正常结果,不应通过异常表达,可以使用封闭 Result 类型或可空 Record:
(T value,)? findFirstOrNull<T>(
Iterable<T> values,
bool Function(T value) test,
) {
for (final value in values) {
if (test(value)) {
return (value: value);
}
}
return null;
}
调用时需要处理整个 Record 可能为 null:
final result = findFirstOrNull<int>(
[1, 2, 3],
(value) => value.isEven,
);
if (result case (value: var value)) {
print(value); // 2
}
这里的模式先检查外层 nullable Record 是否非 null,再解构其中的 value。
17. 版本、平台和工具链边界
Record、模式匹配、sealed class 和 switch 表达式属于 Dart 3 语言能力。Flutter 使用自己捆绑的 Dart SDK,因此实际支持版本由当前 Flutter SDK 中的 Dart 版本决定。
可以使用以下命令确认本机工具链:
dart --version
flutter --version
如果项目使用 pubspec.yaml 中的 SDK 约束,应保证约束允许 Dart 3:
environment:
sdk: ">=3.0.0 <4.0.0"
实际项目应根据所采用的当前稳定 Flutter 版本设置更准确的下限,而不是机械复制示例版本。
这些语言特性在 Android、iOS、Windows、macOS、Linux 和 Web 上具有相同的 Dart 语义:
- 模式的匹配规则相同;
- Record 的静态类型规则相同;
- switch 的穷尽性分析相同;
- sealed class 的继承约束相同。
差异主要发生在编译目标和互操作边界:
- Android、iOS、桌面通常编译为原生代码;
- Web 编译为 Web 目标代码;
- 与 Java/Kotlin、Objective-C/Swift、JavaScript 或平台通道通信时,不能直接把 Dart Record 当作对方天然理解的数据结构;
- 应在边界处转换为明确的 Map、List 或协议对象。
语言机制本身不会因为运行在某个平台上而改变,但构建工具链、互操作协议和序列化方式会改变。
18. 如何诊断模式匹配错误
遇到模式相关编译错误时,可以按以下顺序定位。
18.1 先检查输入的静态类型
Object value = getValue();
// 直接解构通常不安全
// final (x, y) = value;
如果输入类型太宽,先使用可反驳匹配:
if (value case (int x, int y)) {
print(x + y);
}
18.2 检查 Record 是位置字段还是命名字段
final positional = (1, 2);
final named = (x: 1, y: 2);
以下模式不能混用:
// final (:x, :y) = positional; // 错误
// final (x, y) = named; // 错误
应该分别写:
final (a, b) = positional;
final (:x, :y) = named;
18.3 检查模式是否可能失败
声明模式要求结构可靠:
final value = (1, 2);
final (x, y) = value;
对于可能不是目标结构的输入,应放入 if-case 或 switch:
if (unknown case (int x, int y)) {
// 只有这里可以安全使用 x 和 y
}
18.4 检查 sealed 层次是否真正封闭
如果父类型不是 sealed,分析器通常不能根据当前文件推断所有未来子类型:
abstract class OpenState {
const OpenState();
}
这种开放层次更适合配合 _ 处理未知实现:
String label(OpenState state) {
return switch (state) {
_ => '未知状态',
};
}
如果业务要求所有状态都必须被处理,应使用 sealed 并显式列出子类型。
19. 语言能力与工程取舍
可以把这几种工具按职责区分:
- Record:组合多个已知值;
- 模式:检查结构并提取值;
- switch 表达式:根据模式计算统一结果;
- sealed class:定义有限、互斥、可穷尽的状态集合;
- 命名类:承载稳定身份、行为和领域不变量。
一个典型的数据流可以写成:
sealed class ParseResult {
const ParseResult();
}
final class Parsed extends ParseResult {
final ({String name, int age}) user;
const Parsed(this.user);
}
final class ParseError extends ParseResult {
final String message;
const ParseError(this.message);
}
String render(ParseResult result) {
return switch (result) {
Parsed(user: (name: var name, age: var age)) =>
'$name ($age)',
ParseError(message: var message) =>
'错误:$message',
};
}
这里有三层建模:
ParseResult用 sealed class 表示“成功或失败”这一有限集合;Parsed.user用命名 Record 表示成功结果中的两个相关值;- switch 模式同时解构外层对象和内层 Record,并返回一个
String。
如果未来需要给用户增加验证、格式化或持久化行为,可以把内层 Record 替换为 User 类;外层的 sealed 建模和 switch 结构仍然可以保留。
Record 与模式匹配的核心价值,不是减少几行代码,而是让“数据的结构”和“分支的覆盖范围”进入静态类型系统。返回值可以具有明确字段,状态可以具有封闭变体,解构可以在通过检查后发生,switch 则能在模型扩展时暴露遗漏路径。对于 Flutter 项目中的状态转换、解析结果和多值计算,这些能力尤其适合用来减少位置约定、强制类型转换和遗漏分支带来的错误。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Dart 错误处理:Exception、Error、StackTrace、Zone 和契约
- 下一篇:Dart Future:Event Loop、async/await、错误、超时和并发组合
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论