☰
Dart循环与集合类型实战:遍历、删除、去重及避坑指南
2026/10/4 4:26:27 网站建设 项目流程

很多人第一次学 Dart 的时候,都觉得循环和集合类型太基础了,不就是for、while、List、Map吗?我一开始也这么想,结果写 Flutter 业务代码没多久就翻了车:在for循环里直接删List元素,删着删着下标越界;后来用Map做缓存,又在并发场景下被 Concurrent Modification 坑到怀疑人生。这篇文章就是把 Dart 里循环和集合类型相关的实战经验梳理一遍,不光是语法,更重要的是“为什么这样做”以及“哪些坑必须躲”,适合刚开始接触 Dart/Flutter 的开发者,也适合从 Java、C++、Python 转过来的朋友。

1. 循环不是只会背语法:先理清 Dart 里的几种循环结构

1.1for循环的三种写法:经典for、for-in、forEach

Dart 里的for循环相比其他语言多一点花样,但核心还是老三样:初始化、条件、递增。经典写法长这样:

for (int i = 0; i < 5; i++) { print(i); }

这种写法适合需要下标、需要控制步长、需要倒序遍历的场景。比如你要从列表末尾往前找最后一个符合条件的数据,直接倒着循环最简单:

for (int i = items.length - 1; i >= 0; i--) { if (items[i].isValid) { return items[i]; } }

第二种是for-in,这是 Dart 里最常用的遍历方式。它不需要下标,代码读起来更自然,也不容易出现越界问题:

for (final item in items) { print(item); }

如果你在遍历集合的同时还想知道元素的位置,可以用indexed或者自己维护计数器。比较推荐的是 Dart 3 里Iterable扩展的indexed方法:

for (final (index, item) in items.indexed) { print('$index -> $item'); }

这种写法在 Flutter 里生成列表组件时特别好用,避免手动i++带来的低级错误。

第三种是forEach,这其实不是循环语句,而是一个方法。它能遍历集合,但有局限性:普通forEach里不支持break和continue,想提前结束就只能用return(只跳过当前这次迭代,不是跳出整个循环)。很多人刚开始没意识到这一点,以为return能退出整个forEach,实测会发现只是跳过了当前元素。

1.2while和do-while:什么时候才轮到它们上阵

while循环更适合“不知道循环次数,只知道结束条件”的场景。比如从网络流里读数据,直到读不到内容为止;或者写一个轮询等待用户操作确认的循环。基础语法如下:

while (!isDone) { doSomething(); }

do-while和while的区别在于,它至少会执行一次循环体。这在某些场景里很关键:比如游戏里的消息队列处理,哪怕队列为空,你也希望先尝试拉取一次再决定是否退出。另外在一些异步等待的场景里,do-while也经常出现,先发送一个请求,再循环检查响应结果。

我实际使用中比较常见的一个坑是:while循环里忘了更新退出条件,导致死循环。最典型的例子是模拟硬件串口读取,循环体里不断检测某个状态,如果数据流意外中断,又没有设定最大重试次数,就会一直卡在while里。所以只要循环依赖外部条件,我都习惯加一个超时计数器,或者用Stopwatch控制最长时间,避免把整个应用拖死。

1.3break、continue和带标签的循环:跳出多层循环的正确姿势

break能跳出当前一层循环,continue跳过当前这次迭代进入下一次。在嵌套循环里,如果你想直接跳出外层循环,Dart 提供了标签(label)语法:

outerLoop: for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; j++) { if (something) { break outerLoop; } } }

这个写法比用一个布尔标志位一层一层判断干净得多。我在写二维矩阵查找、或者多列表格数据匹配时经常用。但要注意,标签循环读起来略显诡异,最好封装成独立函数后用return代替,比如“找到就返回结果”,这样外部看到的就是一个正常的接口,内部怎么跳都无所谓。代码可读性永远是第一位的。

2. 集合类型不只是容器:List、Set、Map 的底层差异和选型逻辑

2.1List:有序且可重复,但性能不等于“什么时候都快”

