Dart学到第6天,终于把循环和集合类型放到一起讲了。这两个知识点拆开看都很简单:循环无非是 for、while 那几套写法,集合无非是 List、Set、Map 三种容器。但真正写起代码来你会发现,它们几乎总是成对出现——集合装数据,循环处理数据。没有循环,集合就是一堆躺在内存里没人碰的东西;没有集合,循环也找不到用武之地。
这篇文章适合正在学 Dart 的初学者,也适合从 Java、JavaScript 转过来想快速上手 Dart 的人。我会把三种集合类型各自的特点、四种循环写法的适用场景、以及我在实际练习中踩过的坑一起记录下来,代码可以直接照着敲。
1. 为什么循环和集合类型要放在一起学
1.1 循环解决的从来不是"会不会写",而是"怎么批量处理"
我见过不少人学循环时只是跟着教程打印 1 到 10、打印九九乘法表,然后觉得自己会了。但真正到项目里,循环处理的很少是数字,而是数据集合——数组里的用户记录、列表里的订单、Map 里的配置项。今天要处理的真实问题形如:有一万个用户,我要找出所有积分超过 500 的;有一批订单,我要统计每个月的销售额。这些事靠手工一条条写是不现实的,靠循环遍历集合才是常规解法。
所以说,循环本身不是目的,批量处理和自动化才是。把循环和集合类型放在同一天学,是因为它们天然是一体的:循环负责反复执行某段逻辑,集合负责存放被反复处理的数据。理解了这层关系,后面看 Flutter 里大量的列表渲染、数据过滤、状态管理等场景会顺畅很多。
1.2 三种集合类型的定位差异
Dart 里最常见的集合类型就是 List、Set、Map,它们都是collection体系里的核心成员,但在行为上有明显差别:
| 集合类型 | 是否有序 | 是否允许重复 | 底层模型 | 典型场景 |
|---|---|---|---|---|
| List | 有序 | 允许重复 | 索引数组 / 链表 | 排行榜、消息列表、任何按位置存取的数据 |
| Set | 无序 | 不允许重复 | 哈希表 | 去重、成员判断、标签集合 |
| Map | 键值有序(按插入序) | key 不允许重复,value 可以 | 哈希表 | 配置文件、字典映射、SQL 查询结果映射 |
这个表格是我自己整理的,建议你先记住"List 有序可重复""Set 无序去重""Map 键值映射"这三句话。后面循环遍历时,它们的差异会直接影响你怎么写代码——比如遍历 Map 时你拿到的是键值对,遍历 Set 时没有索引概念,这些细节我会在第三节展开。
2. 动手前先吃透三种集合类型
2.1 List:最常用的"有序列表"
List 是 Dart 里最直观的集合类型,创建方式很灵活:
// 字面量创建 List<int> scores = [88, 95, 76, 92, 87, 85]; // 生成式创建:长度为 10,每个元素是索引的平方 List<int> squares = List.generate(10, (index) => index * index); // 填充式创建:长度为 5,每个元素都是 0 List<int> zeros = List.filled(5, 0); // 从另一个集合转换 List<double> fromSet = Set<double>.of([1.5, 2.5, 1.5]).toList();访问 List 元素靠索引,索引从 0 开始:scores[0]是第一个元素。需要留意的是,Dart 的 List 在运行时如果访问了越界索引(比如长度为 6 的列表访问scores[10]),会直接抛出RangeError,不会像某些语言那样返回 undefined 或者 null。这个特性其实挺友好,能帮你尽早发现索引计算的错误。
List 常用的方法还有add、removeAt、insert、indexOf、sublist,不过我今天主要关注它与循环的结合。有一个经验供参考:如果事先知道列表规模,用List.generate或者先算好长度再填充,比反复add更能减少扩容开销;写练习时无所谓,但到了 Flutter 列表需要频繁重建的场景,这个习惯能帮你少踩性能坑。
2.2 Set:去重这件事交给它
Set 和 List 最大的区别有两个:元素唯一、不保证顺序。举个例子,假设你收集到一串用户 ID,里面有很多重复的,用 List 你得自己写循环去重,用 Set 则直接转换就行:
List<int> rawIds = [101, 102, 103, 101, 104, 102, 105]; Set<int> uniqueIds = rawIds.toSet(); print(uniqueIds); // {101, 102, 103, 104, 105}Set 也支持集合运算,这在处理交集、并集、差集时特别方便:
Set<String> 前端组 = {'张三', '李四', '王五'}; Set<String> 后端组 = {'李四', '赵六', '孙七'}; // 交集:同时出现在两个组里的人 Set<String> 全栈 = 前端组.intersection(后端组); print(全栈); // {李四} // 并集 Set<String> 全体 = 前端组.union(后端组); // 差集:只在 前端组 而不在 后端组 Set<String> 纯前端 = 前端组.difference(后端组);这里有个细节值得说清楚:Set 判断元素是否重复,依赖的是对象的==和hashCode。对基本类型(int、String)来说,值相同就算同一个元素;但对自定义对象,默认比较的是引用地址,如果你希望两个内容相同的对象去重,需要重写==和hashCode。
2.3 Map:键值对应的"配置字典"
Map 存储的是键值对,创建方式同样很灵活:
// 字面量创建 Map<String, int> stockMap = { '苹果': 12, '香蕉': 8, '橘子': 5, }; // 动态创建 Map<String, double> priceMap = {}; priceMap['鼠标'] = 199.0; priceMap['键盘'] = 399.0; // 从可迭代对象构造 Map<int, String> codeMap = Map.fromEntries( [1, 2, 3].map((e) => MapEntry(e, '编号$e')), );从集合里读取值用键:stockMap['苹果']。如果键不存在,返回 null——注意是 null 而不是 0,所以做数值累加时先判断或者用putIfAbsent来补默认值。
今天重点不是讲 Map 的所有 API,而是强调一个概念:Map 的遍历对象有三个维度——keys遍历键、values遍历值、entries遍历键值对。写循环时,想清楚你到底需要哪个维度,就不会写出"遍历键然后又用键去查值"这种绕弯子的代码了。
3. 四种循环写法的选用指南
3.1 传统 for 循环:索引在手,灵活性最大
传统 for 循环是绝大多数人最先接触的写法,格式是for (初始化; 条件; 步进):
List<String> fruits = ['苹果', '香蕉', '橘子', '西瓜']; for (int i = 0; i < fruits.length; i++) { print('第 $i 个水果是 ${fruits[i]}'); }这个写法最大的优势是:你能拿到索引i,既能访问当前元素,也能根据索引进行前后的定位。比如在循环中需要同时访问当前元素和下一个元素时,用索引就非常自然:
for (int i = 0; i < list.length - 1; i++) { if (list[i] > list[i + 1]) { print('相邻元素前大后小:${list[i]} > ${list[i + 1]}'); } }但这种写法也有明显的缺陷:你需要手动控制边界。只要条件写错一个符号,要么少遍历一个元素,要么越界。我建议把传统 for 循环用在"确实需要索引或需要做位置计算"的场景,而不是所有遍历都无脑用这个。
3.2 for-in 循环:最推荐的首选迭代方式
如果只是按顺序访问集合里的每一个元素,不考虑索引,我会优先推荐 for-in:
List<String> fruits = ['苹果', '香蕉', '橘子', '西瓜']; for (String fruit in fruits) { print('当前水果是 $fruit'); }这段代码读起来几乎和人话一样:"对于 fruits 里的每一个 fruit,打印它。" 不需要管索引从哪里开始到哪里结束,从集合里拿出多少个元素就遍历多少个,天然规避了越界风险。
for-in 同样适用于 Set 和 Map 的键集合:
Set<int> ids = {1, 2, 3}; for (int id in ids) { print('ID: $id'); } Map<String, int> ages = {'张三': 25, '李四': 30}; for (String name in ages.keys) { print('$name 的年龄是 ${ages[name]}'); }这里有个微小但重要的坑:Dart 的 Map 遍历keys得到的是一个Iterable,它按插入顺序返回键。你可以在遍历过程中读取ages[name],此时读取操作本身不会报错,但如果你在遍历的同时向ages里添加或移除键,会抛出ConcurrentModificationError,这点在第五节我会详细展开。
3.3 while 和 do-while:面向"条件成立"而不是"元素个数"
for 循环适合你明确知道要遍历多少次的情况;while 循环则适合你不知道具体次数、只知道什么时候该停的场景。
int count = 0; while (count < 5) { print('第 $count 次'); count++; }在实际项目里,while 循环常见用法是配合"读取状态"或"等待某个条件"来使用,比如消费队列消息直到队列为空:
Queue<String> messageQueue = Queue.from(['msg1', 'msg2', 'msg3']); while (messageQueue.isNotEmpty) { String msg = messageQueue.removeFirst(); print('处理消息:$msg'); }do-while 与 while 的唯一区别是先执行一次循环体再判断条件,因此无论条件是否成立,循环体至少会执行一次。这个特性在某些必须"先做一步再判断"的场景里很实用,但也有不少人因为没注意到这个差异,写出了多执行一次的 Bug。我的习惯是:只有明确需要"先执行后判断"时才用 do-while,否则一律用 while。
3.4 break、continue、标签:控制循环的"刹车和油门"
循环写多了,一定会遇到需要提前终止或跳过某次的情况。
break:结束整个循环continue:跳过本次循环,进入下一个元素- 标签(label):在嵌套循环中指定要跳出哪一层
// 找到第一个大于 90 的成绩就停止 List<int> scores = [88, 95, 76, 92, 87, 85]; for (int score in scores) { if (score > 90) { print('第一个大于 90 的成绩是 $score'); break; } } // 跳过所有低于 60 的成绩,只打印及格的 for (int score in scores) { if (score < 60) continue; print('及格成绩:$score'); }标签在嵌套循环里比较少见但很有用。比如你有一个二维列表,想找到第一个负数:
List<List<int>> matrix = [ [1, 2, 3], [4, -5, 6], [7, 8, 9], ]; outerLoop: for (int row = 0; row < matrix.length; row++) { for (int col = 0; col < matrix[row].length; col++) { if (matrix[row][col] < 0) { print('找到负数:第 $row 行第 $col 列'); break outerLoop; // 跳出两层循环 } } }不写标签的话,内层 break 只能跳出内层 for,外层循环还会继续执行,很多初学者在这里会困惑为什么没达到预期效果。
4. 集合的高级玩法:函数式遍历方法
4.1 forEach、map、where、reduce:让代码更短也更安全
Dart 的集合类型自带了很多函数式方法,它们本质上也是循环,只不过把"遍历"和"如何处理元素"分离了。先看一个对比:
// 传统循环:找出所有大于 90 的成绩 List<int> scores = [88, 95, 76, 92, 87, 85]; List<int> highScores = []; for (int score in scores) { if (score > 90) { highScores.add(score); } } // 函数式写法 List<int> highScores2 = scores.where((s) => s > 90).toList();同样的逻辑,函数式写法更紧凑,更重要的是它强调了"要做什么"而不是"怎么做"。我把几个常用的方法整理一下:
| 方法 | 作用 | 返回类型 | 注意点 |
|---|---|---|---|
forEach | 对每个元素执行一次操作 | void | 循环里不能用 break / continue |
map | 把每个元素转换成另一种形态 | Iterable(惰性) | 如需 List 要加.toList() |
where | 筛选满足条件的元素 | Iterable(惰性) | 相当于 filter |
any | 是否存在满足条件的元素 | bool | 短路,找到即停 |
every | 是否全部满足条件 | bool | 短路,遇到不满足即停 |
reduce | 把元素两两合并成一个值 | 与元素同类型 | 空集合调用会报错 |
fold | 类似 reduce 但可以指定初始值 | 任意类型 | 推荐优先用 fold |
使用map时有个容易踩坑的地方:map返回的是Iterable,是惰性求值的,如果你不加.toList(),它不会立即执行。这在大型集合上可能造成"以为已经转换完了,实际上还没执行"的错觉。实际编码中,凡是要把结果存起来或者传给需要 List 的接口,记得加.toList()。
4.2 案例:构建一个成绩统计分析流水线
把几种方法串起来做一个完整的小案例。假设我们有一组学生成绩,需要完成三个需求:筛选出及格成绩、把成绩换算成等级、统计平均分。
void main() { List<int> scores = [88, 59, 76, 92, 47, 85, 61, 70]; // 1. 筛选出及格成绩(>= 60) List<int> passed = scores.where((s) => s >= 60).toList(); // 2. 把成绩映射成等级 List<String> grades = passed.map((s) { if (s >= 90) return 'A'; if (s >= 80) return 'B'; if (s >= 70) return 'C'; return 'D'; }).toList(); // 3. 计算及格平均分 double average = passed.fold(0.0, (sum, s) => sum + s) / passed.length; print('及格人数:${passed.length}'); print('等级分布:$grades'); print('及格平均分:${average.toStringAsFixed(1)}'); }这个例子虽然简单,但完全复刻了真实项目里的数据处理链条:筛选 -> 映射 -> 聚合。在实际业务里,你可能把scores换成从接口拿到的用户列表,把where换成复杂条件过滤,把map换成 DTO 转换,把fold换成汇总统计。循环的基本功没变,变的是处理的复杂度和语法粒度。
5. 实操中的常见问题与避坑记录
5.1 在循环中删除元素:一个几乎人人都踩过的坑
我在练习时写过这么一段代码,本意是删除列表中所有小于 60 的成绩:
List<int> scores = [88, 59, 76, 47, 85, 61]; for (int i = 0; i < scores.length; i++) { if (scores[i] < 60) { scores.removeAt(i); // 危险! } } print(scores);这段代码的结果可能完全出乎你的意料。原因很简单:每次removeAt(i)后,列表长度减少 1,后面的元素会往前移,而循环的i依然按原来的节奏递增,就会跳过被前移的元素。而且scores.length每次循环都会重新取值,边界条件也在变化,最终结果完全不可控。
正确的做法有三种:
方式一:倒序遍历。从后往前删,前面的元素移动不会影响后续未访问的索引。
for (int i = scores.length - 1; i >= 0; i--) { if (scores[i] < 60) { scores.removeAt(i); } }方式二:用removeWhere,一句话搞定,这也是我最推荐的方式。
scores.removeWhere((s) => s < 60);方式三:筛出要保留的元素,重新赋值。
scores = scores.where((s) => s >= 60).toList();如果你是刚学,可能觉得方式一大费周章,不如方式二方便。但在面试或底层实现里,理解"为什么倒序删除可行"是很有价值的——它背后是数组元素移位、索引失效这些通用原理,换到 Java 的ArrayList、JavaScript 的splice里是同样的逻辑。
5.2 遍历 Map 时动态修改键值:并发修改异常
Map 的 for-in 或 forEach 遍历过程中,如果你直接添加或删除键,Dart 会抛出ConcurrentModificationError。常见场景是:遍历配置项时想过滤掉某些键。
Map<String, int> stockMap = {'苹果': 12, '香蕉': 0, '橘子': 5}; // 这样写会报错 ConcurrentModificationError // stockMap.forEach((key, value) { // if (value == 0) { // stockMap.remove(key); // } // });正确的做法是先把要删除的键收集起来,遍历结束后统一删除:
List<String> keysToRemove = []; stockMap.forEach((key, value) { if (value == 0) { keysToRemove.add(key); } }); for (String key in keysToRemove) { stockMap.remove(key); }或者直接用removeWhere:
stockMap.removeWhere((key, value) => value == 0);这个经验提示我们:凡是能在集合方法层面解决的问题,尽量不要手动写遍历加增删。集合类自带的方法往往已经处理好了并发修改、索引移动等隐藏细节。
5.3 循环性能与编码习惯的三个建议
第一,不要在循环条件里写耗时操作。例如for (int i = 0; i < list.length; i++)中的list.length每次循环都会调用一次。对 List 来说它只是读取一个字段,开销可以忽略;但如果条件是一个会执行复杂计算的方法,就要小心了。更稳妥的做法是先用变量缓存结果:
int targetCount = someExpensiveFunction(); for (int i = 0; i < targetCount; i++) { // 业务逻辑 }第二,循环体越短越好,把复杂逻辑提取成方法。很多人写循环时什么都往里塞,循环体动辄几十行,出了 Bug 很难定位。我的做法是,循环体内尽量只做"取数据、判断、调方法"这几件事,具体逻辑放进单独函数里,方便单测也方便排查。
第三,选择合适的集合类型能省掉很多不必要的循环。如果你需要频繁判断"某个元素是否在集合中",用 Set 的contains通常比 List 的contains快得多,因为 Set 走哈希查找,时间复杂度平均是 O(1),而 List 最坏情况下要遍历整个列表。现实中我见过有人用一个几百项的 List 反复做 contains,结果页面卡顿,换成 Set 后立竿见影。
5.4 一个小技巧:用循环和 Map 做计数器
最后分享一个我经常用的套路。假设有一串颜色列表,想统计每种颜色出现了几次,用 Map 加循环几行就能搞定:
List<String> colors = ['red', 'blue', 'red', 'green', 'blue', 'red']; Map<String, int> countMap = {}; for (String color in colors) { countMap[color] = (countMap[color] ?? 0) + 1; } print(countMap); // {red: 3, blue: 2, green: 1}这段代码里的关键是??运算符:当 Map 里没有这个键时返回 0,有则返回原值,然后加 1。这个模式在处理日志分类、投票统计、词频统计时特别顺手,推荐你把它记下来,写 Flutter 后台接口数据分组时也会用到。
如果你已经能熟练掌握前文的 for-in 和 forEach,再配合 Map 做这种聚合统计,基本上就具备了处理大部分日常数据逻辑的能力了。
我自己在敲这一天的练习时最大的体会是:循环语法再多,真正重要的其实是"数据结构选对、遍历边界想清楚、增删操作谨慎处理"这三件事。Dart 的集合类型设计得比较直观,和 Java 很像,但又有 JS 的灵活性,只要把 List、Set、Map 各自的特性和循环的几种写法对应上,后面学 Flutter 列表组件、状态管理时就不会发怵。这篇文章里的示例代码建议你原封不动敲一遍,然后自己改写几个小场景,比如把统计成绩换成统计一周的天气情况,把列表去重换成关键词过滤,这样记忆会扎实很多。