
前言
洗衣机上的"快洗""标准洗""轻柔洗",不只是三个名字。选中一种模式,也就选定了一套洗涤安排:大约洗多久、使用什么水温。我们不会每次按下"快洗",都重新填一遍这些设置。
程序里也有类似的固定选项。用枚举表示洗涤模式,可以避免把名字写错;但如果洗涤时间放在一张表里,水温又写在另一串判断里,新增模式时就容易改了一处、漏掉一处。
能不能让每个选项把自己的设置一起带上?Dart 的增强枚举就能做到:选项仍然是固定的,但每个选项可以拥有数据,也可以提供操作这些数据的方法。
一、增强枚举是什么
枚举是一种表示固定数量的命名常量值的特殊类。增强枚举是在枚举中进一步声明字段、构造函数和方法的写法。
普通的简单枚举已经能表示"有哪些选择":
dart
enum WashProgram { quick, standard, delicate }
void main() {
WashProgram selected = WashProgram.quick;
print(selected == WashProgram.quick);
}
输出 true。WashProgram 是一种类型,quick、standard、delicate 是这种类型已经声明好的三个值。selected 只能接收 WashProgram 类型的值,不能随手放入一个 'quick' 字符串,更不能写一个不存在的 WashProgram.fast。
如果每个选项还有固定的说明、时长等信息,就可以把它们写进同一个枚举声明。增强的是选项携带的信息与能力,选项数量仍由声明决定。
增强枚举需要 Dart 2.17 或更高的语言版本;文中的 switch 表达式需要 Dart 3.0 或更高版本。Dart 枚举
二、让固定选项带上数据和行为
把洗涤模式的名称和预计时长直接放进枚举:
dart
enum WashProgram {
quick('快洗', 15),
standard('标准洗', 45),
delicate('轻柔洗', 35);
const WashProgram(this.label, this.minutes);
final String label;
final int minutes;
String get summary => '$label:约 $minutes 分钟';
DateTime estimatedEnd(DateTime start) {
return start.add(Duration(minutes: minutes));
}
}
void main() {
final program = WashProgram.quick;
print(program.label);
print(program.minutes);
print(program.summary);
final end = program.estimatedEnd(DateTime(2026, 11, 1, 9));
print('${end.hour}:${end.minute}');
}
输出结果:
text
快洗
15
快洗:约 15 分钟
9:15
这里的时长是教学示意数据。
2.1 声明中的各部分怎样对应
quick('快洗', 15) 声明了一个枚举值,同时把两个参数交给构造函数。构造函数里的 this.label、this.minutes,会把收到的名称和时长分别保存到对应字段中。
因此,WashProgram.quick 已经带着两条信息:显示名称为"快洗",预计时长为 15 分钟。standard 和 delicate 使用相同的数据结构,只是各字段的值不同。
几个标点各有作用:
- 枚举值之间用逗号分隔。
- 最后一个枚举值后用分号,表示选项列表结束,后面开始声明成员。
- 枚举值必须写在成员前面,并且至少声明一个。
这和在普通类里声明构造函数很相似,但用途有区别:这里的构造函数负责初始化声明好的几个枚举值,不是开放一个入口,让调用者随时造出新模式。
2.2 字段、getter 和方法分别负责什么
字段保存每个选项自己的数据。例如 label 保存名称,minutes 保存时长。
getter 提供像属性一样读取的计算结果。program.summary 没有括号;读取时,它根据当前模式的字段组合出说明文字,并没有另外保存一份 summary 字段。
方法则可以接收参数、执行计算。estimatedEnd(start) 接收本次开始时间,再加上该模式的预计时长。开始时间不是模式的固定属性,因此通过方法参数传入。
**实例成员访问的是当前枚举值的数据。**WashProgram.quick.summary 使用快洗的数据,WashProgram.standard.summary 使用标准洗的数据。方法里的 minutes 相当于 this.minutes,this 就是当前接收调用的那个枚举值。
代码虽然写了一份 getter 和方法,每个枚举值调用时却能根据自身数据得到对应结果。只有名称和几个固定参数不同的选项,便不必分别写成多个类。
Dart 3.13 起还支持主构造函数,可以把字段和构造参数合在枚举名后声明;这里使用显式字段与 const 构造函数的写法,便于看清各部分如何对应,也适用于较早支持增强枚举的版本。Dart 增强枚举
三、固定的模式与每次运行的数据要分开
3.1 为什么字段是 final,构造函数是 const
**枚举值是预先声明的常量实例,不能在运行中为它改写实例字段。**所以增强枚举的实例字段必须是 final,生成式构造函数必须是 const;通过 mixin 引入的实例字段也要满足同样要求。
"生成式构造函数"就是负责创建并初始化实例的构造函数。枚举会用它初始化声明中的固定值,而普通代码不能调用它来增加值。
沿用 WashProgram 的声明,下面两种写法都不允许:
dart
WashProgram.quick.minutes = 20;
WashProgram('自定义', 20);
第一行试图改写 final 字段;第二行试图在已声明的三个值之外创建新枚举实例。即使在枚举自己的实例方法中,也不能这样生成第四种模式。
声明中的 quick(...) 没有额外写 const,是因为枚举值的初始化本来就处于常量上下文。**不是把字段写成 final 就万事大吉,初始化枚举值所用的数据也必须满足常量要求。**例如,不能把 DateTime.now() 得到的当前时间存进某个枚举值;它只有运行时才知道。
如果枚举值带有集合,这个集合也必须是常量。例如 final List 字段可以由常量列表初始化,运行时不能通过 add() 往里追加内容。普通类里"final 字段还可能指向可变列表"的情况,不能直接照搬到这里的常量实例初始化上。Dart 常量构造函数
常量约束针对枚举实例本身,并不要求方法的每次计算也在编译时完成。estimatedEnd() 仍然可以接收运行时取得的开始时间,计算出一个新的 DateTime;只要不改写模式自身的字段即可。
3.2 切换选项不等于修改选项
洗衣机从快洗切到标准洗,改变的是"当前选中了谁":
dart
void main() {
var selected = WashProgram.quick;
selected = WashProgram.standard;
print(selected.label);
print(WashProgram.quick.label);
}
这段 main() 使用第二节的 WashProgram 声明,依次输出"标准洗""快洗"。selected 使用 var,所以可以重新指向另一个已有值;快洗本身没有变成标准洗。如果用 final 声明 selected,则连这个变量也不能重新赋值。
至于"这次洗了几分钟""是否暂停",它们属于每次运行,不属于模式本身。可以用普通类保存:
dart
class WashSession {
WashSession(this.program);
final WashProgram program;
int elapsedMinutes = 0;
bool paused = false;
}
void main() {
final first = WashSession(WashProgram.quick);
final second = WashSession(WashProgram.quick);
first.elapsedMinutes = 5;
first.paused = true;
print(second.elapsedMinutes);
print(second.paused);
}
这里同样使用第二节的 WashProgram,输出 0 和 false。first 和 second 是两个独立的运行记录,虽然都选择快洗,却各自保存进度。