List是最常用的集合,它保留插入顺序,允许重复元素。在 Dart 里,List底层默认是可变长度的数组,查找元素时按顺序访问,下标访问很快,但如果频繁在头部插入或者删除,性能会差一些。因为在头部操作时,默认实现需要把后面所有元素挪一个位置。

所以当你需要在头部频繁插入元素时,可以考虑用Queue(dart:collection里的ListQueue),或者调整插入方向:先把元素加到List末尾,最后一次性反转。这种方式在排序大量数据时很实用。另外,List的removeAt(0)看起来很正常,但其实耗时是 O(n),而removeLast()是 O(1)。设计数据流的时候要心里有数。

List的另一个常用变体是固定长度List:

final fixed = List<int>.filled(5, 0); fixed[0] = 1;

如果你确定列表长度不会变,用固定长度能减少扩容开销。不过实际业务里,长度不变的情况很少,我一般只在滑动窗口算法、或者预分配缓冲区的场景里用它。

2.2Set:去重只是入门,底层哈希结构才是重点

Set的特点是元素唯一且无序,底层基于哈希表。它带来的第一个好处是去重:

final ids = [1, 2, 3, 2, 1]; final unique = ids.toSet(); print(unique); // {1, 2, 3}

第二个好处是快速判断是否存在。如果你有一个几十万条数据的列表,需要反复判断某个值在不在里面,用List.contains()是 O(n),而Set.contains()平均是 O(1)。我做过一个多语言翻译字段合并的场景,几千个 key 反复查重,换成Set后性能提升非常明显。

但Set也有坑:如果你存储的是自定义对象,必须正确覆写hashCode和==,否则两个“相同”的对象因为引用不同,会被当成两个元素。这是 Java 迁移者最容易踩的坑。实践中我通常给Set存基础类型,或者存不可变的值对象,并显式实现相等性判断。

2.3Map:键值对的本质是“字典”,但它和 JSON 并非完全等价

Map在 Dart 里是键值对集合,键唯一,访问时通过键直接定位,平均时间复杂度 O(1)。常见的写法:

final scores = <String, int>{'Alice': 90, 'Bob': 85}; final aliceScore = scores['Alice'];

很多场景里,Map和 JSON 长得像,但 JSON 的键一定得是字符串,Dart 的Map键可以是任意对象。在 Flutter 里做接口数据转换时,我一般会先jsonDecode得到Map<String, dynamic>,再手动转成强类型模型。这里要注意Map的下标访问:如果键不存在,返回null,所以scores['Unknown']不会报错,但后续处理很可能因为你没判空而抛异常。

另外,Map也是有顺序的。Dart 默认的LinkedHashMap会保留插入顺序,这在需要“按添加先后输出配置项”时很有用。但如果你只关心数据是否有无序性需求,可千万别假设哈希表的物理顺序,那会带来隐蔽的 BUG。

2.4final和const对集合的影响:看似简单,其实容易误解

很多人以为final List<int> list = []就不能再改这个列表了,这是个经典误区。final修饰的只是变量引用,也就是说你不能重新赋值list = [1,2],但list.add(3)完全合法。如果你想要真正不可变的集合,有几个办法:

  • 用List.unmodifiable()包一层;
  • 直接用const字面量创建,比如const [1, 2, 3];
  • 使用collection包里的不可变集合类型。

const集合在 Dart 里会变成编译期常量,相同内容的常量集合可以复用同一份内存,省内存且线程安全。在 Flutter 的 Widget 列表里,const也能减少重建成本。不过要注意,const列表如果嵌套了非const对象,编译会直接报错。

我自己的经验是:能声明const的地方优先const,这能帮编译器做更多优化;想要防止误修改数据,就用List.unmodifiable,不要只依赖final。

3. 循环与集合的结合操作:用实际案例搞懂过滤、映射、折叠

3.1 在遍历过程中删元素:一个大坑,三种解法

写循环时最常见的问题是:边遍历边删除元素。比如有这样一个需求:从一个订单列表里删除所有已取消的订单。

