
Android 工程师上手 Flutter第一个让人皱眉的控件基本就是 ListView。拿着 RecyclerView 那套 Adapter、ViewHolder、DiffUtil 的心智模型去套 Flutter你会在前三天连续发出灵魂拷问adapter 在哪notifyDataSetChanged 在哪LayoutManager 为什么也不见了但只要把“命令式操作视图”这根弦换成“声明式描述状态”你就会发现 Flutter ListView 不是复杂了是把列表这件事从根上简化了一个量级。这篇文章是我从 RecyclerView 迁移到 Flutter 后对 ListView 的完整复盘会按实际开发顺序讲透构造器怎么选、滚动性能怎么调、刷新加载怎么写、哪些坑我反复踩过。1. 先认清一个事实RecyclerView 的思维惯性是理解 ListView 最大的路障1.1 命令式适配器 vs 声明式构建两种完全相反的列表哲学在 RecyclerView 里你的核心工作是写一个 Adapter。onCreateViewHolder 负责创建条目的根布局onBindViewHolder 负责把数据填进 ViewgetItemCount 告诉列表有多少条。数据一变你得手动调用 notifyItemInserted、notifyItemRangeChanged、notifyDataSetChanged 这一整套“通知”逻辑通知错了索引还会崩溃。说到底这是一种命令式思路你直接指挥列表控件“这一步该干嘛”。Flutter 则完全相反。ListView.builder 里只有一个 itemBuilder它的职责是纯函数式的给一个 index返回一个 Widget。没有 onCreateViewHolder没有 onBindViewHolder数据变化时你只管调用 setState 更新状态框架会自动重新执行 build把新的数据描述成新的 widget 树。你唯一的任务就是把“当前数据长什么样”描述清楚。这种差异在代码量上的体现是碾压式的。RecyclerView 一个最简单的文本列表Adapter 大概要写小一百行Flutter 的核心代码不到十行。我第一次从 Kotlin 切过来写这种列表时一度怀疑自己是不是漏了什么后来才意识到声明式 UI 把“数据到界面的映射”内置进了框架你要做的只是声明映射规则。1.2 没有 ViewHolder元素复用到底发生在哪里RecyclerView 的 ViewHolder 是为了复用 itemView 而存在的减少 inflate 和 findViewById 的开销。Flutter 里没有 ViewHolder但这不代表它不做复用。理解 Flutter 的复用机制是理解整个 ListView 性能模型的关键也是理解 Key 为什么重要的前提。Flutter 每一帧真正在维护的是三棵树Widget 树、Element 树、RenderObject 树。Widget 只是轻量级的配置描述每次 build 都会重建Element 是 Widget 的实例化结果会跨帧保留RenderObject 负责真实的布局和绘制。当 ListView 滚动时viewport 会不断地把离屏 item 的 Element 销毁同时对进入可视区域的 item 创建 Element。而 itemBuilder 每次返回的 Widget 虽然“看起来是新的”但框架在 Element 层做了 diff如果 Widget 的 runtimeType 和 key 没变它就会复用已有的 Element 和 RenderObject只更新配置属性。这个机制解释了一个新手经常困惑的问题“itemBuilder 每次都重新执行那不等于每次滚动都重建所有东西吗” 不是的。itemBuilder 执行只是创建 Widget而 Widget 是极其廉价的配置对象。真正昂贵的布局和绘制由 RenderObject 承担只要你不乱换 key、不乱改类型RenderObject 就能被持续复用。这也就是为什么后面我会反复强调 Key 的用法——它在 Flutter 里的角色比 RecyclerView 里的 ViewType 还要微妙。1.3 一个 TodoList 例子让你直观感受两种写法我当年在脑内对比过这两个版本至今记得那个冲击感。Android 侧一个简单的 Todo 列表需要定义数据类、Adapter、ViewHolder、item 布局 XML四份文件打底。数据变化时还要小心翼翼地调用 notifyItemRemoved(position)搞错索引直接 crash。Flutter 侧的逻辑是另一种画风ListView.builder( itemCount: todos.length, itemBuilder: (context, index) { final todo todos[index]; return ListTile( leading: Checkbox( value: todo.done, onChanged: (v) setState(() todos[index].done v!), ), title: Text(todo.title), ); }, )勾选一个 Checkbox不需要 notify 任何“条目变化”事件setState 触发一次重建勾选状态自然反映到界面上。这套写法的好处是你永远不需要维护“数据”和“视图”之间的同步关系因为数据本身就是视图的唯一真源。每次 build 都是一次从数据到界面的全量描述但框架用 Element diff 保证实际渲染开销远小于“全量”二字。2. ListView 家族选型四个构造器对应四种完全不同的场景Flutter 的 ListView 不是一个类而是一整个家族。ListView、ListView.builder、ListView.separated、ListView.custom 四兄弟各有各的脾气。选错了人轻则性能打折重则直接打不开页面。2.1 ListView传统构造方式只适合“小且固定”的列表这是最原始的构造器直接把 children 列表传进去ListView( children: [ ListTile(title: Text(设置)), ListTile(title: Text(关于)), ListTile(title: Text(退出登录)), ], )它和 builder 最大的区别在于所有 child 在 ListView 创建时就全部被构建了不存在懒加载。很多人拿它写长列表滚动起来发现卡顿就是因为每个 item 一开始就全被 layout 了一遍内存和布局开销全拉满。所以我的建议很简单只有列表项数量确定且不超过几十个时才用它比如设置页、帮助页、固定菜单。一旦列表数据来自接口、可能变长就直接放弃这个构造器。这类场景里可以配合 const 发挥一点优化作用ListView( children: const [ ListTile(title: Text(设置)), ListTile(title: Text(关于)), ], )const 关键字让这些 widget 变成编译期常量每次 build 复用的是同一个实例省去重复创建的开销。反过来说如果你在 children 里写了一个网络请求或者耗时计算这个构造器会把它们一次全执行掉。2.2 ListView.builder绝大多数场景的默认首选ListView.builder 是你在 Flutter 里写的第一个长列表该用的构造器没有之一。它只在 item 即将进入可视区域时调用 itemBuilder滚动过程中不断销毁离屏的 Element把“按需构建”的内建机制发挥到极致。ListView.builder( itemCount: items.length, itemBuilder: (context, index) { return ListTile(title: Text(${index}: ${items[index]})); }, )itemCount 传 null 时表示无限列表配合分页非常方便。你的分页数据还没回来时可以在列表底部显示一个 loading 的 item。itemBuilder 里可以写 if (index items.length) 返回 loading 控件这种自由度是 RecyclerView 的 Adapter 不太好模仿的。从 Android 迁移过来的人最容易犯的一个错误在 itemBuilder 里做耗时操作比如读 SharedPreferences、同步做图片解码、执行 JSON parse。记住 itemBuilder 的调用频率和你手指滚动的频率是绑定的任何超过几毫秒的操作都会变成肉眼可见的卡顿。所有耗时工作都应该提前在数据层完成itemBuilder 只做 UI 映射。2.3 ListView.separated分割线的索引逻辑不要想当然带分割线的列表直接用 ListView.separated不用自己往每个 item 底部塞 DividerListView.separated( itemCount: items.length, itemBuilder: (context, index) ListTile(title: Text(items[index])), separatorBuilder: (context, index) const Divider(height: 1), )这里有个容易搞混的细节separatorBuilder 的 index 不是 item 的 index。它表示分割线自己在列表里的序号。有 N 个 item 时N 个 item 的 index 是 0 到 N-1而分割线的 index 是 0 到 N-2。这意味着第一条分割线出现在第一个 item 和第二个 item 之间第二条出现在第二个和第三个之间。你不能拿 itemBuilder 里的 index 逻辑直接套到 separatorBuilder 里否则分割线的位置会对不上。另外 itemCount 为 0 时 separatorBuilder 根本不会被调用所以写 0 长度数据时不需要额外判空。如果你想要首尾也有分割线比如头部上面也要一条线最省事的做法不是改 separated而是用 Column 包一层Column( children: [ const Divider(height: 1), Expanded( child: ListView.separated(...), ), ], )2.4 ListView.custom想完全控制子项时才需要ListView.custom 平时用得少但必须知道它存在。它接收一个 SliverChildDelegate它决定了子项的构建和销毁策略。默认的 SliverChildBuilderDelegate 其实就是 ListView.builder 背后的实现而 SliverChildListDelegate 是 ListView 背后的实现。什么时候会用到 custom最常见的场景是你需要在自定义 Sliver 里做复杂的懒加载控制或者实现自己的 ChildDelegate 来精确管理子项缓存。还有想在一个滚动区域里混排不同结构的场景真正的答案是 CustomScrollView 而不是 ListView.customCustomScrollView( slivers: [ SliverToBoxAdapter(child: HeaderWidget()), SliverList.builder( itemCount: items.length, itemBuilder: (context, index) ListItem(data: items[index]), ), ], )CustomScrollView 加 Sliver 系列组件才是 Flutter 里“万能”的滚动方案。列表头部、吸顶、多滚动区组合都能靠它实现。我个人的建议是把 ListView.custom 当作了解 Sliver 体系的一扇门而不是日常主力工具。四个构造器的定位可以用一张表直接说明构造器懒加载典型场景性能预期ListView否少量固定子项适合少量大量会卡ListView.builder是长列表、分页列表默认首选ListView.separated是带行分割线的长列表和 builder 相当ListView.custom可控自定义 Sliver 体系视 delegate 决定3. 滚动性能真相build 不是全量重绘关键在于如何声明尺寸3.1 Widget、Element、RenderObject 三棵树与“伪复用”机制很多人对 Flutter 性能有一种误解觉得既然每次 setState 都重建 widget 树那长列表滚起来必定很卡。实际情况恰恰相反因为 Flutter 维护了三棵树而重建的只有第一棵。打个比方Widget 是施工图纸Element 是已经建好的房间结构RenderObject 是房间里的家具。你换一张图纸不等于要把房间拆了重盖只是把需要改的家具换掉。Flutter 的 diff 算法就是这个“按图改房”的过程。ListView 滚动时离屏 item 对应的 Element 会被销毁以释放内存进入视口的 item 则复用旧 Element只更新它的属性。整个过程没有“从零 inflate 一个 view”的成本这也是为什么 Flutter 列表滚动能做到和原生相差无几。要验证这个机制可以在 itemBuilder 里加一行 print多滚动几屏你就会发现print 的调用次数基本等于“进入视口时的构建次数”而不是帧率乘以 item 数。滚动过程中同一个 item 在屏幕上平移时只要还在缓存区里它就不会被重复 build。3.2 itemExtent 的价值恒定高度带来的计算捷径List 性能调优第一个大招是 itemExtent。它可以告诉 ListView 一个信息“所有 item 的高度都一样别一个个量了。”ListView.builder( itemExtent: 80, itemBuilder: (context, index) MyListItem(...), )当没有 itemExtent 时ListView 为了知道滚动范围必须在每个 item 进入视口时执行完整的布局计算来获取它的高度。如果 item 高度是固定的这个计算就完全是多余的。声明 itemExtent 后框架可以直接推算出总滚动范围、精确跳过哪些 item滚动条的尺寸和位置也更准确。在纯列表页这类“天生等高”的场景这个参数带来的性能提升是立竿见影的。当然它也有代价你强制所有 item 高度统一。如果列表里混着不同高度的 item轻则视觉错位重则整个滚动范围计算错误。所以只对确定等高的列表用它。3.3 prototypeItem 与 cacheExtent 的调优尺度Flutter 3.7 之后引入了 prototypeItem它走的是另一条路线ListView.builder( prototypeItem: ListTile(title: Text(预估值)), itemBuilder: (context, index) buildItem(index), )prototypeItem 通过提供一个“样本 item”来预测所有 item 的平均高度从而估算滚动范围。它和 itemExtent 的区别在于它不是强制约束只是预估。如果 item 实际高度和 prototype 差得远滚动位置会有偏差但列表不会崩溃。它适合那些“高度大致相等但不想硬性写死”的列表比如消息流里大部分消息高度接近但偶尔有一条长文本。cacheExtent 是另一个容易被忽视的参数。它控制视口前后需要预构建多少距离的 item默认值大约是 250 逻辑像素。cacheExtent 调大滚动时就不容易看到“空白区域”或者 item 构建的闪烁代价是内存占用升高。如果你发现列表在快速滑动时会看到空白格可以把 cacheExtent 调到 500 试一下反过来如果内存吃紧也可以适当调小。这个参数没有银弹值得结合列表项的复杂度和设备机型的性能去实测。附带说一句如果你在 iOS 上跑 Flutter 3.10 以上的版本滚动性能还会受渲染引擎影响——Impeller 替换 Skia 后Shader 编译卡顿少了很多列表第一次滚动的掉帧明显改善。遇到“首屏流畅但快速滚动突然掉帧”的情况先确认一下当前引擎是不是 Impeller别一上来就调列表。4. 列表页三大高频需求刷新、加载、定位的完整写法4.1 RefreshIndicator 下拉刷新的正确打开方式Flutter 的下拉刷新用 RefreshIndicator 包住可滚动组件即可RefreshIndicator( onRefresh: _refreshData, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), itemCount: items.length, itemBuilder: (context, index) ListTile(title: Text(items[index])), ), )有两个细节是坑过不少人的。第一onRefresh 必须返回一个 Future指示器在 Future 完成之前一直处于转动状态你的刷新函数结束前要保证“刷新完毕”的状态已经执行完。第二如果列表内容不满一屏默认的 ListView 是不能下拉的必须加上 AlwaysScrollableScrollPhysics()让列表即使在内容不足时也能响应下拉手势。刷新函数内部的标准写法是Futurevoid _refreshData() async { try { final data await api.fetchFirstPage(); if (!mounted) return; setState(() { _items data; _page 1; }); } catch (e) { // 提示失败不要什么都不做 } }这里强调 mounted 检查是因为 async 操作返回时组件可能已经被销毁直接 setState 会触发 flutter 的 “setState() called after dispose()” 报错这是新手崩溃记录里非常高频的一条。4.2 ScrollController 监听实现加载更多注意防重入加载更多通常用 ScrollController 监听滚动位置ScrollController _controller ScrollController(); bool _loadingMore false; override void initState() { super.initState(); _controller.addListener(_onScroll); } void _onScroll() { if (_controller.position.pixels _controller.position.maxScrollExtent - 200) { _loadMore(); } } Futurevoid _loadMore() async { if (_loadingMore) return; _loadingMore true; try { final more await api.fetchPage(page: _page 1); setState(() { _items.addAll(more); _page; }); } finally { _loadingMore false; } }这个监听逻辑有几个容易翻车的点尤其要注意第一maxScrollExtent - 200中的 200 是“提前量”意思是在还剩 200 像素滚到底时就开始加载避免用户滚到底后等半天。第二防重入标志位必须有。如果不加 _loadingMore 判断滚动过程中监听器会被连续触发多次同一个 page 的数据甚至会请求两三次。第三ScrollController 一个实例只能 attach 到一个 Scrollable 上如果你的页面用 TabBarView 放了多个 Tab每个列表需要各自的 controller共用一个会直接报 “ScrollController is attached to multiple scroll views” 这种运行时崩溃。4.3 滚动到指定位置与吸顶嵌套的实战组合列表页常见的“回到顶部”按钮、定位到某条评论、下拉刷新后保持滚动位置本质都是在操作 ScrollController// 回到顶部 _controller.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); // 跳到指定 item需要先拿到该 item 的 GlobalKey Scrollable.ensureVisible( itemKey.currentContext!, duration: const Duration(milliseconds: 400), alignment: 0.2, );ensureVisible 是比 controller 更灵活的方案因为它在理论上不需要知道 item 的具体 offset只要拿到 context 就能让滚动区域把目标滚出来。再往上走一步如果列表页上方有个 CollapsingToolbar、吸顶 Tab、或者类似 AppBar 一样的东西普通 ListView 就不够用了得上 NestedScrollViewNestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) [ SliverAppBar( title: Text(详情), pinned: true, expandedHeight: 200, flexibleSpace: FlexibleSpaceBar(background: banner), ), ], body: ListView.builder(itemBuilder: ...), )注意 NestedScrollView 的 body 里如果还需要下拉刷新RefreshIndicator 必须包在 NestedScrollView 外面而不是包住 body 里的 ListView否者手势冲突会让你调半天都调不对。5. 迁移后反复踩到的那些坑从 crash 到卡顿的完整排查链路5.1 Column 里放 ListViewunbounded height 报错与 shrinkWrap 的取舍Android 里你在 LinearLayout 里塞一个 RecyclerView 再正常不过。到了 Flutter你把 ListView 塞进 Column运行时会得到一行让你血压升高的报错Vertical viewport was given unbounded height。意思是 ListView 想在垂直方向测量自己的高度但 Column 没给它任何约束于是它不知道该占多少高。解法的核心是“给 ListView 一个确定的边界”。最标准的就是 Expanded 或者 FlexibleColumn( children: [ const Text(标题), Expanded( child: ListView.builder(itemBuilder: ...), ), ], )如果你是踩坑之后来搜索解决方案大概率会看到另一个“万金油”shrinkWrap: true。它会强制 ListView 按内容收缩高度。这个参数在少量子项时勉强能用但在长列表里会让懒加载失效因为框架必须构建并测量所有子项才知道总高度。理解清楚这一点后你就不会在看到 shrinkWrap 时无脑复制了——它只在 Column 或别的滚动组件里嵌套一个“小型列表”时才值得用。5.2 列表项状态错乱Key 的正确使用列表项里的 TextField 输错了内容、Checkbox 打了勾却被别的行继承、收起展开的状态错位……这些诡异 bug 的根源基本是 Key 缺失。Flutter 的 Element diff 默认按位置匹配 item如果你对列表做插入或删除操作item 数量变了框架可能把原来位置 0 的 Element 复用给了新的位置 0而它们的状态却不匹配。正确的做法是给每个 item 一个稳定的、和数据绑定的 Keyfinal items [ Item(id: a, title: A), Item(id: b, title: B), ]; ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final item items[index]; return ListTile( key: ValueKey(item.id), title: Text(item.title), ); }, )用 index 当 key 等于没用因为 index 会跟随位置变化。用唯一 id 当 key 后框架就能准确识别“这一条还是这一条”从而保留它的 Element 和内部状态。如果你在列表中维护了勾选状态、输入内容、动画状态这个细节直接决定你的页面是稳定还是“薛定谔的错乱”。5.3 图片列表卡顿与内存上涨的排查链路图片列表是 ListView 性能问题的重灾区。症状很典型快速滑动时帧率断崖下跌、内存肉眼可见地涨、有时图片还会“闪一下”从占位图跳到完整图。我最初遇到这问题第一反应是去优化 itemBuilder 的代码结构但排查下来发现卡顿源在图片加载策略。几个关键教训第一不要在 itemBuilder 里直接用 Image.network 加载网图。这个 API 没有内置缓存每次滚动经过一个 item 都可能重新解码图片解码是纯粹的 CPU/GPU 密集操作。换成 cached_network_image 是第一步它能缓存到磁盘避免重复下载。第二给的缓存尺寸要和显示尺寸匹配。一张 2000x2000 的大图塞进一个 100 像素高的 item解码开销是巨大的。用 cached_network_image 配合 memCacheWidth、memCacheHeight将解码目标限制在显示尺寸附近内存占用会成数量级下降。第三处理列表项里的图片加载失败和占位状态。别在 itemBuilder 里写image null ? 占位 : 图片这种组合逻辑占位本身就是一个动态分支。用 imageBuilder 或者自定义占位组件保持 item 结构稳定避免每次 build 都切换 widget 类型导致 Element 频繁销毁重建。第四Profile 模式下用 DevTools 的 Performance overlay 看 Raster 线程占用。如果 Raster 线程一滚动就飙到接近 100%再回头查图片解码如果 UI 线程高才去查 build 函数里的耗时操作。很多人一卡就怀疑 ListView 本身实际上多数是图片、 Shader 或文本布局的问题列表控件在骂骂咧咧背锅。这条排查链路走下来你会发现一个规律Flutter 列表卡顿大多数时候不是列表的问题而是 item 内部的工作量问题。ListView 的复用机制和缓存机制做得再好也架不住你让它在每个 item 出现时干重活。个人经验是从 RecyclerView 迁移到 ListView最需要克服的不是 API 差异而是“控制感”的丧失。在 Android 里你手动管理 adapter 和 view在 Flutter 里你管理数据和状态框架拿走了一半的话语权但也帮你挡住了大量 View 层维护的复杂度。遇到滚动性能问题先别急着骂框架给 item 减负、给尺寸加声明、给图片加缓存这三板斧能解决九成以上的列表卡顿。等你习惯了声明式的思维方式回头看 ListView 会发现它确实把列表开发的下限抬高了一大截。