资讯详情

资讯详情

Flutter零基础:List列表与遍历详解及实战

写博客这些年见过最多人倒在第X步的就是 Flutter 学到列表这一章。前面学个Container、Row、Column还觉得挺好玩的一碰到List和遍历就突然卡住了。因为这不是记 API 就行的事背后是一套“数据怎么存、怎么取、怎么变成界面”的思维转换。这篇是 Flutter 零基础系列的第五篇就专门围绕List列表和遍历从最基础的创建、增删改查到各种遍历写法的取舍再到最后把列表真正渲染到界面上、处理动态数据。这篇适合谁刚学完 Dart 基础语法、知道Widget是什么但还没写过真实列表页的人。看完你能得到一个明确结论什么时候用for什么时候用forEach什么时候必须用ListView.builder以及列表数据更新后界面为什么不刷新这种老坑。1. 先认识这位主角Flutter里的List到底是个什么1.1 它不是数组也不是Set它是“有序、可重复、可索引”的容器很多从 Java 或 JavaScript 转过来的人会用“数组”去理解List。实际差别还挺大的。Dart 里的List是一个有序集合元素按插入顺序排列允许重复每个元素可以通过下标访问下标从 0 开始。这里说的“有序”不是排序而是“有先后顺序”。[3, 1, 2]和[1, 2, 3]是两个不同的List它们的顺序就是插入时候的顺序没人会帮你排好。Set跟List的区别更明显。Set是无序且不允许重复的集合底层用哈希表实现判断contains很快。如果你只是想知道“列表里有没有这个数据”List要遍历一遍Set是 O(1) 查询。但Set失去了索引所以这两个东西不是互相替代的关系而是按场景选。List在 Dart 里是个泛型容器Listint、ListString、ListWidget、ListMapString, dynamic都可以。初学时不用被泛型吓到你只需要理解Listint表示这个列表里每一项都是int类型往里塞字符串编译直接报错。这是一个非常好的约束能帮你把一半的类型 bug 扼杀在编译期。1.2 创建List的几种姿势Dart 创建List最自然的方式是字面量Listint numbers [1, 2, 3]; var names [张三, 李四];这种字面量创建出来的列表长度是可变的growable也就是说你可以随意add、remove。但还有两种创建方式写在代码里的频率也很高List.filled和List.generate。List.filled(3, 0)会创建一个长度为 3、每一项都是0的列表。List.generate(5, (index) index * 2)会生成[0, 2, 4, 6, 8]。前者适合初始化固定长度的数据后者适合按某种规则生成数据。这里有个坑我刚开始踩过List.filled(3, CustomObject())这个构造函数会让每一项都引用同一个CustomObject实例。也就是说你改list[0]的某个属性list[1]也变了因为它们就是同一个对象。如果你要生成 3 个独立对象得用List.generate(3, (index) CustomObject())因为generate的回调会执行 3 次。1.3 final修饰列表真的不能修改吗这是 Dart 新人必踩的认知误区。final list [1, 2, 3]; list.add(4); // 这行代码能正常执行final修饰的是list这个引用不是列表内容。引用不能变就是说你不能重新赋值list [5, 6]但list.add(4)是在改原对象内部数据编译器管不着。想创建一个真正不可变的列表用final list List.unmodifiable([1, 2, 3]); list.add(4); // 运行时报错Unsupported operation: add我自己的习惯是当某个列表作为全局配置、或者作为 Model 暴露给外部时用List.unmodifiable包一下防止别人无意中改了数据源。作为一个零基础的人你只要记住——final不等于不可变别在面试或者写代码时搞混。2. 遍历方式大盘点每种写法都有它的脾气2.1 for、for-in、forEach先搞懂这三兄弟遍历一个List你至少会见到这三种写法// 写法一传统 for 循环用下标 for (int i 0; i list.length; i) { print(list[i]); } // 写法二for-in直接拿元素 for (var item in list) { print(item); } // 写法三forEach 函数式 list.forEach((item) { print(item); });顺序讲一下适用场景。传统for循环在需要用到索引的时候用——比如你想隔一个取一个、想在遍历时知道当前是第几个、想通过下标修改原列表的值。它能精确控制起点、终点、步长。for-in是最舒服的一种写法它隐藏了下标直接给你每个元素。绝大多数情况下你只需要“从头到尾把每个元素处理一遍”用for-in就够了代码最少看起来最干净。forEach也是从头到尾遍历但它有个特殊限制不能在回调里使用break或return来中断遍历。forEach的回调函数会对每个元素调用一次你return只是从这次回调里返回循环照样继续。如果你遍历到某个条件想提前退出老老实实用for或for-in加break。后来我写代码养成了一个习惯不确定要不要提前退出默认就写for-in比forEach少踩一个脑子转不过来的坑。2.2 map、where、expand这几个高阶函数才是List的精华for-in能解决“遍历”的问题但解决不了“把列表转换成另一种列表”的问题。比如你有一个ListString的姓名列表想变成ListWidget的 Text 列表又或者想从一堆订单里筛选出金额大于 100 的然后再把每个订单项转换成展示模型。这种场景手写循环也能做但用 Dart 内置的高阶函数会更清晰。map负责一对一转换返回一个新的列表Listint numbers [1, 2, 3]; ListString strings numbers.map((e) 数字$e).toList(); // 结果[数字1, 数字2, 数字3]注意map返回的不是List而是一个懒加载的Iterable所以通常要调.toList()转回List。这看起来繁琐其实是 Dart 刻意设计的让中间结果不占内存只在真正消费时才计算。where负责过滤配合条件表达式使用Listint numbers [1, 2, 3, 4, 5, 6]; Listint even numbers.where((e) e.isEven).toList(); // 结果[2, 4, 6]expand负责把一个元素展开成多个元素或者把嵌套列表压平。比如你有几个订单每个订单里有多个商品你想把所有商品汇聚到一起ListListint nested [ [1, 2], [3, 4, 5] ]; Listint flat nested.expand((e) e).toList(); // 结果[1, 2, 3, 4, 5]这三个函数配合链式调用写出来的代码非常接近自然语言“把所有订单取出来筛选出已支付的转换成订单卡片全部展开成商品列表”。我特别喜欢这种声明式的风格因为它描述的是“我要什么结果”而不是“每一步怎么做”。你后期回看代码时扫一眼就能知道意图不用一行行去推算循环里发生了什么。2.3 遍历时能不能修改原列表这是个高频面试题也是实战里会碰到的问题。在遍历一个List的同时直接删除元素会出问题Listint numbers [1, 2, 3, 4, 5]; numbers.forEach((e) { if (e.isOdd) { numbers.remove(e); // 不要这样做 } });原因很简单遍历依赖索引顺序和列表长度你一边遍历一边删除列表长度发生变化下标就会错位轻则漏元素重则直接抛Concurrent modification during iteration之类的异常。正确的姿势是用removeWhere它内部已经处理好了删除逻辑numbers.removeWhere((e) e.isOdd);如果逻辑很复杂没法用removeWhere表达那就先筛选出要保留或删除的元素遍历结束后再统一删或者干脆利用where生成一个新列表Listint newList numbers.where((e) e.isEven).toList();我的经验是尽量养成“不要原地修改列表”的习惯而是生成新列表。这种不可变数据思维在 Flutter 的setState更新界面时尤其重要后面会专门讲。3. 核心实操怎么把List变成真正的UI界面3.1 ListView的两种用法区别比你想象的大数据在内存里存了遍历也会了接下来就是最后一步——渲染到屏幕上。Flutter 里展示列表的标准组件是ListView。但它有两种用法新手很容易一头扎进去就出不来了。第一种是直接给childrenListView( children: [ Text(第一行), Text(第二行), Text(第三行), ], )这种方式适合静态、少量、确定数量的小列表。它的原理是把所有子组件一次性全部构建出来。如果数据有 1000 条这 1000 个组件就会一次性加载、测量、布局性能和内存都很糟糕。第二种是ListView.builderListView.builder( itemCount: items.length, itemBuilder: (context, index) { return ListTile( title: Text(items[index]), ); }, )这种方式是懒加载的——只有当某个 item 滚动到屏幕可视区域附近时itemBuilder才会被调用去构建这个 widget。滚出去的 item 可以被回收复用。这就是为什么长列表、动态列表要用ListView.builder的原因。一个常见的错误是数据量不大但也用了ListView.builder结果发现没问题数据量很大却用了ListView然后卡成 PPT。我现在写代码的原则很简单只要列表数据是从接口拿的、或者数量可能超过一屏直接用ListView.builder不做任何犹豫。你就把它当成 Flutter 的默认解锁姿势。3.2 从List到ListView最自然的写法是用map由于ListView.children需要的是一个ListWidget而我们手里的数据通常是ListString或ListModel所以中间需要一个转换过程。用前面讲的map是最顺手的ListString names [张三, 李四, 王五]; ListView( children: names.map((name) ListTile(title: Text(name))).toList(), )注意map返回的是IterableWidget用.toList()转成ListWidget才能塞给children。这个.toList()是新手碰到报错时问得最多的一个点——其实不是 Flutter 特殊是 Dart 的类型系统要求。如果你用ListView.builder那itemBuilder每次返回单个 widget 就行不需要转列表这也是它更省心的原因之一。3.3 Column里塞ListView报错Viewport exceeded该怎么破这是我见过的零基础报错排行榜前三名。Column( children: [ Text(标题), ListView(children: [...]) // 运行时报错 ], )报错信息类似RenderFlex overflowed或者Viewport exceeded。原因很直白ListView是一个可滚动组件它需要一个有界的约束高度来知道自己滚动的范围。但Column会把它的高度约束设成“无限大”于是ListView就懵了你给了我无限高的空间我到底该滚什么解决办法有两个。如果你希望这个ListView占满剩余空间用Expanded包住它Column( children: [ Text(标题), Expanded( child: ListView.builder( itemCount: items.length, itemBuilder: (context, index) ListTile(title: Text(items[index])), ), ), ], )如果你希望这个ListView只占它内容那么高不滚动而是跟随外层页面一起滚就给它加shrinkWrap: trueListView( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), children: [...], )shrinkWrap: true的意思是“让 ListView 根据自己的内容去收缩高度而不是强制撑满空间”。而NeverScrollableScrollPhysics用来禁止它内部滚动否则会跟外层滚动冲突。这个方法在“列表外面还要嵌套一个列表”这种场景里非常实用。但我得提醒你shrinkWrap会让ListView一次性布局所有 children性能会打折。能用Expanded的优先用Expanded。4. 一个能跑的Demo5分钟手写一个待办列表4.1 完整代码与思路拆解概念讲了半天不如直接上手。这里做一个最简单的待办事项上方一个输入框和添加按钮下面用ListView.builder展示所有待办。import package:flutter/material.dart; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( home: TodoPage(), ); } } class TodoPage extends StatefulWidget { override StateTodoPage createState() _TodoPageState(); } class _TodoPageState extends StateTodoPage { final ListString _todoList [学习Flutter, 写一篇博客]; final TextEditingController _controller TextEditingController(); void _addTodo() { final text _controller.text.trim(); if (text.isEmpty) return; setState(() { _todoList.add(text); }); _controller.clear(); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(待办列表)), body: Column( children: [ Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ Expanded( child: TextField( controller: _controller, decoration: const InputDecoration(hintText: 输入新的待办), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: _addTodo, child: const Text(添加), ), ], ), ), Expanded( child: _todoList.isEmpty ? const Center(child: Text(暂无待办添加一条吧)) : ListView.builder( itemCount: _todoList.length, itemBuilder: (context, index) { return ListTile( leading: CircleAvatar(child: Text(${index 1})), title: Text(_todoList[index]), ); }, ), ), ], ), ); } }这段代码麻雀虽小五脏俱全你慢慢拆_todoList是数据源它被定义在State类里所以界面可以直接读取。_addTodo方法里先拿输入框的文本非空才执行setState。setState是 Flutter 里最核心的机制它的作用是告诉框架“数据变了请重新执行build方法刷新界面”。build里用三元表达式判断列表是否为空空就显示提示文字非空就用ListView.builder。4.2 运行后你应该观察到的现象跑起来后第一屏会看到“学习Flutter”和“写一篇博客”两条数据。在输入框里打字点添加按钮列表会多一行序号自动加 1。输入框里的内容会被清空。这里面有一个关键点值得专门讲为什么数据变了界面会自动多一行因为_addTodo里调用setState之后Flutter 会把整个TodoPage的build方法重新执行一遍ListView.builder重新拿到新的_todoList.length自然会构建出新行。如果你忘了写setState直接写_todoList.add(text)数据是加了但界面毫无反应。这种“数据变了但界面没变”的问题是所有 Flutter 新手的第一道坎。另一个小细节是Expanded包住了ListView.builder。没有它你又会在那报Viewport exceeded。上面那段代码里的Column有三个子组件前面两行是固定高度最后一行用Expanded把剩余空间全部给列表列表才能正常滚动。4.3 从这个Demo能延伸出去什么这个 Demo 虽然很简单但它已经是一个“数据驱动界面”的完整闭环。你可以试着做这几件事每件都能加深理解给每一行加一个删除按钮点击后删除对应项。你会自然地想起removeWhere或按索引删除然后别忘了setState。加一个“全部完成”的勾选状态把数据源从ListString升级成ListTodoModel。这能让你提前感受“Model 类 UI”的分层。把添加按钮改成键盘的完成动作一个TextField的onSubmitted就能实现。这三个延伸分别训练的是列表增删、数据结构设计、事件交互。把这三个点吃透你在 Flutter 里碰到的 80% 列表需求都能应对。5. 列表与数据的那些事JSON转换、状态更新、不可变思维5.1 从接口拿到的List通常长这样现在真实项目里列表数据十有八九来自后端接口。接口返回 JSON 字符串Dart 里最常用的是jsonDecodeimport dart:convert; String jsonStr [{name: 张三, age: 18}, {name: 李四, age: 20}]; Listdynamic jsonList jsonDecode(jsonStr) as Listdynamic;但Listdynamic不是我们想要的东西里面每个元素都是MapString, dynamic直接拿到 UI 层使用很不安全也不方便。所以一般会转成强类型的 Modelclass User { final String name; final int age; User({required this.name, required this.age}); factory User.fromJson(MapString, dynamic json) { return User( name: json[name] as String, age: json[age] as int, ); } } ListUser users jsonList .map((e) User.fromJson(e as MapString, dynamic)) .toList();注意这里的e as MapString, dynamic因为jsonDecode出来的每一项是dynamic类型需要显式转成MapString, dynamic才能传给fromJson。这是类型系统在保护你别嫌它啰嗦。我自己在实际项目里会封装一个通用的 JSON 解析工具但零基础上手阶段没必要引入json_serializable这类代码生成库。手写fromJson十几行先把原理吃透之后再用工具不迟。5.2 浅拷贝陷阱为什么列表没改里面的对象却被改了List的拷贝问题是很多人从初级跳到中级时容易栽的地方。先说结论newList List.from(oldList)只是拷贝了外层列表容器里面的对象引用还是同一个。也就是说newList[0]和oldList[0]指向同一个对象改对象属性两边都会变。如果你想“复制一份完全独立的列表改其中一个元素不影响原列表”就得做深拷贝。对简单值类型int、String来说List.from就够了因为值类型本来就是复制。但对对象来说要么手动创建新对象要么引入copyWith模式class User { final String name; final int age; User copyWith({String? name, int? age}) { return User( name: name ?? this.name, age: age ?? this.age, ); } }在实际写 Flutter 页面时我经常遇到这种状态管理问题列表更新时“漏改了某层的对象”导致界面部分更新、部分不更新。排查到后来发现都是浅拷贝惹的祸。所以建议是列表里面的元素是对象时永远不要依赖“改对象的属性去触发 UI 更新”而是用copyWith生成新对象替换到列表里再setState。5.3 状态更新时尽量“新建列表”而不是修改原列表Flutter 的setState本身要求你做的是“改变状态后调setState”。这一步大多数人都知道。但把范围缩小到列表还有一个经验值当你更新一个列表的某一项时不要直接list[2] newItem而是生成一个新列表setState(() { final newList ListUser.from(_users); newList[2] _users[2].copyWith(name: 新的名字); _users newList; });为什么这么麻烦因为在 Flutter 的组件树 diff 过程中如果列表是一个新对象框架就能感知到这一层的状态变化从而更可靠地触发依赖这个列表的组件重建。如果你直接原地修改列表的内容框架层面常常检测不到引用变化UI 就不刷新你就会花半天时间找“为什么明明改了数据却不刷新”。这也是我在前文提到的“不可变数据思维”每次状态变更生成一个新列表而不是试图修改旧列表。这个习惯越早养成遇到复杂状态管理时越不会手忙脚乱。6. 常见问题排查与避坑实录6.1 问题速查表直接上表这里汇聚了我被问过最多的问题也是我当年自己踩过的坑问题现象根本原因解决方案列表数据变了界面不刷新改了数据但忘记调用setState或原地修改对象引用导致框架感知不到在修改列表后用setState更新元素时用copyWith生成新对象RangeError (index): Invalid value: Not in inclusive range访问了不存在的索引通常是index等于list.length或数据清空后旧索引还在用先判断index list.length检查itemCount是否与数据源一致Column里放ListView报Viewport exceededListView在无界约束下不知道自身高度用Expanded包裹或设置shrinkWrap: truemap的结果塞给children报类型错误map返回Iterable不是List加.toList()遍历时删除元素报错或漏元素遍历过程中修改列表长度用removeWhere或先生成新列表再整体替换List.filled创建的对象改一个全变了filled会让所有元素指向同一个对象用List.generate替代列表数据量几百条滚动明显卡顿用了ListView(children:...)一次性构建所有 item换成ListView.builder输入框内容没清空忘了调用controller.clear()在提交成功后调用_controller.clear()从接口拿到数据后jsonDecode报类型转换错误JSON 结构调整或没做as MapString, dynamic转换打印 JSON 结构严格按类型转换6.2 排查技巧一个被我用了两年的调试方法遇到列表问题我建议你第一步先打印数据源而不是盯着界面找原因。debugPrint(当前列表长度: ${_todoList.length}); debugPrint(当前列表内容: $_todoList);debugPrint是 Flutter 提供的打印方法比print更好用太长的内容会被正确分段不会吞消息。把这两行放在build方法开头或setState之前你就能准确判断是数据源问题还是 UI 刷新问题。如果打印出来数据是对的界面却不更新那就是状态机制的问题往setState、对象引用拷贝方向查如果数据本身就是错的那就别赖 UI回到业务逻辑排查。这个习惯帮我节省了很多时间。现在每次遇到“列表不刷新”或者“列表项显示错乱”我的条件反射式第一步永远是加打印看数据而不是先改代码、碰运气。6.3 招聘面试里List相关的高频考察点热词里提到不少“flutter面试题”我干脆把和List相关的考察点也整理一下。面试官问列表相关东西目的不是考你背 API而是看你对数据结构和框架渲染机制有没有真理解。经常会问到的final Listint list [1,2,3]这个列表能不能继续add——考final和不可变集合的区别。ListView和ListView.builder的区别什么场景用哪个——考性能意识。遍历一个 100 万条数据的大列表怎么处理不卡顿——考懒加载、itemBuilder机制、或者ListView.builder/SliverList。遍历时删除元素为什么报错——考对迭代器原理的理解。map返回类型是什么为什么通常要toList()——考Iterable和List的区别。两个列表怎么判断完全相等——你会想到listEquals而不是直接因为 Dart 的默认比较的是引用。这些问题你全部看完这篇文章后应该都能答得上来。如果答不上来回去对照章节再理一遍尤其是final和map返回值这两块是高频中的高频。最后再分享一个我自己的习惯每次写完列表相关代码我都会问自己三个问题——这个列表的数据源是哪里来的数据更新时会走哪个方法触发setState列表变长时会不会卡三个问题都答得上来这个列表相关的代码才算真的写完了。稳扎稳打把List和遍历吃透后面学到 Stream、Bloc、数据持久化这些主题你会回来感谢自己的。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →