关于 dart3-idioms-and-coding-standards
规范 Dart 3 类型设计、不可变性、命名和全函数的编码技能,面向希望利用编译器检查穷尽分支并统一代码结构的开发者。
主要功能
- 01密封类型使用穷尽式 switch
- 02类修饰符与枚举的使用规范
- 03不可变值类型与稳定身份标识
- 04全函数及非抛出领域逻辑规范
- 05Dart 命名与复杂度限制
- 06Dart 3 惯用法检查脚本
适用场景
- 01建模封闭的领域结果集合
- 02设计跨应用层的不可变值
- 03检查 switch 是否遗漏变体
- 04提交 PR 前检查 Dart 代码
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill dart3-idioms-and-coding-standards安装后在 Claude Code 中输入/dart3-idioms-and-coding-standards确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/immutable_value.dartsealed_outcome.darttotal_domain_function.dartreferences/complexity-and-honesty.mdconstruct-verdict-table.mdimmutability-and-equality.mdscripts/check-dart3-idioms.sh详细介绍
Dart 3 惯用法和编码规范
现代 Dart 3.x,优先使用值类型,领域逻辑采用全函数。只有当某项语言特性能将运行时的静默问题转化为编译错误时,它才有存在的价值;其他一切都只是装饰。本规范约束每个声明的类型标注、命名、不可变性和全函数性——建立在这些机制之上的错误处理架构由 error-handling-typed-results 负责。
请根据当前任务查阅相应参考资料:
references/construct-verdict-table.md— 逐项列出功能的判定表(sealed / enum / record / class 修饰符 /extension type/ 代码生成包),并说明每项选择使用 / 跳过的理由。references/immutability-and-equality.md— 不可变值类型、copyWith、何时手写==/hashCode,以及稳定标识与值相等。references/complexity-and-honesty.md— 长度和嵌套层级限制及其依据,以及完整列出的late/!/dynamic规避规则。
提交 PR 前运行 scripts/check-dart3-idioms.sh。
不容妥协的规则
- 对 sealed 类型或枚举使用
switch时,不要写default:或case _:。 通配分支会让 switch 永远都能编译,丢掉类型存在的唯一编译时保证——添加变体后,运行时会静默落入该分支。穷尽性就是全部价值所在。 - 只使用三种类修饰符,其余一概忽略。 对需要编译器穷尽检查的封闭变体集合使用
sealed class;对所有具体叶子类使用final class;对由测试替身实现的扩展接口使用abstract interface class。具体类型默认设为final。不要使用base、extension type、主构造函数或宏。 - 将每个情况都可单独处理的封闭集合建模为
sealed层级;不带载荷的封闭集合建模为enum。 从enum开始;一旦任何成员需要字段,就改为sealed+final class。不要为了只适用于部分成员的数据而给enum添加可空字段——这会让每次访问都需要空值检查,而编译器无法推理其安全性。 - 记录永远不能跨越层边界。 记录是没有名称、按位置访问且没有文档说明的结构——适合在单个层内临时返回多个值。一旦某个结构从仓库返回、被存储或传给 widget 构造函数,就应使用具名类。
- 领域值不可变:使用
final字段、在允许时使用const构造函数、值相等性,以及通过copyWith派生新值。 修改交给 widget 的值,会破坏重建和 golden test。只要分析器允许,就优先使用final局部变量和const构造函数。 - 身份必须是明确且稳定的字段,绝不能按所有字段判等。 用作列表元素或映射键的值类型应包含
final String id(或类型化 ID)。从所有字段派生身份,会把值恰好相同的两个不同实体(例如两个都名为 “Draft” 的Item)合并成一个。 - 让非法状态无法表示。 将带判别条件的选择编码为 sealed 变体或以 enum 为键的联合类型,而不是编码为只有一个字段会被设置的可空字段集合。若类型无法表达错误状态,就不需要任何分支来防范它。
- 领域函数必须是全函数——绝不抛出异常。 每个纯函数都应为每个输入返回一个值;不确定性应作为明确的输出表示(例如经过钳制的值、“N 步内无结果”的结果或低置信度标记)。程序员不变量使用
assert(发布版中会移除),绝不使用throw。可恢复的 I/O 故障应返回类型化结果——参见error-handling-typed-results。 - 不要掩饰问题:不要用
late规避空值处理,不要对重要值使用!,不要把dynamic/Map<String, dynamic>当作临时模型。 这些做法都会隐藏类型系统原本会迫使你处理的运行时故障。空值使用?./??/类型提升;结构化数据使用类型化模型。 - 原样遵循 Effective Dart 的命名规则;常量使用
lowerCamelCase。 类型/扩展/枚举使用UpperCamelCase;文件/目录/导入前缀使用lowercase_with_underscores;变量/参数/方法/常量使用lowerCamelCase(maxItems,绝不使用MAX_ITEMS);超过两个字母的缩写按单词形式大写(JsonMap、HttpClient,而非JSONMap)。文件名应与其主要声明一致。 - 将复杂度限制严格作为默认标准——本表是本库唯一的标准来源;其他技能应引用本表,而非重述数字: 方法 ≤ 约 30 行,
build()≤ 约 80 行,文件 ≤ 约 300 行,类的公共 API ≤ 约 10 个成员,位置参数 ≤ 3 个,逻辑嵌套 ≤ 3 层。widget 构建树合理地会有更深的嵌套——widget 构建嵌套 ≤ 5 层是唯一明确的例外(由widget-composition引用)。这些是促使重构的提示,不是硬性规定——若某处超限但保持内聚是合理的(把一个状态机拆散到五个片段中反而更糟),可在 PR 中说明理由。 - 优先使用不可变值类型;简单类型手写,样板代码占主导时使用
freezed。 对简单的不可变类型(1–3 个字段),手写@immutable+const构造函数 +final字段(用作映射键时再手动实现==/Object.hash)。对于copyWith/==/hashCode/sealed 联合类型的样板代码变得繁琐的领域值类型或 UI 状态值类型,允许使用freezed,且这是默认选择——*.freezed.dart是一等生成产物。不要使用equatable(五行==+Object.hash就足够),不要使用fpdart/dartz(Either会抹去穷尽性),也不要使用任何--enable-experiment标志(标志一旦被移除,停止维护的仓库从那天起就无法构建)。
密封类:支撑性惯用模式
将一组封闭且可单独处理的情形建模为一个文件中的密封层次结构。sealed 要求每个子类型都位于同一个库中——该文件就是这组封闭情形,编译器会强制执行这一点。
@immutable
sealed class SyncOutcome {
const SyncOutcome();
}
final class SyncApplied extends SyncOutcome {
const SyncApplied(this.appliedCount);
final int appliedCount;
}
final class SyncSkipped extends SyncOutcome {
const SyncSkipped(this.reason);
final String reason;
}
使用不带 default: 的 switch——这样,添加变体后,在每个位置处理该变体之前都会出现编译错误:
// RIGHT — exhaustive, no wildcard.
switch (outcome) {
case SyncApplied(:final appliedCount):
log.info('applied $appliedCount');
case SyncSkipped(:final reason):
log.warning('skipped: $reason');
}
如果遗漏了一个分支,dart analyze 会报告 non_exhaustive_switch_statement,而且 dart compile 也会失败。绝不要抑制此错误;被抑制的分析器诊断仍会导致 AOT 构建失败,因此 CI 必须执行构建,而不能只做分析。
类修饰符:优先考虑三种
| 意图 | 声明 |
|---|---|
| 编译器必须穷尽处理的一组封闭变体 | sealed class(隐式抽象;无法在其库外实例化或实现) |
| 具体的叶子类——变体、服务实现、值类型 | final class |
| 测试替身必须满足的接缝 | abstract interface class |
默认将每个具体类型声明为 final class:它会同时禁止扩展和实现,因此不会有子类在不知情的情况下继承一半行为,也不会有测试意外地通过 implements 一个具体类来获取其字段。abstract interface class 表示“实现我,不要扩展我”——这正是测试替身所需的契约;单独使用 abstract class 会允许 extends,而服务接口上的继承默认实现,正是测试替身悄悄调用真实实现的原因。跳过 base(它强制执行单包应用并不具备的库边界不变量)和 extension type(一种明确标记为不安全的抽象——其底层表示仍然可访问)。
Record:适用场景与代价
在单个层中,用 record 返回临时的多个值:(int row, int col) 作为局部坐标非常理想——免费获得结构相等性,无需类,也无需代码生成。当一个 record 不再只是几行代码中的临时值时,为字段命名:({int row, int col}) 在使用处一目了然;而 (int, int) 若把顺序写反,编译器无法发现这一错误。
// Intra-layer only. Two same-typed positional fields = a latent swap bug.
({int page, int size}) nextPage(({int page, int size}) current) =>
(page: current.page + 1, size: current.size);
不要为了绕过类的选择而用 typedef 定义 record 形状——在多个文件中使用的具名形状应当使用类。
Enum 还是 sealed
当集合是封闭的,且每个成员都不带载荷时,先用 enum——例如显示类别或模式。Enum 在 switch 中同样具备编译器穷尽性检查,而且每个成员只需一行代码。只要有成员需要字段,就应改用 sealed + final class。
enum OrderStatus { draft, submitted, shipped, cancelled }
// A member needs data -> sealed, not an enum with parallel nullable fields.
@immutable
sealed class Payment {
const Payment();
}
final class Cash extends Payment { const Cash(); }
final class Card extends Payment {
const Card(this.last4);
final String last4;
}
Switch 表达式与 if-case
当每个分支都会产生一个值,且没有分支包含语句体时,优先使用 switch 表达式——它在构造上就是穷尽的,也不会发生贯穿执行:
final label = switch (status) {
OrderStatus.draft => 'Draft',
OrderStatus.submitted => 'Submitted',
OrderStatus.shipped => 'Shipped',
OrderStatus.cancelled => 'Cancelled',
};
当各分支会执行副作用时,使用 switch 语句;不要为了写成表达式而把副作用塞进 void 接收器。对于只有一种值得关注的形状,而 switch 会变成一个有效分支加一个无用分支的情况,使用 if (x case Pattern)——但绝不要对 sealed 类型使用 if-case,否则会悄悄重新引入 switch 本可捕获的非穷尽漏洞。
if (settings.retry case final RetryPolicy p when p.isEnabled) {
await scheduler.arm(p);
}
不可变值类型与标识
手写四行代码即可替代代码生成器。只有当类型会用作映射键或集合成员时,才手写 ==/hashCode;对于列表中的身份识别,应使用稳定的 id。
@immutable
final class Task {
const Task({required this.id, required this.title, required this.done});
final String id; // identity — NOT derived from title/done
final String title;
final bool done;
Task copyWith({String? title, bool? done}) =>
Task(id: id, title: title ?? this.title, done: done ?? this.done);
@override
bool operator ==(Object other) =>
other is Task && other.id == id && other.title == title && other.done == done;
@override
int get hashCode => Object.hash(id, title, done);
}
全定义域函数
纯函数会为每个输入返回结果。不确定性应表示为值;程序员不变量则应使用 assert。
/// Total: clamps rather than throwing on an out-of-range page.
int clampPage(int requested, int pageCount) {
assert(pageCount >= 1, 'pageCount must be positive'); // programmer invariant
if (requested < 0) return 0;
if (requested >= pageCount) return pageCount - 1;
return requested;
}
反模式
- 在密封类型或枚举上使用
default:或case _:—— 这会丢弃穷尽性检查;新增变体时会在运行时落入该分支,而不是编译失败。 - 在密封类型上使用
if (x case SomeVariant())—— 这会重新引入switch能捕获的非穷尽性漏洞。 - 使用带构造函数和可空字段的
enum,且这些字段只适用于部分成员 —— 每次访问都需要进行编译器无法推理的空值检查;改用sealed+final class。 - 将从仓库返回的记录存储起来,或通过小部件构造函数传递 —— 把无名的位置形状当作领域类型;应改用类。
- 将
class Order extends ChangeNotifier用作可变领域模型 —— 领域值不可变;应通过 ViewModel 修改(参见state-management-riverpod)。 - 根据所有字段的
==推导身份 —— 两个值相同的实体会被合并为一个;应使用稳定的id。 - 用
late Foo foo;/foo!.value规避空值,或将Map<String, dynamic>用作模型 —— 这会掩盖运行时故障;应使用?./??/类型提升和有类型的模型。 - 从纯领域函数中
throw来表示正常结果 —— 应返回结果;assert仅用于程序员不变量。 - 使用
const MAX_ITEMS/class JSONModel/final s = 3;—— 使用了 SCREAMING_CAPS、大小写不当的缩写或简写;应使用maxItems、JsonModel和完整单词。 - 使用
Widget _buildHeader() => ...—— 它会随父级一起重建,也不能是const;应提取为const小部件类(参见widget-composition)。 - 添加
equatable/fpdart/dartz或--enable-experiment标志 —— 这会增加冗余负担,或使用抹去穷尽性的Either;手动编写这几行代码,或在样板代码确实过多时使用freezed。
完成标准
- 对密封类型或枚举的每个
switch都是穷尽的,且没有default:/case _:。 - 具体类型使用
final class;接口边界使用abstract interface class;封闭集合使用sealed/enum;不使用base/extension type/实验标志。 - 封闭的可操作集合使用
sealed+final class;不带负载的集合使用enum;不使用带可空字段的标记枚举。 - 记录仅在层内使用;所有跨边界的数据形状都是具名类。
- 领域值不可变(字段为
final,在允许时使用const构造函数,提供copyWith);身份使用稳定的id,而不是所有字段相等。 - 非法状态无法表示(使用
sealed/enum联合,而非可空字段集合)。 - 领域函数是全函数——返回不确定性,不抛出异常;不变量使用
assert。 - 对重要值不使用
late/!/dynamic/Map<String,dynamic>来掩盖问题。 - 原样遵循 Effective Dart 大小写规范;常量使用
lowerCamelCase;文件名与其主要声明一致。 - 遵守复杂度限制;超出限制时,须在 PR 中说明其合理性。
-
scripts/check-dart3-idioms.sh通过;dart analyze无问题。
相关技能
error-handling-typed-results—— Result/Failure 主干和全局错误网,它们依赖这些密封类型机制。naming-conventions—— 使用角色后缀命名(Screen/Notifier/Repository/Service/Failure),以标明所属层。state-management-riverpod—— 可变状态的存放位置(基于不可变状态的 Notifier ViewModel);此处的领域值保持不可变。widget-composition—— 提取const小部件类,而不是_buildX()方法。dart3-idioms-and-coding-standards由lint-and-style-config和ci-pipeline-and-gates在 CI 中强制执行。
参考资料
- Dart 团队。Effective Dart(Style / Documentation / Usage / Design)。https://dart.dev/effective-dart
- Dart 团队。Class modifiers. https://dart.dev/language/class-modifiers
- Dart 团队。Patterns & pattern types(解构、
if-case)。https://dart.dev/language/patterns - Dart 团队。Branches — exhaustiveness. https://dart.dev/language/branches#exhaustiveness-checking
- Dart 团队。Records. https://dart.dev/language/records
- Dart 团队。Sound null safety. https://dart.dev/null-safety