资讯详情

资讯详情

Dart List 核心用法全解析:基础属性到实战避坑

我平时写 Flutter 的时候发现不少初学者最容易被“卡住”的反而是 Dart 最基础的数据结构——List。说白了大家在别的语言里用的数组、列表到了 Dart 里都统一成了 List但它的行为习惯和一些 API 设计跟 Java、JavaScript、Python 都很不一样。这篇文章就是我自己的 List 使用笔记从最基础的属性到日常项目里高频操作再到我踩过的几个坑一次性写清楚适合刚开始写 Flutter 或者想系统过一遍 Dart List 的同学参考。1. 先把“数组”这个词放下Dart 的 List 到底是什么很多人第一次看到 Dart 代码里写ListString names [Tom, Jack];会下意识觉得“这不就是数组嘛”。但我想先说一个观点如果你一直拿 Java 数组或 JavaScript 数组的思维去写 Dart 代码后面会遇到不少“不知道为什么就报错”的情况。1.1 Dart 没有数组关键字List 就是官方指定的“数组”Dart 语法里没有Array没有array()只有一个List。你写var arr [1, 2, 3];得到的也是一个Listint。所以第一步要建立的理解是在 Dart 里数组这个概念的官方名字就叫 List它的本质是一个“有序的元素集合”元素可以重复索引从 0 开始。跟其他语言对比一下会更清楚语言对应概念是否定长是否可变DartList默认可变可以fixedLength可变居多JavaArray / ArrayListArray 定长ArrayList 可变两者都有JavaScriptArray可变可变Pythonlist可变可变Dart 的 List 更接近 JavaScript 的 Array 或 Python 的 list都是动态长度、可以随便增删。跟 Java 的ArrayList也很像但跟 Java 的int[]这种定长数组有本质区别。1.2 List 字面量背后的真实类型表面上你写var list [1, 2, 3];好像很简单但这一行背后做的事情是Dart 会根据元素自动推断泛型。比如[1, 2, 3]推断为Listint[a, b]推断为ListString如果你写[]这种空列表Dart 会推断成Listdynamic。这正是我在实际开发中经常看到新手迷惑的地方var list []; list.add(hello); list.add(123); print(list); // [hello, 123]这里不会报错因为list被推断成了Listdynamic可以装任何类型。但如果你在一开始就用ListString list [];后面再添加数字就会被编译器直接拦下来。这种“泛型约束”不是故意为难你而是在代码层面提前挡住一部分类型错误比运行时崩溃好处理得多。1.3 可变与不可变的区分要从创建那一刻做起Dart 的 List 默认是“可变长度”的你可以随意add、remove、修改元素。但有三种特殊情况通过List.filled(3, 0)创建出来的列表默认长度固定无法增加或删除通过List.unmodifiable([...])创建出来的列表完全不可修改通过const [1, 2, 3]创建的编译期常量列表也是不可变的。这三种特殊列表我后面第 5 部分会专门细说。这里先提醒你一个常见误解就算你写了final list [1, 2, 3];final只是让list 这个变量不能重新赋值但list.add(4)依然合法。很多初学者以为final修饰后列表就不可变了这是个很需要纠正的点。2. 常用属性逐个数过去理解比死记硬背更重要讲属性之前先说一下我的一个学习习惯不要孤立地背 API要把属性和“这个属性在什么场景下会坑我”放在一起记。List 的常用属性其实不多的但每一个都可能有隐藏的行为细节。2.1 length 与空判断这两个顺序一定不能反length返回列表的元素个数。这是你一定会用到的属性但我要重点提醒的是在 Dart 2.x 之后使用length判断一个列表是否为空是一种不太推荐的做法。官方更推荐用isEmpty或isNotEmpty。原因有两层isEmpty这个表达更符合语言的语义代码读起来也更接近人类的思维某些List的实现里计算length可能不是 O(1) 操作虽然标准List基本是 O(1)但写成if (list.isEmpty)从表达上就规避了这种可能。// 不推荐 if (list.length 0) { // do something } // 推荐 if (list.isEmpty) { // do something }还有一个很小的性能细节isNotEmpty的实现本质就是!isEmpty两个都不贵别在代码里写list.length 0这种绕一圈的写法。2.2 何时使用 first 和 last何时别用list.first返回第一个元素list.last返回最后一个元素。这两个属性看着方便但在空列表上使用时会直接抛出StateError异常var list int[]; print(list.first); // 直接抛 StateError这说明一个原则first和last不能替代“先判空再取值”这一步。如果你的列表可能为空安全的写法是if (list.isNotEmpty) { var first list.first; }另一种替代方案是使用扩展操作符配合空列表的默认处理var first list.isEmpty ? null : list.first;不过这样的话first的类型会变成可空类型后续使用要小心判空。所以我的建议是在一个明确不为空的上下文里使用first和last比如刚做过add或刚过滤出结果否则就规规矩矩先isEmpty。2.3 一个很多人没注意的 single 属性single不属于高频属性但如果你理解了它的实现逻辑就能知道这个 API 的定位它要求列表恰好只有一个元素如果元素数量不是 1直接抛异常。var list [42]; print(list.single); // 42 var list2 [1, 2, 3]; print(list2.single); // StateError这看起来好像很鸡肋但我在写状态管理或者做一些单结果查询时会用它来“强行校验数据”如果服务端返回的列表多于一个结果立即暴露问题而不是假装取了第一个就完事。2.4 反向视图 reversed注意它不是一个 Listlist.reversed返回的是一个Iterable不是List。看起来只是类型上的差异但要知道var list [1, 2, 3, 4]; var reversed list.reversed; print(reversed.runtimeType); // ReversedListIterableint var newList list.reversed.toList(); print(newList); // [4, 3, 2, 1]如果你直接用reversed[0]这种索引方式会报错因为 Iterable 不支持下标访问。这也算一个小小的“为什么这里不直接返回 List”的反直觉设计返回一个视图而不是拷贝在某些场景下更省内存但带来了使用上的限制。实际项目里如果要用反序列表直接.toList()转一下更省心。3. 高频操作实操记录增删改查每一步都有讲究这部分是真正写业务逻辑时天天要用到的我尽量把“最简单的写法”和“可能踩坑的写法”都给你列出来。3.1 新增元素add、addAll、insert、insertAll 的边界行为新增元素有这么几个常用方法var list int[1, 2, 3]; list.add(4); // 尾部追加单个元素 list.addAll([5, 6]); // 尾部追加一组元素 list.insert(0, 0); // 在索引 0 处插入元素 list.insertAll(2, [7, 8]); // 从索引 2 开始插入一组元素add和addAll的语义很简单就是在尾部追加。insert系列需要留意索引的位置是“插入后该元素所在的位置”。比如list.insert(0, 0)就是把 0 放到列表首部之前的第一位变成第二位。如果索引超出了当前列表长度Dart 会报RangeError不会像 Python 一样自动补齐空位。var list [1, 2, 3]; list.insert(5, 99); // RangeError这一点需要刻意记忆一下。配合addAll在头部添加的时候有人会写list.insertAll(0, [99, 100]);这种写法合法且常用但如果你频繁在头部插入大量元素建议考虑换用ListQueue之类的数据结构。因为insertAll(0, ...)每次都会移动后面所有元素的位置数据量上来之后性能会明显下降。3.2 删除元素按值删除、按下标删除、按条件删除删除操作分场景var list [1, 2, 3, 2, 4]; list.remove(2); // 删除第一个匹配的 2结果是 [1, 3, 2, 4] list.removeAt(0); // 删除索引 0 的元素 list.removeLast(); // 删除最后一个元素 list.removeWhere((e) e.isOdd); // 删除所有奇数 list.clear(); // 清空整个列表这些方法里最容易被忽视的是remove的参数是“值”不是“索引”而且删除的是第一个匹配项。如果你写list.remove(2)它不会把列表里所有 2 都删光只删第一处。想删掉所有重复值得用removeWherelist.removeWhere((e) e 2);还有一个重要细节remove方法判断相等的逻辑走的是方法。对于基础类型int、String这是值比较对于自定义对象需要使用对象重写后的。如果你没有重写默认是同一引用才相等这在很多业务场景下会造成“明明内容相同但 remove 不掉”的现象。3.3 修改元素不只是 list[i] something最常见的修改方式是索引赋值var list [1, 2, 3, 4]; list[1] 20; // [1, 20, 3, 4]但有两个批量操作的 API 也很实用list.fillRange(1, 3, 0); // 将索引 1 到 3不包含 3范围内的元素都设为 0 list.replaceRange(0, 2, [7, 8, 9]); // 将索引 0 到 2不包含 2范围内的元素替换为后面的元素序列replaceRange的语义比较特殊被替换的区间和替换进去的元素数量不需要一致。比如原列表[1, 2, 3, 4]执行replaceRange(1, 3, [8, 9, 10])之后原索引 1、2 位置被删除换上 8、9、10结果变成[1, 8, 9, 10, 4]。这种“边删边补”的行为在某些数据处理场景里非常高效但第一次用的人容易产生“为什么长度变了”的疑问。3.4 遍历的几种姿势选对方法比“能用就行”重要遍历一个 List至少有这几种写法// 传统 for 循环适合需要用到索引的场景 for (var i 0; i list.length; i) { print(list[i]); } // for-in 循环适合不需要索引的场景 for (var item in list) { print(item); } // forEach适合简短的函数式写法 list.forEach((item) { print(item); }); // map适合需要变换成另一个集合的场景 var doubled list.map((e) e * 2).toList();我个人的使用原则是需要访问索引就老老实实写 for 循环只想处理每个元素不关心返回值就用 for-in要把一个列表转换成另一个列表用mapforEach我几乎不用因为它不能方便地跳出循环也没有返回值实际编码里并不比 for-in 有优势。map方法返回的是Iterable不是 List所以用到下标访问前必须.toList()。如果只是遍历不转也行但注意map是惰性求值的不遍历到对应元素回调不会执行。这个延迟求值的特性在配合where时会有一些意想不到的行为但工程里更常见的坑是忘了转 List 直接试图用下标。4. 排序、去重、转换这些工程中每天都会遇到的组合用法单独懂add、remove还是不够真正常用的是一组操作叠加起来处理数据。这里记几个高频场景。4.1 去重最快的一行代码toList toSetDart 的 List 没有内置distinct方法但可以借助 Set 去重var list [1, 2, 2, 3, 1, 4]; var unique list.toSet().toList(); print(unique); // [1, 2, 3, 4]这个写法有两个关键点toSet()会去掉重复元素同时保留元素之间的相对顺序结果是无序的说法其实是错的Dart 的LinkedHashSet是默认的 Set 实现它保序因此最终unique的顺序就是原列表第一次出现的顺序。如果你需要的是“根据对象的某个字段去重”就不能直接用toSet因为两个对象的相等逻辑默认跟字段无关。此时需要fold或者手动维护一个 Set 来过滤var list [ Person(A, 18), Person(B, 18), Person(C, 20), ]; var seenNames String{}; var uniquePersons list.where((p) seenNames.add(p.name)).toList();seenNames.add(p.name)返回布尔值如果是新名字返回 true重复名字返回 false。利用这个返回值配合where一行过滤逻辑特别简洁。这种方式我在写列表筛选和本地数据合并时使用频率非常高。4.2 sort 的默认排序数字排序绝对是最大的坑List.sort()默认是按Comparable比较。对于ListString默认按字母序排这个没什么问题。但对于Listint直接调用sort()Dart 是按数字升序排吗var numbers [10, 9, 8, 100, 80]; numbers.sort(); print(numbers); // [8, 9, 10, 80, 100]没错数字列表默认按数字升序。这跟 JavaScript 的数组排序把数字转成字符串的坑完全不一样。如果只记得 JavaScript 的习惯反而会在 Dart 里踩相反的错——乱写比较函数。真正需要自己传比较函数的场景是排序对象或者倒序// 按字符串长度排序 list.sort((a, b) a.length.compareTo(b.length)); // 数字倒序 numbers.sort((a, b) b.compareTo(a)); // 多字段排序 list.sort((a, b) { if (a.age ! b.age) { return a.age.compareTo(b.age); } return a.name.compareTo(b.name); });注意sort是原地排序直接修改原列表不会返回新列表。如果你需要保留原顺序必须先拷贝一份再排var sorted [...list]..sort();这里用到了展开操作符...list后面再聊。4.3 组合使用 map、where、toList 的管道式写法Dart 的 Iterable API 非常丰富链式调用是日常工作里最舒服的写法。比如我从服务端拿到ListMapString, dynamic想提取其中所有大于 18 岁的用户姓名ListString names data .map((e) User.fromJson(e)) .where((u) u.age 18) .map((u) u.name) .toList();这种管道写法可读性很高有一个潜在的效率差别需要说明每一步都独立执行不会像某些语言的 lazy pipeline 一样合并优化。如果你只有一个几万元素的小列表完全无所谓如果是几十万以上且操作链很长就需要考虑中间结果的内存占用。另外提一个我在代码审查时常见的问题有人会在map里面做有副作用的事情比如写日志、改外部变量。这在 Dart 里是可以运行的但非常不推荐。map应该是纯转换保持可预期的行为副作用交给forEach或 for-in这样逻辑才清晰。4.4 List 与 JSON/字符串的互转Flutter 开发里逃不掉的一个场景是 JSON 解析。Dart 的jsonDecode在解析 JSON 数组时返回的是Listdynamic而不是Listint或ListStringimport dart:convert; var raw [1, 2, 3]; var list jsonDecode(raw); print(list.runtimeType); // Listdynamic因此后续拿到每一个元素做int.parse或类似处理时最好显式做类型转换不要依赖隐性推断。反过来把 List 转成 JSON 字符串var list [1, 2, 3]; var raw jsonEncode(list);jsonEncode只支持可编码的数据类型比如int、String、bool、null、嵌套的List、Map。如果你放一个自定义对象进去会直接报错。自定义对象必须自己先转成MapString, dynamic。这个规则我在第一次封装网络层的时候就踩过把ListUser直接jsonEncode编辑器没报错运行时直接抛异常。5. 不可变列表和类型约束这些“保险丝”真别省很多人觉得 List 无非就是“能装的集合”用起来随意一点没问题。但真实工程里数据安全比少写两行代码重要得多。我见过同事因为把一个列表直接传到下层组件结果下层顺手给add了一个元素导致上层数据被无声修改排查了很久才发现。所以有必要专门讲讲不可变列表和类型约束。5.1 final 不等于不可变真正不可变要用 List.unmodifiable先把这个点彻底讲透。用final修饰列表变量只表示“不能给这个变量重新赋值”。final list [1, 2, 3]; list.add(4); // 合法 // list [5]; // 编译错误如果希望列表本身也不可修改要使用List.unmodifiablefinal list List.unmodifiable([1, 2, 3]); list.add(4); // 运行时抛出 UnsupportedError注意List.unmodifiable是在运行时拦截修改操作编译阶段不会报错只有真正执行到add等修改方法时才会抛异常。const列表是编译期常量性能更好但内容必须是编译期常量不能是运行时算出来的。还有一个偏门但必须知道的嵌套陷阱List.unmodifiable是浅层不可变里面的元素如果是可变对象依然可以被修改。比如List.unmodifiable([int[]])虽然不能给外层列表增加或删除元素但可以给内层那个列表add。做深层不可变需要用deep collection方案或自己递归处理。5.2 使用 List.filled 与 List.generate 创建预填充列表创建固定长度的列表最常用的是这两个方法var zeros Listint.filled(3, 0); print(zeros); // [0, 0, 0] var generated Listint.generate(5, (index) index * index); print(generated); // [0, 1, 4, 9, 16]List.filled(3, 0)创建了 3 个 0 的列表默认长度固定但注意如果传入的元素是对象所有位置引用的都是同一个对象var list ListPerson.filled(2, Person(A)); list[0].name B; print(list[1].name); // 输出 B因为两个位置是同一个 Person 实例这个跟 JavaScript 的Array.fill性质一样用它填充默认对象时需要格外小心共享引用问题。List.generate没有这个问题因为每个元素由回调生成回调每次都会重新创建对象。如果只是想生成 0 到 n 的连续数字列表List.generate很顺手。5.3 泛型约束带来的类型安全Listdynamic虽然什么都能装但会失去静态类型检查的价值。我写过一段代码从Listdynamic里取出一个元素赋值给int变量当时没报错因为动态类型让编译器放行了运行后元素实际是字符串结果类型转换异常。有了Listint这类错误在编译阶段就能被拦住。Flutter 项目中常见的列表类型有这么一些ListString names []; Listint scores []; ListMapString, dynamic rawData []; ListWidget children [];尤其是ListWidget在 Flutter 里非常常见。之前我见过有同学用Listdynamic装 Widget在children参数里好像也能工作但一旦做类型判断或where筛选问题就出来了。坦白说在 Flutter 开发中类型的正确性比“运行时不报错”重要得多。6. 实际项目里最容易踩的 List 坑帮你们提前排掉这一节是重点中的重点。我根据自己的开发经历把最容易踩却又最容易忽略的 List 相关坑列出来每一个都是真实发生过的。6.1 空安全环境下的默认值陷阱在空安全启用后Listint和Listint?是两个完全不同的类型。前者不能装null后者可以。Listint? list [1, null, 3]; print(list); // [1, null, 3]这个设计本身很合理但实际使用中有一个坑通过jsonDecode解析出来的列表元素哪怕是数字运行时类型也是dynamic存进Listint时需要判断有没有转失败的可能。比如jsonDecode([1, a, 3])返回的列表你想把它塞进Listint编译器不会拦但运行时会报类型错误。正确的做法是在解析的入口就做好类型校验和转换。此外在从Listint?取值时不能直接赋值给int变量Listint? list [1, null]; int value list[0]; // 编译错误必须处理可空性int value list[0] ?? 0;这类代码在写表单、配置文件解析等场景里尤其多养成“取出即判断”的习惯会省很多跑来跑去的错。6.2 子列表视图与拷贝一不小心就“改错了原数据”list.sublist(0, 2)返回的是一个新列表吗是也不是——它返回一个新列表对象但里面的元素是共享引用的。var list [ Person(A), Person(B), ]; var sub list.sublist(0, 1); sub[0].name C; print(list[0].name); // 输出 C如果你只改了sub里对象的属性会作用到原列表的对象上。因为拷贝的是对象的引用。而如果你想完全拷贝一个列表让两个列表互不影响元素的增删但要小心使用list.toList()拷贝后只能保证各自列表的结构独立元素本身如果是可变对象仍然是同一个引用。var list [1, 2, 3]; var copy list.toList(); copy.add(4); print(list); // [1, 2, 3]结构互不影响但如果元素是Person这种自定义对象toList()不会深拷贝对象属性。真正需要深拷贝的场景必须自己实现clone方法或序列化反序列化。说实话Flutter 项目里大部分时候用toList()浅拷贝就足够了但是当你在做编辑器的撤销、重做功能时浅拷贝会带来一串“为什么改了历史记录”的问题。6.3 大列表下的性能注意点与 filter 效率最后聊一下性能。List 的where和map在小数据量下完全不需要优化但如果你的列表动辄几十万条数据有几个点在意一下removeAt(0)开销很大因为它需要把后面的元素整体向前移动。频繁在头部增删建议用ListQueue或LinkedList频繁在头部插入也是同理insert(0, e)是 O(n) 的list.contains(e)是线性遍历O(n)。如果频繁需要判断存在性同时维护一个 Set 会更高效链式调用map、where、toList会生成多个中间对象大数据量下注意内存波动。处理大集合尽量用原生的for循环或一次性完成过滤再收集结果。还有个小技巧如果只是判断集合中是否存在某个元素用any可以提前短路var hasEven numbers.any((e) e.isEven);比起先把所有满足条件的元素收集到列表再判断这个写法更高效也更简洁。最后再分享一点我的体会List 在 Dart 里的地位相当于“地基中的地基”Flutter 的各种组件参数、状态管理、网络解析、本地存储操作几乎到处都有它的身影。我自己的一个经验是遇到 List 相关的问题先不要急着搜“How to remove element from List in Dart”坐下来想一想这个操作背后是“修改结构”还是“修改元素”是“视图”还是“拷贝”是“引用相等”还是“值相等”想清楚了 API 自然就顺手了。另外建议刚开始学 Flutter 的读者可以把这篇笔记里的代码从头到尾敲一遍每个方法都运行一次看看输出。尤其建议主动试错故意在空列表上取first故意在unmodifiable列表上add故意用toList()浅拷贝后修改对象属性。刻意地踩过几次坑比纯看十遍文档都记得牢。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →