如果一个App里的列表页只能挑一个交互来做我大概率会先做下拉刷新和上拉加载。这两个功能看着基础真正落地时翻车率却高得吓人刷新完列表直接给你弹回顶部上拉加载同一页数据请求了三次切到后台再回来还显示loading……这篇文章不聊空壳理论直接把RefreshIndicatorScrollController这套原生方案拆开揉碎再给出一份能直接上生产环境的完整实现顺带把我在 Flutter 列表场景里踩过的坑都列出来。适合正在做 Flutter 列表页、或者想把第三方加载库迁回原生方案的开发者参考。先说结论市面上那些花里胡哨的第三方下拉刷新库能不用尽量不用。原生方案缺的只是“看起来好看的自定义动画”而这些完全可以通过组合RefreshIndicator、ScrollController和自绘 Indicator 解决。真正考验人的不是刷新动画长什么样而是分页状态怎么管、竞态怎么防、触底判断怎么不抖动。1. 刷新加载的整体设计思路1.1 先想清楚刷新和加载到底在改变什么很多开发者一上来就写RefreshIndicator(onRefresh: _refresh, child: ListView(...))然后往ListView里塞一个FutureBuilder。表面看着能跑但实际上把“刷新”和“加载”两件事混成了一件事导致后面状态越来越乱。下拉刷新和上拉加载虽然都涉及网络请求但语义完全不同。下拉刷新是“重置式操作”用户想表达的是重新拉取最新数据列表第一页覆盖旧数据。上拉加载是“追加式操作”用户想表达的是在现有数据后面继续拿下一页。如果用同一个函数处理就会出现“刷新后页码忘记重置”“刷新结果直接 append 到旧列表后面”之类的低级问题。我自己的习惯是所有列表页的数据操作都围绕三个状态变量展开——items已加载的数据、page当前页码、hasMore是否还有下一页。刷新操作负责把它们归零并重建加载操作负责递增并追加。这两个方法永远独立实现哪怕内部调的是同一个接口。还有一个交互细节容易被忽略下拉刷新成功后列表应该保持当前的滚动位置而不是“刷的一下”弹回顶部。很多第三方库为什么体验差就是因为它们内部走的state initialState再重新请求数据一变ListView的高度也跟着变用户直接被甩到第一屏。原生RefreshIndicator本身不做这种坏事问题通常出在开发者用setState把整个列表数据替换成空数组再等新数据回来重新渲染中间一帧列表变空滚动位置就崩了。处理方式是刷新前保留当前 items请求成功后用新数据整体替换并且刷新期间列表尾部显示 loading 状态而不是清空。1.2 三种分页协议对应不同的刷新加载写法后端分页接口如果归纳一下本质上就三种页码分页、偏移分页、游标分页。这三者决定着你前端怎么维护page。页码分页最常见典型参数是page1pageSize20返回体里带total、hasMore。刷新时page重置为 1加载时page 1逻辑最简单没有歧义。偏移分页的典型参数是offset0limit20隐藏含义是“从第 N 条开始取”。这类接口如果列表中间的数据被删除offset 会漂移下一页可能漏数据或重复数据。所以用偏移分页时我一般会以当前items.length作为下一次请求的 offset而不是用一个简单的计数器。游标分页近年来用得越来越多典型参数是cursorxxx返回体里带nextCursor。这类接口对前端最友好因为不需要关心 page 概念只要记住上一次返回的nextCursor作为下一次请求参数直到nextCursor为空就说明没有更多。刷新时把 cursor 置空加载时传nextCursor。前端不管对接哪种协议落到 UI 层都要收敛成同一套状态items、hasMore、loadingMore、error。后端协议差异只在请求层处理不要让分页协议影响你的 UI 层状态设计。这个习惯能让你在替换后端接口时UI 代码基本不动。1.3 为什么不推荐无脑上第三方库pull_to_refresh、EasyRefresh这类库我曾经在生产项目里用得不少它们确实提供了开箱即用的自定义头部、底部组件初期接入很快。但用一段时间你会发现几个现实问题依赖的维护速度经常跟不上 Flutter 主版本迭代。Flutter 每升级一次这些库总有几个小版本在边界情况下报错尤其是涉及ScrollPosition、ScrollPhysics内部 API 变更的时候你只能等作者更新或者在 issue 里翻 workaround。我做过的项目里不止一次因为第三方库在 Flutter 3.x 某次升级后出现触底判断失效最后被迫花一晚上把库替换成原生方案。第三方库为了适配各种自定义需求往往会在列表外面包一层额外的ScrollView甚至接管你的ScrollController。这在简单页面里没事一旦页面里有NestedScrollView、CustomScrollView、多 Tab 复用同一个列表的场景冲突会非常隐蔽。原生RefreshIndicator本身就是官方组件所有参数都稳定ScrollController在自己手里问题排起来快得多。2. 原生方案核心细节解析2.1 RefreshIndicator 的触发原理与正确配置RefreshIndicator在 iOS 和 Android 上的视觉差异很大但在 Flutter 里它们共用同一个组件只是内置动画的样式不同。它靠通知监听器监听子组件的ScrollNotification当滚动位置为负且超过阈值时进入armed状态松手后触发onRefresh回调。这里有一个默认配置容易踩坑physics。如果你的列表数据不足一屏ListView默认的ClampingScrollPhysicsAndroid或BouncingScrollPhysicsiOS可能根本拉不出 overscroll这时候下拉刷新怎么拉都不触发。解决方案是在ListView上显式设置physics: const AlwaysScrollableScrollPhysics()即使是空数据、短数据也保证列表可以向下回弹刷新手势才会被识别到。这个配置我在每个列表页都会统一加上避免空态和短内容场景下刷不动。另一个坑是onRefresh必须返回一个Future并且这个 Future 只有在真正完成刷新后才能 resolve。如果你在onRefresh里直接调一个异步函数但没await或者函数内部提前返回指示器会在网络还没回来时就收回去视觉上就是“转了一下就结束但数据没更新”。Futurevoid _onRefresh() async { await _cubit.refresh(); }有些开发者喜欢在_onRefresh里setState这没问题但要保证异常别往上抛。RefreshIndicator的 Future 如果以 error 结束控制台会直接打印一个未捕获的异常甚至会触发全局错误上报。所以在刷新方法内部要自己 catch 掉异常把错误状态写进业务状态里UI 用 SnackBar 或列表 error view 展示。2.2 触底判断的阈值怎么定上拉加载最常见的实现方式是监听ScrollController当滚动位置接近底部时触发加载。但判断条件写不好会出现两种极端触发太早导致用户还没看到底部就加载了下一页触发太晚导致白色空白长时间露出来。我常用的判断公式是这样if (position.pixels position.maxScrollExtent - 300) { _loadMore(); }300是触发距离单位是逻辑像素。这个值不是拍脑袋定的它取决于你列表页底部 Loading 组件的高度。如果底部加载指示器高约 80 像素那么 300 的提前量意味着用户滑到离底部还差一小段屏的位置时就开始加载下一页等用户真正滑到底部时新数据大概率已经渲染出来了视觉上几乎无缝。但300这个值不要写死在每个页面。如果列表项很高比如大卡片、图片墙一屏可能只能看到 3 条数据300 像素的提前量会变成“提前了一整个屏”导致用户根本还没看到底部下一页就加载了。比较好的做法是把触发距离做成页面的一个参数根据列表项高度和底部组件高度共同决定默认 200~300高卡片列表适当缩小到 100 左右。防抖动逻辑也必须加上否则_loadMore会被连续触发。我在状态里加了一个原子锁if (_loadingMore || !_hasMore) return; _loadingMore true;请求回来后无论成功失败都必须把_loadingMore恢复为false。这里最容易被忽略的是失败分支——很多新手只在 try 成功里复位锁catch 里忘了结果一次请求失败之后列表永远不再自动加载只能靠手动重试按钮救回来。2.3 列表尾部的三种状态缺一不可上拉加载底部组件不能只做一个“加载中转圈”。我见过太多列表加载到最后一页后底部一直挂着转圈用户以为还在加载其实是接口返回了hasMorefalse但代码里没做结束展示。底部组件至少要覆盖三种状态状态展示内容用户操作加载中CircularProgressIndicator “加载中…”无需操作没有更多“已经到底了”或品牌文案无需操作加载失败“加载失败点击重试”点击后重新触发加载这里一个容易被吐槽的细节加载失败时千万不要直接换成一整屏错误页。用户的心理预期是“我已经浏览了部分内容只想继续加载”你要做的只是保留已有数据、在底部给一个重试入口。一整屏错误会强迫用户退出列表页再进来明显违背“沉浸式浏览”的交互直觉。3. 从基础到可复用完整实现3.1 基础版一根 ScrollController 打天下先给出一份最朴素的实现。这段代码没有任何状态管理框架适合中小项目快速落地也方便理解核心机制。class NewsListPage extends StatefulWidget { const NewsListPage({super.key}); override StateNewsListPage createState() _NewsListPageState(); } class _NewsListPageState extends StateNewsListPage { final ScrollController _controller ScrollController(); final ListString _items []; int _page 1; bool _loadingMore false; bool _hasMore true; String? _error; override void initState() { super.initState(); _controller.addListener(_onScroll); _loadFirstPage(); } void _onScroll() { if (!_controller.hasClients) return; final position _controller.position; if (position.pixels position.maxScrollExtent - 200) { _loadMore(); } } Futurevoid _loadFirstPage() async { await _refresh(); } Futurevoid _refresh() async { _page 1; _hasMore true; _error null; try { final result await ApiClient.fetchNews(page: _page); if (!mounted) return; setState(() { _items ..clear() ..addAll(result.items); _hasMore result.hasMore; _page 1; }); } catch (e) { if (!mounted) return; setState(() _error 刷新失败请检查网络); } } Futurevoid _loadMore() async { if (_loadingMore || !_hasMore) return; _loadingMore true; setState(() {}); try { final result await ApiClient.fetchNews(page: _page); if (!mounted) return; setState(() { _items.addAll(result.items); _hasMore result.hasMore; _page 1; _error null; }); } catch (e) { if (!mounted) return; setState(() _error 加载失败点击重试); } finally { if (mounted) setState(() _loadingMore false); } } override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(资讯列表)), body: RefreshIndicator( onRefresh: _refresh, child: ListView.builder( controller: _controller, physics: const AlwaysScrollableScrollPhysics(), itemCount: _items.length 1, itemBuilder: (context, index) { if (index _items.length) { return _buildFooter(); } return ListTile( title: Text(_items[index]), subtitle: const Text(这是一个列表项), ); }, ), ), ); } Widget _buildFooter() { if (_error ! null) { return TextButton( onPressed: _loadMore, child: Text(_error!), ); } if (_loadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center(child: CircularProgressIndicator()), ); } if (!_hasMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center(child: Text(已经到底了)), ); } return const SizedBox(height: 40); } }这段代码有几个关键细节要说清楚。itemCount是_items.length 1多出来的 index 就是底部状态区这样无论有没有更多数据底部都能有一个统一出口。_loadFirstPage()在initState里直接调_refresh()好处是首次加载和手动下拉刷新共用一套逻辑不会出现两套初始化代码。很多人会问为什么_loadMore里的finally还包了一层setState因为_loadingMore必须保证在成功、失败两种路径下都复位否则一旦异常下一次触底判断会因为锁没释放而永久失效。finally是最好用的兜底地方。mounted判断也必须加异步请求回来如果页面已经 dispose再调setState会直接抛异常这是 Flutter 官方手册里反复强调的。3.2 进阶版用 Cubit 管理分页状态基础版能跑但状态一多就散落在setState里。页面里如果还有筛选条件、点赞操作、搜索联动_page、_loadingMore、_hasMore这些变量会越加越多逻辑线和时间线开始混乱。这时候就该状态管理上场了。我个人偏好Cubit因为它比Bloc简单又比setState严格。先定义一个分页状态模型class NewsState extends Equatable { const NewsState({ this.items const [], this.hasMore true, this.loadingMore false, this.loadMoreError false, }); final ListNewsItem items; final bool hasMore; final bool loadingMore; final bool loadMoreError; NewsState copyWith({ ListNewsItem? items, bool? hasMore, bool? loadingMore, bool? loadMoreError, }) { return NewsState( items: items ?? this.items, hasMore: hasMore ?? this.hasMore, loadingMore: loadingMore ?? this.loadingMore, loadMoreError: loadMoreError ?? this.loadMoreError, ); } override ListObject? get props [items, hasMore, loadingMore, loadMoreError]; }再写 Cubitclass NewsCubit extends CubitNewsState { NewsCubit({required this.api}) : super(const NewsState()); final ApiClient api; int _page 1; Futurevoid refresh() async { _page 1; try { final result await api.fetchNews(page: _page); emit(NewsState( items: result.items, hasMore: result.hasMore, loadingMore: false, )); _page 1; } catch (_) { emit(state.copyWith(loadMoreError: true)); } } Futurevoid loadMore() async { if (state.loadingMore || !state.hasMore) return; emit(state.copyWith(loadingMore: true, loadMoreError: false)); try { final result await api.fetchNews(page: _page); emit(state.copyWith( items: [...state.items, ...result.items], hasMore: result.hasMore, loadingMore: false, )); _page 1; } catch (_) { emit(state.copyWith(loadingMore: false, loadMoreError: true)); } } }用 Cubit 之后UI 层只剩一个BlocBuilderBlocBuilderNewsCubit, NewsState( builder: (context, state) { return RefreshIndicator( onRefresh: () context.readNewsCubit().refresh(), child: ListView.builder( controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), itemCount: state.items.length 1, itemBuilder: (context, index) { if (index state.items.length) { if (state.loadMoreError) { return TextButton( onPressed: () context.readNewsCubit().loadMore(), child: const Text(加载失败点击重试), ); } if (state.loadingMore) { return const Center(child: CircularProgressIndicator()); } if (!state.hasMore) { return const Center(child: Text(已经到底了)); } return const SizedBox(height: 40); } return ListTile(title: Text(state.items[index].title)); }, ), ); }, )这里有个测试上的好处Cubit 的refresh()和loadMore()是纯异步方法可以脱离 Widget 单独用bloc_test跑单测构造各种失败场景验证状态是否正确比如“第一页失败时loadMoreError是否为 true”“下一页返回空数组时hasMore是否被置为 false”。这是setState写法做不到的。3.3 自定义刷新动画两种实用路线默认的RefreshIndicator转圈其实已经很顺眼但很多产品和设计会要求“换掉那个丑转圈”。这里基本有两种路线。一种是用RefreshIndicator自带的color、backgroundColor、strokeWidth参数去调样式适合只改颜色的需求。另一种是自绘刷新头部组件。自绘的核心是理解RefreshIndicator只是监听ScrollNotification自己写一个NotificationListenerScrollNotification来监听ScrollUpdateNotification的metrics.extentBefore或pixels为负的差值和ScrollActivityNotification来判断松手后的状态再用一个AnimationController驱动自定义头部旋转、位移、文案变化。如果你用的是CustomScrollViewSliverList那RefreshIndicator也能包但要注意它只能包一个可滚动子组件所以CustomScrollView整体被包就没问题。另一种选择是CupertinoSliverRefreshControl原生 iOS 风格的下拉刷新在CustomScrollView的slivers里直接插入CupertinoSliverRefreshControl(onRefresh: ...)配合SliverOverlapInjector可以实现系统级的下拉反弹。两种组件我都在项目里用过iOS 风格页面用CupertinoSliverRefreshControl的观感确实更自然。自绘头部组件里有一个容易忽略的细节动画状态切换的时机。下拉过程中头部跟着手指移动是“拖动态”松手后进入“刷新态”刷新结束进入“收尾态”。判定松手动作要用ScrollStartNotification的dragDetails和ScrollUpdateNotification的dragDetails区分是用户拖动还是惯性滚动否则会出现“还没松手就开始转圈”的怪异表现。这块代码量比较大一般不建议每个页面都手写我的习惯是封装成一个CustomRefreshHeader放到项目公共组件库里统一维护。4. 高频问题与排查实录4.1 常见问题速查表现象原因解决方案下拉刷新死都不触发列表数据不满一屏默认 physics 不支持 overscroll显式设置AlwaysScrollableScrollPhysics刷新转一圈就结束但数据没变onRefresh没返回 Future 或内部没 await方法改成 async并await请求完成上拉加载一次请求连续发好几次触底判断反复进入loadMore方法加_loadingMore锁请求结束前不释放网络失败后列表再也不自动加载catch 分支里忘复位 lock用finally统一复位刷新后列表弹回顶部刷新时先清空 items 再等新数据刷新期间保留旧 items请求完再替换Web 端下拉刷新手感很怪Web 浏览器默认 overscroll 行为与 Flutter 冲突用Listener监听手势或考虑 Web 端只保留加载按钮切后台回来还在 loading页面 dispose 之后setState被调用所有异步回调加if (!mounted) return底部“已经到底了”被下次加载覆盖页面重新加载时没有重置hasMore刷新时显式设置_hasMore true这张表里前四条是我在建列表组件时几乎必遇到的问题。尤其是第二条很多人一看转圈收回去了就一直纠结为什么列表没变化其实只是忘了返回Future。这个错误很隐蔽因为代码不报错编译不失败只有运行时的行为不对。4.2 竞态问题慢网络下的数据错乱上拉加载最常见的疑难杂症不是崩溃而是数据错乱。典型的场景是这样的用户在加载第 3 页时网络慢等第 3 页还没回来用户又下拉刷新了刷新请求先发出去、后发出去都有可能最后谁先回来决定了页面显示哪些数据。如果后发的刷新请求先回来第 3 页加载再姗姗来迟那页面上就会出现新旧数据混合的情况。解决竞态的办法有几个层级。最轻量的是在刷新操作里直接跳过当前未完成的加载操作用_loadingMore锁加一个请求序号int _requestSeq 0; Futurevoid _refresh() async { final seq _requestSeq; // 请求发出去之前先取消掉逻辑上的加载 _loadingMore false; // 请求结果回来时检查 seq 是否仍然是最新 }每次请求响应回来后先判断seq _requestSeq如果不相等直接丢弃结果。这个思路简单可靠不需要引入额外的并发库。复杂一点的做法是用StreamController或RxDart的switchMap让新的刷新请求自动取消旧的加载结果但一般列表页里没必要引入这么重的异步模型。另一种竞态是“刷新后页码和正在返回的旧页面错位”。比如刷新操作把_page重置为 1但旧代码里正在等待page2的响应响应回来之后又_page 1导致下一页直接跳到了 page3跳页了。请求序号方案可以一并解决这个问题因为过期的响应会被直接丢弃。4.3 版本与性能Flutter SDK 升级后的刷新加载坑写这篇的时候我正好经历了 Flutter 3.x 的一次大版本升级下拉刷新动画和性能问题在版本替换后暴露得很明显。如果你也在终端里看到诸如current configured Flutter SDK is not known to be fully supported或者You are applying Flutters main Gradle plugin imperatively using the apply script这类提示说明工程和当前 SDK 版本之间已经存在兼容性偏差列表页的刷新动画虽然不是直接报错但可能会出现 iOS 上回弹阻尼不正常、Android 上刷新指示器位置偏移等副作用。遇到这类情况我一般先做两件事一是flutter upgrade到稳定渠道再看看二是执行dart pub outdated检查项目里依赖包是否都在兼容区间。很多第三方列表库放出来的旧版本是几年没更新的一旦 Flutter 主版本升级就彻底无法使用这就是我在第一节强烈建议迁移到原生方案的原因。原生组件永远跟随 Flutter SDK 走不存在“没人维护”的问题。关于下拉刷新动画的帧率Flutter 新版本默认用 Impeller 渲染引擎刷新动画的圆环转圈在高刷新率设备上看起来更平滑。但在部分低端 iOS 设备上Impeller 的着色器编译可能会出现首帧卡顿导致下拉刷新出现一瞬间的掉帧。这种场景可以暂时通过关闭 Impeller 验证是不是渲染引擎的问题确认后如果确实是 Impeller 的锅就升级到修复版本而不是长期关闭因为 Android 端 Impeller 带来的渲染一致性提升更明显。Flutter Web 上刷新加载又是另一套表现Web 端浏览器手势会和 Flutter 的 overscroll 冲突尤其桌面浏览器里鼠标中键上下滚动RefreshIndicator的表现会有违和感。我在 Web 端列表页通常不启用下拉刷新交互改用右上角“刷新”按钮触发数据重置上拉加载也换成显式的“加载更多”按钮这样既符合 Web 使用习惯又绕开了浏览器手势劫持的问题。这算是一个务实取舍不要为了“移动端交互一致性”强行在 Web 上复刻所有手势。写在最后的一点体会这套原生刷新加载组合我在自己维护的几个 App 项目里稳定跑了一年多期间经历了一次 Flutter 主版本大升级除了RefreshIndicator的默认样式微调没有任何破坏性改动。换做以前用第三方库的时候升级一次 Flutter SDK 往往要跟着改好几处 API甚至要替换掉列表库本体这份省心是实打实的。如果你想复现文章里的方案建议从基础版开始跑通再逐步换到 Cubit 那版。先理解ScrollController的监听机制和状态锁的复位逻辑再去考虑自定义动画和性能优化顺序别反了。动手过程中如果遇到这里没写到的怪问题多半出在你的列表嵌了多层可滚动组件先用NotificationListener打印出ScrollNotification的事件类型八成能从日志里直接看出端倪。