ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flutter无限滚动列表从设计到落地:ScrollController与分页加载实战

Flutter无限滚动列表从设计到落地:ScrollController与分页加载实战 1. 无限滚动的整体设计与实现思路做 Flutter 开发的朋友应该都有体会列表是我们日常打交道最多的场景之一。但真正把列表做好的—尤其是无限滚动这种带分页加载的列表—不只是把ListView.builder摆上去那么简单里面涉及滚动监听、数据状态管理、加载时机判断、异常处理这一串环节。这篇文章我会用一个完整的案例把 Flutter 无限滚动组件的实现从设计到落地拆开讲清楚。先说清楚我们要解决的问题电商 App 的商品列表、社交 App 的信息流、资讯 App 的文章列表几乎都是无限滚动模式—用户往上滑滑到底部了自动加载下一页数据一直加载一直滑永远看不到尽头。我们要做的东西本质上就是“尾部自动加载更多”配合下拉刷新构成移动端列表的标准交互范式。我选择了ListView.builder作为基础载体而不是ListView一次性构建全部 children或者SingleChildScrollView Column 的组合。原因很简单真正的无限列表数据量不可控如果用非懒加载的方式数据一多就直接把内存打爆。ListView.builder底层基于 Sliver 的延迟构建机制它只构建当前视口内可见的 item 以及前后缓存区域的少量 item滑出去的内容会被回收内存占用是恒定的。这个特性决定了它就是无限滚动列表的第一选择。整体设计上我把它拆成四个模块来思考数据层管理数据列表、当前页码、是否还有更多、是否正在加载中触发层通过ScrollController监听滚动位置判断到达什么位置时触发加载展示层列表项 UI、底部加载状态指示器、空态和错误态控制层负责重置、刷新、加载更多等动作的调度。这个分层思路跟做后端接口设计是一个逻辑每一层只关心自己的事情数据层不关心 UI触发层不关心业务展示层只管渲染。这样拆的好处是后期扩展很省事比如你想加图片懒加载、加缓存、加骨架屏都是在各自的层里做增补互不干扰。分开说每个模块的要点。数据层我是用一个_page记录当前已加载到第几页_hasMore标记接口是否还有下一页_isLoadingMore防止并发重复请求。这三个变量就是整个列表状态机的核心。触发层的核心是一个滚动监听函数每次滚动位置变化都会触发判断是否接近底部结合状态机的限制条件决定是否发起加载。展示层除了正常列表项还要处理 footer 区域的四种状态加载中、加载完成、没有更多了、加载失败可以点击重试。这里我特别想强调一个设计思路无限滚动不是简单的“滚动到底触发请求”而是一套状态机。_isLoadingMore为 true 时不能再次触发_hasMore为 false 时也不再触发。这两道闸门看起来简单但少了任何一个真实场景下都会出现疯狂重复请求的问题。2. 滚动监听与加载触发的核心细节2.1 ScrollController 的工作原理与监听策略ScrollController是 Flutter 框架提供的滚动控制器它背后的原理是在ScrollPosition上注册监听器当滚动位置发生变化时回调addListener注册的函数。我们要做无限滚动核心就是在监听函数里计算当前滚动位置是否到了该加载数据的临界点。判断逻辑的核心表达式是if (_scrollController.position.pixels _scrollController.position.maxScrollExtent - 200) { // 触发加载更多 }maxScrollExtent是整个可滚动内容的总高度pixels是当前滚动位置两者的差就是距离底部的距离。我设置的是 200 像素的阈值也就是距离底部还有大约一个 item 高度时就开始预加载。这个阈值是有讲究的太小会导致用户看到底部 loading 闪烁体验不好太大则可能用户还没滑到地方数据就已经加载完了白白消耗流量。实际项目里可以根据你自己的 item 高度调整一般 150 到 300 之间是常见的取值区间。有个新手很容易踩的坑ScrollController必须在State销毁时 dispose 掉否则会引发内存泄漏。另外如果你的页面用了 TabBar 切换多个 Tab 页每个 Tab 页有独立的列表每个列表必须使用自己独立的 ScrollController不能共用一个否则滚动位置会互相干扰。override void dispose() { _scrollController.dispose(); super.dispose(); }这个代码必须写在 dispose 里看起来是顺手的事但团队里我见过不止一次因为漏掉这行导致页面反复进出后内存暴涨的线上事故。2.2 触发条件的边界场景分析监听函数写好了不代表万无一失因为有一个众所周知的边界情况当第一页数据不足一屏的时候maxScrollExtent会等于 0也就是说内容根本撑不起滚动用户滑不动监听函数永远不会触发。这种情况下列表就永远停在了第一屏后面的数据根本加载不出来。这个问题的标准处理方式是在首屏数据加载完成之后检查一下内容是否能够填满视口如果填不满就继续补一页。我通常是在addPostFrameCallback里获取ScrollController的 position 信息判断WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) return; final position _scrollController.position; if (position.maxScrollExtent 0 _hasMore) { _loadMore(); } });hasClients检查不能省因为 ScrollController 未被任何 Scrollable 关联时访问 position 会直接抛异常。这个“数据不足一屏补加载”的逻辑是我踩了几次坑之后总结出来的不处理的话在很多低高度设备或者热门数据较少的页面用户会以为列表 bug 了。还有一个边界maxScrollExtent在加载过程中是会变的。当你加载完第二页数据列表高度增加可滚动范围也随之扩大。所以判断条件必须在每次滚动时实时读取最新的maxScrollExtent不要缓存旧值。有些新手喜欢在加载前把maxScrollExtent存到变量里加载后还在用那个旧值判断就会导致明明滑到底了却始终不触发加载。2.3 从 Scrollbar 到滚动体验的完整闭环无限滚动列表还要注意一个细节滚动条。当列表数据量很大时用户需要知道自己的浏览进度所以建议给 ListView 加上 ScrollbarScrollbar( controller: _scrollController, child: ListView.builder( controller: _scrollController, itemBuilder: ..., ), )注意Scrollbar和ListView.builder必须使用同一个 ScrollController否则滚动条无法跟随。滚动体验还有一个容易忽视的参数叫cacheExtent。这个参数定义了视口前后额外构建的区域大小默认是 250 像素。如果你的列表项里有网络图片图片解码需要时间可以把cacheExtent调大到 500 或 800让框架提前构建视口外的 item给图片加载留出缓冲时间。这样做的代价是内存占用会略微上升但对于图片较多的列表换取的是流畅的滑动体验完全值得。3. 完整实操从零写一个高性能无限滚动的 ListView3.1 数据模型与模拟接口准备我会用一个模拟的分页接口来演示完整流程因为真实开发中后端接口各不相同但前端处理的套路是一样的。假设我们有一个数据模型叫Article模拟接口函数每次基于页码返回 20 条数据延迟 1 秒模拟网络耗时。class Article { final int id; final String title; final String summary; Article({ required this.id, required this.title, required this.summary, }); } // 模拟分页接口 class ApiService { static FutureListArticle fetchArticles(int page, int pageSize) async { await Future.delayed(const Duration(seconds: 1)); if (page 5) { // 模拟第 5 页之后没有数据了 return []; } return List.generate(pageSize, (index) { final id (page - 1) * pageSize index 1; return Article( id: id, title: 第 $page 页第 $index 条咨询标题 $id, summary: 这是第 $id 条内容的摘要。真实项目中这里会是一段热点资讯的描述文本。, ); }); } }模拟接口里我特意做了“第 5 页之后返回空数据”的逻辑这模拟的是真实的业务场景—后端数据总会有分页边界。后面 footer 区域的_hasMore状态全靠这个返回值驱动。关于分页参数的命名我建议用page和pageSize而不是offset和limit因为 RESTful 接口里这两个词是行业通用约定后端同学看到就懂。page 从 1 开始而不是从 0 开始这个细节最好跟后端对齐避免边界不一致导致的重复加载或漏数据。3.2 核心代码完整的无限滚动列表页面接下来是核心部分我直接给出一个完整的页面实现代码里我会注释每个关键点。class InfiniteListPage extends StatefulWidget { const InfiniteListPage({super.key}); override StateInfiniteListPage createState() _InfiniteListPageState(); } class _InfiniteListPageState extends StateInfiniteListPage { final ScrollController _scrollController ScrollController(); final ListArticle _articles []; int _page 1; final int _pageSize 20; bool _hasMore true; bool _isLoadingMore false; override void initState() { super.initState(); _scrollController.addListener(_onScroll); _loadFirstPage(); } override void dispose() { _scrollController.dispose(); super.dispose(); } void _onScroll() { if (!_scrollController.hasClients) return; final position _scrollController.position; if (position.pixels position.maxScrollExtent - 200) { _loadMore(); } } Futurevoid _loadFirstPage() async { _page 1; _hasMore true; final data await ApiService.fetchArticles(_page, _pageSize); if (!mounted) return; setState(() { _articles ..clear() ..addAll(data); }); // 首屏数据不足一屏时继续加载下一页 WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) return; if (_scrollController.position.maxScrollExtent 0 _hasMore) { _loadMore(); } }); } Futurevoid _loadMore() async { if (_isLoadingMore || !_hasMore) return; setState(() _isLoadingMore true); final nextPage _page 1; try { final data await ApiService.fetchArticles(nextPage, _pageSize); if (!mounted) return; setState(() { _page nextPage; if (data.isEmpty) { _hasMore false; } else { _articles.addAll(data); } _isLoadingMore false; }); } catch (e) { if (!mounted) return; setState(() _isLoadingMore false); // 加载失败可在这里弹出提示或展示重试组件 } } Futurevoid _onRefresh() async { await _loadFirstPage(); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(无限滚动列表)), body: RefreshIndicator( onRefresh: _onRefresh, child: Scrollbar( controller: _scrollController, child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), itemCount: _articles.length 1, itemBuilder: (context, index) { if (index _articles.length) { return _buildFooter(); } return _buildArticleItem(_articles[index]); }, ), ), ), ); } Widget _buildFooter() { if (_isLoadingMore) { return const Padding( padding: EdgeInsets.all(16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } if (!_hasMore) { return const Padding( padding: EdgeInsets.all(16), child: Center( child: Text(已经到底啦明天再来看看吧), ), ); } return const SizedBox.shrink(); } Widget _buildArticleItem(Article article) { return ListTile( leading: CircleAvatar(child: Text(${article.id % 100})), title: Text( article.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( article.summary, maxLines: 2, overflow: TextOverflow.ellipsis, ), ); } }3.3 关键设计选择与代码细节解读有几个细节我必须要单独拎出来讲。为什么itemCount是_articles.length 1因为我在列表末尾追加了一个 footer 区域来展示加载状态。footer 区域是无限滚动的灵魂—加载中转圈、没有更多了显示提示文案、加载失败显示重试按钮都在这个位置呈现。如果不加这个 footer用户滑到底会像撞上一堵墙没有任何反馈体验很生硬。RefreshIndicator配合AlwaysScrollableScrollPhysics()实现下拉刷新。这里有个老生常谈的坑列表内容不满一屏时列表根本不能下拉RefreshIndicator 的交互就没办法触发。加上AlwaysScrollableScrollPhysics之后即使内容高度小于视口高度列表也始终可以下拉。这个参数是下拉刷新场景下的标配但确实很多人会漏掉我再强调一次。_loadMore里的nextPage _page 1有个微妙之处用户可能连续快速触发多次加载而_page只在网络请求返回时才更新。如果我把_page放在请求前就加 1那并发请求时页码就会错乱。所以我先算出nextPage请求成功后把_page赋值为nextPage请求失败则_page保持原值下次重试时还是请求同一页数据。这是分页加载最容易被忽略的细节。mounted检查出现在每个异步回调里。await之后组件可能已经被用户销毁了如果不检查mounted就直接setState会抛出异常。这个检查在 Flutter 异步编程中是一个铁律跟 Kotlin 协程里的isActive检查是一个道理。3.4 加载失败与底部状态处理网络请求不是每次都成功的真实环境里弱网、超时、后端 5xx 都是常态。我的_loadMore里 catch 了异常并把_isLoadingMore置回 false但只有这个处理还不够因为用户滑到底的时候 footer 区域会是一条空白没有重试入口体验很差。完整做法是再加一个_loadError状态字段footer 区域在错误状态下展示“加载失败点击重试”的按钮。这个按钮的点击回调里再次调用_loadMore()即可。bool _loadError false; Futurevoid _loadMore() async { if (_isLoadingMore || !_hasMore) return; setState(() { _isLoadingMore true; _loadError false; }); final nextPage _page 1; try { final data await ApiService.fetchArticles(nextPage, _pageSize); ... } catch (e) { setState(() { _isLoadingMore false; _loadError true; }); } }footer 区域在_loadError true时替换成一个TextButton文案是“加载失败点击重试”。点击时重新调用_loadMore。这一步做完无限滚动列表的基本闭环就完整了加载 → 失败 → 重试 → 成功 → 加载更多 → 到底。另外下拉刷新失败也是一种常见场景。RefreshIndicator的onRefresh如果抛异常Flutter 会在控制台打印错误。我的建议是_loadFirstPage内部不要向上抛异常而是 catch 住失败后在页面上方弹一个 SnackBar提示“刷新失败请检查网络”。刷新过程中原有的数据不要清空保持原样展示刷新成功才替换成新数据这样用户在弱网环境下不至于“刷一下列表就空了”。4. 常见问题与排查技巧实录无限滚动实际开发中遇到的问题网上能搜到很多但我挑出来的这几个是出现频率最高、影响最严重的我把排查思路和解决方案一起贴出来。4.1 _loadMore 被疯狂触发的并发问题症状是用户刚滑到底部网络请求在途还没返回这时日志里打印出四五次重复请求。原因就是_isLoadingMore的状态没有及时生效。setState之后状态会同步变更吗其实 Flutter 的setState会立刻更新字段值所以if (_isLoadingMore || !_hasMore) return; setState(() _isLoadingMore true);这个顺序是安全的请求发出前状态已经是 true 了后续的触发会被拦在门外。但如果哪一天你手一滑把顺序写成setState(() _isLoadingMore true); if (_isLoadingMore || !_hasMore) return;那就又变成先加锁再判断锁永远生效不了因为判断条件是判断加锁前的值。虽然这段代码看起来离谱但我在 code review 里真的见过这种写法。排查建议先在_loadMore入口处打日志看是否连续进入多次。如果是优先检查状态的赋值顺序和初始值。另一个并发隐患是RefreshIndicator的下拉刷新和_loadMore同时进行。用户在下拉刷新过程中列表滑到底两个请求并发执行返回的数据可能互相覆盖。我的解决方案是在_loadFirstPage的开始处把_hasMore置为 true然后下拉刷新过程中天然有_isLoadingMore作为互斥条件如果刷新和加载同时发生_loadMore会因为_isLoadingMore为 true 被拦下。当然你也可以引入Future链或者把请求串行化但对大多数业务来说一个布尔互斥锁已经够用了。4.2 数据不足一屏时永远触达不到加载我在前面已经提到了这个问题的解决方案这里从排查角度再补充一个验证方法你可以在_loadMore里打日志然后在一个宽屏设备上打开页面观察下有没有调用日志。如果首屏数据不足以撑满屏幕监听函数永远不会达到触发条件。这时候不要犹豫直接在addPostFrameCallback判断maxScrollExtent 0去补加载。这个问题的坑点在于它只在特定条件下出现平时数据多的时候根本复现不出来所以容易漏掉。我写代码的习惯是凡是可以触发加载的地方都加日志包括监听触发的、补加载触发的、点击重试触发的这样用户报 bug 时能根据日志快速定位加载链路走到了哪一步。4.3 列表滑动时滚动条跳动和页面抖动如果你用Scrollbar包了无限列表可能会发现加载更多数据后滚动条往回跳一下或者页面顶部轻微抖动。这个问题的根源是新数据加载后maxScrollExtent变大而ScrollPosition的偏移量是按像素算的如果列表项高度变化或者图片异步加载导致高度重排滚动位置会偏移。解决办法有几个方向。首先ListView.builder的 item 如果高度是固定的强烈建议给itemExtent设置固定值ListView.builder( itemExtent: 72, // 列表项固定高度 ... )这能显著减少布局抖动因为框架不需要在滚动过程中动态计算每个 item 的高度。其次图片加载导致的抖动可以用Image的frameBuilder或者固定图片宽高比来预留位置。我见过很多列表一卡一卡的案例最后定位到的原因就是图片没有预留占位空间网络图片加载后高度突然撑开把后面的内容全部挤下去了。解决办法很粗暴也有效图片加载完成前就占好一样大的格子。如果你用的是复杂 item高度无法固定那就需要引入RepaintBoundary来隔离重绘或者利用ListView.builder的prototypeItem参数指定一个样版 item 来预估高度。但这些属于高阶优化大多数场景下固定高度就够用了。4.4 列表闪回到顶部的诡异问题经常有朋友遇到这样的场景滑了很长的距离加载了好几页数据然后做了一次下拉刷新或者切出页面再切回来列表就闪回顶部了。这个问题的常见原因有几个。第一PageStorageKey缺失。ListView.builder默认的滚动位置是保存在页面里的但如果列表页面是在 Tab 间切换且没有给 ListView 设置 key切换 Tab 再返回时会重新创建 State滚动位置就丢了。解决方案很简单给 ListView 一个稳定的 keyListView.builder( key: const PageStorageKey(infinite_article_list), ... )第二下拉刷新时你对_articles做了clear()操作导致列表短时间内变成空列表滚动位置自然归零刷新完成后重新插回数据位置已经拉不回来了。解决思路是刷新时不要清空旧数据新数据回来后在setState里直接替换列表内容因为 ListView 是增量更新的位置会基本保持不变。第三页面被回收了。Flutter 的 State 是可能被框架销毁的比如页面被压入栈底时如果内存紧张它的 State 可能被销毁返回时重新创建所有列表数据需要重新拉取。这种情况没法完全规避但可以通过AutomaticKeepAliveClientMixin让列表页面在滑动视图中保持存活或者在 State 销毁前把数据缓存到内存里。4.5 热重载后崩溃ScrollController 未 attached开发调试时可能遇到这种情况你在代码里加了 debug 日志然后热重载结果页面立刻崩溃报错内容是ScrollController not attached to any scroll views。原因你肯定猜到了热重载时整个 widget 树重新构建但 scroll controller 可能没有正确关联到新的 scroll view。我自己的规避方案是写一个安全的获取 position 的工具方法所有使用 position 的地方都先检查hasClientsdouble? get _scrollExtent { if (!_scrollController.hasClients) return null; return _scrollController.position.maxScrollExtent; }把hasClients检查收敛到一个函数里不要在业务代码里散落一地。这既是防御性编程也是可维护性的问题。4.6 常见问题速查表我把上述问题和解决方案整理成一张速查表方便你以后直接对照排查。问题现象可能原因解决方案请求重复触发、日志多次进入状态锁未生效或顺序错误确保_isLoadingMore在请求前已置为 true进入函数先判断再加锁首屏数据填不满屏幕后续数据加载不了maxScrollExtent 0监听永远不触发首帧回调后检查 maxScrollExtent为 0 且还有数据时主动补加载下拉刷新失效列表无法下拉缺少AlwaysScrollableScrollPhysicsListView 设置physics: AlwaysScrollableScrollPhysics()页面抖动、滚动条跳动item 高度不固定图片加载撑开布局用itemExtent固定 item 高度图片预留占位尺寸切换 Tab 或返回页面后列表闪回顶部未保存滚动位置或刷新时 clear 了列表使用PageStorageKey刷新时先不清空旧数据异步回调里 setState 崩溃组件已销毁未检查 mounted每次 await 后判断if (!mounted) return;滑到底部加载失败没有提示缺少错误状态和重试入口增加_loadErrorfooter 展示重试按钮5. 无限滚动的进阶优化方向基础版能跑通之后有几个方向值得继续深化这算是我自己在项目中逐步完善的沉淀。数据量非常大的时候比如几千条甚至上万条建议在 item 上包裹RepaintBoundary。它的作用是隔离重绘区域让单个 item 的变化不会触发整个列表重绘。对图片加载比较多的列表尤其明显视觉效果就是滑动时其他 item 不再闪一下。另一个方向是缓存策略。无限滚动的列表滑出去后 item 会被销毁再次滑回来还要重新构建。如果 item 构建成本较高比如有复杂的布局、网络图片可以考虑引入cached_network_image这类网络图片缓存库。它能把已经加载过的图片缓存到本地用户来回滑动时直接走本地缓存不会闪白屏、不会重复请求。还有滚动位置的持久化保存。电商 App 的用户经常是逛半天看到的商品退出去再回来得回到上次位置。做法是把当前滚动偏移量存到本地存储比如 shared_preferences 或者 MMKV页面重新打开时通过ScrollController.initialScrollOffset恢复位置。这里有个注意点恢复位置时列表数据必须先加载出来否则直接设置 scroll offset 会报错或者定位失败。如果你觉得每次手写无限滚动太麻烦还可以考虑用现成的第三方库比如infinite_scroll_pagination。这个库把分页状态封装成了PagingController支持列表尾部加载、错误重试、首屏加载态等一套完整逻辑API 设计得也不错。我自己是建议先手写一遍理解原理再用库来提效因为库虽然方便但出了问题你如果完全不懂底层排查起来会非常痛苦。写到这里无限滚动组件的核心实现和常见问题就讲得差不多了。我自己这几年写列表最深的体会是无限滚动看起来是一行ListView.builder的事真正的复杂度全部藏在状态管理和边界条件的细节里。你可以在实现完“滑到底自动加载”之后的某一天仔细体验一下自己在手机上刷信息流的感受再看看你的代码是否也做到了同样丝滑的加载节奏那个时刻就是你对无限滚动的理解真正到位的时候。
返回列表