Flutter 下拉刷新与上拉加载:从原理到工程化避坑指南
Flutter 里做下拉刷新、上拉加载几乎是每个 App 的标配需求。但说句实话我见过太多项目在真正落地时被这两个功能坑得死去活来下拉刷新转两圈就没反应上拉加载连续触发接口爆掉iOS 和 Android 表现还不一致。今天这篇东西不准备讲虚的直接把我从零实现到工程化的完整思路、代码模板和踩坑记录都摊开来说适合正打算做列表页、或者已经被刷新加载折磨过的 Flutter 开发者参考。我默认你已经会建 Flutter 工程、会用 ListView 渲染列表。这里不会从 widget 基础开始讲而是直接聚焦在“刷新机制”和“分页加载机制”这两个高频需求上。文章里的代码都是我实际写过的风格你可以直接抄到项目里改一改就能用。1. 动手之前先把 Flutter 的滚动机制聊透1.1 为什么下拉刷新在 Flutter 里不是“开箱即用”很多新手都以为下拉刷新就是给 ListView 包一层 RefreshIndicator 完事。真这么干你会发现要么拉开了松手没反应要么列表内容不满一屏时根本拉不动。要搞清楚这个问题得先理解 Flutter 的滚动体系。Flutter 里所有滚动视图都建立在 Scrollable、ScrollPosition 这两个核心概念上。Scrollable 负责处理手势、惯性ScrollPosition 负责记录当前滚动位置是 0 还是已经滚到了 1000。下拉刷新本质上不是“刷新”而是“在滚动位置已经到顶之后继续往下拖”的过滚动行为。RefreshIndicator 监听的就是这种过滚动状态当用户拖出的距离超过某个阈值并松手它就会调用你传进去的 onRefresh 回调。这里就有两个容易被忽略的细节。第一RefreshIndicator 默认只在“滚动内容到达顶部”时才会响应下拉手势。如果你的 ListView 内容不足一屏ScrollPosition 的 maxScrollExtent 小于视口高度它可能根本不会进入可下拉状态。解决办法是给列表加上AlwaysScrollableScrollPhysics()强制列表在任何时候都可以滚动回弹这样 RefreshIndicator 才能稳定触发。第二RefreshIndicator 的 onRefresh 必须返回一个 Future而且这个 Future 必须在所有逻辑结束后完成否则指示器会一直转圈。这个坑我后面专门讲。再往深一层说iOS 上的 BouncingScrollPhysics 本身自带回弹效果Android 上是 ClampingScrollPhysics。RefreshIndicator 的处理逻辑在不同平台会有差异。很多团队为了 UI 统一会给所有平台都套上 Clamping 效果这没问题但要注意下拉时的手感会变硬。如果你想保留 iOS 原生那种“拽橡皮筋”的反馈又想让 RefreshIndicator 正常工作可以用平台判断动态返回 physics而不是一根筋写死。1.2 上拉加载背后是“分页”不是“无限滚动”上拉加载这个东西新手最容易犯的错就是把它当成“无限滚动”来实现一滑动就疯狂请求接口。实际上我们需要的是一套清晰的分页状态机。分页加载的核心逻辑可以拆成三个状态Idle空闲、Loading加载中、NoMore没有更多了。用户上拉到列表底部附近时如果当前状态是 Idle就发起请求把状态切到 Loading拿到数据后拼接到列表尾部再根据返回结果判断是回到 Idle还有下一页还是切成 NoMore数据已经全部取完。这里必须加一个 guard在 Loading 状态下重复触发加载请求直接忽略否则并发请求会打爆你的服务端也会造成列表数据错乱。还有一个点是“距离底部多少像素时触发加载”。有人喜欢用ScrollController监听position.pixels和maxScrollExtent做差值判断。这没问题但更稳妥的方式是监听extentAfter也就是当前底部还有多少内容没有呈现。当extentAfter 200时触发下一页这样无论列表高度、屏幕大小怎么变逻辑都成立。常见的坑是某些列表项是动态高度的图片没加载出来之前高度为 0导致用户快速滑动时一次性把好几页的请求全部触发。所以除了距离判断还要加一个“是否正在加载”的布尔锁双保险。2. 核心组件拆解RefreshIndicator 和 ScrollController 的双剑合璧2.1 RefreshIndicator 的常用参数和触发模式日常用 RefreshIndicator主要就那几个参数但很多人不知道它们到底影响什么。我先说最常用的几个再说不常见但很关键的一个。onRefresh必填需要一个返回 Future 的回调。刷新完成后 Future 结束指示器自动收起。color/backgroundColor分别控制小圆点的颜色和背景色。在深色主题下如果不设置会出现白底白圈这种尴尬画面。displacement指示器距离顶部的偏移量。默认 40.0如果 AppBar 太高或者不想让圆圈盖住标题可以调大。notificationPredicate决定哪些滚动通知会被监听。默认是notification.depth 0也就是说只监听最外层滚动。如果你在 CustomScrollView 里还嵌着水平滚动的 TabBarView这个参数就要小心调整。再说触发模式。Flutter 3.10 之后 RefreshIndicator 多了一个triggerMode参数可选RefreshIndicatorTriggerMode.onEdge默认和RefreshIndicatorTriggerMode.anywhere。onEdge 模式下你必须把列表滚到顶端才能触发下拉anywhere 模式下即使你正在列表中间只要做一个向下的拖拽手势也可以把刷新指示器拉出来。这个在交互上很实用比如商品详情页是个很长的滚动视图用户已经往下翻了很多想看最新库存就得猛划回顶部太不友好了。用 anywhere 模式反而能减少返顶操作的实际频率。但要提醒一句anywhere 模式很容易和“页面内横向滑动返回”手势冲突如果你的页面本身有复杂手势建议保留默认值。还有一个隐藏知识点RefreshIndicator 只能包裹“可滚动组件”但它并不关心内部是什么ListView、CustomScrollView、SingleChildScrollView 都可以。唯一的要求是它们必须产生 ScrollNotification并且滚动内容能过滚动。因此我经常在项目里写一个通用封装内部统一是CustomScrollView把刷新指示器和页面所有的 Sliver 包在一起这样既灵活又能保证 SliverAppBar 的折叠联动。2.2 ScrollController 监听“到底部”的正确姿势很多教程教你用 ScrollController 写加载更多controller.addListener(() { if (controller.position.pixels controller.position.maxScrollExtent - 200) { _loadMore(); } });这段代码在简单场景下能用但它有个隐蔽问题ScrollController 一次只能绑定一个 ScrollPosition。如果你的页面里有 TabBarView、PageView 或者多个滚动区域共享同一个 controllerFlutter 会直接抛异常。另外ScrollController 的 listener 是在滚动过程中高频触发的如果_loadMore()里没有做好防抖一次触底会连发好几个请求。我个人的做法是优先用NotificationListener。它能感知整棵 widget 树里冒泡上来的 ScrollNotification并且不依赖 controller 的创建顺序。代码长这样bool _onLoadMoreNotification(ScrollNotification notification) { if (notification is ScrollUpdateNotification || notification is ScrollEndNotification) { if (notification.metrics.extentAfter 200) { _loadMore(); } } return false; }这里有几个注意点。第一notification.depth必须做过滤。如果你页面是一个竖向 ListView每一项里面又有一个横向滚动的组件那么横向滚动的 ScrollNotification 也会冒泡上来。这时候用notification.depth 0过滤掉子滚动区域的事件否则你会发现在横滑项上停留也会触发加载。第二return false表示不阻止事件继续向父级传递如果你在父组件里做了AbsorbPointer或者有其它手势逻辑返回 true 可能会影响滚动事件。没事不用瞎改返回值。第三metrics.extentAfter在滚动停止时也可能不准确所以把 ScrollUpdateNotification 和 ScrollEndNotification 都监听一下确保用户快速滑到某个位置停下来时依然能触发加载。如果你用的是ScrollController方案我也给一个进阶技巧不要在 listener 里做太多计算只记录一个“是否触底”的布尔值真正的加载逻辑放在ScrollEndNotification里统一触发。这样既避免高频请求也不会漏掉惯性滑动后的加载动作。3. 实操从零实现一个可复用的刷新加载列表3.1 手写版RefreshIndicator NotificationListener 完整分页示例我先把一个能直接跑起来的完整例子放出来。这里面包含了下拉刷新、触底加载、footer 状态展示以及防重复请求保护。它不依赖任何第三方库只用了 Flutter 自带的组件目的是让你先看清底层逻辑。import package:flutter/material.dart; class RefreshLoadMoreList extends StatefulWidget { const RefreshLoadMoreList({super.key}); override StateRefreshLoadMoreList createState() _RefreshLoadMoreListState(); } class _RefreshLoadMoreListState extends StateRefreshLoadMoreList { static const _pageSize 20; final ListItem _items []; int _page 1; bool _hasMore true; bool _loading false; override void initState() { super.initState(); _loadFirstPage(); } Futurevoid _loadFirstPage() async { setState(() _loading true); try { final data await fetchItems(_page, _pageSize); setState(() { _items.addAll(data); _page; _hasMore data.length _pageSize; }); } finally { setState(() _loading false); } } Futurevoid _onRefresh() async { if (_loading) return; _loading true; try { final data await fetchItems(1, _pageSize); setState(() { _items ..clear() ..addAll(data); _page 2; _hasMore data.length _pageSize; }); } catch (_) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text(刷新失败请重试))); } finally { _loading false; } } Futurevoid _loadMore() async { if (_loading || !_hasMore) return; _loading true; // 为了让 footer 区出现加载动画先 setState setState(() {}); try { final data await fetchItems(_page, _pageSize); setState(() { _items.addAll(data); _page; _hasMore data.length _pageSize; }); } catch (_) { setState(() {}); ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text(加载失败请重试))); } finally { _loading false; // 如果此时已没有更多数据footer 要立即切换成“没有更多了” setState(() {}); } } override Widget build(BuildContext context) { return RefreshIndicator( onRefresh: _onRefresh, child: NotificationListenerScrollNotification( onNotification: (notification) { if (notification.metrics.extentAfter 200) { _loadMore(); } return false; }, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: 16), itemCount: _items.length 1, itemBuilder: (context, index) { if (index _items.length) { return _buildFooter(); } return ListTile( leading: const CircleAvatar(child: Icon(Icons.article)), title: Text(_items[index].title), ); }, ), ), ); } Widget _buildFooter() { Widget child; if (_hasMore) { child const SizedBox( height: 48, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } else { child const Padding( padding: EdgeInsets.all(12), child: Text( 没有更多了, textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), ); } return child; } } class Item { const Item({required this.title}); final String title; } FutureListItem fetchItems(int page, int pageSize) async { // 模拟网络请求 await Future.delayed(const Duration(milliseconds: 600)); if (page 4) return []; return List.generate( pageSize, (i) Item(title: 第 $page 页 第 $i 条数据), ); }这段代码里有一个关键点_loadMore里用了if (_loading || !_hasMore) return;来防止并发。NotificationListener里不管触发多少次只要一次请求没结束后面的调用都会被拦下来。这比很多人写的只在 UI 上判断“footer 正在转圈”要严谨得多因为 UI 状态更新是异步的你没法保证这两帧之间不会多出一次回调。另外注意_loadMore里我调用了三次setState。第一次是为了让 footer 从“没有更多了”变回加载态第二次是请求失败后刷新 UI第三次是 finally 里确保_loading变化被渲染。其实你可以用一个小技巧把 footer 组件设计成完全依赖_loading和_hasMore这样每次状态变化必须触发重建。但也要小心频繁 setState 在列表很长时会有性能损耗更好的做法是把 footer 包在 ValueListenableBuilder 里或者直接把加载状态放在最外层 State 并配合 AnimatedBuilder。项目初期用 setState 没问题列表项数量过百后建议再优化。3.2 使用 pull_to_refresh 包快速实现多端统一体验如果你不想维护这么多手写细节最省事的是用社区成熟的pull_to_refresh包。它把下拉刷新和上拉加载做了统一封装还自带多种指示器样式支持自定义 header、footer。项目里我一般直接用它因为它能解决几个我懒得重复造轮子的点iOS 和 Android 的触觉反馈差异、列表不满一屏时的上拉隐藏逻辑、以及加载更多时 footer 的动画切换。基本用法如下import package:pull_to_refresh/pull_to_refresh.dart; RefreshController _refreshController RefreshController(initialRefresh: false); SmartRefresher( controller: _refreshController, enablePullDown: true, enablePullUp: true, onRefresh: () async { final result await _repository.fetchItems(1); setState(() { _items result; _hasMore result.length pageSize; }); _refreshController.refreshCompleted(); if (!_hasMore) { _refreshController.loadNoData(); } }, onLoading: () async { final result await _repository.fetchItems(_page); setState(() _items.addAll(result)); _refreshController.loadComplete(); if (result.length pageSize) { _refreshController.loadNoData(); } }, child: ListView.builder( itemCount: _items.length, itemBuilder: (context, index) ListTile(title: Text(_items[index])), ), )用这个包要注意几个问题。一是RefreshController的refreshCompleted()和loadComplete()必须对应调用否则指示器会一直停在加载状态。二是在onRefresh里不要让两个状态同时更新比如刷新成功后直接把分页页码重置成 2同时loadNoData的逻辑也要判断当前是否是刷新引起的否则会出现刷新后明明还有更多数据footer 却一直显示“没有更多了”。三是这个包的默认 header/footer 在不同 Flutter 版本上样式有细微差异如果你们视觉要求严格可以自定义 header我一般用CustomHeader包一个 40x40 的 Animation。3.3 配合 Bloc/Cubit 管理刷新状态的一线实践如果项目用了 Bloc 或 Cubit 做状态管理那么刷新、加载更多、分页这些状态就不应该散落在 widget 的 setState 里。把它们收拢到 Cubit 里最大的好处是方便测试和复用。比如同一个列表在 A 页面用了B 页面可能要把同样的列表逻辑嵌入到 Tab 里如果是 setState 写法你还要复制整套 State而 Cubit 只需要 new 一个实例就行。我这里给一个典型的分页 Cubit 设计sealed class ListState { const ListState({ this.items const [], this.page 1, this.hasMore true, this.isInitialLoading false, this.isRefreshLoading false, this.isLoadMoreLoading false, this.errorMessage, }); final ListItem items; final int page; final bool hasMore; final bool isInitialLoading; final bool isRefreshLoading; final bool isLoadMoreLoading; final String? errorMessage; ListState copyWith({...}) {...} } class ListCubit extends CubitListState { ListCubit(this._repository) : super(const ListState()); final ListRepository _repository; Futurevoid refresh() async { if (state.isRefreshLoading || state.isLoadMoreLoading) return; emit(state.copyWith(isRefreshLoading: true, errorMessage: null)); try { final result await _repository.fetchItems(1); emit(state.copyWith( items: result, page: 2, hasMore: result.length pageSize, isRefreshLoading: false, )); } catch (e) { emit(state.copyWith( isRefreshLoading: false, errorMessage: 刷新失败$e, )); } } Futurevoid loadMore() async { if (state.isLoadMoreLoading || state.isRefreshLoading) return; if (!state.hasMore) return; emit(state.copyWith(isLoadMoreLoading: true, errorMessage: null)); try { final result await _repository.fetchItems(state.page); final items [...state.items, ...result]; emit(state.copyWith( items: items, page: state.page 1, hasMore: result.length pageSize, isLoadMoreLoading: false, )); } catch (e) { emit(state.copyWith( isLoadMoreLoading: false, errorMessage: 加载失败$e, )); } } }然后 UI 层在build里使用BlocBuilder监听刷新动画用RefreshIndicator的onRefresh调context.readListCubit().refresh()。Cubit 方案的另一个好处是页面结构可以拆得更清晰列表 body 只负责渲染state.itemsfooter 根据isLoadMoreLoading和hasMore渲染错误提示可以统一在页面顶部做一个 SnackBar 监听。我见过很多团队在 UI 里写if (state.errorMessage ! null) showErrorDialog()这会导致页面重建时弹窗重复出现正确的做法是在BlocListener里监听错误字段并只触发一次或者在copyWith返回新对象后在 Cubit 内部做一个clearError的定时任务。这个小细节能省不少 QA 报的 bug。Bloc 系列用起来不难但要注意 8.x 版本之后emit的状态必须用新对象直接改原对象的字段是不会触发更新的。很多升级 Flutter 版本后列表不刷新的人八成就是在这踩了坑。如果你还在用旧版bloc7.x升级到 8.x 后顺手把cubit.close()的生命周期处理好页面销毁时别忘了调用。4. 避坑指南真实项目中踩过的坑与排查记录4.1 刷新时列表抖动或回弹不自然我最早做下拉刷新时遇到一个很诡异的现象下拉时刷新指示器已经出现在顶部但列表本身还会继续往下弹一段距离松手后又要回弹一次整个交互非常“肉”。排查下来是两个原因叠加。第一个原因是 RefreshIndicator 自带的位移动画和列表的 physics 回弹动画叠加了。如果你给列表设置了BouncingScrollPhysics同时 RefreshIndicator 也在做位移两边都在对滚动位置做 clamp就会出现“明明松手了内容还在动”的观感。建议是 Android 上用ClampingScrollPhysicsiOS 上保留默认的BouncingScrollPhysics即可不要手动给全局设置一个AlwaysScrollableScrollPhysics之外的回弹物理。第二个原因是嵌套滚动。如果页面结构是RefreshIndicator CustomScrollView NestedScrollView ListView那么内层 ListView 和外层 CustomScrollView 都在响应同一个拖拽手势位移量会被放大。这种情况我建议把刷新逻辑放在最外层滚动容器上并使用notificationPredicate把内层滚动过滤掉。更稳妥的做法是直接放弃 NestedScrollView改用一个CustomScrollView加SliverAppBar加SliverList的组合这样滚动层次最少RefreshIndicator 的表现最可控。4.2 加载更多一直触发或压根不触发这个“一直触发”的坑多半出在extentAfter判断写得太大。比如你写了if (notification.metrics.extentAfter 400)在长列表快速滑动时每次滚动事件都满足条件即使你加了_loading锁请求也会因为“上一页还没返回下一页已经发出”而排队最后全部返回后列表一下多出好几十条数据用户看着会以为是 bug。阈值建议控制在 100 到 250 之间具体要看你们列表项的高度。如果是图文卡片这类高度比较大的阈值可以适当调大让加载提前一点提升体验如果是纯文本列表阈值设 150 就够。“压根不触发”的情况比较多常见几个原因滚动发生在内嵌的 ScrollView 里没有冒泡到外层 NotificationListener所以监听不到。这时候需要检查NotificationListener包的位置它必须包裹“能产生对应通知的滚动组件”。内容不够一屏maxScrollExtent本来就是 0extentAfter恒等于 0理论上反而满足条件但如果你的_loadMore里判断了_items.isNotEmpty或者hasMore已经变成 false就不会加载了。列表不满一屏却没有自动加载第一页或第二页是需求设计问题。我一般会在 initState 里先拉第一页如果第一页已经填满一屏还不触发加载更多就在第一页返回后手动检查renderObject高度不够一屏就继续拉第二页直到内容可滚动为止。用户用键盘呼出输入框后滚动区域高度变化maxScrollExtent被重新计算这时候触底判断可能失效。解决方案是监听MediaQuery.of(context).viewInsets.bottom的变化在键盘收起后重新判断一次。4.3 网络异常时刷新卡死SocketException 与状态机上拉加载和下拉刷新都会遇到网络问题最常见的异常就是SocketException: Failed host lookup或者Connection timed out。如果你只把try/catch写在请求里但忘了在finally里把刷新状态复位后果就是刷新指示器一直转怎么拉都拉不回来。这里我特意想说一下很多人觉得SocketException只是弱网问题实际上在 Flutter 里如果 DNS 配置异常、后端返回了错误 JSON、或者请求被代理拦截都会以SocketException的形式抛出来。所以不要在 catch 里只打印日志一定要给用户可见的反馈。我项目里的统一做法是刷新失败时保留旧数据顶部弹一个 SnackBar文案是“刷新失败请检查网络”加载更多失败时保留旧数据把 footer 从加载态恢复成“上拉重试”的可点击状态而不是直接显示“没有更多了”。这样用户重新上拉还能再次触发加载而不是只能重启页面。状态机的严谨程度决定了你在弱网环境下的口碑。用一个bool _loading锁住了并发请求还不够还要区分“初次加载失败”“刷新失败”“加载更多失败”三种情况。比如初次加载失败时页面应该显示全屏错误视图并带一个“重试”按钮刷新失败时列表内容不应该清空加载更多失败时hasMore不能因为这一次失败就变成 false。如果你看不懂这段在说什么建议回头看一眼 1.2 节的状态机描述再对照手写版代码里的 catch/finally 体会一下。4.4 渲染引擎与动画的小坑Impeller 和 TabBar 点击动画Flutter 3.10 之后iOS 平台默认启用了 Impeller 渲染引擎。多数情况下 Impeller 让动画更流畅但个别低端设备上会出现一些和 Shader 编译相关的卡顿尤其是那种用自定义CircularProgressIndicator旋转动画做的刷新指示器。如果你发现真机上刷新动画偶尔会“卡一帧”而在模拟器上完全正常可以先怀疑是不是 Impeller 对某个特定 paint 操作编译有问题。临时排查手段是在Info.plist里加上keyFLTEnableImpeller/key false/强制切回 Skia 引擎对比一下是否还会卡顿。如果确认是 Impeller 的问题你可以继续跟踪 Flutter 的 issue也可以把刷新指示器的动画从CircularProgressIndicator换成Transform.rotate加AnimationController后者在某些设备上表现得反而更稳定。这里要说一句公道话Impeller 本身不是洪水猛兽它带来的帧率提升在复杂页面上非常明显不要因为一次卡顿就全局关闭。用FLTEnableImpeller关闭只是排查手段不是最终解法。再提一个让人哭笑不得的小坑TabBar点击切换 Tab 时有一个点击涟漪动画如果你在 TabBarView 里嵌了支持下拉刷新的列表点击 Tab 的瞬间正好手指往下滑了一点列表可能会触发行刷新。这个交互冲突在代码层面很难完全避免我目前用的方案是给 RefreshIndicator 加一个“最短间隔”锁比如刷新完成后 2 秒内不允许再次触发这样既能避免误触也不影响正常的使用习惯。如果你遇到类似的偶发刷新不妨也试试这种节流方案。5. 最后再分享一个我在实际项目里沉淀下来的经验工程化做久了我习惯把刷新加载这一套状态机收敛成固定的模板而不是每次写列表都现想。具体来说我会用一个基类PaginatedListController里面封装了refresh、loadMore、reset三个方法底层使用ChangeNotifier向外广播状态。这样不管页面里用的是 setState、Bloc 还是 GetX都能通过ListenableBuilder把它接进 UI。另外一个非常实用的技巧是给列表的 footer 加一个“上拉到底后自动隐藏”的逻辑。很多列表首屏加载完已经能填满一屏此时用户还没开始滚动footer 的“没有更多了”就会暴露在屏幕底部非常丑。我的做法是一旦列表可滚动并且当前滚动位置不在底部就隐藏 footer只有用户滚到底部那一下才把 footer 显示出来。这个逻辑用NotificationListener里的ScrollStartNotification设置_footerVisible true再用一个 300 毫秒的延时判断extentAfter 5时再把 footer 隐藏回去实现成本很低效果却很明显的。最后说一句藏在心里的建议下拉刷新和上拉加载看起来是列表的附属品但它们在App里承担着“用户对数据新鲜度”的信任。如果这块交互做得毛糙用户很快会怀疑整个应用的质量。别嫌这功能简单真正把它打磨到火候需要你对滚动、状态、异常处理都有清楚的认识。希望这篇东西能帮你省下几个晚上的排查时间也欢迎你在评论区聊聊自己遇到的那些奇奇怪怪的刷新加载问题。