资讯动态

Flutter通用列表组件封装实践:从重复代码到一行配置

发布时间:2026/10/4 2:56:11 来源:尧图企业网站定制
做Flutter开发做了三年多接到的大多数需求都离不开列表。不管是商品列表、订单列表、消息列表还是个人中心的菜单列表表面上业务长得不一样实际上骨架高度相似请求数据、处理loading、展示空态、失败重试、渲染item。一开始我也老老实实每个页面都重新写一套结果是每个页面都有复制粘贴的痕迹后面被产品反复改需求折磨之后终于下定决心做一次“列表展示的抽取”把列表里那些重复出现的东西沉淀成一个通用组件。这篇文章就是我这次抽取过程的完整记录从设计思路到核心代码再到后来踩过的几个深水坑都会展开讲。如果你正在被各种列表页面的重复代码困扰或者准备做一次类似的底层沉淀这篇记录应该能给你一个可以直接落地的参考。1. 为什么要把列表展示折腾成“一套代码处处用”1.1 实际开发中的痛点场景我在项目里接到最多的需求就是列表页。一个订单列表一个优惠券列表一个积分明细列表表面上业务完全不同但打开代码你会发现大家的逻辑都是同一套进入页面先发请求接口返回时loading消失数据为空就显示一个空图片加一句文案接口报错就显示重试按钮触底就加载下一页。如果这些逻辑每个页面都写一遍就出现了一个很现实的问题同一个需求改动要同步到十几个页面。比如后来产品说空态要换一张图、加一句“暂时没有数据去逛逛吧”我改了第一个页面还有十来个页面要跟着改。再比如下拉刷新和加载更多的样式想统一也只能逐个页面动刀。这种重复劳动特别消耗精力而且代码质量完全靠个人自觉有人写了错误重试有人忘了写有人loading样式五花八门。所以“抽取”不只是为了省代码量更重要的是把列表展示的规范定下来让团队所有人写出来的列表页都是一个模板。后来我们组接新需求碰到列表页第一反应不再是“写一个新StatefulWidget”而是“填配置”。1.2 抽取的边界在哪里做抽取之前最需要想清楚的问题就是边界。列表展示到底哪些东西应该抽进通用组件哪些东西必须留给业务方自己决定。我的划分很简单凡是跟“数据怎么加载”和“列表怎么展示”相关且每个页面都重复的环节都进组件凡是跟“业务长什么样”相关的全部留给外部。具体拆下来组件要管的就是loading、error、empty、data这四种状态切换分页加载逻辑下拉刷新滚动控制器以及列表项索引的传递。组件不管的是列表项的样式、点击某个item之后的业务操作、每个列表页特有的参数拼接。边界清晰之后组件才不会变成一个大杂烩业务方接入的时候也只需要关心自己要写的那一小段。1.3 设计目标一行配置四态齐全我给自己定了一个设计目标业务方接入时只需要提供一个“数据加载函数”和一个“item构建函数”组件内部自己管理loading、error、empty和data四态。这句话听起来简单真正做起来需要在状态机设计上花不少心思后面第四节我会把具体实现拆开讲。先说一下最终效果一个商品列表页的核心代码被压缩成了不到十行剩下的全是业务自己的商品卡片组件整个页面清爽得不像话。2. 整体设计思路从需求倒推封装方案2.1 先从数据层说起泛型化的真正意义抽取列表组件的第一个问题就是类型。一个组件如果被写死成只能接收订单模型那这个组件就废了一半。我第一时间想到的就是使用泛型但泛型并不是简单地把List变成ListT就行关键在于整个数据流链条要通。数据加载函数必须是FutureListT Function(int page, int pageSize)这种签名item构建器必须是Widget Function(BuildContext context, T item)或者带索引的版本。这样业务方的模型是什么类型组件就用什么类型渲染完全不需要做类型转换或强转。我见过有些项目用dynamic偷懒结果就是运行时类型异常满屏飞调试起来极其痛苦。2.2 四种状态的本质一个简单的状态机列表页面看起来复杂其实核心就是一个四态状态机。初始是loading接口返回成功且数据不为空就变成data返回成功但数据为空就变成empty接口抛异常就变成error。而data态内部还有一个“正在加载下一页”的子状态这个子状态决定了底部是显示加载中、没有更多了还是继续上拉。我在这里走了一点弯路一开始用了四个布尔值来控制比如isLoading、isError、isEmpty、isLoadMore结果发现状态组合一旦多起来各种匪夷所思的显示问题就出现了。后来改成枚举状态一个_listState变量走天下逻辑瞬间清晰了。这件事给我一个很深的体会做状态管理的时候能用枚举就不要用多个布尔值。2.3 选型理由为什么不直接用第三方库做之前我也查过一些成熟的第三方列表封装比如pull_to_refresh这类库确实功能挺全。但最后的决定还是自己封装一层原因有两个一是第三方库往往绑定了特定的刷新和加载样式产品的UI规范不可能跟库的默认样式完全一致定制起来反而要绕开库的设计二是列表抽取这件事的核心不只是刷新和加载还包括状态管理、组件通信、错误处理的一整套闭环这个闭环自己写反而更容易控制。当然不是说第三方库不能用如果你的项目对刷新和加载没有特殊要求直接用成熟库能省很多事。但如果你跟我一样需要高度定制还是建议基于Flutter自带的ListView和RefreshIndicator做一层封装底层的滚动性能完全不用担心。3. 手把手拆解通用列表组件的完整实现3.1 定义组件的参数与初始化流程组件的对外参数我要做到“少而精”。核心参数只有四个onLoad数据加载回调、itemBuilder列表项构建器、emptyWidget空态控件和errorWidget错误控件。其他的比如controller、enableRefresh、enableLoadMore都是可选项有默认值。初始化的时候有一个细节需要注意默认状态是loading但loading不能只靠一个布尔值控制要设定一个初始状态枚举。第一帧就显示一个居中的菊花转圈数据回来之后切到data态这样用户进入页面的第一眼不会看到空白的屏幕。数据加载函数在initState里就要被触发一次不要在build里触发否则每次重绘都会发请求。3.2 数据加载与状态切换的代码实现下面这个是组件的核心骨架我简化了业务字段保留了最重要的状态切换逻辑enum ListState { loading, error, empty, data, loadingMore } class CommonListViewT extends StatefulWidget { final FutureListT Function(int page, int pageSize)? onLoad; final Widget Function(BuildContext context, T item, int index)? itemBuilder; final Widget? emptyWidget; final Widget? errorWidget; final bool enableRefresh; final bool enableLoadMore; const CommonListView({ Key? key, this.onLoad, this.itemBuilder, this.emptyWidget, this.errorWidget, this.enableRefresh true, this.enableLoadMore true, }) : super(key: key); override StateCommonListViewT createState() _CommonListViewStateT(); }请求方法的核心逻辑就是围绕page和列表数据做拼接Futurevoid _loadData({int page 1, bool isRefresh false}) async { if (page 1) { setState(() _state ListState.loading); } else { setState(() _state ListState.loadingMore); } try { final ListT result await widget.onLoad!(page, _pageSize); if (result.isEmpty) { setState(() { if (page 1) { _state ListState.empty; _list.clear(); } else { _state ListState.data; _hasMore false; } }); return; } setState(() { if (isRefresh) { _list.clear(); } _list.addAll(result); _state ListState.data; _hasMore result.length _pageSize; _page page 1; }); } catch (e) { setState(() { if (page 1) { _state ListState.error; } else { _state ListState.data; _hasMore false; } }); } }这里有个关键决策下拉刷新成功之后要不要清空旧列表。我一开始是先清空再请求但这样会出现一个瞬间的空白体验不好。改成了先请求成功拿到数据再把旧列表替换掉视觉上下拉刷新的过程是连续的没有闪断。3.3 下拉刷新与加载更多的实现要点下拉刷新我直接用了RefreshIndicator这个控件是Material风格自带的不需要引额外包。但有一个坑是它的onRefresh回调要求返回一个Future而我的_loadData已经是Future了所以直接传_loadData(isRefresh: true)就行。加载更多的判断要依赖ScrollController的监听。我用了一个比较稳妥的做法当滚动位置触底前300像素时提前触发下一页加载。这样用户还没滑到最底部下一页的数据已经在请求了体验上会流畅不少。void _onScroll() { if (_scrollController.position.pixels _scrollController.position.maxScrollExtent - 300) { if (_hasMore _state ! ListState.loadingMore) { _loadData(page: _page); } } }_hasMore这个标记很重要它防止了用户频繁上拉时发出重复请求。还有一个细节如果第一页返回的数量不足一页比如每页20条但只返回了10条说明没有更多数据了_hasMore要置为false。3.4 item构建器的设计如何让业务方只写一行item构建器的入参我设计了三个BuildContext、当前item的数据模型T、以及索引index。为什么要带索引因为很多时候列表项里需要显示序号或者奇偶行需要不同的背景色没有索引外部就得自己维护一个计数器很蠢。业务方接入的时候组件使用起来非常简单CommonListViewGoodsModel( onLoad: (page, pageSize) api.getGoodsList(page, pageSize), itemBuilder: (context, goods, index) GoodsCard(goods: goods), emptyWidget: const EmptyView(text: 暂时没有商品), )这么一来页面的build方法只剩下一行组件调用列表逻辑全部下沉。如果需要给组件加个页面标题或者筛选栏那是页面自己布局的事情跟列表组件完全解耦。4. 组件通信与数据流转别把Controller玩坏4.1 组件和页面的通信方式选择列表组件里有一个非常现实的通信需求页面可能需要主动让列表刷新。比如用户在上一个页面操作了某个数据回到列表页时列表必须同步更新。这个需求常见到几乎每个列表页都要用。Flutter里组件间通信有很多种方式回调函数、GlobalKey、ChangeNotifier、事件总线甚至StatefulBuilder。我最终选择的是给组件暴露一个GlobalKeyCommonListViewStateT通过key.currentState调用组件内部的公共方法比如refresh()、reload()。这种方式最直接逻辑调用关系清清楚楚代码里一眼就能看出“我要让那个列表重新加载”。4.2 为什么不用InheritedWidget或事件总线InheritedWidget适合的是从上到下的数据共享比如主题、语言包不太适合反向调用子组件方法。事件总线比如EventBus也能实现刷新效果但用起来要注册监听、注销监听稍不留神就会内存泄漏而且页面的刷新逻辑会被分散到各个监听回调里排查问题时要全局搜关键字。面向对象里最朴素的方式往往最可靠持有对象引用直接调方法。4.3 一个真实的数据不同步问题这里说一个真实踩过的坑。订单列表页有一个取消订单的操作取消成功之后需要在列表里移除那一项并提示“订单已取消”。我一开始的做法是取消成功后调用setState把本地列表的对应项移除列表确实更新了。问题是用户从详情页返回时列表页的initState并不会重新执行于是被移除的订单又从接口里拉回来了。后来我在列表组件的公共方法里加了一个removeItem(int index)方法专门处理局部移除同时把业务的提示放在回调里。更重要的是我建议取消订单的场景直接调用refresh()重新拉一遍接口因为订单状态可能除了被取消还会有其他变化只做本地移除容易漏掉服务端的其他字段更新。这里的一个经验是涉及状态变更的操作不要只改本地数据最好用服务端返回的数据做基准。5. 封装后必踩的几个深水坑与排查实录5.1 初次加载白屏与loading闪烁接入组件之后遇到第一个问题是白屏。原因是我把loading状态设计成了一种“状态”但build里没有对loading态做渲染分支结果状态是loading界面却什么都没有。排查的时候发现代码写成了只在数据为空时显示空态其他情况一律显示列表而列表还没数据自然就是白屏。解决的办法是牢牢把握“状态驱动UI”的原则build里只用_state做分支任何情况下都不允许出现“某个状态没有任何UI”的处理方式。这也说明了为什么用枚举状态而不是布尔值组合枚举状态全了漏一个分支编译不报错但运行效果会立刻暴露问题。还有一个兄弟问题是loading闪烁。如果接口返回特别快loading转圈可能只出现了一瞬间然后立刻切到data视觉上会闪一下。处理方式是在loading态显示时做一个最小展示时间的约束比如用Future.delayed保证loading至少展示300毫秒或者在Future.wait里同时等数据和延时。实测下来300毫秒比较合适太短还是闪太长影响感知速度。5.2 Tab切换后列表滚动位置丢失Tab页切换场景下列表组件遇到了滚动位置丢失的问题。原因是TabBarView切换时脱离视口的页面可能被销毁列表的滚动偏移量也随之清零。用户切回这个Tab列表从顶部重新开始体验非常糟糕。解决办法有两个方向。第一是用PageStorageKey保留滚动位置这个方案简单适合列表数据不依赖实时刷新、数量也不大的页面。第二个是保持页面的存活状态比如用IndexedStack来包住Tab页让离开视口的页面依然保存在内存里滚动位置天然保留。我个人更偏向IndexedStack因为它同时还能保留页面的其他临时状态比如筛选条件的勾选状态、搜索关键词的输入内容。代价是内存占用会高一些但Tab数量控制在5个以内问题不大。5.3 大量数据渲染卡顿先别急着怪ListView组件应用到长列表之后有一段时间用户反馈滑动有些掉帧第一反应是ListView性能不行想换复杂的懒加载方案。后来排查发现卡顿的根本原因是列表项itemBuilder里有一个图片控件用了非缓存网络加载每滚动一个item图片就要重新拉一次。给图片加上内存缓存之后滑动立刻流畅了。在这里补充一个关于渲染引擎的知识点。Flutter新一代的Imepeller渲染器在列表滚动场景下做了不少优化理论上比旧引擎更快。但很多人可能遇到一个问题升级到新版Flutter之后列表出现了滚动抖动或者奇怪的绘制问题。排查这类问题时先保持冷静不要一上来就怀疑自己的业务代码先切回旧渲染引擎对比一下排除掉引擎的兼容性因素再继续。5.4 状态更新不触发重建是回调时机问题还有一个比较隐蔽的坑有些列表项里的按钮点击之后外部变量已经变化了但UI没有更新。排查的时候发现问题可能出在Future的then回调执行时机上。Dart的Future回调是放入微任务队列的也就是说它会在当前同步代码执行完毕后立刻执行但不一定在setState所属的帧周期内。如果在then里直接调用setState大概率没问题但如果在then里继续做异步操作再更新UI就要确认此刻组件还在挂载状态否则会报setState called after dispose。我处理这类问题的方式是给组件加一个_disposed标记在dispose里置为true所有异步回调里先判断这个标记再决定是否更新状态。这个标记虽然丑但在异步操作多的时候能省下大量的崩溃排查时间。6. 性能与体验的调优笔记6.1 减少列表项的不必要重建列表组件的数据源一旦更新整个列表都会进入重建流程。如果列表项本身是一个复杂的自定义组件重建成本就会很高。这里的关键是给列表项实现shouldRebuild逻辑或者直接用const构造减少不必要的实例化。实际开发中我会把商品卡片、订单卡片这类核心列表项单独抽成组件并在内部用AutomaticKeepAliveClientMixin做一些存活管理让列表项在滑动过程中不会反复创建和销毁。6.2 图片加载的缓存策略列表图片是性能大头。网络图片建议统一走缓存加载不要在Image.network里裸用。一个简单的策略是首屏必须加载的图片优先级高屏幕外的图片延迟加载。这个效果用FadeInImage配合占位图就能做出不错的效果既避免了白屏也不会因为图片加载抖动列表高度。6.3 使用itemExtent固定列表项高度如果你的列表项高度是固定的一定要给ListView.builder传itemExtent这能显著提升滚动性能因为滚动过程中引擎不用重新计算每一项的高度。如果列表项高度不固定但可以预先估算也要尽量给一个prototypeItem作为参考。这两项对滚动帧率的提升非常直观值得养成习惯。7. 还可以继续优化的扩展方向7.1 对接原生能力PlatformView与混合开发列表组件沉淀稳定之后后续可以考虑跟原生能力做衔接。比如列表中的视频播放器或者一些特殊的原生地图卡片都需要用到PlatformView。这些插件在Flutter端的生命周期跟普通Widget不太一样接入通用列表时需要单独处理onDispose和控件的复用逻辑。我还没有完全消化这块但它一定是列表组件往复杂业务场景延伸的关键方向。7.2 打包给其他团队复用AAR打包如果你的列表组件沉淀得足够通用可以考虑打包成AAR供原生团队在混合工程里使用。Flutter支持将Module打包成AAR集成到原生App但这要求工程结构一开始就按Module方式组织不能是一个纯Flutter Application工程。这个改造会牵涉到构建脚本和依赖管理是一个值得单独开一篇记录的课题这里先留个入口。实际操作中的个人体会做完这次列表展示的抽取之后最大的感受是一个好的封装不是把代码藏起来而是把复杂度收敛在可控的地方。组件内部可以很复杂但对外暴露的接口必须简单稳定。现在团队里新增列表页的效率确实高了不少但我后来也刻意提醒自己不要因为封装好用就把所有页面都往这个组件里套像首页那种瀑布流、或者带复杂header的列表该自己写的还是要自己写一个组件不可能覆盖所有场景。最后留一个小建议如果你也想做类似的抽取先从你最常碰到的两个列表页开始梳理它们的共同点和差异点而不是一开始就想做一个万能组件。你很快会发现第一版封装一定不是最优的但它能帮你理清思路等你哪天需要加新功能的时候再回头迭代这个组件会比凭空设计靠谱得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