把Dart的列表单独拎出来写一篇笔记,起初我是拒绝的——列表嘛,哪个语言没有,不就是增删改查。但真正在Flutter里写了几个页面之后,才发现这个想法太天真。列表在Dart里不只是数据结构,更是业务数据流转的主要载体:接口吐出来的JSON数组要转成List,ListView要渲染的数据源是List,状态管理里要维护的动态数据还是List。可以这么说,Flutter页面的“动态”二字,很大程度上就体现在列表上。
这篇笔记我尽量按照自己实际学习时的顺序来写,不搞教科书式铺开,而是从“我在代码里到底怎么用列表”出发,把创建、遍历、增删改查、切片、排序这些高频操作一次讲透,顺带把我踩过的坑也都记录下来。无论你是刚接触Dart,还是已经在写Flutter但列表用得比较“顺手就行”,这篇都值得花十分钟看完,尤其是最后那节踩坑记录,我敢说大多数人都中过招。
1. 列表在Dart里的定位:为什么Flutter开发者必须吃透List
1.1 列表就是Flutter业务里的“数据传送带”
我先说一个自己的体会:写Flutter页面,十行业务代码里至少有五行在跟列表打交道。
后端返回的商品列表,你要渲染到屏幕上,先在Dart里转成List<Product>;用户滚动加载更多,你往这个List尾部塞新数据;筛选功能做出来了,你又用where从原列表里筛出一个新列表;购物车要改数量,你还得先indexOf找到目标元素再改。整个数据流从接口到UI,列表几乎是绕不开的中间载体。
而在UI层面,Flutter最常用的滚动组件ListView,名字里就直接带着List。它要渲染的内容,本质上就是“把一份Dart列表映射成一串Widget”。所以列表掌握得牢不牢,直接决定了你写接口数据展示这类页面时的流畅度。
1.2 从数组到List:Dart列表的类型安全和底层逻辑
很多从JavaScript转过来的同学,第一次写List<int> list = [1, 2, 3]时会觉得多此一举。但Dart是强类型语言,泛型约束不是摆设——声明了List<int>,你就没法往里塞字符串,编译期直接报错。这个特性在某些“接口字段类型变来变去”的项目里,能帮你挡掉一大批运行时才能发现的低级问题。
再说底层。Dart的List和其他语言的动态数组类似,内部是一块连续内存,支持随机访问。你可以把它理解成“可变长的数组”:既能按下标快速取元素,又能在尾部动态添加。和Java的ArrayList、Python的list、JavaScript的Array处在同一个生态位。但也因为底层是连续存储,insert和removeAt这种操作会涉及元素搬移,频繁在头部或中间插入删除,性能会比尾部操作差不少。这个点面试偶尔会问,平时性能敏感的场景也要心里有数。
还有个细节容易被忽略:List是Iterable接口的一个重要实现,但反过来不成立——一个Iterable不一定是List。这意味着你写函数时如果只需要遍历,参数类型尽量用Iterable会更灵活;但如果你需要按下标访问、获取长度后随机跳转,那就要保证传入的是List。
2. 创建列表的多种姿势:从字面量到生成器,以及可变与不可变的取舍
2.1 创建列表的几种姿势(多数人只用过前两种)
Dart里创建列表最简单的方式就是字面量,这个和绝大多数语言一样:
var list1 = [1, 2, 3]; List<int> list2 = [4, 5, 6];第二种常见写法是带泛型的:
final fruits = <String>['apple', 'banana'];这里把泛型写在字面量左边,还是写在变量名前面,两种都行,个人更喜欢<String>[...]这种,因为语义更直观:一看就知道这个列表里面装的是字符串。
但工作里只靠字面量远远不够。比如你要创建一个长度为5、元素全是0的列表,再比如你要生成一个包含平方数的列表。手写5个0还能接受,写100个就离谱了。这时候用构造函数:
final zeros = List<int>.filled(5, 0); final squares = List<int>.generate(5, (index) => index * index); // squares -> [0, 1, 4, 9, 16]List.filled适合占位、初始化;List.generate适合有规则的数据。还有个容易被忽略的细节:filled和generate都可以传入growable: false,表示创建定长列表。定长列表不能增删元素,只能改已有下标的值,这在某些固定容量的业务场景里能防止误操作。
从已有列表创建新列表,常见的还有List.of和List.from:
final original = [1, 2, 3]; final copy1 = List<int>.of(original); final copy2 = List<int>.from(original);两者区别在于:of要求迭代器里的元素类型严格匹配泛型,from则会更宽松一些,比如你有一个List<dynamic>,用from转成List<int>时,它会尝试逐个转换元素类型,而of通常直接编译报错。平时优先用of,因为它更严格,能更早暴露类型问题。
2.2 final、const、展开运算符与空安全
创建列表时,final和const的区别很容易搞混。final表示变量只能被赋值一次——你可以修改列表内部元素,但不能再把另一个列表赋给它。const则意味着整个列表在编译期就是常量,不可修改,任何增删改操作都会直接报错:
final finalList = [1, 2, 3]; finalList.add(4); // 没问题 const constList = [1, 2, 3]; constList.add(4); // 编译错误:Cannot modify an unmodifiable list在Flutter里,const列表经常用来定义固定选项数据,比如性别列表、城市列表、状态枚举列表。因为它在编译期就确定了,不会有额外的分配开销。
展开运算符是Dart列表创建里非常实用的语法。...可以展开另一个列表,...?则可以安全展开一个可能为null的列表:
final base = [1, 2, 3]; final combined = [0, ...base, 9]; // [0, 1, 2, 3, 9] List<int>? maybeList = null; final safe = [1, ...?maybeList]; // [1],不会因为null而报错这个写法在拼接列表时特别高效,比起一个个add,一行搞定,而且不需要中间变量。
空安全下还有一个容易踩的点:List<int?>和List<int>不是一回事。前者允许列表里有null元素,后者每个元素都必须是int。如果接口返回的数据可能缺字段,你就得明确处理好null,否则取出元素做运算时会出现空安全错误。
3. 增删改查与索引边界:高频操作里最容易翻车的细节
3.1 增删改:API虽多,记住核心几条
增删改这部分API看着多,其实按照使用频率排个序,核心就七八个。
增
add(element):尾部添加一个元素addAll(elements):尾部添加一整个列表,等价于循环add,但更简洁高效insert(index, element):在指定位置插入一个元素insertAll(index, elements):在指定位置插入一整个列表
final list = [1, 2]; list.add(3); // [1, 2, 3] list.insert(1, 99); // [1, 99, 2, 3]删
remove(element):按值删除,只删第一个匹配到的元素,删除成功返回trueremoveAt(index):按下标删除,返回被删除的元素removeWhere(condition):按条件批量删除,非常常用removeRange(start, end):删除区间内元素clear():清空
final list = [1, 2, 3, 2]; list.remove(2); // [1, 3, 2],只删了第一个2 list.removeAt(0); // [3, 2] list.removeWhere((e) => e.isOdd); // [2]改
- 下标赋值:
list[0] = xxx fillRange(start, end, value):把一段区间统一改为某个值replaceRange(start, end, replacement):用新列表替换一段区间
从个人经验看,Flutter业务里用得最多的组合是:add往列表尾部加新数据、removeWhere按条件删除、insert在顶部插入,以及下标赋值更新某一个元素。
3.2 查与索引边界:越界才是真正的风险
查询类操作里,contains和indexOf是使用频率最高的两个:
final list = ['a', 'b', 'c']; list.contains('b'); // true list.indexOf('b'); // 1,找不到返回-1 list.lastIndexOf('b'); // 如果重复,找最后一个位置indexOf找不到会返回-1,这个和Java、JS一致,写判断条件时不少新手会写成if (list.indexOf('b')),这在Dart里是错的,因为-1也是真值。正确写法是if (list.contains('b'))。
查下标还有一个更灵活的indexWhere:
final users = [ {'name': 'Tom', 'age': 20}, {'name': 'Jack', 'age': 18}, ]; final index = users.indexWhere((u) => u['name'] == 'Jack'); // index == 1说真的,查列表元素的API不算复杂,最坑的地方在于索引越界。Dart里访问不存在的下标会抛出RangeError:
final list = [1, 2, 3]; final x = list[3]; // RangeError (index): Index out of range这种错误在运行时才会暴露,而且一旦出现在build方法里,整个页面可能直接红屏。我做Flutter开发后,写访问下标的逻辑时都会下意识确认边界,或者用Dart 3新增的firstOrNull、lastOrNull扩展:
final list = <int>[]; final first = list.firstOrNull; // null,不会抛错如果你用的Dart版本低于3.0,就得自己写个判断,或者用list.isEmpty ? null : list.first。另外,list.first和list.last在空列表上同样会抛StateError,真不是只有下标访问才会翻车。
4. 切片、排序、复制:列表操作中容易被忽略的进阶套路
4.1 sublist、getRange与切片时容易忽略的“浅拷贝”
做列表分页、截取前几个、切割时间段数据,都离不开切片。
final list = [0, 1, 2, 3, 4, 5]; final sub = list.sublist(1, 4); // [1, 2, 3]sublist(start, end)前闭后开,end可以省略,省略时截到末尾。这个API非常简单,但有一个概念必须搞清楚:Dart的sublist返回的是一个新列表,不是原列表的视图。改变新列表的长度、增删元素,不会影响原列表。
不过,如果列表里的元素是对象,那么新列表和原列表共享的是元素对象的引用,是浅拷贝。举个例子:
class User { String name; User(this.name); } final users = [User('Tom'), User('Jack')]; final newUsers = users.sublist(0, 1); newUsers[0].name = 'Lucy'; // users[0].name 也变成了 'Lucy'如果你希望连元素对象也复制一份,那光靠sublist不够,需要手动对每个元素做克隆。这个坑在做编辑功能时特别常见:你复制了一份列表,改了副本里的对象属性,结果原列表也跟着变了,排查半天才发现是引用共享的问题。
getRange的边界和sublist一样,但它返回的是Iterable,得加个toList()才变成List:
final list = [0, 1, 2, 3, 4, 5]; final range = list.getRange(1, 4).toList(); // [1, 2, 3]4.2 sort排序与reversed反转:原列表还是新列表
sort()是很多新手容易误用的方法,因为它直接修改原列表,而不是返回新列表。这在Dart里很反直觉,因为函数式风格强烈的语言往往返回新集合,但Dart的sort偏命令式。
final nums = [3, 1, 2]; final result = nums.sort(); // 错误!sort()没有返回值,result是null nums.sort(); // 这样才对,nums变成了[1, 2, 3]如果不想改变原列表,需要先toList()复制一份再排序:
final sorted = [...nums]..sort();..是级联运算符,先复制列表再调用sort,结果赋值给sorted。这个双点语法在Flutter里用得不比Dart少,值得顺手掌握。
自定义排序逻辑通过比较器实现。比如按对象的某个字段排序:
final people = [ {'name': 'Tom', 'age': 20}, {'name': 'Jack', 'age': 18}, {'name': 'Lucy', 'age': 25}, ]; people.sort((a, b) => (a['age'] as int).compareTo(b['age'] as int)); // Jack(18), Tom(20), Lucy(25)reversed同样有陷阱:它返回的不是List,而是Iterable。大部分场景下你可能想用list.reversed.toList()拿到反向的新列表。别直接把它当成List用,否则后续按下标访问时会编译报错。
复制列表也有深坑。赋值操作只是复制引用,两个变量指向同一个列表;toList()是浅拷贝;List.of也是浅拷贝。只有列表里所有元素都是基本类型(int、String、bool等不可变对象)时,浅拷贝用起来才和深拷贝没区别。一旦元素是对象,想真正深拷贝,要么手动遍历复制,要么用jsonDecode(jsonEncode(list))兜底,但后者有类型丢失风险,不推荐用于核心业务。
5. 遍历与函数式处理:Flutter业务中最常用的几种写法
5.1 常规遍历与forEach的选择
Dart的遍历方式很常规,for循环、for-in、forEach、while都能用。
final list = [1, 2, 3]; // 经典下标循环 for (var i = 0; i < list.length; i++) { print(list[i]); } // for-in,拿到的是元素本身 for (final item in list) { print(item); } // forEach list.forEach((item) { print(item); });for-in是我平时用得最多的,因为它既不用管理下标,又能拿到元素本身。但如果需要下标,可以用indexed这个扩展(Dart 3内置):
for (final (index, item) in list.indexed) { print('$index: $item'); }forEach适合在链式调用的场景里收尾,比如先map再where最后forEach。但要注意,forEach里不能使用break和continue,也不能用await做异步,所以有中断需求或者要处理异步逻辑时,老老实实用for-in。
5.2 函数式操作:map、where、fold在业务中的组合
函数式操作才是Dart列表的精髓,一旦用顺了,写出来的代码既短又不容易出低级错误。
map:对每个元素做变换,返回Iterable,需要toList()才能真正成为List。
final nums = [1, 2, 3]; final squares = nums.map((n) => n * n).toList(); // [1, 4, 9]where:按条件过滤,同样返回Iterable。
final nums = [5, 12, 8, 130, 44]; final filtered = nums.where((n) => n > 10).toList(); // [12, 130, 44]fold / reduce:做累加、拼接操作。两者区别在于fold需要一个初始值,reduce用第一个元素作为初始值。空列表上调用reduce会报错,稳妥起见优先用fold:
final nums = [1, 2, 3, 4]; final sum = nums.fold(0, (acc, n) => acc + n); // 10 final names = ['Tom', 'Jack']; final joined = names.fold('', (acc, name) => '$acc, $name'); // ", Tom, Jack"业务里这几个函数经常组合使用。比如从模拟商品列表里,筛出价格大于100的商品名并按价格降序返回:
final products = [ {'name': '手机', 'price': 1999}, {'name': '耳机', 'price': 299}, {'name': '充电器', 'price': 99}, ]; final result = products .where((p) => (p['price'] as int) > 100) .toList() .reversed .map((p) => p['name']) .toList();这段代码看起来像流水线,每一行做一件事,调试时也容易定位问题。相比写一大堆for循环和中间变量,这种链式写法不仅代码量少,读起来也更接近自然语言。
any和every也值得提一嘴,它们用来判断是否存在满足条件、是否全部满足:
final nums = [1, 2, 3]; nums.any((n) => n > 2); // true nums.every((n) => n > 0); // true6. 列表配合ListView渲染:一套实战下来的经验
6.1 ListView.builder与列表的天然契合
Flutter里把列表渲染成UI,最常见的组合是ListView.builder加一个大列表:
final List<String> _items = List.generate(100, (index) => 'Item $index'); ListView.builder( itemCount: _items.length, itemBuilder: (context, index) { return ListTile( title: Text(_items[index]), ); }, )ListView.builder的懒加载机制和列表的下标访问非常契合——它不会一次性创建所有Widget,而是滚动到哪渲染到哪。但有个细节:itemBuilder拿到的index可不只是从0递增,滚动时Widget会被回收复用,所以千万别在itemBuilder里对下标做记录的假设,一切以当前传入的index为准。
如果想在列表项之间加分隔线,ListView.separated比ListView.builder更合适,它多了一个separatorBuilder参数:
ListView.separated( itemCount: _items.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { return ListTile(title: Text(_items[index])); }, )实际项目里,列表项往往不是简单的文本,而是包含图片、按钮、状态等复杂内容,处理方式也一样:把每一项数据映射成一个Widget。这里的核心思维和map一致——数据列表到UI列表的映射。
6.2 列表更新与刷新:不可变更新思路
Flutter页面要刷新,绕不开setState。但操作列表时,很多人会直接改原列表再setState:
setState(() { _items.add('新数据'); });这种做法在简单场景下没问题,但在涉及状态管理的项目里容易埋雷。比较推荐的做法是生成新列表再赋值:
setState(() { _items = [..._items, '新数据']; });这种不可变更新方式,能避免多个地方持有同一个列表引用时互相污染的问题。在Bloc、Provider、Riverpod这些状态管理框架里,更新状态时几乎都要求你返回新对象,底层就依赖“引用变了才通知UI刷新”的机制。你提前适应[...旧列表, 新元素]这种写法,后面接触这些框架时会轻松很多。
删除和修改也尽量走新列表:
// 删除按下标 _items = [..._items]..removeAt(index); // 修改按下标 _items = [..._items]..[index] = newValue;刚开始可能觉得每次都要复制一份很浪费,但在Flutter的UI更新频率下,这种成本可以忽略。相比之下,为了复用同一个列表引用而搞出一堆奇怪的bug,排查成本高得多。
7. 我踩过的列表坑与排错笔记
7.1 fixed-length陷阱:List.filled创建的是共享引用
这个坑我用血泪帮大家踩过了。
final grid = List<List<int>>.filled(3, []); grid[0].add(1); print(grid); // 输出 [[1], [1], [1]]为什么会这样?因为List.filled填充的是同一个[]对象,三个位置指向同一个内存地址。你往grid[0]里塞数据,其它两个“看上去”也跟着变了——因为它们本来就是同一个列表。
在Flutter里,这个陷阱在网格布局、表格编辑、二维数据结构里经常出现。解决办法是改用List.generate,每行创建一个新列表:
final grid = List.generate(3, (_) => <int>[]);同理,List.filled(3, someObject)也一样,三个元素都指向同一个对象。如果你要一组独立的副本,记得用generate。
7.2 遍历时增删元素:一个绕不开的经典错误
用一个经典的案例来说明。我想删除列表里的所有奇数:
final list = [1, 3, 5, 7, 9]; for (var i = 0; i < list.length; i++) { if (list[i].isOdd) { list.removeAt(i); } } // 实际结果:[3, 7]为什么会这样?因为每次removeAt都会让后面的元素前移,而循环下标还在往后走,相当于跳过了原来下标1、3位置上的元素。想用下标循环删元素,得让下标不前进,或者倒着遍历:
final list = [1, 3, 5, 7, 9]; for (var i = list.length - 1; i >= 0; i--) { if (list[i].isOdd) { list.removeAt(i); } }但更推荐的做法是用removeWhere,一行搞定:
list.removeWhere((e) => e.isOdd);还有一个经典错误是在forEach里删除元素,Dart会抛出ConcurrentModificationError,直接告诉你不允许并发修改。如果非要在遍历中做删除,用for-in配合临时列表标记,或者干脆用removeWhere。
7.3 其他零散踩坑记录
map之后忘记toList
map返回的是惰性的Iterable,一旦原列表后续发生修改,或者你迭代这个Iterable时才去取元素,得到的结果可能和预期不一致。更直接的问题是,很多API只接受List,你传一个Iterable过去编译都过不去。我的习惯是:只要最终需要列表形式,就立刻toList()。
sort没有返回值
这个前面提过,但值得再强调一遍——把sort()的返回值赋给其它变量,得到的永远是null。这是新手最容易犯的错,也是编码时最难发现的那类坑。
把数组字段直接存进列表
实际项目里,接口返回的数组经过jsonDecode后,得到的往往是List<dynamic>,里面每个元素是Map<String, dynamic>。如果直接把整个列表传给需要强类型的Widget,很可能会在运行时才暴露类型错误。正确的做法是尽早做一次映射,把dynamic转成你定义的Model对象:
final products = (json['data'] as List) .map((item) => Product.fromJson(item)) .toList();这样做的好处是,后续代码全程都是强类型,IDE提示健全,类型错误也能在编译期暴露出来。
依赖first/last触发空列表报错
我写过一段代码,在空列表上直接访问list.first,结果StateError: No element直接崩掉。之后我养成了一个习惯:凡是可能为空的列表,优先用firstOrNull,再配合空判断处理默认值。现在Dart 3自带这些扩展,用起来确实香,旧版本的话就自己封装个工具函数。
这次写列表笔记的过程中,我又把平时写的Flutter页面翻出来过了一遍,发现列表相关代码的占比确实高得吓人。也正因如此,Dart的列表虽然看起来基础,但值得把每个细节都磨清楚。后面如果继续踩到什么新坑,我还会回来补一篇。