☰
Flutter通用列表组件抽取:从下拉刷新到加载更多的实战指南
2026/10/7 11:15:06 网站建设 项目流程

做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换成_loadCategoryArticles,ArticleCard换成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像素作为“快要到底了”的触发条件,这样在用户滑到底之前,数据就已经在后台加载了,体感上会觉得“滚动不完”。

另一种是用NotificationListener<ScrollNotification>包裹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抽象

抽取后的第一原则:组件不关心列表里的数据是什么类型。

所以我定义组件时使用了泛型LoadMoreListView<T>,外部传入List<T> 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有itemBuilder,ListView.separated有separatorBuilder,这两个都是“变化点”,所以必须暴露给外部。

我保留了separatorBuilder作为可选参数,因为不同页面的分割线差异很大:有的需要等高的Divider,有的需要间距+阴影,有的干脆不要。组件内部默认提供一个SizedBox.shrink(),外部不传就当作没有分隔线。

padding、physics这些直接对应ListView自身属性的参数,我也一并透传。但我不暴露controller作为必选,而是允许外部可选传入,内部默认创建一个。这个细节在下一节说。

3.4 刷新和加载回调的签名设计

下拉刷新和加载更多,本质都是“让外部去拿更多数据”,所以签名设计成Future<void> Function()是最简单的。组件只负责“在合适的时机调用”,不负责“如何调用”。

如果业务流程里需要传递参数,比如刷新时需要重置page,那是store内部的事,回调的签名可以保持无参。你可以在store里写一个refresh()方法,内部访问自身state,这样外部回调直接传store.refresh即可。

final Future<void> Function()? onRefresh; final Future<void> 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 LoadMoreListView<T> 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 List<T> items; final Widget Function(BuildContext context, T item, int index) itemBuilder; final Widget Function(BuildContext context, int index)? separatorBuilder; final Future<void> Function()? onRefresh; final Future<void> 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 State<LoadMoreListView<T>> createState() => _LoadMoreListViewState<T>(); } class _LoadMoreListViewState<T> extends State<LoadMoreListView<T>> { 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(); } } Future<void> _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 { List<Article> _articles = []; int _page = 1; bool _hasMore = true; LoadStatus _status = LoadStatus.loading; List<Article> get articles => List.unmodifiable(_articles); bool get hasMore => _hasMore; LoadStatus get status => _status; Future<void> 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(); } } Future<void> 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.watch<ArticleListStore>(); return Scaffold( appBar: AppBar(title: const Text('首页')), body: LoadMoreListView<Article>( 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才会越来越轻松。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询