这几天在训练营里把Day1-3的基础内容理顺之后,手头这个 Demo 已经能跑通页面跳转、基础数据展示和简单的组件布局。Day4-6 的内容其实更贴近真实业务场景——给列表加上拉加载、下拉刷新和数据加载提示。这三个能力几乎是所有内容型应用的标配,不管你做的是资讯客户端、商品列表还是社交信息流,都躲不开这套交互闭环。而且这个训练营用的是 Flutter for OpenHarmony 这套跨平台方案,意味着同一套代码未来可以跑在多种设备上,省掉的重复开发量非常可观。
我先把 Day4-6 做的事说清楚:基于 Day3 已经搭好的列表页面,实现三件事——用户下拉列表时触发刷新并回到第一页;用户滚动到列表底部时自动加载下一页数据并追加;整个过程中给用户明确的状态反馈,包括首屏加载中、加载失败、没有更多数据、底部加载中这几种提示。看起来是三件事,其实是一个完整的分页加载状态机,处理不好就会出现重复请求、列表错乱、刷新后数据拼接出问题这些经典坑。
这篇内容我按照这几天的实操顺序来梳理,会把每一步为什么这么做、代码怎么写、坑在哪里都讲透。如果你已经完成了 Day3 的页面搭建,照着往下做应该很顺;如果你是刚接触 Flutter 的开发者,也能通过这套实现完整理解列表分页加载的通用套路,把它平移到其他项目里去复用。
1. 整体设计与思路拆解
1.1 为什么列表加载要做成“刷新 + 加载更多 + 状态提示”
先说说为什么 Day4-6 要集中做这三件事,而不是继续堆页面。
Day3 的列表是静态的,数据直接写在代码里,页面一打开就全部渲染。这种写法在演示阶段完全没问题,但拿到真实项目里就撑不住了。真实数据源通常来自接口,接口有两个天然限制:第一,网络有延迟,用户打开页面不可能瞬间拿到全部数据;第二,服务端不会一次性返回所有数据,尤其是列表这种高频场景,几千上万条数据一次性下发,流量和渲染性能都扛不住。
所以业界通用的做法就是分页加载:先请求第一页数据渲染首屏,用户往下滚动时再请求后面的数据追加到列表尾部。分页加载必然要解决三个问题:用户想重新拉取最新数据怎么办(下拉刷新);数据到底还有没有下一页(加载提示里的“没有更多”);每一页加载过程中的等待状态怎么表达(加载中提示)。这三件事是配套的,缺一个,体验就会断裂。
把这三个能力放在一个页面上实现,还有个额外的好处:它们的底层状态是关联的。比如刷新和加载更多都会发起网络请求,都需要一个“正在请求”的标志位来防止重复触发;刷新成功之后页码要复位到第一页,列表要整体替换而不是追加。把这些状态理清了,整个页面的数据流就彻底通顺了。
1.2 技术方案选型:为什么用 RefreshIndicator + ScrollController + 轻量状态管理
这几天的训练基于 Flutter for OpenHarmony,所以选型上有一个前提:优先使用 Flutter 官方组件和 Flutter 本身的机制,而不是引入太多第三方依赖。原因很实际——跨平台适配层对第三方库的兼容性是逐步完善的,依赖越少,踩到适配问题的概率越低。
下拉刷新直接用 Flutter 自带的 RefreshIndicator 组件,只要把列表包在里面,再给它一个返回 Future 的 onRefresh 回调就能工作。这个组件在 Flutter for OpenHarmony 的适配版本里是可以正常使用的,不用自己做手势识别,省掉了大量自绘逻辑。
上拉加载没有官方现成组件,通用做法是给 ScrollController 加监听,在滚动到底部附近时触发加载更多。这个方案的优点是可控性最强,阈值、触发条件都自己掌握,而且不依赖具体的列表组件,ListView、GridView、CustomScrollView 都能用。
状态管理方面,这个训练营不建议一上来就引入重量级框架。列表页本身的状态无外乎页码、列表数据、加载标志位这几个,用 StatefulWidget 配合 setState 就是最直观的方案。等以后页面复杂了再迁移到其他状态管理框架,迁移成本也不高,因为状态本身就是页面级的,没有跨页面共享的痛点。
1.3 分页数据模型设计
上拉加载和下拉刷新都离不开一个基础模型:分页。我用的分页模型很简单,两页参数加一个结束标记:
- page:当前请求的页码,从 0 或者 1 开始,每次加载成功后自增;
- pageSize:每页数量,固定值,我这个 Demo 里用了 10;
- hasMore:是否还有下一页,这个是 loading 提示里“没有更多了”的数据依据。
hasMore 这个字段很关键。很多新手只关注 page 和 pageSize,忽略了 hasMore,结果数据明明已经拉完了,下拉滚动还在不停地发请求。服务端接口如果没返回 hasMore,客户端也可以根据“本页返回条数小于 pageSize”来判断是否到底,但这需要约定好边界情况。最稳妥的做法是接口明确返回一个布尔值或者总条数,由服务端来判断后面还有没有数据,客户端只做消费。
2. 核心细节解析与实操要点
2.1 RefreshIndicator 的正确打开方式
RefreshIndicator 用起来有几个容易被忽略的细节,我先说最容易踩的:onRefresh 必须返回一个 Future,而且这个 Future 要在刷新动作真正完成时才结束。如果你在 onRefresh 里直接调了一个同步方法就 return,会出现下拉指示器闪一下就消失的情况,因为框架认为刷新已经结束了。
实际项目里刷新通常要请求网络,所以 onRefresh 写成 async 方法,内部 await 数据请求即可。请求结束后要做的两件事:把页码重置为第一页,把列表数据整体替换成第一次请求的结果。这里“替换”和“追加”的区别很重要——下拉刷新代表用户要的是最新数据,旧数据应该清掉,如果写成追加,列表会越来越长,数据也乱了。
另一个细节是 RefreshIndicator 默认只在列表内容不满一屏时也能触发下拉,但需要配合 AlwaysScrollableScrollPhysics 使用。因为列表内容太少时,Scrollable 默认不可滚动,下拉手势就没有载体。给 ListView 显式指定 physics: AlwaysScrollableScrollPhysics() 可以保证任何情况下都能下拉触发刷新。
2.2 ScrollController 监听与上拉加载的触发条件
上拉加载的原理不复杂:监听滚动位置,当滚动位置接近底部时触发加载方法。核心判断公式是:
滚动当前位置 + 预加载阈值 >= 最大滚动范围
用代码表达就是 controller.position.pixels >= controller.position.maxScrollExtent - threshold。threshold 是一个提前量,意思是还没滚到底部,但剩得不多了,就先触发加载,这样做能明显提升体验——用户看到的内容是连续加载出来的,而不是硬生生卡到最后一屏才等一下。
threshold 设多少合适?我测试下来 200 到 400 之间比较合理。太小了会等到完全到底才加载,用户能感知到停顿;太大了会在用户还没看多少内容时就疯狂加载,浪费流量。我一般取 300。
触发条件还需要搭配一个 isLoadingMore 标志位。这个标志位有多重要?你可以想象一下:用户快速滑动列表,一秒钟内 pixels 变化很大,监听回调会被调用很多次,如果没有标志位拦截,一次滚动触发五六次加载请求是很常见的。加上标志位之后,只有在上一次加载完成之后才能触发下一次,这样网络请求数量就完全受控了。
2.3 数据加载提示的完整状态划分
数据加载提示不是一句“加载中”就完事的,要分场景:
- 首屏加载中:页面刚打开、列表还没有任何数据时,在页面中间显示转圈,并伴随文案;
- 首屏加载失败:首次请求出错时不能只转圈,要给出错误信息和一个重试按钮,不然用户只能干瞪眼;
- 列表底部加载中:上拉加载更多时,在列表底部显示一个小转圈或“加载中”文字,提示用户还有内容在追加;
- 没有更多数据:已经最后一页了,底部显示“没有更多了”,同时最重要的——不能再触发加载请求了;
- 底部加载失败:加载更多失败了,底部显示“加载失败,点击重试”,用户点击后重新加载这一页。
这四种状态看着多,实现起来只需要一个 footer widget 根据当前状态做分支渲染。关键是状态之间要互斥,比如底部加载中时不能同时又触发首屏加载中的展示,否则界面会打架。
3. 实操过程与核心环节实现
3.1 数据层:模拟分页接口与数据模型
为了让训练营的同学能脱离后端独立运行 Demo,Day4-6 的数据层用了一个模拟实现:用一个 Repository 类模拟分页接口,内部用固定数据源加 Future.delayed 模拟网络延迟。这样写的好处是后续接入真实接口时,只需要替换 Repository 的实现,页面层的代码完全不用动。
先定义数据模型。列表项我沿用 Day3 的简化结构,只保留展示需要的最小字段:
class Article { final int id; final String title; final String summary; final int readCount; Article({ required this.id, required this.title, required this.summary, required this.readCount, }); }Repository 的模拟逻辑是这样的:根据传入的 page 和 pageSize 计算起始索引和结束索引,从总数据源里切分出一页数据返回;如果切分结果已经是最后一段,就把 hasMore 置为 false。为了方便观察刷新和加载的效果,模拟数据源里做了一个小设计——第一页数据里混入一些带“NEW”标记的标题,这样下拉刷新后能直观看到数据确实变了。
class ArticleRepository { static const _allData = [...]; // 生成一组可观察变化的模拟数据 Future<PagedResult<Article>> fetchArticles({ required int page, required int pageSize, }) async { await Future.delayed(const Duration(milliseconds: 800)); if (page >= 4) { return PagedResult(items: [], hasMore: false); } final start = page * pageSize; final end = (page + 1) * pageSize; final items = _allData.sublist( start, end > _allData.length ? _allData.length : end, ); return PagedResult(items: items, hasMore: end < _allData.length); } }PagedResult 是我定义的一个泛型包装类,包含 items 和 hasMore 两个字段。这个结构很朴素,但足够支撑整个分页流程。实际项目中如果用的是真实接口,这一层可以扩展出更多字段,比如总条数、游标、错误码等,Page 层的调用方式可以保持稳定。
3.2 页面状态与控制器搭建
页面的主体是一个 StatefulWidget,我给它起了个名字叫 ArticleListPage。状态字段包含以下几类:
- 数据相关:articles、page、hasMore;
- 加载标志:isInitialLoading(首屏加载中)、isLoadingMore(加载更多中)、isRefreshing(刷新中);
- 错误相关:initialError(首屏错误信息)、loadMoreError(加载更多失败标记)。
把状态拆细的好处是每个标志都对应一个明确职责,不会出现一个 bool 变量被到处复用导致状态混乱。isRefreshing 虽然 RefreshIndicator 自带转圈动画,但我在代码里还是留了这个字段,用于在刷新期间禁用上拉加载的触发,避免两个请求同时在跑。
ScrollController 在 initState 里创建并添加监听,在 dispose 里释放,这个习惯必须养成,否则页面退出后监听器还在,滚动时会回调已经被回收的对象,直接报错。
@override void initState() { super.initState(); _scrollController = ScrollController(); _scrollController.addListener(_onScroll); _loadInitialData(); } @override void dispose() { _scrollController.dispose(); super.dispose(); }3.3 下拉刷新的实现
刷新动作本身很简洁:重置页码、清空现有列表、拉取第一页数据。关键是拉取成功后要替换而不是追加,同时更新 hasMore。再看代码:
Future<void> _onRefresh() async { if (_isRefreshing) return; _isRefreshing = true; try { final result = await _repository.fetchArticles(page: 0, pageSize: _pageSize); if (!mounted) return; setState(() { _articles = result.items; _page = 1; // 下次加载更多从第 1 页开始 _hasMore = result.hasMore; _initialError = null; _isRefreshing = false; }); } catch (e) { if (!mounted) return; setState(() { _isRefreshing = false; // 刷新失败时保留原有数据,并给出 SnackBar 提示 }); } }刷新失败的处理我特别说一下。刷新失败不能把用户已有的数据清掉,否则列表变成空白,用户会以为内容没了。保留旧数据,在 SnackBar 里提示“刷新失败,请检查网络”,这已经是成熟产品的标准做法。
3.4 上拉加载的实现
上拉加载的触发入口是 ScrollController 的监听回调,我用一个单独方法处理:
void _onScroll() { if (!_scrollController.hasClients) return; final position = _scrollController.position; final threshold = 300.0; if (position.pixels >= position.maxScrollExtent - threshold) { _loadMore(); } }_loadMore 里要做三道防线:
- 如果没有更多数据了,直接 return;
- 如果正在加载更多,直接 return;
- 如果在刷新期间,直接 return。
Future<void> _loadMore() async { if (!_hasMore || _isLoadingMore || _isRefreshing) return; _isLoadingMore = true; setState(() {}); // 让底部 footer 变为加载中状态 try { final result = await _repository.fetchArticles(page: _page, pageSize: _pageSize); if (!mounted) return; setState(() { _articles.addAll(result.items); _hasMore = result.hasMore; _page++; _isLoadingMore = false; }); } catch (e) { if (!mounted) return; setState(() { _isLoadingMore = false; _loadMoreFailed = true; }); } }注意 _page 的自增时机。我是在请求成功之后才 _page++,不是在发起请求之前。这样如果请求失败,页码不会前进,下次重试加载的仍然是当前这一页,不会出现跳页丢数据的问题。
3.5 首屏加载、错误与空态展示
页面主体我用了一个简单的条件渲染结构:
- 首屏加载中:居中转圈 + 三四条灰色占位块,比纯转圈更有内容感;
- 首屏加载失败:居中的错误图标、错误文案、重试按钮;
- 首屏加载成功但列表为空:居中空态文案,提示用户暂时没有数据;
- 首屏加载成功且有数据:RefreshIndicator 包着的 ListView.builder。
核心代码结构如下:
Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('开源鸿蒙跨平台列表')), body: _buildBody(), ); } Widget _buildBody() { if (_isInitialLoading) { return const _InitialLoadingView(); } if (_initialError != null) { return _ErrorView( message: _initialError!, onRetry: _loadInitialData, ); } if (_articles.isEmpty) { return const _EmptyView(); } return RefreshIndicator( onRefresh: _onRefresh, child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), itemCount: _articles.length + 1, itemBuilder: (context, index) { if (index < _articles.length) { return _ArticleCard(article: _articles[index]); } return _buildFooter(); }, ), ); }itemCount 里多出来的 1 就是留给底部 footer 的位置。footer 根据当前状态渲染“加载中”“没有更多了”“加载失败点击重试”“加载更多”等不同内容,这也是上拉加载体验的最后一块拼图。我在 footer 的加载失败分支里加了点击重试的 GestureDetector,调用 _loadMore 重新请求当前页。
3.6 完整运行效果
写完后我在模拟器和真机设备上各跑了一遍。下拉刷新的交互是:手指下拉到一定距离,出现标准的下拉指示器,松手后指示器保持转动,等接口返回后收起,列表数据更新。因为模拟接口延迟设了 800 毫秒,整个过程体验刚好能感知到的程度。
上拉加载的效果是:滚动接近底部时 footer 从“上拉加载更多”变为转圈状态,约 800 毫秒后新数据追加到列表末尾。当所有模拟数据加载完毕,footer 固定显示“没有更多了”,并且不再发起新请求。整个交互状态是连贯的,不会出现按钮闪烁、重复加载。
4. 常见问题与排查技巧实录
4.1 下拉刷新没反应
这是训练营里问得最多的一个问题。页面确实包了 RefreshIndicator,onRefresh 也写了,但怎么下拉都不出指示器。排查方向就两个。
第一,检查 ListView 的 physics 属性。如果列表内容不足以撑满一屏,默认 physics 下这个 Scrollable 根本不能滚动,下拉手势不会触发刷新。加 AlwaysScrollableScrollPhysics 就解决了。
第二,确认 RefreshIndicator 是不是直接包裹在 Scrollable 组件外面。如果你在 RefreshIndicator 和 ListView 之间还隔了一层 Column、Container,指示器可能无法正确监听滚动事件。保持 RefreshIndicator 的 child 直接是 ListView 或 CustomScrollView,是最稳妥的结构。
4.2 上拉加载触发两次
这个问题的根源在于标志位没生效。ScrollListener 在底部边界附近连续回调,如果你只判断滚动位置,不判断 isLoadingMore,一次滚动就会触发多次 _loadMore,发好几个重复请求。
我的处理是在 _loadMore 开头做三重拦截,上面代码已经写了。但还有一个小细节容易被忽略:_loadMore 里 await 结束之后,setState 把 _isLoadingMore 置为 false,这个状态变更会导致 ListView 重建 footer,重建后的 ListView 又会产生滚动位置变化,可能再次触发监听回调。如果此时恰好还在底部区域,就会立刻发起下一次加载。这个现象在某些设备上会出现,看起来像是“刚加载完又加载了”。
要规避它,可以在 _loadMore 的尾部加一个滚动位置兜底修正:当 ListView 追加数据后,虽然像素位置不变,但 maxScrollExtent 变大了,原来“贴近底部”的位置可能不再贴近新底部,理论上不会再次触发。但如果 footer 高度变化明显,仍可能出现边界抖动。我测试下来,只要加载中的 footer 高度和加载完成的 footer 高度保持一致,就不会有这个问题。所以统一 footer 高度,是最省事的办法。
4.3 刷新后数据错乱或重复
训练营里还有人遇到这种问题:下拉刷新之后,列表里既有新数据又有旧数据,而且页码也对不上了。这个几乎都是刷新成功后的数据合并方式错了——用了 addAll 而不是重新赋值。
我的代码里刷新是 _articles = result.items,直接替换整个列表。如果你写的是 _articles.addAll(result.items),刷新就是追加,旧数据和新数据混在一起,页数也会继续累加,整个分页状态就崩了。
另外还有一个隐蔽的问题:刷新请求发出后,用户又快速触发了加载更多,此时两个请求同时在飞。刷新结果返回时先把列表重置成了第一页数据,但加载更多的旧请求随后返回,把第二页数据 addAll 进来,列表就变成“第一页 + 之前某一页”的错乱组合。我在代码里用 _isRefreshing 拦截了刷新期间的上拉加载,就是为了挡掉这种竞态。真实项目里如果请求更快、场景更复杂,建议给每次请求加一个自增的 requestId,响应返回时对比 requestId,旧请求直接丢弃。
4.4 OpenHarmony 适配相关的问题
Flutter for OpenHarmony 的适配已经比较成熟,但毕竟不是所有组件都经过同等强度的测试,我这几天的实际体验有几个值得注意的点。
RefreshIndicator 在部分 OpenHarmony 设备上,指示器的动画帧率略低于 Android 真机,下拉跟手性稍差,但功能正常。代码层面没有需要改的地方,主要是心理预期要调整,不要一上来就怀疑组件坏了。
ListView 在深色模式下如果用了默认的 Material 背景色,会和 OpenHarmony 系统的深色主题不太协调。建议给页面显式设置 theme 的 scaffoldBackgroundColor 和 cardColor,避免出现一块白一块灰的割裂感。
字体方面,OpenHarmony 默认字体对中文显示没问题,但数字和英文的宽度控制上,和标准 Flutter 测试环境有一些细微差异,这对列表布局影响不大,但如果做富文本对齐之类的精细效果,需要注意预留宽度。
4.5 调试工具怎么用
Flutter for OpenHarmony 的调试基本沿用了 Flutter 的标准工具链。我推荐两个调试手段。
第一,监听滚动数值。可以用 debugPrint 把 position.pixels 和 maxScrollExtent 打出来,快速确认是不是阈值问题。第二,检查请求次数。在 Repository 的 fetchArticles 里加一个静态计数器,每次调用打印页码,这样能直观看到下拉刷新和上拉加载各自发了几次请求。
这两个手段比直接猜问题高效得多,训练营里遇到困扰的同学,我都是先让他们加打印日志,问题基本能定位到是逻辑问题还是组件问题。
5. 扩展思路与实用建议
5.1 从模拟数据切换到真实接口
Day4-6 用的 Repository 是模拟实现,接真实接口时只需要做两件事。第一,把 fetchArticles 里的 Future.delayed 换成真实的网络请求,返回的 JSON 解析成 PagedResult。第二,确认接口的分页参数名和返回结构,组装请求参数。
这个 Repository 隔离设计从一开始就考虑到了后续替换,所以页面层完全感知不到数据源的变化。我在其他项目里也是这么干的——数据源永远在页面代码之外,页面只认抽象的数据模型。
5.2 性能优化:列表项复用的注意点
ListView.builder 本身就是懒加载模式,只要不在 itemBuilder 里做重度计算,性能一般没问题。真正容易翻车的地方是 itemBuilder 里构建卡片时创建了太多临时对象。
我给 _ArticleCard 的字段都是 final,卡片本身是 const 构造的,这样 Flutter 在列表滚动时能极大减少重建的开销。另外如果卡片的图片是从网络加载的,建议配合缓存组件管理图片缓存,避免滚动时反复请求图片资源。
5.3 骨架屏与首屏体验
目前首屏加载是一个转圈加占位块。如果想要更精致的体验,可以换成骨架屏组件——用灰色色块按卡片的布局画一遍,加载完成后替换成真实卡片。这个方案在 Flutter 里实现不算复杂,就是一个根据加载状态切换的 widget。训练营里没做这部分是因为骨架屏不是 Day4-6 的核心目标,但如果你想往产品级靠,强烈建议加上。
5.4 上拉加载的两种触发方式
我这套实现是“滚动接近底部自动加载”,适合大多数信息流场景。还有一种方式是“点击加载更多”——footer 显示一个按钮,用户点击后才加载。两种方式各有适用场景。
内容密度高、用户浏览速度快的场景适合自动加载;本身内容量大、但用户通常是在特定位置停留阅读的场景,手动触发反而更能避免不必要的流量消耗。我在生产里两种都写过,可以根据业务属性做切换,代码层面只需要在 footer 里加一个 onTap 回调,复用的还是同一个 _loadMore。
5.5 结合 OpenHarmony 多设备适配
最后说一个 Flutter for OpenHarmony 特有的优势:一套代码可以跑手机、平板、电视等多种形态。列表页在三件套(刷新、加载、提示)这套逻辑上是完全通用的,但展示层可以做差异化——手机上卡片窄一些,平板上可以做多列网格。
实现方式很简单:根据 MediaQuery 的宽度动态切换 ListView 和 GridView,分页逻辑完全复用。这也是训练营 Day4-6 的价值所在,核心的加载状态机跑通之后,后面的多设备适配就顺理成章了。
我在实际跑这个 Demo 的过程中,最大的感受是:分页加载的三个能力,单独拆开每一个都不难,难的是把它们的边界和状态理顺。页码计数、hasMore 判断、请求竞态、状态互斥,这些细节处理得好,列表页就稳了;处理不好,后面接真实接口时会反复被同一个问题折腾。这一套逻辑如果你在训练营里彻底吃透了,以后换任何技术栈写列表,思考路径都是一样的。