1. 从一次崩溃聊起:为什么List基础值得单独写一篇
先讲个真实场景。前几天有个朋友在群里发了一段Flutter代码,说列表页一滑动就崩,控制台报错信息很长,核心就一句:RangeError (index): Index out of range: index should be less than 3。我让他把数据拉取那段代码贴出来,一眼就看到问题了——他用了一个从接口返回的数组,直接按照固定下标去取元素,结果这次接口返回的数据比往常少了几条,越界崩溃。
这种问题在Flutter开发里太常见了。Dart的List是所有集合类型里面使用频率最高的一个,基本每个页面都会用到。但正因为太常见,很多人反而不会专门去系统梳理它的属性和操作,都是用到哪个查哪个,查完就忘,下次再踩一遍坑。这篇文章就是想把Dart数组(List)这一块彻底讲透,从最基础的属性到日常用得最多的操作方法,再到那些容易让人翻车的细节,比如浅拷贝和深拷贝、遍历时能不能改元素、remove和removeAt的区别等等。
适合谁来读?刚接触Flutter不久、对Dart语法还没完全形成肌肉记忆的同学,可以把这篇当字典用;已经写了一阵子Flutter但偶尔还会在List上踩坑的老手,也可以扫一遍,重点看后面的问题排查部分,说不定能帮你解决一个一直没搞明白的隐性问题。内容会配合大量可以直接跑起来的代码片段,建议打开编辑器边看边敲。
2. List入门:先搞懂底层逻辑,再记属性
2.1 数组和List的关系
Dart里面其实没有传统意义上的“数组”关键字,List承担了数组的职责。但它比传统数组灵活得多——长度可以动态变化,元素类型可以在声明时确定也可以不固定,底层存储方式也有讲究。
声明一个List最常见的方式是:
List<String> names = ['张三', '李四', '王五'];这里的List<String>叫带泛型的List,意思是这个列表里面只能放字符串。如果你放一个数字进去,编译阶段就会报错,这是Dart作为强类型语言的好处。当然你也可以不写泛型:
List mixed = ['张三', 18, true];但这种写法不推荐在正式项目中用。虽然Dart语言层面允许,但等到你从里面取数据的时候,类型不明确会导致一堆运行时判断,代码可读性也很差。如果你需要不同类型的元素,声明一个通用父类型或者用Object更合理。
2.2 底层存储:固定长度List和动态List
可能有人不知道,Dart的List其实分为两种底层实现。一种是固定长度的,一旦创建就不能增加或删除元素,只能修改已有元素的值;另一种是可扩容的,也就是我们平时最常用的那种,可以随意add、remove。
创建固定长度List:
List<int> fixedLengthList = List.filled(3, 0); fixedLengthList[0] = 1; // 可以 fixedLengthList.add(2); // 运行时报错:Unsupported operation创建可扩容List:
List<int> growableList = [1, 2, 3]; growableList.add(4); // 可以List.filled和List.generate这两个构造函数都能创建固定长度的List,但List.generate更灵活,它允许你根据索引去初始化每个元素的值:
List<int> generated = List.generate(5, (index) => index * 2); // [0, 2, 4, 6, 8]理解了底层这两类List的区别,“为什么List会在某些操作上报Unsupported operation”这个问题就不会再困扰你了。
2.3 基础属性速查表
直接给你一张表,把List的常用属性全部列出来,后面代码演示里会逐个过一遍。
| 属性 | 返回类型 | 作用 | 注意事项 |
|---|---|---|---|
length | int | 元素个数 | 空List的length是0 |
isEmpty | bool | 是否为空 | 推荐用它判断,不要用length == 0 |
isNotEmpty | bool | 是否非空 | 语义清楚,代码更易读 |
first | E | 第一个元素 | 空List会抛StateError |
last | E | 最后一个元素 | 空List会抛StateError |
reversed | Iterable | 倒序视图 | 返回的是Iterable不是List,需要toList() |
hashCode | int | 哈希码 | 基于元素内容计算,内容变了结果就变 |
runtimeType | Type | 运行时类型 | 很少直接用 |
single | E | 唯一元素 | 如果元素数量不是1就抛状态错误 |
indexed | Iterable | 带索引的视图 | Dart 3.0新增,返回(index, value)对 |
indexed这个属性比较新,很多老代码里看不到它的身影。它会返回一个可迭代对象,每次迭代给你一个包含索引和值的记录(record):
List<String> fruits = ['苹果', '香蕉', '橙子']; for (final (index, fruit) in fruits.indexed) { print('$index: $fruit'); }这种写法做带下标遍历的时候特别方便,不需要再手动维护一个for循环的计数器变量。
2.4 基础属性实操演示
把上面表格里的属性挨个写一遍代码,看着更直观:
void main() { List<String> cities = ['北京', '上海', '广州']; print(cities.length); // 3 print(cities.isEmpty); // false print(cities.isNotEmpty); // true print(cities.first); // 北京 print(cities.last); // 广州 // reversed返回的是Iterable,需要转回List List<String> reversedCities = cities.reversed.toList(); print(reversedCities); // [广州, 上海, 北京] List<int> numbers = [10]; print(numbers.single); // 10 }first和last在空List上直接调用会报错,这个要特别注意。如果你不能确定List一定有元素,先判断isEmpty再取,或者直接用firstOrNull——Dart 3新增的firstOrNull和lastOrNull扩展属性不会抛异常,取不到就返回null:
List<String> emptyList = []; print(emptyList.firstOrNull); // null这三个属性(firstOrNull、lastOrNull、singleOrNull)是IterableExtensions里提供的,实际开发中用处很大,建议优先使用它们。
3. List增删改查:每一种操作都配一个场景
3.1 添加元素:add、addAll、insert
add是最基本的追加操作,把一个元素放到List末尾。一次只能加一个:
List<int> scores = [80, 90]; scores.add(95); // [80, 90, 95]addAll用于一次性添加多个元素,参数可以是另一个Iterable。比如从接口拿到一个列表,你想把它并到现有列表后面,用addAll就够了:
List<int> firstHalf = [1, 2, 3]; List<int> secondHalf = [4, 5, 6]; firstHalf.addAll(secondHalf); // [1, 2, 3, 4, 5, 6]insert允许你在指定位置插入元素。注意索引是从0开始的,insert(0, value)相当于在队首插入:
List<String> queue = ['A', 'B', 'C']; queue.insert(1, 'D'); // [A, D, B, C]一次插入多个用insertAll:
List<String> queue2 = ['A', 'B']; queue2.insertAll(1, ['X', 'Y']); // [A, X, Y, B]注意:插入位置的下标如果大于当前List的length,会抛
RangeError。比如length是3的List,insert(5, 'D')会崩。插入前最好判断一下。
3.2 删除元素:remove、removeAt、removeWhere、clear
这里有一个高频迷惑点:remove和removeAt别看长得像,作用完全不同。
remove(Object? value):删除第一个等于指定值的元素,参数是元素本身。如果没找到,什么都不做,返回false。removeAt(int index):删除指定下标的元素,参数是下标位置。如果下标越界,抛RangeError。
List<String> letters = ['A', 'B', 'C', 'B']; letters.remove('B'); // 只删掉第一个B print(letters); // [A, C, B] letters.removeAt(2); // 删除下标2的元素 print(letters); // [A, C]removeWhere可以传一个判断函数,把所有符合条件的元素一次性删掉:
List<int> nums = [1, 2, 3, 4, 5, 6]; nums.removeWhere((num) => num.isEven); // [1, 3, 5]clear没什么好说的,清空所有元素,List长度直接变成0。注意clear之后List对象本身还在,只是里面没东西了,你依然可以往里面加新元素。
3.3 修改元素:直接用下标赋值
修改List元素最简单的方式就是通过下标赋值。前提是下标必须存在,不然就会触发RangeError。
List<int> nums = [10, 20, 30]; nums[1] = 99; // [10, 99, 30]有一种常见场景是用replaceRange替换一段区间的元素:
List<int> nums2 = [1, 2, 3, 4, 5]; nums2.replaceRange(1, 3, [8, 9, 10]); // [1, 8, 9, 10, 4, 5]这个方法把下标1(包含)到3(不包含)之间的元素替换成后面传的列表。起始位置和结束位置遵循左闭右开的原则,和Dart里的字符串截取、切片逻辑保持一致。
3.4 查找元素:indexOf、contains、where、firstWhere
判断一个元素在不在List里,用contains:
List<String> users = ['Alice', 'Bob', 'Carol']; print(users.contains('Bob')); // trueindexOf可以找到元素第一次出现的下标,找不到返回-1:
print(users.indexOf('Carol')); // 2 print(users.indexOf('David')); // -1如果你想找“第一个满足某些条件的元素”,firstWhere更好用。注意firstWhere找不到会抛StateError,除非你在后面传一个orElse参数:
List<int> ages = [15, 22, 18, 30]; int firstAdult = ages.firstWhere((age) => age >= 18, orElse: () => 0); print(firstAdult); // 22如果不希望抛异常,也可以用where把所有满足条件的元素找出来,返回的是一个Iterable:
List<int> allAdults = ages.where((age) => age >= 18).toList(); // [22, 18, 30]where找不到不会抛异常,只会返回空的可迭代对象,所以日常业务里做筛选用它更安全。
3.5 五种遍历方式,哪一种最适合你的场景
遍历List是每天的常规操作,Dart提供了好几种方式。我直接列个对比,方便你按场景选。
| 方式 | 代码 | 适用场景 |
|---|---|---|
| for循环 | for (int i = 0; i < list.length; i++) | 需要手动管理下标或倒序遍历 |
| for-in | for (final item in list) | 最推荐,简单直接 |
| forEach | list.forEach((item) { ... }) | 需要写回调函数风格 |
| while循环 | while (i < list.length) | 需要动态控制退出条件 |
| Map+toList | list.map(...).toList() | 需要把元素转换后生成新List |
for-in是性能最好也是可读性最高的选择,优先用它。forEach无法在循环体中提前break,需要提前跳出的时候别用它。
List<String> words = ['Flutter', 'Dart', 'List']; for (final word in words) { print(word); } words.forEach((word) { print(word); });还有一种情况需要注意:遍历中修改List。在for-in循环里直接add或remove元素,很可能会抛出ConcurrentModificationError。这个坑后面问题排查一节里会详细说。
4. List高阶操作:排序、过滤、映射、折叠
4.1 排序:sort方法与自定义比较器
sorted会不会改变原List?Dart的sort方法是原地排序,也就是直接修改原列表顺序,返回void,这一点和JavaScript里的sort一样。
List<int> nums = [3, 1, 4, 1, 5]; nums.sort(); // [1, 1, 3, 4, 5]默认排序是从小到大。如果你想要降序,传一个翻转的比较器:
nums.sort((a, b) => b.compareTo(a)); // [5, 4, 3, 1, 1]对象List按某个字段排序,需要你实现比较函数:
class Person { final String name; final int age; Person(this.name, this.age); } void main() { List<Person> people = [ Person('张三', 30), Person('李四', 25), Person('王五', 35), ]; // 按年龄从小到大 people.sort((a, b) => a.age.compareTo(b.age)); // 按年龄从大到小 people.sort((a, b) => b.age.compareTo(a.age)); // 先按年龄排序,年龄相同按姓名 people.sort((a, b) { int ageCompare = a.age.compareTo(b.age); if (ageCompare != 0) return ageCompare; return a.name.compareTo(b.name); }); }提示:自定义对象的排序逻辑如果多次使用,建议实现
Comparable接口,直接在类里写compareTo方法,比在外部到处写比较函数更干净。
4.2 过滤:where方法筛选“符合条件的子集”
过滤用一个where就完事了。但要记住,where返回的是Iterable,不是List。如果你马上要使用List的特性(比如按下标访问、调用firstOrNull等),必须.toList()转回List。
List<int> nums = [1, 2, 3, 4, 5, 6]; Iterable<int> even = nums.where((n) => n % 2 == 0); List<int> evenList = even.toList(); // [2, 4, 6]配合contains做多条件过滤时要注意优先级:&&和||的混用容易出错,条件多建议提取成一个单独的方法,方便测试和维护。
4.3 映射转换:map与expand的差别
map是List操作中使用频繁度最高的一个。它把每个元素映射成新值,返回一个新Iterable:
List<int> numbers = [1, 2, 3]; List<String> stringNumbers = numbers.map((n) => '数字$n').toList(); // [数字1, 数字2, 数字3]expand和map的区别在于:map每个元素生成一个新元素,expand每个元素可以生成多个新元素,返回值在语义上是一个“展平”的操作:
List<List<int>> nested = [[1, 2], [3, 4]]; List<int> flattened = nested.expand((inner) => inner).toList(); // [1, 2, 3, 4]这个在解析树形结构的时候特别有用,比如菜单数据从嵌套JSON转成扁平列表。
4.4 归约折叠:reduce与fold
reduce把List里的元素两两合并,直到变成一个值:
List<int> numList = [1, 2, 3, 4]; int sum = numList.reduce((a, b) => a + b); // 10reduce有个前提:List不能为空,空List调用reduce会抛StateError。如果可能为空,用fold更安全,因为fold允许你先指定一个初始值:
List<int> emptyList = []; int sum2 = emptyList.fold(0, (prev, curr) => prev + curr); // 0,不会崩fold的第一个参数是初始值,可以是任意类型。比如你想把一个List拼成一个带格式的字符串:
List<String> items = ['苹果', '香蕉', '橙子']; String result = items.fold('水果: ', (acc, item) => acc + item + ','); // 水果: 苹果,香蕉,橙子,4.5 使用扩展操作符展开List
Dart的扩展操作符(...和...?)在处理List合并时特别优雅。直接用[]包裹,配合扩展操作符就能把List塞进另一个List的任何位置:
List<int> first = [1, 2]; List<int> second = [3, 4]; List<int> combined = [...first, ...second]; // [1, 2, 3, 4] List<int>? nullableList; List<int> safeCombined = [0, ...?nullableList, 5]; // [0, 5]...?扩展操作符专门处理可能为null的List,不会因为null报错。收集参数中用扩展操作符也很常见,比如函数的可变参数:
void printAll(List<int> numbers) { show([0, ...numbers]); } void show(List<int> data) { print(data); }5. List的拷贝和不可变List:具体工作中最容易掉进去的陷阱
5.1 引用拷贝 vs 浅拷贝 vs 深拷贝
这个必须单独拿一节来讲,因为我在实际帮别人排查问题的时候,发现几乎每个人都在这上面翻过车。
直接赋值(引用拷贝):List b = a;这行代码执行完之后,a和b指向的是同一个List对象。你改b的元素,a也跟着变。
List<int> a = [1, 2, 3]; List<int> b = a; b[0] = 99; print(a); // [99, 2, 3]浅拷贝:用.toList()或者List.from(list)复制一份,但是只复制外壳,不会复制里面的元素对象。如果List里面装的是引用类型(比如对象),那两个List里的对象仍然指向同一个内存地址。
List<List<int>> original = [ [1, 2], [3, 4] ]; List<List<int>> shallowCopy = original.toList(); shallowCopy[0][0] = 99; print(original[0][0]); // 99,内部被改了深拷贝:List里面的对象也要一起复制,生成完全独立的副本。Dart标准库没有直接提供深拷贝方法,需要你自己实现。对于可序列化的对象,一种通用做法是jsonDecode(jsonEncode(list)),但不推荐在性能敏感的地方用。另一种是递归拷贝:
List<List<int>> deepCopy(List<List<int>> source) { return source.map((inner) => List<int>.from(inner)).toList(); }5.2 unmodifiable与const:如何创建真正不可变的List
有些情况下你不希望List内容被意外修改。Dart提供两个层面来做到这一点。
List.unmodifiable创建一个运行时不可修改的List。任何尝试修改的操作都会抛UnsupportedError:
List<String> immutable = List.unmodifiable(['a', 'b', 'c']); immutable.add('d'); // 运行时报错const创建编译期常量List。它不仅仅是运行时不可变,在编译阶段就已经确定了内容,并且如果两个const List内容相同,它们在内存里是同一个对象:
const List<String> constList = ['a', 'b', 'c'];注意:
constList和List.unmodifiable最大的区别在于,const是编译期常量,可以有优化;unmodifiable是运行时包装。另外const []因为空List是同一个常量实例,不能通过在const List []后面加元素,否则也报错。
创建不可变List的时候,如果里面装的是可变对象,对象内部的修改不会受到List不可变的限制。这种场景需要对象本身也设计成不可变的,或者配合后面提到的copyWith模式处理。
5.3 为什么状态管理里一直强调“不要直接修改List”
在用Flutter的setState或者状态管理框架的时候,直接修改List的元素是常见的bug来源。核心原因在于Flutter的widget重建机制——某些组件依赖==比较来判断列表是否变化,如果直接修改List元素但List本身引用没变,框架无法感知数据变化。
// 错误示范 setState(() { _list[0] = '修改后的值'; }); // 正确示范 setState(() { _list = [..._list[0] = '修改后的值']; });实际开发中,尤其是使用Provider或者Riverpod的时候,我总是会强调这一点:更新列表状态时,要么创建新的List实例,要么在统一的地方管理列表的修改逻辑。这样UI才能可靠地去响应变化。
6. Flutter页面里List的真实应用场景
6.1 用ListView展示List数据
List在Flutter UI层最常见的用途就是喂给ListView.builder,处理长列表的懒加载渲染:
ListView.builder( itemCount: _items.length, itemBuilder: (context, index) { final item = _items[index]; return ListTile( title: Text(item.title), subtitle: Text(item.subtitle), ); }, )ListView.builder是按需构建的,它不会像Column里面直接放一堆widget那样一次性全部渲染,所以数据量再大都不太卡。这是Flutter List在UI层提供的天然优势。
6.2 网络请求数据转成List模型
接口返回的JSON解析是List操作的另一大场景。常见流程是这样的:
class Article { final String title; final String author; Article.fromJson(Map<String, dynamic> json) : title = json['title'], author = json['author']; } void parseArticles(String responseBody) { List<dynamic> data = jsonDecode(responseBody); List<Article> articles = data .map((item) => Article.fromJson(item)) .toList(); }网络请求中的List解析有很多边界情况:返回的不是数组而是对象、某个字段缺了、数据量太大等等。每次拿到List<dynamic>之后,我会先看一眼isEmpty状态,再走后续逻辑,避免解析空列表时出问题。
6.3 列表分页加载和数据合并
分页加载是移动端列表的核心交互之一。通常的代码模式是:
List<Item> _items = []; int _page = 1; Future<void> _loadMore() async { final newItems = await _fetchPage(_page++); setState(() { _items = [..._items, ...newItems]; }); }这里的_items = [..._items, ...newItems]创建了一个新List,然后UI依赖List引用的变化来刷新列表。如果你用_items.addAll(newItems),在部分状态管理方案下,UI不会感知到数据变化,因为List对象的引用没有改变。
6.4 从List生成下拉选项、筛选结果
比如从一个城市List生成下拉选择器的选项列表:
List<String> cities = ['北京', '上海', '广州']; List<DropdownMenuItem<String>> items = cities .map((city) => DropdownMenuItem(value: city, child: Text(city))) .toList();再比如根据关键词过滤List:
List<String> filterCities(String keyword) { return cities .where((city) => city.contains(keyword)) .toList(); }这种场景虽然代码不多,但往往涉及输入框的实时响应。每次用户输入都要重新过滤,所以尽量用高效的方式,避免不必要的copy和循环。
7. 常见问题与排查技巧实录
7.1 RangeError: 越界访问
这是List相关报错中出现频率最高的。原因几乎都是访问了不存在的下标。
List<int> nums = [1, 2, 3]; print(nums[5]); // RangeError排查思路:先确认length,再确认下标范围。凡是使用变量作为下标的地方,都要思考这个变量有没有可能越界。常见的越界场景是循环里用了<=而不是<。
7.2 ConcurrentModificationError: 遍历中修改
在遍历List的过程中修改List(增加或删除元素),会导致这个错误。
List<String> items = ['A', 'B', 'C', 'D']; for (final item in items) { if (item == 'B') { items.remove(item); // ConcurrentModificationError } }正常解法:先收集要删除的元素,遍历结束后再统一删除;或者使用for循环从后往前遍历删除。
List<String> items = ['A', 'B', 'C', 'D']; items.removeWhere((item) => item == 'B'); // 最省事7.3 contains判断的是对象引用还是值
contains判断相等性使用的是==操作符。对于String、int等基本类型,比较的是值;对于自定义对象,如果没有重写==,比较的是内存地址:
class Person { final String name; Person(this.name); } void main() { List<Person> people = [Person('张三')]; print(people.contains(Person('张三'))); // false }如果你想让两个名字相同的人被视为“同一个”,需要重写==和hashCode。或者在业务逻辑中用any方法自己写比较规则。
7.4 addAll之后原List被影响了吗
如果addAll的参数是另一个List,元素会被复制引用。对于基本类型没问题,互相独立;对于引用类型,两个List里的对象指向相同的内存地址。修改对象属性会让另一个List里的“同一个对象”也跟着变。要规避的话就做深拷贝。
7.5 空List调用first/last/reduce
空List调用first、last、reduce、single都会抛异常。统一使用firstOrNull、lastOrNull、fold方案可以规避大部分问题。
7.6 排序后原List顺序变了
sort()是原地排序,如果你需要保留原来的顺序排序后另存为新List,先copy一次再排序:
List<int> sorted = List<int>.from(nums)..sort();7.7 如何安全地“修改原List并得到一个新List”
可以组合spread操作符和列表方法来实现:
List<int> oldList = [1, 2, 3]; List<int> newList = [...oldList]..add(4); // 原List不变,newList是[1,2,3,4]这里用到了..级联操作符,它表示对前面生成的新List执行add(4),并返回这个List。
8. 我的几点实操心得
List相关的知识写到这里,基本覆盖了日常项目里九成以上的使用场景。最后分享几个我自己的习惯。
第一,能用where+toList完成的过滤不要用循环。Dart函数式API写出来更简洁,where、map、fold这些方法组合起来,比一长串for循环加if判定的可读性高太多了。
第二,团队协作时,约定List不可修改的场景要用不可变List。如果一个列表不应该被后续代码修改,初始化的时候就强制包一层List.unmodifiable,或者声明为final的同时使用List.of拷贝,避免别人无意间改掉。
第三,在Flutter的State类里保存List时,尽量保证每次更新都生成新的List实例。这是一个非常好的习惯,能省掉很多状态管理框架下“界面不刷新”的排查时间。
第四,性能方面要注意:高频调用的代码路径里,尽量避免频繁使用List.from和toList去复制大List。如果只是需要遍历查看,用Iterable类型就够了,不用急着转成List。
踩过的坑多了之后你会发现,List不是那种需要背语法的东西,关键在于理解它“引用传递”的本质,理解Dart的不可变模式,以及理解Flutter框架对数据变更的感知机制。把这几点想通了,遇到任何跟数组相关的bug,你都能很快定位到问题根源。