错误写法:

for (int i = 0; i < orders.length; i++) { if (orders[i].status == 'cancelled') { orders.removeAt(i); } }

元素删除后,后面所有元素的下标都往前移了,但i还在继续增加,最后就会跳过某些元素。我见过有人因为这个 BUG 导致库存数据少计算了一半。

推荐解法有三种。第一种是倒序遍历,删除当前下标不会影响前面未遍历的元素:

for (int i = orders.length - 1; i >= 0; i--) { if (orders[i].status == 'cancelled') { orders.removeAt(i); } }

第二种是不在循环里改原集合,用where生成新的集合:

orders = orders.where((o) => o.status != 'cancelled').toList();

第三种是遍历副本,修改原集合:

for (final order in orders.toList()) { if (order.status == 'cancelled') { orders.remove(order); } }

三种我都用过。最推荐的是第二种,因为代码意图最清晰,而且不容易出错。倒序遍历适合内存敏感、不想新建集合的场景,但可读性差一点。遍历副本的方式要小心对象相等性,如果两个订单内容一样但引用不同,remove可能删不对。

3.2 用map、where、reduce替代手写循环

Dart 集合类型自带一批函数式方法,用好了代码简洁很多。它们本质也是循环,但把循环细节封装起来了,让读代码的人更关注“做什么”,而不是“怎么循环”。

  • map:把每个元素转换成另一种形式;
  • where:过滤满足条件的元素;
  • reduce:把所有元素折叠成一个值;
  • expand:把一个元素扩展成多个元素;
  • fold:类似reduce,但允许设置初始值,且结果类型可以和元素类型不同。

举个例子,把一组人名格式化成带序号逗号分隔的字符串:

final names = ['Alice', 'Bob', 'Carol']; final result = names.indexed .map((e) => '${e.$1 + 1}. ${e.$2}') .join(', ');

如果用纯手写for循环,也能实现,但要多写变量和维护拼接逻辑。函数式写法更容易一眼看出这是一个转换流程。

关于reduce,要注意它是“没有初始值的折叠”,所以空集合会直接抛异常。如果你处理的数据可能为空,建议用fold而不是reduce:

final total = numbers.fold<int>(0, (sum, n) => sum + n);

这里fold的初始值是 0,即使集合为空也不会报错。很多新手在这里踩过坑,包括我。

3.3 分页、去重、统计:三个高频业务场景的循环写法

页码和每页条数,这是后端分页接口常见的参数。我们自己处理集合的时候,也经常需要把一个长列表切成若干页:

List<List<T>> paginate<T>(List<T> items, int pageSize) { final pages = <List<T>>[]; for (int i = 0; i < items.length; i += pageSize) { final end = (i + pageSize > items.length) ? items.length : i + pageSize; pages.add(items.sublist(i, end)); } return pages; }

这里的sublist是左闭右开区间,end计算要小心,不然很容易出现最后一页被截断或越界。

去重统计也是常见需求,比如统计一段文本里每个单词出现的次数:

final wordList = text.split(RegExp(r'\s+')); final countMap = <String, int>{}; for (final word in wordList) { countMap[word] = (countMap[word] ?? 0) + 1; }

这里用??处理第一次出现的场景。如果你觉得手写循环太繁琐,也可以用fold写同样逻辑,但可读性未必更好。

3.4 “一行两个”的布局判断:循环里怎么处理最后两个元素

这个场景我是在 Flutter 里写网格布局时遇到的。假设每行显示两个商品卡片,数据总数是奇数时最后一行只有一个元素,这时候样式上可能需要区别处理。对应到常见说法就是“最后两个元素不加伪类”的那种效果,比如最后一行不想加分隔线。

在 Dart 里可以这样判断:

