Flutter通用列表组件抽取:从下拉刷新到加载更多的实战指南

发布时间:2026/10/7 11:15:10
Flutter通用列表组件抽取:从下拉刷新到加载更多的实战指南 做Flutter开发第三年我发现自己写的最多的一段逻辑不是复杂的动画也不是自定义Canvas而是列表下拉刷新、加载更多、空态、错误重试。前阵子改第三个页面的时候我实在忍不住了——首页推荐列表、分类页列表、搜索结果页列表这三处展示逻辑几乎一模一样却因为当初各自复制粘贴现在改一个分页bug要同时改三个地方。这篇文章就记录一下我把“列表展示”这个重复逻辑从业务页面里抽取出来的全过程包括接口怎么设计、状态怎么管理、踩了哪些坑希望能给同样被重复列表折磨的Flutter开发者一点参考。1. 三个页面逼出来的重构为什么要把列表展示抽出来1.1 最早的复制粘贴写法长什么样我最早写列表的时候可以说完全没有“抽取”这个概念。新页面需要列表好复制上一个页面的代码把接口地址换掉把item样式换掉完事。当时还觉得自己效率挺高一个下午能出两个页面。举个例子首页推荐列表长这样RefreshIndicator( onRefresh: () _loadArticles(page: 1), child: ListView.builder( itemCount: _articles.length, itemBuilder: (context, index) ArticleCard(article: _articles[index]), ), )分类页面复制过来除了_loadArticles换成_loadCategoryArticlesArticleCard换成CategoryCard剩下的结构几乎一字不差。搜索页再来一遍。这种写法在小项目里确实没什么问题页面少改动少怎么写都行。但当你手上的业务页面越来越多每个页面都开始长出自己的分页逻辑、空态文案、错误状态时复制粘贴的代价就会以指数级增长。1.2 复制粘贴带来的三个痛感第一个痛改一个bug要三处同步。有一次分页的page起始值写错了首页从1开始分类页从0开始搜索结果页从1开始联调的时候一个页面一个页面地对着接口文档改。改完之后我就想如果当初有一个公共的加载逻辑这个bug根本不会出现因为大家都走同一套标准。第二个痛状态展示不一致。首页列表空的时候我写了“这里什么都没有”分类页空的时候直接白屏搜索页空的时候显示一个“无结果”图片。产品经理截图质问为什么同样是空三个页面三种表现。这些散落在各处的状态处理是复制粘贴最容易遗漏的部分。第三个痛分页逻辑漂移。有的页面用page 1有的页面用offset limit有的页面干脆不做加载更多。后续维护的人包括我自己看到这些代码的时候根本不敢动怕改坏哪一处的边界条件。1.3 抽取的目标边界哪些要抽哪些不能抽当时我提醒自己抽取列表展示不是把业务逻辑也一起抽进来。如果抽取之后还是要传一大堆配置参数组件的复杂度会反噬调用方。所以先划清楚边界要抽的列表的滚动容器、下拉刷新、加载更多触发、空态/错误态/加载态的切换、滚动控制器的管理。不抽的具体的数据请求接口地址、参数拼接、item控件的样式、分隔线样式、空态文案和点击重试逻辑。一句话组件只负责“骨架”业务方负责“血肉”。这样组件才能保持稳定业务才能保持灵活。这个边界在我后续设计接口的时候帮了大忙。很多通用组件之所以难用就是因为把所有东西都揉在一起导致任何一个业务变化都要改组件本身那就失去抽取的意义了。2. 动手抽取前得先摸清Flutter列表的滚动和刷新机制2.1 ListView.builder的懒加载和itemBuilder回调很多初学者会把ListView和ListView.builder混为一谈但在抽取组件的时候必须搞清楚ListView默认会一次性构建所有子项而ListView.builder是懒加载只构建当前视口附近以及缓存区域内的item。这个差异直接决定了我们能不能用“构造参数里传itemBuilder”的方式抽象列表。ListView.builder的itemBuilder是一个回调通过index按需创建item。抽取时组件不知道item长什么样但可以通过参数接受外部的itemBuilder由外部决定如何根据数据构建UI。原理上讲这就是把“如何构建列表”这个能力下放到调用方而组件保留“何时构建、滚动到哪构建”的控制权。懒加载机制还意味着列表不用担心一次性数据量太大ListView.builder会帮你控制渲染成本。2.2 滚动监听和“加载更多”的触发时机加载更多的本质是监听滚动位置在接近底部的时候发起一次请求。Flutter里有两种常见做法一种是用ScrollController通过controller.position.extentAfter判断剩余滚动距离if (position.extentAfter 200) { loadMore(); }extentAfter表示当前滚动位置到列表底部的距离。我一般用一个阈值比如200像素作为“快要到底了”的触发条件这样在用户滑到底之前数据就已经在后台加载了体感上会觉得“滚动不完”。另一种是用NotificationListenerScrollNotification包裹ListView在ScrollUpdateNotification里拿metrics.extentAfter判断。两种方式都能用但NotificationListener不占用ScrollController后续如果外部还要用controller做“返回顶部”会更方便。我最后在组件里选择了controller方案因为要同时处理“外部传入controller”和“内部创建controller”这两种情况controller的合并方式比NotificationListener的嵌套要直观一些。2.3 RefreshIndicator的Future语义与状态管理RefreshIndicator是Material库自带的下拉刷新组件它的onRefresh必须返回一个Future。这个Future代表刷新任务Future完成时刷新指示器收回Future报错时指示器也会收回但错误会往上抛控制台容易出现未捕获异常。所以抽取的时候我给刷新回调留了异常兜底的口子组件内部await onRefresh()时用try/catch包一层避免异常直接冒泡到Flutter框架层。同时和状态管理配合时刷新方法内部自己catch错误并更新状态不要把错误抛出来。理解了这几层机制接口设计才有依据。如果不懂这些直接撸一个通用组件大概率会在滚动监听、刷新异常这些细节上翻车。3. 通用列表组件的接口设计哪些参数该暴露哪些逻辑该收口3.1 数据源用泛型T抽象抽取后的第一原则组件不关心列表里的数据是什么类型。所以我定义组件时使用了泛型LoadMoreListViewT外部传入ListT items组件内部只把items当成一个列表具体构建的时候把T交给itemBuilder。这样设计的好处是这个组件既可以展示Article列表也可以展示Category列表甚至展示一组字符串、一组模型对象统统不用改组件代码。泛型在Flutter的组件体系里是非常自然的抽象方式ListTile的leading接收Widget就是类似思路。3.2 用LoadStatus枚举表达页面状态列表页常见的状态无非五种初始加载中、请求成功、请求失败、数据为空、正在加载更多。我把前四种状态归到一个枚举里并作为组件的入参enum LoadStatus { loading, error, empty, success }组件根据status决定显示什么loading且列表为空显示居中进度圈。error且列表为空显示错误兜底可以配置errorWidget。empty或success但items为空显示空态。success且items非空展示正常列表。这里有心的读者会注意到我把“空态”和“请求成功”做了重叠判断只要items为空且状态是success也按空态处理。这样设计是为了兼容服务端“请求成功但没数据”的场景业务方不用额外在store里判断一次。3.3 itemBuilder和separatorBuilder透传ListView.builder有itemBuilderListView.separated有separatorBuilder这两个都是“变化点”所以必须暴露给外部。我保留了separatorBuilder作为可选参数因为不同页面的分割线差异很大有的需要等高的Divider有的需要间距阴影有的干脆不要。组件内部默认提供一个SizedBox.shrink()外部不传就当作没有分隔线。padding、physics这些直接对应ListView自身属性的参数我也一并透传。但我不暴露controller作为必选而是允许外部可选传入内部默认创建一个。这个细节在下一节说。3.4 刷新和加载回调的签名设计下拉刷新和加载更多本质都是“让外部去拿更多数据”所以签名设计成Futurevoid Function()是最简单的。组件只负责“在合适的时机调用”不负责“如何调用”。如果业务流程里需要传递参数比如刷新时需要重置page那是store内部的事回调的签名可以保持无参。你可以在store里写一个refresh()方法内部访问自身state这样外部回调直接传store.refresh即可。final Futurevoid Function()? onRefresh; final Futurevoid Function()? onLoadMore;onLoadMore还有一个配合字段hasMore标记是否还有下一页。组件在hasMore false时不会触发加载也不会在列表尾部渲染加载指示器。3.5 ScrollController的内外结合滚动监听需要controller但调用方有时候也想用同一个controller去做“点击返回顶部”之类的操作。这个矛盾如果不处理组件就会很拧巴。我的方案是组件接受一个可选的scrollController如果外部传了就使用外部的如果没传内部自己创建一个。内部监听统一挂载在最终使用的那个controller上但销毁时只销毁内部创建的controller外部传入的controller留给外部管理。ScrollController? _internalController; ScrollController get _controller widget.scrollController ?? _internalController!;这里有个常见的坑如果外部传入controller组件在dispose时不能擅自dispose它因为外部可能还在用。但监听器必须移除否则会引发泄漏。我在dispose里先removeListener然后只dispose内部controller。4. 完整落地LoadMoreListView组件 Provider状态管理4.1 完整组件代码先说清楚我下面的实践用的是Provider作为状态管理因为项目里已用了provider库。如果你用的是Riverpod或Bloc接入思路完全一样只是状态对象的获取方式不同。完整的组件代码贴在下面已按实际使用调过一轮import package:flutter/material.dart; enum LoadStatus { loading, error, empty, success } class LoadMoreListViewT extends StatefulWidget { const LoadMoreListView({ Key? key, required this.items, required this.itemBuilder, this.separatorBuilder, this.onRefresh, this.onLoadMore, this.hasMore true, this.status LoadStatus.success, this.emptyWidget, this.errorWidget, this.padding EdgeInsets.zero, this.physics, this.scrollController, this.loadMoreThreshold 200, }) : super(key: key); final ListT items; final Widget Function(BuildContext context, T item, int index) itemBuilder; final Widget Function(BuildContext context, int index)? separatorBuilder; final Futurevoid Function()? onRefresh; final Futurevoid Function()? onLoadMore; final bool hasMore; final LoadStatus status; final Widget? emptyWidget; final Widget? errorWidget; final EdgeInsetsGeometry padding; final ScrollPhysics? physics; final ScrollController? scrollController; final double loadMoreThreshold; override StateLoadMoreListViewT createState() _LoadMoreListViewStateT(); } class _LoadMoreListViewStateT extends StateLoadMoreListViewT { ScrollController? _internalController; bool _loadingMore false; ScrollController get _controller widget.scrollController ?? _internalController!; override void initState() { super.initState(); if (widget.scrollController null) { _internalController ScrollController(); } _controller.addListener(_handleScroll); } override void dispose() { _controller.removeListener(_handleScroll); if (_internalController ! null) { _internalController?.dispose(); } super.dispose(); } void _handleScroll() { if (!_controller.hasClients) return; final position _controller.position; if (position.extentAfter widget.loadMoreThreshold) { _tryLoadMore(); } } Futurevoid _tryLoadMore() async { if (_loadingMore) return; if (!widget.hasMore) return; if (widget.status LoadStatus.loading) return; final onLoadMore widget.onLoadMore; if (onLoadMore null) return; _loadingMore true; try { await onLoadMore(); } catch (e) { debugPrint(load more error: $e); } finally { _loadingMore false; } } override Widget build(BuildContext context) { if (widget.status LoadStatus.loading widget.items.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (widget.status LoadStatus.error widget.items.isEmpty) { return widget.errorWidget ?? Center( child: TextButton( onPressed: widget.onRefresh, child: const Text(加载失败点击重试), ), ); } if (widget.status LoadStatus.empty || (widget.status LoadStatus.success widget.items.isEmpty)) { return widget.emptyWidget ?? const Center(child: Text(这里空空如也)); } return RefreshIndicator( onRefresh: () async { final onRefresh widget.onRefresh; if (onRefresh ! null) { await onRefresh(); } }, child: ListView.separated( controller: _controller, physics: widget.physics ?? const AlwaysScrollableScrollPhysics(), padding: widget.padding, itemCount: widget.items.length (widget.hasMore ? 1 : 0), separatorBuilder: widget.separatorBuilder ?? (context, index) const SizedBox.shrink(), itemBuilder: (context, index) { if (index widget.items.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } return widget.itemBuilder(context, widget.items[index], index); }, ), ); } }需要说明的是itemCount在hasMore为true时加1这个多出来的项专门渲染底部的loading指示器。这样用户到滚动到最底部时能明确看到“正在加载”。4.2 配套状态管理ArticleListStore组件只是骨架状态管理仍然在业务方。我用一个ChangeNotifier的子类来管理文章列表的数据、分页和状态import package:flutter/foundation.dart; enum LoadStatus { loading, error, empty, success } class ArticleListStore extends ChangeNotifier { ListArticle _articles []; int _page 1; bool _hasMore true; LoadStatus _status LoadStatus.loading; ListArticle get articles List.unmodifiable(_articles); bool get hasMore _hasMore; LoadStatus get status _status; Futurevoid refresh() async { try { final result await Api.fetchArticles(page: 1); _articles result.items; _hasMore result.hasMore; _page 1; _status _articles.isEmpty ? LoadStatus.empty : LoadStatus.success; notifyListeners(); } catch (e) { _status _articles.isEmpty ? LoadStatus.error : LoadStatus.success; notifyListeners(); } } Futurevoid loadMore() async { if (!_hasMore || _articles.isEmpty) return; try { final result await Api.fetchArticles(page: _page 1); _articles.addAll(result.items); _page; _hasMore result.hasMore; _status LoadStatus.success; notifyListeners(); } catch (e) { debugPrint(load more failed: $e); } } }这里有几个设计选择值得说一下第一refresh()里catch住异常但不会rethrow。之前提过如果不catch异常会抛到组件内部的onRefresh回调最终导致刷新指示器收起时出现未捕获错误。业务上刷新失败后我仍然把状态置为error页面会显示错误兜底用户点“重试”会再次调用refresh。第二loadMore()里对_hasMore和_articles.isEmpty做了防御。空列表不需要加载更多没有更多也不需要请求。catch之后我没有更新状态因为列表里已有数据用户看到的还是旧列表只是加载更多按钮或者底部loading消失而已。要让“加载更多失败”可视需要再增加一个状态字段现阶段这个项目里我把错误打日志就够了。第三articles返回的是List.unmodifiable避免外部直接通过修改返回的列表污染store内部数据。虽然页面通常不会主动修改但组件一旦被多人复用这种防护很有必要。4.3 页面接入示例接入页面时只需要在build里获取store然后把items、status、hasMore、onRefresh、onLoadMore传给组件再写好itemBuilder就行class HomePage extends StatelessWidget { const HomePage({Key? key}) : super(key: key); override Widget build(BuildContext context) { final store context.watchArticleListStore(); return Scaffold( appBar: AppBar(title: const Text(首页)), body: LoadMoreListViewArticle( items: store.articles, status: store.status, hasMore: store.hasMore, onRefresh: store.refresh, onLoadMore: store.loadMore, itemBuilder: (context, article, index) ArticleCard(article: article), separatorBuilder: (context, index) const SizedBox(height: 12), emptyWidget: const Center(child: Text(暂无推荐内容)), padding: const EdgeInsets.all(12), ), ); } }看到没有页面里不再有ScrollController的创建不再有RefreshIndicator的嵌套不再有各种状态判断。业务方的关注点只剩三件事数据从哪来、item长什么样、空态文案是什么。整个页面的build方法从原本的几百字压缩到几十行可读性提升了不止一个档次。4.4 抽取前后对比我拿项目里实际改完的三个页面做了个粗略统计页面抽取前代码行数抽取后业务代码行数备注首页推荐23678保留itemCard和store逻辑分类列表25981保留分类筛选逻辑搜索结果22074保留关键词逻辑公共组件0230新增但三页共用总代码量其实没有减少太多因为组件本身的代码加进来了。但重复的部分被消灭了每个页面的独有逻辑更聚焦。后续如果再新增一个列表页新页面的成本大约只有store加一个页面组件完全不用动。更关键的是分页bug只需要在一个地方修了。像之前说的“page从0开始”这种低级错误现在只会在负责数据请求的store里出现修一处全局生效。5. 上线前踩过的坑和调优记录5.1 下拉刷新与列表滚动冲突第一个坑来自RefreshIndicator和自定义滚动体之间的冲突。有些页面在列表头部放了一个横向的Banner原本是用SingleChildScrollView包着一个纵向列表结果发现纵向拖动时下拉刷新不灵敏有时甚至触发不了。排查之后发现问题出在physics上。默认情况下ListView在没有内容时是不可滚动的RefreshIndicator自然拉不出来。而嵌套滚动容器之间的手势竞争也可能吞掉下拉手势。解决办法很直接给列表统一设置AlwaysScrollableScrollPhysics()。无论列表内容是否填满视口都允许滚动刷新手势就能稳定触发。这也是为什么我在组件里把physics默认值设成AlwaysScrollableScrollPhysics()外部可以通过参数覆盖。至于Banner最好的做法不是再套一层滚动而是把它作为列表的第一个item塞进ListView这样整个页面就是一个滚动体手势冲突最少。5.2 分页重复加载和诡异的重试循环分页重复加载是我调优过程中最头疼的问题。组件里虽然有_loadingMore标志但实际使用中发现滚动到接近底部时_handleScroll会在极短时间内触发多次。如果onLoadMore是网络请求第一次请求还没回来时第二次、第三次已经发出去了。后来我在_tryLoadMore开头加了三道闸if (_loadingMore) return; if (!widget.hasMore) return; if (widget.status LoadStatus.loading) return;第一道是自己内部的原子锁第二道判断是否还有更多第三道防止刷新和加载更多同时进行。但还有一个更隐蔽的场景加载失败后hasMore仍然为true如果用户停留在底部滚动监听会不断调用_tryLoadMore等于失败之后马上重试形成重试循环。这个循环最终因为网络恢复而结束但期间会产生大量无意义的请求。我最后的处理是在store的loadMore里增加了失败冷却记录lastLoadMoreFailedAt时间戳失败后5秒内忽略同一页的加载请求。这个方案不完美但简单有效。如果你的项目还需要处理加载更多失败后的UI提示建议在组件里再加一个loadMoreFailed状态用底部条提示用户点击重试。5.3 item复用与图片加载闪屏列表抽取成通用组件后item的复用变得更加频繁。因为ListView只保留视口附近的元素滑出屏幕很快会被回收并重新绑定到新的数据上。如果item里的图片是直接Image.network加载会出现网络请求闪烁、图片跳动的问题。我在项目里的做法是统一使用cached_network_image库配合占位图。同时在itemBuilder里给图片加cacheWidth参数避免高分辨率图在列表中被无谓解码。抽取组件后这些优化只需要在每个页面的item实现里做一次不需要在组件层重复。另一个和缓存有关的细节分页加载后列表更新时不要直接new一个List替换否则会导致所有item重建。我在store里用addAll而不是赋值新列表组件内部通过widget.items的变化刷新ListView会尽量复用已有元素的state。5.4 空态/错误态切换时的列表跳动还有一个交互层面的坑当列表从加载态切到空态时因为组件内部根据status返回了不同的widget树用户会看到一整个区块的跳动。如果空态和列表高度差异过大视觉上非常突兀。解决办法是给状态切换加一个AnimatedSwitcher包裹在build返回的最外层。但要注意RefreshIndicator和ListView的组合不能让AnimatedSwitcher来回切换时重置滚动位置。我在实际项目中用了一个折中方案只有“加载中-空态”和“列表-错误态”这种跨类型切换才用AnimatedSwitcher列表内部的数据更新不做动画保持滚动位置稳定。如果你也遇到类似问题可以按这个思路处理。6. 抽取完成后的几点个人体会这个通用列表组件在项目里跑了两个月帮我省下了不少重复工作。后来我又在它的基础上扩展了网格列表支持加了gridDelegate参数内部在GridView和ListView之间切换其实核心思路没变骨架归组件业务归页面。我的最大体会是抽取不是“把代码变少”而是“把变化隔离”。当你发现改动一个需求要同时改多个地方时就该考虑抽了。但抽取的时机要讲究不要在第一个页面写完就急着抽象至少要等第二个、第三个页面出现重复模式后再动手这时候你才真正知道哪些部分是稳定的哪些部分是易变的。实操上还有一个小技巧通用组件的参数命名尽量贴近Flutter原生习惯比如itemBuilder、separatorBuilder、padding、physics。这样团队里的其他Flutter开发者接手时不用查文档也能猜个八九不离十学习成本大大降低。最后列表展示的抽取只是前端组件化的一小步。同样的思路还能继续用在上拉加载的footer设计、错误重试的通用交互、空态插画组件等场景。克制地把重复逻辑抽出来未来加新功能、改旧bug才会越来越轻松。