最后核验:2026-09-07
本篇结论
把多条灵感放进 List,用 where 筛选、用 map 转换,再把需要排序的数据复制成列表。标签用 Set 去重,按标识查找用 Map。结构化输入先通过模式匹配检查字段类型,再交给 SparkIdea 构造函数检查业务规则。
本篇只解决一个问题:把一批原始灵感整理成可展示的待办数据,同时保留原列表顺序。示例使用本地数据,不接入网络、存储或 Flutter 页面。
学完你能做到
- 按列表、去重、键值查找三种需求选择集合。
- 分清
Iterable、延迟转换和已经生成的列表。 - 筛选未完成灵感,并按优先级排序。
- 用 Map 模式检查和提取输入字段。
- 区分
final、不可修改列表和元素自身不可变。 - 验证空集合、非法输入和集合修改边界。
开始前检查
先完成 第 11 课 。本篇直接导入其中的 SparkIdea,保留它的标题清洗、优先级检查和只读字段,避免复制同一套规则。
从仓库根目录操作,需要保留这两个文件的相对位置:
examples/lesson_11_functions_and_classes.dart
examples/lesson_12_collections_and_patterns.dart
按 第 08 课 设置 FLUTTER_UPSTREAM_HOME,确认 Track A:
"$FLUTTER_UPSTREAM_HOME/bin/dart" --version
本篇实际运行版本为 Dart 3.13.2。官方 API 页面核验时显示 Dart 3.13.3,本文使用的接口另经本机 3.13.2 分析和运行确认。示例与边界验证只依赖 Dart 标准库及本仓库文件,无需下载第三方包。
01 先按业务需求选择集合
Spark 要保留多条灵感、整理重复标签,还要通过标识找到某一条数据。这三件事分别对应三种集合:
| 需求 | 类型 | 本篇用法 |
|---|---|---|
| 保存有顺序的多条灵感 | List<SparkIdea> |
筛选后生成待办列表 |
| 让相同标签只保留一次 | Set<String> |
清洗之后去重 |
| 根据标识取出一条灵感 | Map<String, SparkIdea> |
使用 idea-003 查找 |
尖括号约束元素或键值的类型。列表下标从 0 开始;访问空列表的 first 或越界下标会失败。本例只对固定的三条样例数据使用下标,不把它当成处理未知输入的方式。
空集合要写清类型,特别是空 Set:
final ideas = <SparkIdea>[];
final tags = <String>{};
final byId = <String, SparkIdea>{};
var tags = {}; 创建的是 Map。final 只限制变量重新赋值,这里的列表仍然可以 add()。集合基础见 Dart 官方集合说明 。
02 用模式匹配把原始字段交给构造函数
样例输入先用本地 Map 表达。Object? 表示输入可能是任意对象或 null,接收后需要检查才能使用:
import 'lesson_11_functions_and_classes.dart' show SparkIdea;
SparkIdea parseIdea(Object? value) {
if (value case {
'title': String title,
'priority': int priority,
'isCompleted': bool isCompleted,
}) {
return SparkIdea(
title: title,
priority: priority,
isCompleted: isCompleted,
);
}
throw FormatException('灵感数据需要 title 字符串、priority 整数和 isCompleted 布尔值');
}
show SparkIdea 表示只导入这个类;导入文件不会执行那个文件的 main()。
if (value case 模式) 尝试匹配输入。这里要求它是包含三个指定键的 Map,并且值分别符合 String、int、bool。成功后,title 等局部变量可以在分支中直接使用,不需要用 as 强制转换。
本例允许额外键,但不读取它们;缺少指定键、值为错误类型或输入为 null,都会走到末尾的 FormatException。不要把这里的条件匹配换成直接解构声明:二者对匹配失败的处理方式不同。模式的匹配与解构概念见 Dart 官方模式说明 ,字段模式见 模式类型参考 。
类型检查通过后还要调用构造函数:空白标题虽然是字符串,但违反标题规则;优先级 4 虽然是整数,但不在 1~3 范围内。它们分别触发第 11 课已有的 StateError 和 RangeError。
本篇约定输入必须明确给出这三个字段,说明使用构造函数默认的 null。这是一份教学用输入约定,完整的数据序列化留到后续数据课程。
03 筛选、复制,再排序
本篇约定优先级数字越小越靠前;同优先级时按标题的 compareTo 排序:
List<SparkIdea> pendingIdeas(Iterable<SparkIdea> ideas) {
final pending = ideas.where((idea) => !idea.isCompleted).toList();
pending.sort((a, b) {
final byPriority = a.priority.compareTo(b.priority);
return byPriority != 0 ? byPriority : a.title.compareTo(b.title);
});
return List<SparkIdea>.unmodifiable(pending);
}
Iterable<SparkIdea> 表示可以逐个遍历的灵感序列。这里不依赖下标,所以参数不必限定为 List。
where 根据回调返回的布尔值保留元素。(idea) => !idea.isCompleted 是一个匿名函数,读取每条灵感并判断它是否未完成。where 返回延迟执行的 Iterable:真正遍历时才检查元素,结果不会自动缓存。toList() 触发遍历并生成新列表。依据见 where API 。
sort 原地修改列表,返回值为 void。本例先生成 pending,因此排序不会改变传入列表的顺序。比较函数返回负数表示 a 在前,正数表示 b 在前,零表示本次比较相等。
排序不保证稳定,因此本例增加标题作为第二个比较条件;如果优先级和标题都相同,仍不承诺两条灵感的相对顺序。标题排序也不是中文拼音排序。排序行为见 List.sort API 。
04 把待办转换成标题,把标签清洗后去重
待办对象保留业务字段,展示时可以转换成标题列表:
final titles = pending.map((idea) => idea.title).toList();
map 为每个元素产生一个转换结果,不负责筛选;这里把 SparkIdea 转为 String。它也延迟执行且不缓存转换结果,因此回调保持为单纯的数据转换,不在里面发送请求或修改业务状态。依据见 map API 。
标签需要依次完成清洗、去空白、去重和排序:
List<String> normalizeTags(Iterable<String> values) {
final tags = values
.map((value) => value.trim())
.where((value) => value.isNotEmpty)
.toSet()
.toList();
tags.sort();
return List<String>.unmodifiable(tags);
}
先清洗再去重,' Flutter ' 和 'Flutter' 才会合并。这里按字符串值去重,区分大小写,'dart' 和 'Dart' 是两个标签。去重后显式排序,展示顺序不依赖 Set 的迭代顺序。
05 用 Map 按标识查找
完整示例用固定标识建立一个小索引,不改动前一课的类:
final byId = <String, SparkIdea>{
'idea-001': ideas[0],
'idea-002': ideas[1],
'idea-003': ideas[2],
};
print(byId['idea-003']?.title ?? '未找到');
byId['missing'] 返回 null,因此通过 ?. 与 ?? 表达缺失情况。不要直接加 ! 假定键一定存在。同一个键再次赋值会替换原值,所以 Map 不能用重复键保留多条记录;完整应用的标识生成与重复输入处理留到数据模型课程。键值和查找规则见 Dart 官方 Map 说明 。
06 给结果建立不可修改边界
pendingIdeas() 和 normalizeTags() 都返回 List.unmodifiable 创建的列表,调用方不能增加、删除或替换元素。它接收运行时计算结果,不要求数据是编译期常量。
这只是集合层面的保护,不是深拷贝。源列表和结果可以引用同一个元素;只有元素本身也不可变,才能把整个结果当作不可变数据。本例 SparkIdea 的字段都是 final,且只包含字符串、整数、布尔值和可空字符串,因此符合当前需要。如果以后加入可变的标签列表字段,还需要单独保护该字段。依据见 List.unmodifiable API 。
toList(growable: false) 只限制列表长度,不等于禁止替换元素,不能替代本例的不可修改边界。见 toList API 。
07 运行完整示例与边界验证
打开 完整示例 ,从仓库根目录执行:
"$FLUTTER_UPSTREAM_HOME/bin/dart" format --output=none --set-exit-if-changed \
examples/lesson_12_collections_and_patterns.dart scripts/verify_lesson_12.dart
"$FLUTTER_UPSTREAM_HOME/bin/dart" analyze \
examples/lesson_12_collections_and_patterns.dart scripts/verify_lesson_12.dart
"$FLUTTER_UPSTREAM_HOME/bin/dart" run \
examples/lesson_12_collections_and_patterns.dart
静态分析应显示 No issues found!,程序输出应为:
待办标题:练习集合转换、整理 Flutter 笔记
原始首项:整理 Flutter 笔记
标签:Dart、Flutter
查找 idea-003:练习集合转换
查找 missing:未找到
空列表待办数:0
再执行 边界验证脚本 :
"$FLUTTER_UPSTREAM_HOME/bin/dart" run scripts/verify_lesson_12.dart
预期输出 第 12 课边界验证通过,退出状态为零。脚本用显式检查和异常验证,不依赖可能关闭的 assert,也不引入测试包;系统的测试写法留到第 15 课。
| 输入或操作 | 预期结果 |
|---|---|
| 空列表、全部已完成 | 返回空待办列表 |
同优先级的 C、A |
A 排在 C 前面 |
| 筛选排序后清空原列表 | 待办结果仍保留三条,原列表排序未被改变 |
| 重复标签、空白标签 | 清洗、去空白、去重后得到 Dart、Flutter |
| 给待办结果追加或替换元素 | 抛出 UnsupportedError |
缺字段、错误类型、null |
抛出 FormatException |
| 空白标题、81 字符标题 | 抛出 StateError |
| 1 或 80 字符标题、优先级 1 或 3 | 成功创建对象 |
| 优先级 0 或 4 | 抛出 RangeError |
这些失败是预期业务边界,脚本只接收指定异常类型;操作意外成功或抛出其他异常会让验证失败。
三端差异
- Track A:本篇使用 Dart 3.13.2 运行纯 Dart 示例与边界验证,不调用平台 API。
- iOS/Android:独立 Dart 示例无需在两端重复执行;应用基线另行执行 Flutter 分析、测试、iOS Simulator 构建和 Android debug APK 回归构建。构建结果不等于新增页面或平台行为经过设备验证。
- Track B:本机尚未安装 Flutter-OH,本篇未执行该轨道,也不宣称示例或
Spark已完成鸿蒙兼容验证。
预期结果
- 多条原始输入转换为有效的
SparkIdea。 - 展示列表只含未完成灵感,排序不影响原始列表。
- 标签清洗去重,缺失标识有明确的展示结果。
- 结果列表禁止修改,非法输入不会静默变成正常对象。
- 示例输出和边界验证均符合本篇记录。
常见问题
为什么不直接对 ideas 调用 sort
这会改变原列表的顺序。如果其他地方还要显示输入顺序,就会受到影响。本例在 where 后调用 toList(),只排序新列表。
where 和 map 调用后,数据就处理完了吗
没有。它们返回延迟执行的序列;本例通过 toList() 或 toSet() 遍历并生成集合。保存延迟序列后再修改源集合,后续遍历可能得到不同结果,不能把它当作创建时的快照。
Set 能自动按标题去重 SparkIdea 吗
不能从本篇的字符串标签推导出这个结论。第 11 课的类没有定义按标题判断相等的规则,两个独立创建、标题相同的对象不会因此自动合并。本篇只对字符串标签去重。
模式匹配通过,是否说明数据一定有效
只说明符合本例要求的结构和类型。标题长度、空白和优先级范围仍由构造函数检查。额外字段不会自动存入对象。
为什么 final 列表还能 add
final 限制变量重新赋值,不限制对象内部变化。需要把运行时结果交给调用方并禁止修改时,本例使用 List.unmodifiable;元素是否可变仍要另行判断。
完成检查
- 我能按需求选择 List、Set 或 Map。
- 我能说明
where和map的区别与延迟执行行为。 - 我知道
sort修改哪个列表,以及为何先复制。 - 我能区分模式匹配的类型检查与构造函数的业务检查。
- 我不会把
final或固定长度列表误认为不可修改列表。 - 我已运行示例,并让边界验证脚本通过。