for (final (index, item) in items.indexed) { final isLastRow = index >= items.length - 2; if (isLastRow) { // 最后一行特殊处理 } }

如果每行两个元素,我们可以用index ~/ 2算出当前元素所在的行号,再用(items.length - 1) ~/ 2算出最后一行行号,两者相等就表示当前元素属于最后一行。这个思路可以扩展到任意每行 N 个元素:

final n = 2; final rowIndex = index ~/ n; final lastRowIndex = (items.length - 1) ~/ n; if (rowIndex == lastRowIndex) { // do something }

这个写法的好处是不需要关心总数是奇数还是偶数,统一逻辑就能处理。

4. 常见问题与排查技巧实录:踩过的坑都在这里

4.1 Concurrent Modification:遍历集合时不要偷偷改结构

Dart 在遍历List时如果直接增删元素,很多时候并不会立刻抛异常,而是产生诡异的结果。比如用forEach遍历时调用add,可能死循环也可能数组越界。这是因为迭代器拿到的是原集合的快照引用,一边读一边改,长度和位置会错乱。

规范的解法就是我在 3.1 里提到的三种之一:遍历前先.toList()生成副本,或者用where生成新集合,或者在循环里标记后再统一删除。另外如果你在写 Flutter 的ListView.builder,也千万别在 build 方法里修改集合数据,应该把修改操作放到事件回调或者异步任务里。

我排查这个问题时,最常用的手段是加日志观察集合长度变化。比如在每次迭代开头打印length,往往能看到长度忽大忽小,定位就快了。

4.2forEach、map的返回值陷阱

forEach的返回值是void,它不会返回新集合。如果你写:

final result = list.forEach(doSomething); // result 是 null

这明显是无效的。想要转换集合,要用map。

但map也有一个诡异特点:它返回的是惰性求值的Iterable,不是List。如果你马上对返回值做length判断,会触发一次迭代;如果多次遍历,可能会多次执行映射函数。业界把这个叫“懒加载”或“惰性求值”。遇到这种问题,最简单的办法是立刻.toList(),把结果固化下来:

final result = list.map(transform).toList();

另外,map里如果有异步操作,不会自动等待异步完成。这时候你需要Future.wait或者用async循环,不要指望map替你解决并发等待的问题。

4.3 循环里做异步操作:break 和 return 的表现差很多

在 Dart 里,如果在一个for循环里调用await,循环是逐次等待的,看起来没问题。但在forEach里你不能直接写await,即使写了也会编译报错,因为forEach不等待异步完成。有人会用Future.forEach或者for循环解决问题,但要注意:Future.forEach是串行执行异步任务,而Future.wait是并发执行。

需要“同时发起多个请求,全部完成后再继续”可以用:

await Future.wait(items.map((item) => fetchData(item)));

如果需要“逐个处理,处理完第一个再处理第二个”,就用:

for (final item in items) { await process(item); }

在等待过程中想提前终止,break能正常工作。但如果在forEach里用return,只会跳过当前元素,不是终止整个集合的遍历。这是新手最不容易察觉的行为差异。

4.4 从 C、Java、Python 迁过来的易错点

用过 C 语言的 for 循环,到 Dart 里可能不习惯for-in的直接遍历;但最需要注意的是 Dart 的int没有自动溢出,大多数数值运算不会出现 C 里的 undefined behavior。

Java 程序员容易把List当成ArrayList的平替,但 Dart 的List有removeAt、removeLast等不同操作,复杂度差异要记住。另外 Java 的Map键值对用getOrDefault,Dart 里对应map[key] ?? defaultValue。

Python 程序员写惯了列表推导式,到 Dart 里可能会问:为什么没有[x for x in list if cond]?Dart 其实也有集合推导式(collection-if 和 collection-for),Flutter 的布局代码里经常出现:

final widgets = [ for (final item in items) if (item.visible) ItemWidget(item) ];

这其实是 Dart 特有的语法糖,阅读起来像列表推导式,但本质是在集合字面量里嵌入循环和条件。这种写法在某些场景下很优雅,但嵌套多了也会很难读,我一般只在 Widget 列表构建时使用。

4.5 循环和集合排查的两板斧:断言复制 + 日志打印

我调试集合相关 BUG 时,第一板斧是“复制”。所有涉及修改的集合,先在关键点复制一份.toList(),对比修改前后的差异。

第二板斧是“打印”。不要只打印变量本身,打印长度、下标、元素状态。比如:

for (final (i, item) in items.indexed) { print('[loop] i=$i, item=${item.id}'); }

如果循环体复杂,我还会把关键分支用日志包起来,确认进入哪条路径。这种原始但有效的排查方式,很多时候比单步调试更快,因为问题是随机的,不能在断点上复现。

5. 再往前一步:从循环到集合的设计思想

5.1 循环不变量:不只是算法课上的概念

在写稍复杂的循环时,脑子里一定要有“循环不变量”这个概念。简单说,就是在进入循环前、每次循环结束后都保持的真条件。比如选择排序里,外层循环每次结束,前i个元素就已经排好了,这就是不变量。写 Dart 时我经常用这个思想来验证循环是否正确,尤其是手写排序、查找、去重逻辑时。

比如我要移除列表里重复元素,条件是“已遍历过的所有元素中不存在重复值”。每次往新集合里添加元素前,用不变量校验一下:

final uniqueSet = <int>{}; final uniqueList = <int>[]; for (final n in numbers) { if (uniqueSet.add(n)) { uniqueList.add(n); } }

Set.add返回布尔值,既能判断是否重复,又不会破坏不变量。这种写法比先contains再add少一次哈希查找,而且逻辑更紧凑。

5.2 可迭代协议与yield:让循环拥有“流水线”能力

Dart 的集合类型大多实现了Iterable接口,它定义了一个能产出元素的序列。通过sync*函数配合yield,你可以自己写一个“每次循环时动态生成数据”的迭代器:

Iterable<int> countTo(int n) sync* { for (int i = 1; i <= n; i++) { yield i; } }

这个countTo看起来像函数,但调用时并不是立即执行所有循环,而是每次迭代才推进一点。这种惰性生成的能力,让处理超大集合时不会一次性占用大量内存。比如读取一个几 GB 的日志文件,逐行处理时用sync*按需生成行,比一次性readAsLines稳得多。

理解了yield,就能理解为什么map和where返回的是Iterable而不是List。它们本质都是惰性流水线,直到你toList()或遍历时才会真正计算。

5.3 用函数式组合降低循环复杂度

一个复杂的业务循环里往往混杂了过滤、转换、聚合多种逻辑。如果全部用手写for,读代码的人要模拟很多状态变化;如果拆成函数式组合,每一步都可见、可测、可复用。

我常举的例子:从一组用户中选出活跃用户并计算年龄均值。

final activeUsers = users.where((u) => u.isActive); final ages = activeUsers.map((u) => u.age); final averageAge = ages.isEmpty ? 0 : ages.reduce((a, b) => a + b) / ages.length;

这段代码每一步都很清楚,排查问题时可以直接验证中间结果。手写循环当然也能算,但分支一多,出错的概率就会成倍上升。不是说要彻底抛弃for,而是集合操作足够适合函数式表达时,优先选择函数式。

5.4 循环的思想不止在代码里:算法、状态机、响应式流

循环的本质是“重复执行直到满足条件”。这种思想在排序算法、加密分块、网络重试、状态机里都有体现。比如 RNN 这类循环神经网络的“循环”概念,本质上也是反复处理时间序列的各个节点;前端生成一堆重复 DOM 也要靠循环;Shell 脚本里批量处理文件也要靠for循环。理解了 Dart 里的循环控制,换到任何语言都只是语法差异,核心逻辑是相通的。

这也是为什么我会强调:不要只背语法,要理解循环的条件、终止条件、不变量,以及集合在循环中的状态变化。跨语言工作过的人都有体会,真正难的从不是写不写得出循环,而是能不能保证循环在边界条件下不出错。

我在实际项目里的体会是:循环和集合类型是 Dart 代码里最容易“表面简单、实际埋坑”的部分。只要出现“数据不对”或“偶发崩溃”,我第一反应就是检查有没有人一边遍历一边修改集合。先把这张知识网补全,后续写复杂业务时会省下非常多排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询