图中,两次运行的 program 都指向同一个 WashProgram.quick。共享的是不变的模式设置,进度仍分别保存在各自的 WashSession 中。选项的固定数据放枚举,每次发生的变化放普通对象,两者就不会混在一起。
四、枚举自带的名称、顺序与查找
4.1 values、name、index 各是什么
不需要自己写列表,Dart 已经为每个枚举生成了 values:
dart
void main() {
for (final program in WashProgram.values) {
print('${program.name} / ${program.index} / ${program.label}');
}
}
使用第二节的声明,结果是:
text
quick / 0 / 快洗
standard / 1 / 标准洗
delicate / 2 / 轻柔洗
- WashProgram.values 是按声明顺序排列、不能修改的枚举值列表。
- name 是源代码中声明该值时使用的名称,例如 'quick'。
- index 是它在声明中的位置,从 0 开始。
- label 是我们自己定义的字段,用来保存显示文字,与 name 没有自动转换关系。
WashProgram.quick.toString() 通常显示为 'WashProgram.quick',不是单独的 'quick'。需要声明名称时用 name,需要界面文字时读取 label,不要把调试输出当作显示文案。
**index 表示位置,不是稳定的业务编号。**如果把 delicate 挪到第一位,它的 index 就从 2 变成 0;如果数据库以前用 0 表示快洗,此后按新顺序读取,就可能把快洗错认成轻柔洗。name 也会随代码中的重命名变化,保存到外部时同样需要考虑兼容。
4.2 按名称查找时,找不到怎么办
values.byName() 会按声明名称查找:
dart
void main() {
final program = WashProgram.values.byName('quick');
print(program.label);
final byName = WashProgram.values.asNameMap();
print(byName['standard']?.label);
print(byName['missing']);
}
这里仍使用第二节的 WashProgram,依次输出"快洗""标准洗"和 null。byName('quick') 能找到 quick,但 byName('快洗') 不会拿 label 来匹配;它也不会自动忽略大小写或去掉空格。
找不到时,byName() 抛出 ArgumentError,不会返回 null 。如果输入可能不存在,可以像示例一样先用 asNameMap() 建立"名称 → 枚举值"的 Map,再用下标查询;Map 中没有这个键时会返回 null。Map 若要多次使用,可以保存起来,不必每次查询都重新建立。Dart 按名称查找枚举
五、和接口、配置文件交换数据
外部接口不一定使用 Dart 的枚举名称。即使目前恰好相同,也应该先约定清楚:传递的是声明名称,还是业务定义的编号。
比如配送接口约定用 'P' 表示自提、'D' 表示快递。这些编号和 Dart 里的 pickup、courier 并不相同,可以单独用 code 字段保存:
dart
enum DeliveryMethod {
pickup('P'),
courier('D');
const DeliveryMethod(this.code);
final String code;
static DeliveryMethod? tryFromCode(String code) {
for (final method in values) {
if (method.code == code) return method;
}
return null;
}
}
对外编号与代码里的名称分开管理后,即使以后重命名 courier,仍可保留 code 为 'D',按原约定与接口交换数据。code 的唯一性和稳定性由我们维护,Dart 不会自动检查自定义字段是否重复,也不会阻止我们改错编号。
tryFromCode() 逐个检查 code,找到就返回对应实例,找不到返回 null。这个方法不依附某一个配送选项,因此用 static,通过 DeliveryMethod.tryFromCode(...) 调用。在 main() 中使用它:
dart
final savedCode = DeliveryMethod.courier.code;
final restored = DeliveryMethod.tryFromCode(savedCode);
print(restored == DeliveryMethod.courier);
final unknown = DeliveryMethod.tryFromCode('X');
if (unknown == null) {
print('暂不支持这种配送方式');
}
第一次查找根据 'D' 还原出 courier,因此输出 true;'X' 不在已声明的 code 中,得到 null 并输出提示。未知编号可能来自新版本接口,也可能是错误数据,不能一律当成自提。应该提示不支持、拒绝数据,还是采取其他处理,需要业务明确决定。
5.1 factory 能不能创建新枚举值
增强枚举允许声明 factory 工厂构造函数,但只能返回已经声明的枚举实例。工厂可以完成查找和校验,不能突破固定值的限制。
例如,文件格式只接受两种固定选择:
dart
enum FileFormat {
text,
binary;
const FileFormat();
factory FileFormat.fromName(String name) {
return values.byName(name);
}
}
void main() {
final format = FileFormat.fromName('text');
print(identical(format, FileFormat.text));
}
输出 true,identical 检查两者是否为同一个对象。fromName 返回的就是已有的 FileFormat.text,没有创建新的对象。这里复用了 byName,所以名称不存在时仍会抛出 ArgumentError。想让查找失败返回 null,使用前面的可空静态方法更合适,因为构造函数不能返回 null。
六、用 switch 检查有没有遗漏选项
有些信息适合放字段,例如模式名称;有些处理则取决于当前场景。例如,后台任务的页面提示可以这样生成:
dart
enum JobState { waiting, running, finished }
String statusText(JobState state) => switch (state) {
JobState.waiting => '等待开始',
JobState.running => '正在处理',
JobState.finished => '已经完成',
};
void main() {
print(statusText(JobState.running));
}
程序输出"正在处理"。JobState 的可能值都已经列出来,switch 也逐个处理了,所以没有兜底分支仍然完整。这叫穷尽检查:编译器检查是否覆盖了所有可能情况。
如果新增 JobState.failed,却没有补充对应分支,这个 switch 表达式就会产生编译错误,提醒我们处理新增状态。若提前用 _ 接住所有剩余情况,新增选项也会进入兜底分支,不会再得到同样的遗漏提醒。switch 语句中的 default 也有覆盖剩余情况的作用;确实需要统一处理时才加兜底。Dart 穷尽检查
还要注意:**枚举可以列出状态,却不会自动规定状态转换。**JobState 只是定义"等待、运行、完成"三个值,并没有自动禁止从 finished 改回 running。哪些转换合法、切换时做什么,需要方法、普通类或其他业务逻辑明确实现。
同样,给可空的 JobState? 做 switch 时,还需要处理 null;固定选项之外的"没有值"也是一种可能情况。
七、实现接口与复用行为
增强枚举能实现接口,也能使用符合约束的 mixin。例如,日志级别既有固定选项,又希望按严重程度排序:
dart
mixin LevelDescription {
int get severity;
bool get needsAttention => severity >= 2;
}
enum LogLevel with LevelDescription implements Comparable<LogLevel> {
info(0),
warning(1),
error(2);
const LogLevel(this.severity);
@override
final int severity;
@override
int compareTo(LogLevel other) => severity.compareTo(other.severity);
}
void main() {
final levels = [LogLevel.error, LogLevel.info, LogLevel.warning]..sort();
print(levels.map((level) => level.name).join(', '));
print(LogLevel.error.needsAttention);
}
输出依次为 'info, warning, error' 和 true。Comparable 要求提供 compareTo():小于零表示排在前面,零表示排序上同级,大于零表示排在后面。这里比较自己定义的 severity,因此排序依据是严重程度,而不必依赖声明顺序。mixin 则提供 needsAttention 这段共用行为;它需要的 severity,由枚举的 final 字段提供。
这里是枚举 implements Comparable、枚举 with LevelDescription。反过来,其他类不能 extends LogLevel、implements LogLevel,也不能把 LogLevel 放进 with;否则就可能在已经声明的选项之外增加实例。枚举自动继承 Enum,也不能再用 extends 指定其他父类。
还有两类成员由 Dart 保留,不能自己改写:
- **不能重写 index、hashCode 和 ==。**枚举值的序号、哈希和相等规则由语言提供。两个枚举值即使自定义字段相同,也仍是不同的值;若只想比较严重程度,应显式比较 severity。
- 不能声明名为 values 的成员,它会与自动生成的静态 values 冲突。
toString() 则可以重写,用来改变对象的文字表示,但不会改变 name 或 index。
八、什么时候选择枚举
先看"选项能否在代码中列完",再看"每次使用是否要创建不同的数据"。
| 情况 | 更合适的表示 |
|---|---|
| 选项固定,只需要区分名称,例如方向 | 简单枚举 |
| 选项固定,每项还有固定参数或行为,例如预设模式 | 增强枚举 |
| 每次创建都有不同数据,例如某次洗涤的进度、某一笔订单 | 普通类 |
| 结果种类固定,但每次结果携带不同数据,例如成功时的数据、失败时的原因 | sealed 类层次 |
"固定"指这份程序声明中选项固定,不是产品永远不能更新。可以在新版本代码里新增一个枚举值,但旧版本程序不会因此自动认识它。
比如购物网站里的商品来自数据库,可以不断新增,就不适合把每件商品写成枚举;商品的"上架、下架"状态倒可能是枚举。下载结果中,每次成功取得的文件和每次失败的原因又各不相同,用 sealed 的不同子类型携带数据会更自然。
如果固定选项只涉及某个页面的展示,相关文字或样式也可以留在页面的映射逻辑中。增强枚举方便集中与选项紧密相关的规则,但不必把所有使用场景都塞进枚举。
九、总结
**增强枚举让固定选项拥有数据和行为,同时保留"实例已经声明好、不能任意增加"的特点。**字段保存选项自己的固定参数,getter 和方法根据这些参数提供结果;每次运行的可变数据交给普通对象保存。
values 用于遍历,name 是声明名称,index 是声明位置。与外部数据交换时,应使用约定好的稳定编号,并明确处理未知值;按名称查找时,要区分 byName() 的抛错与 Map 查询的可空结果。
需要逐项处理时,switch 的穷尽检查能提醒遗漏。决定是否使用枚举,关键在于选项是否固定、是否需要为每次发生的事情创建独立数据,而不是看字段和方法多不多。
十、测试题
- WashProgram.standard 是一个类型、一个字符串,还是一个已经声明好的对象?增强枚举新增了字段,为什么仍不能随时创建第四种模式?
- var selected = WashProgram.quick; 之后把 selected 改成 WashProgram.standard,改变了什么?如果改成给 WashProgram.quick.minutes 赋值呢?
- 两次洗涤都选快洗,为什么进度不应该放进 WashProgram.quick?应该怎样组织这些数据?
- 原来把 LogLevel.error.index 存入数据库。调整枚举值顺序后,旧数据可能出现什么问题?改存 name 是否就完全不用考虑兼容性?
- WashProgram.values.byName('快洗') 能找到 quick 吗?找不到时会返回什么,怎样写出允许查找失败的方式?
- 为 JobState 新增 failed 后,为什么逐项列出的 switch 表达式会报错?如果加了 _ 兜底,结果有何不同?
- 枚举里的 factory 能否根据用户输入创建一个未声明过的新值?如果输入未知,应在哪里决定如何处理?
- "支付方式""每笔支付的金额""支付成功时返回的交易信息",分别适合用固定枚举、普通类,还是 sealed 类层次表示?说明理由。