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
}

这个函数的契约明确说明了返回值包含 minmax,调用者不需要记忆 $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) 中,minmax 是文档化的字段注释名称,但它们仍然是位置字段,调用时应使用 $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,
);

显式声明的价值主要在于:

  1. 让公共函数的输入和输出契约更加清楚;
  2. 让编译器检查字段类型;
  3. 避免因为字段名或位置结构错误而在后续代码中暴露问题。

例如:

({String name, int age}) createUser() {
  return (
    name: 'Ada',
    age: 37,
  );
}

下面的返回值不能匹配该类型:

// return ('Ada', 37); // 这是 (String, int),不是命名字段 Record

3. 什么是模式

模式是一个用于匹配值的结构。模式可以同时完成两件事:

  1. 判断一个值是否符合某种结构;
  2. 从匹配成功的值中提取变量。

例如:

final (x, y) = (3, 4);

print(x); // 3
print(y); // 4

(x, y) 不是一个普通的 Record 表达式,而是一个Record 模式

  • 右侧 (3, 4) 是 Record 表达式;
  • 左侧 (x, y) 是用于解构的模式;
  • 匹配成功后,x 绑定为 3y 绑定为 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) 同时完成了:

  1. 判断 value 是否是 User
  2. 读取 nameage getter;
  3. 将读取结果绑定到局部变量。

这里依赖的是对象的 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');
}

执行过程是:

  1. 读取 value
  2. 检查它是否是包含两个位置字段的 Record;
  3. 检查两个字段是否都是 int
  4. 全部成功后,才在 if 分支中绑定 xy
  5. 任一步失败,都进入 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 从上到下进行:

  1. 如果 valueintn > 0,匹配第一个 case;
  2. 否则,如果 valueint,匹配第二个 case;
  3. 否则,如果 valueString,匹配第三个 case;
  4. 其余值由 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
}

这里有两层返回值:

  1. switch 表达式根据 input 选择一个分支;
  2. 被选中的分支构造并返回一个命名 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

  1. Point 是类型别名,等价于 ({double x, double y})
  2. 函数参数 point 保证具有 xy 两个 double 命名字段;
  3. 第一个模式 (x: 0, y: 0) 只匹配原点;
  4. 第二个模式提取 xy,然后由 when 检查曼哈顿距离是否不超过 10
  5. 第三个模式仍然提取 xy,作为其余所有合法 Point 的兜底;
  6. 每个分支都返回相同结构的 ({String region, double distance})
  7. 调用处再次使用命名 Record 模式解构返回值。

这里的 distance 使用的是曼哈顿距离:

d=x+yd = |x| + |y|

其中:

  • xx 是点的横坐标;
  • yy 是点的纵坐标;
  • x|x|y|y| 是绝对值;
  • near 的条件是 d10d \leq 10

例如 (3, 4) 的距离是:

3+4=7|3| + |4| = 7

因此匹配第二个 case。

如果使用欧氏距离,则应改成:

d=x2+y2d = \sqrt{x^2 + y^2}

这属于业务规则变化,而不是模式匹配机制变化。模式只负责结构匹配和条件分支。


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,
};

上面的值仍然可以匹配只声明 nameage 的映射模式,因为未声明的额外键不会因此自动导致失败。

如果需要严格验证输入,应显式检查允许的键集合,或者先使用专门的 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 是一个封闭的父类型:分析器可以知道它允许哪些直接子类型。LoadingLoadedFailed 使用 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 进行匹配时,可以把可能值集合抽象为:

S={Loading,Loaded,Failed}S = \{Loading, Loaded, Failed\}

三个模式覆盖的集合分别是:

P1={Loading}P_1 = \{Loading\}

P2={Loaded}P_2 = \{Loaded\}

P3={Failed}P_3 = \{Failed\}

因为:

P1P2P3=SP_1 \cup P_2 \cup P_3 = S

所以 switch 是穷尽的。

如果漏掉 Failed

String renderState(LoadState state) {
  return switch (state) {
    Loading() => '加载中',
    Loaded(data: var data) => '已加载 ${data.length} 项',
  };
}

分析器会报告 switch 可能无法匹配所有 LoadState。这通常是编译错误,而不是等到用户运行到该路径时才发现。

10.2 Nullable 类型会额外引入 null

如果表达式类型是 LoadState?,可能值集合变成:

S={Loading,Loaded,Failed,null}S' = \{Loading, Loaded, Failed, null\}

原来的三个 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'),
  };
}

示例中的强制类型转换仍可能抛出 TypeErrorFormatException。生产代码应根据实际协议进一步验证字段是否存在、类型是否正确以及列表元素是否合法。


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

关键点是:

  1. Result<T> 表示一个封闭的状态集合;
  2. SuccessFailure 是互斥变体;
  3. switch 负责识别变体并提取字段;
  4. Record 负责把转换后的多个结果一次返回;
  5. 调用者可以再次解构 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 完全相同:

  1. Flutter 调用 build
  2. switch 根据当前 state 选择一个模式;
  3. 返回对应的 Widget;
  4. 状态对象变化并触发重建时,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',
    _ => '其他',
  };
}

但它把 LoadedFailed 以及未来新增的状态都合并成了“其他”。如果业务上每种状态都需要独立处理,应该显式写出所有变体,让编译器在模型扩展时提醒你。

15.5 case 顺序错误会导致分支不可达

switch (value) {
  case Object():
    print('对象');
  case String text:
    print(text);
}

StringObject 的子类型,第一个模式已经覆盖了它。更具体的模式必须放在更一般的模式之前。


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-caseswitch

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',
  };
}

这里有三层建模:

  1. ParseResult 用 sealed class 表示“成功或失败”这一有限集合;
  2. Parsed.user 用命名 Record 表示成功结果中的两个相关值;
  3. switch 模式同时解构外层对象和内层 Record,并返回一个 String

如果未来需要给用户增加验证、格式化或持久化行为,可以把内层 Record 替换为 User 类;外层的 sealed 建模和 switch 结构仍然可以保留。

Record 与模式匹配的核心价值,不是减少几行代码,而是让“数据的结构”和“分支的覆盖范围”进入静态类型系统。返回值可以具有明确字段,状态可以具有封闭变体,解构可以在通过检查后发生,switch 则能在模型扩展时暴露遗漏路径。对于 Flutter 项目中的状态转换、解析结果和多值计算,这些能力尤其适合用来减少位置约定、强制类型转换和遗漏分支带来的错误。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。