训练营到Day3结束时,我用Flutter for OpenHarmony跑通了一个最简单的清单页面:ListView铺开一堆本地JSON模拟数据,能滚动,点击也能进详情详情页。但说实话,这个页面离“能交付”还差得挺远——用户把列表滑到底,它不会自动去拿下一页;抬手往下拉,它也不会重新请求最新数据;加载过程中页面上一点反馈都没有,白屏等了半天还不知道发生了什么。Day4到Day6要解决的,就是把这几个缺口一个一个补上:让清单具备上拉加载更多、下拉刷新和数据加载提示能力。
这篇文章按我实际动手的顺序来写,不列大纲式的概念,只讲我踩过的坑和最终采用的写法。里面会讲清楚每个状态位为什么存在、滚动触发阈值为什么设在200、刷新和加载更多并发时为什么必须用一个递增序号去丢弃过期响应,以及最后在OpenHarmony真机上验证时暴露出来的三个问题。如果你已经完成了Day1-3的环境搭建和基础列表开发,这篇可以直接跟着做;即便你暂时没碰过开源鸿蒙,把本文当成一篇普通的Flutter分页加载实战笔记来看,思路也完全通用。
1. Day3基线复盘:能在真机上跑通,但离“能用”还差三种能力
1.1 基线代码长什么样
Day3结束时,训练营里大部分人的列表页代码长这样:
class _TodoListPageState extends State<TodoListPage> { final List<TodoItem> _items = []; @override void initState() { super.initState(); _loadOnce(); } Future<void> _loadOnce() async { await Future.delayed(const Duration(milliseconds: 500)); setState(() { _items.addAll(mockItems); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('清单')), body: ListView.builder( itemCount: _items.length, itemBuilder: (context, index) => TodoItemCard(item: _items[index]), ), ); } }这段代码在Day3能跑,是因为Day3只需要证明一件事:Flutter UI可以正常跑在开源鸿蒙设备上。但到了Day4-6,如果直接在它上面叠下拉刷新和上拉加载,你会很快发现问题:数据源只有一坨不分页的数组,没有页码、没有hasMore,你根本不知道该在哪一页停下来;所有状态都靠setState硬刷,刷新中和加载更多同时发生时,后返回的响应会把先返回的数据直接覆盖。
所以Day4开工前,我先把基线代码做了重构。不是推翻重写,而是把它从“一次性加载数据的静态列表”改成“按页取数、状态显式管理的动态列表”,后面所有工作都建立在这个结构上。
1.2 改造之前先定下四条数据约定
为了避免写到后面状态打架,我在动手前先定了四条约定,后面所有代码都围着它们转:
- 分页参数统一:
page从1开始,pageSize固定为20。模拟接口和真实接口都用这套参数。 - 接口返回结构统一:用
PageResult<T>,包含items和hasMore两个字段。hasMore表示服务端是否还有下一页,不靠前端自己猜。 - 只有请求成功才推进页码:加载第N页失败了,页码仍然停留在N,重试时继续请求N,不会漏数据也不会重复数据。
- 尾巴不是数据:列表底部那些“正在加载”“没有更多了”“失败重试”,不应该塞进
_items数组里,而是用一个独立的footer类型来控制。
这些约定看着简单,但实际很管用。尤其是页码只在成功后自增这条,我见过不少人把_page++写在请求发出前,结果失败重试的时候直接跳过了失败的那一页,数据凭空少了一条。
1.3 准备一个可替换的Mock分页仓库
Day3用的是本地一次性Mock数据,Day4开始我把它改成一个带分页语义的仓库,方便后面反复测试刷新和加载:
class TodoRepository { Future<PageResult<TodoItem>> fetchPage({ int page = 1, int pageSize = 20, }) async { await Future.delayed(const Duration(milliseconds: 800)); final start = (page - 1) * pageSize; final items = List.generate( pageSize, (i) => TodoItem(id: start + i, title: '待办 #${start + i + 1}'), ); final hasMore = start + pageSize < 120; return PageResult(items: items, hasMore: hasMore); } }这里总共模拟120条数据,也就是6页。把Future.delayed替换成http请求,PageResult结构不用变,就能直接接真实接口。要注意一点:如果后端没有直接返回hasMore,你可以根据items.length < pageSize来推导,但这个推导成立的前提是后端每页固定返回pageSize条,并且只有最后一页才可能不满。真实项目里如果后端做了过滤或去重,返回条数可能不固定,这种推导就不可靠,最好让后端把hasMore带出来。
2. 下拉刷新:RefreshIndicator的触发逻辑并不只是“包一层”
2.1 包裹RefreshIndicator前要确认的三件事
下拉刷新最容易犯的错,就是以为把列表塞进RefreshIndicator就完事了。它表面上是个包裹组件,实际内部是通过监听Scrollable的滚动通知来识别用户手势的。所以动手前,先确认下面三件事。
第一,child必须是一个真正能滚动的组件。ListView、CustomScrollView、SingleChildScrollView都可以,但不能是一个不怎么滚得动的普通Column。如果列表内容不满一屏,默认的ListView physics是不允许下拉的,RefreshIndicator压根收不到手势。解决办法是给ListView显式设置:
physics: const AlwaysScrollableScrollPhysics(),第二,onRefresh必须返回Future,而且这个Future要在数据真正加载完以后再结束。因为RefreshIndicator的转圈动画是跟着Future走的,Future结束它就把圈收回去。如果只在回调里setState后立刻返回,你会看到顶部那个圈“闪一下就没了”,体验很假。
第三,在OpenHarmony上,Flutter的Material默认主题色不一定是你们App想要的品牌色。我建议显式指定一下额色和背景色,不要依赖默认值:
RefreshIndicator( edgeOffset: 0, displacement: 48, color: const Color(0xFF007DFF), backgroundColor: Colors.white, onRefresh: _onRefresh, child: _buildList(), )如果你的页面用了NestedScrollView,还要注意滚动通知的深度问题。RefreshIndicator默认只接住深度为0的滚动通知,嵌套滚动时可能需要设置notificationPredicate: (notification) => notification.depth == 0,否则手势会被内层列表吃掉,下拉始终不触发。
2.2 onRefresh只做“拉第一页”这件事
很多第一次写下拉刷新的人,会把onRefresh里写得很重:又要清空列表、又要重新拉数据、还要顺便重置几个状态。我的建议是,它的职责只有一件:请求第一页,用结果替换当前列表。代码尽量干净:
Future<void> _onRefresh() async { if (_loadingMore) return; _refreshing = true; final serial = ++_requestSerial; try { final result = await _repository.fetchPage(page: 1, pageSize: _pageSize); if (serial != _requestSerial) return; setState(() { _page = 1; _items ..clear() ..addAll(result.items); _hasMore = result.hasMore; }); } catch (e) { if (serial != _requestSerial) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('刷新失败,稍后再试')), ); } finally { if (serial == _requestSerial) _refreshing = false; } }这里有两个关键点。第一个是开头的if (_loadingMore) return:如果用户正在往上加载更多,下拉刷新这个动作会被直接忽略,避免刷新请求和加载更多请求并发。第二个是_requestSerial这个递增序号,下面单独说。
为什么要用递增序号而不是一个简单的bool isLoading?因为bool只能表达“有没有请求在跑”,表达不了“哪个请求是最新的”。当刷新和加载更多几乎同时发出,后发出的请求可能先返回,先发出的旧请求后返回,如果没有序号校验,旧响应会把新数据覆盖掉。递增序号的做法是:每次发起新请求就++_requestSerial,响应回来先对比序号,发现不是最新就直接丢弃。
2.3 刷新失败不清空旧数据,和首屏失败区别对待
下拉刷新的语义是“尝试更新”,不是“推倒重建”。所以我的原则是:刷新失败时,旧数据保留,列表照常显示,只弹一个SnackBar提示。这样用户至少还有内容可看,不会因为一次网络抖动就面对一个空白页。
真正需要走全屏失败态的,只有首屏第一次加载失败。这两种失败处理方式必须分开。有些项目图省事,把首屏失败和刷新失败统一成一个错误页,结果就是用户下拉刷新失败后列表整个消失,体验非常糟糕。
我把这两种场景做了一个对比:
| 场景 | 数据保留 | 提示方式 | | 首屏加载失败 | 无数据可保留 | 全屏错误页 + 重试按钮 | | 下拉刷新失败 | 保留旧列表 | 不打断浏览,SnackBar轻提示 |
如果你希望刷新失败后给用户更强烈的感知,可以连续失败几次后再弹对话框,但至少不要动列表本身。把这条原则写进代码里,后面就不会乱。
3. 上拉加载更多:从滚动监听开始的状态推进
3.1 别用pixels等于maxScrollExtent做触发条件
上拉加载最经典的错误写法,是用position.pixels == position.maxScrollExtent判断“滚到底了”,然后触发加载。这个写法有两个明显的坑。
第一个坑:当列表内容不满一屏时,maxScrollExtent等于0,pixels也等于0,条件成立,首屏就会莫名其妙触发一次加载更多。你可能用hasMore拦住了,但这不是一个健康的逻辑。第二个坑:用户快速滑到底部后,惯性结束后pixels和maxScrollExtent短暂相等,触发加载;setState之后列表高度变化,滚动位置可能又离开底部,用户再往上回滑一点再往下滑,条件又成立,就会重复触发。
我推荐的写法是用“距底部还有多少距离”作为触发条件:
void _onScroll() { if (!_hasMore || _loadingMore || _refreshing || _initialLoading) return; if (_scrollController.position.extentAfter < 200) { _loadMore(); } }extentAfter就是滚动位置距离底部的剩余像素,语义一眼就能看懂。阈值选200是我个人比较习惯的值,意思是用户滚到还差200像素到列表尾部时,就开始加载下一页。这个值太小,用户会明显感觉到“到底了才突然加载”,有一瞬间的空窗卡顿;太大,用户还没有主动想往下看,后台就开始拉数据,体验也没必要。一般100到300之间都可以,根据列表项高度和网络速度调整。列表项比较高的时候,我会适当调大到250左右。
3.2 _loadMore的完整生命周期与防抖
加载更多的完整逻辑,我写成下面这样:
Future<void> _loadMore() async { if (_loadingMore || !_hasMore || _refreshing) return; final serial = ++_requestSerial; setState(() { _loadingMore = true; _loadMoreFailed = false; }); final nextPage = _page + 1; try { final result = await _repository.fetchPage(page: nextPage, pageSize: _pageSize); if (serial != _requestSerial || !mounted) return; setState(() { _page = nextPage; _items.addAll(result.items); _hasMore = result.hasMore; _loadingMore = false; }); } catch (_) { if (serial != _requestSerial || !mounted) return; setState(() { _loadingMore = false; _loadMoreFailed = true; }); } }这里有几个细节值得展开说。
第一,nextPage在请求发出前就算好,但_page只在请求成功后才更新。失败时_page保持不变,尾部显示“加载失败,点击重试”,点击重试时继续请求同一个nextPage,不会跳过数据。
第二,返回值里的serial != _requestSerial必须判断。前面提到了刷新和加载更多可能并发,这里再具体一点:假设当前列表在第2页,用户快速下拉刷新,同时底部又触发了第3页加载。两个请求几乎同时发出,接口响应时间不同,如果第3页的响应比刷新响应晚回来,它会把第3页的数据追加到一个已经被刷新为第1页的列表后面,最终呈现的是第1页和第3页数据混排。加了序号校验,晚回来的旧响应就会被丢弃。
第三,setState里把_loadingMore置回false,这一步不要漏。很多人在异常分支忘了复位,导致footer永远停留在“正在加载”,后续滚动再怎么触发都被_loadingMore拦截,列表彻底瘫痪。
3.3 footer类型用枚举管起来,itemCount才不会乱
列表尾部的展示,我建议用一个枚举来统一管理,而不是用几个bool去组合判断。
enum ListFooter { none, loading, noMore, failed } ListFooter _footerFor() { if (_loadMoreFailed) return ListFooter.failed; if (_hasMore && _loadingMore) return ListFooter.loading; if (!_hasMore && _items.isNotEmpty) return ListFooter.noMore; return ListFooter.none; }为什么不用_hasMore == false直接推断“没有更多了”?因为你要区分“到底了”和“加载失败了”。如果直接把失败当成没有更多,用户会以为数据真的到底了,实际上只是请求没成功,这是很常见的信息误导。用枚举把三种状态分开,每一件事的语义都清清楚楚。
再配合itemCount的写法:
Widget _buildList() { final footerCount = _footerFor() == ListFooter.none ? 0 : 1; return ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), itemCount: _items.length + footerCount, itemBuilder: (context, index) { if (index < _items.length) return _buildItem(_items[index]); return _buildFooter(); }, ); }这里有一个很关键的细节:当_hasMore为true但用户还没有滚动触发加载时,_footerFor()返回none,itemCount不加1,列表底部不会有任何空白占位。一旦滚动触发调用_loadMore,_loadingMore变成true,footer变成loading,itemCount加1,尾部出现加载中的小圈和文字。加载完成后,如果hasMore仍然为true且loadingMore为false,footer又回到none,列表尾部那个加载节点就消失了。
很多人在这一步写错的原因,是把footer当成了ListView的一个静态尾部组件,放在itemBuilder外面。在ListView.builder里这样做,footer会被当成一个固定child,不仅无法随滚动回收,数据量大的时候还容易引起布局抖动。写成itemBuilder里的一个分支,由itemCount决定是否存在,才是正确的做法。
4. 数据加载提示:首屏、刷新中、加载更多、没更多、失败、空态
4.1 首屏loading用全屏圈还是骨架屏
首屏加载是用户对页面的第一印象,处理方式通常有两种。
方案A是全屏转圈,实现最简单,一个Center包一个CircularProgressIndicator就完事。方案B是骨架屏,在数据还没回来的几百毫秒内,先用灰底圆角块把列表轮廓画出来,给用户一种“页面已经在生长”的感觉。从体验上讲,骨架屏明显更好,尤其是网络稍慢的时候,用户至少能感知页面的结构,不至于盯着空白发呆。
但骨架屏要不要引入第三方shimmer库?我的建议是暂时不要。在OpenHarmony上,第三方动画库的兼容性需要额外验证,训练营期间为了一个小动画去排查平台适配问题,性价比不高。想用骨架屏,自己写一个灰块加透明度渐变的动画就够了,几十行代码,不依赖外部包。
对比来看:
| 方案 | 实现成本 | 视觉体验 | 适用场景 | | 全屏loading | 低 | 中等 | 数据量小、请求快的页面 | | 轻量骨架屏 | 中 | 明显更好 | 内容型列表页 |
4.2 首屏失败态要支持整页重试
首屏失败态和尾部失败态是不同的东西。首屏失败时列表还没有任何数据,你不可能只显示一个尾部的重试入口,必须给出整页的可操作反馈。我的做法是:
if (_initialFailed) { return CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverFillRemaining( hasScrollBody: false, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.wifi_off, size: 48), const SizedBox(height: 12), const Text('加载失败,请检查网络后重试'), const SizedBox(height: 12), ElevatedButton( onPressed: _retryFirstPage, child: const Text('重新加载'), ), ], ), ), ), ], ); }这里用CustomScrollView而不是纯Column,原因是保留下拉手势的能力。首屏失败页外面还包着RefreshIndicator,如果内部不能滚动,用户没法通过下拉重试。用SliverFillRemaining撑满全屏,既能让内容居中,又能让滚动区域始终存在,下拉刷新重试的通道就保住了。重试按钮和下拉动作,两条路都能重新加载第一页。
4.3 列表尾部的“加载中/没有更多/失败重试”三件套
列表尾部组件实现起来不难,但有几个细节影响最终观感。我的_buildFooter大概是这样的:
Widget _buildFooter() { switch (_footerFor()) { case ListFooter.loading: return const SizedBox( height: 56, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 8), Text('正在加载更多...'), ], ), ); case ListFooter.failed: return SizedBox( height: 56, child: InkWell( onTap: _loadMore, child: const Center(child: Text('加载失败,点击重试')), ), ); case ListFooter.noMore: return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center(child: Text('—— 没有更多了 ——')), ); case ListFooter.none: return const SizedBox.shrink(); } }这里要强调几个实践体验。第一,加载中的小圈不要用默认尺寸,默认那种大圈放在列表尾部显得很重,我一般用16x16的SizedBox包一个strokeWidth: 2的小圈,配合一行文字,视觉上轻很多。第二,footer给一个固定高度,比如56,这样加载更多开始和结束时,列表尾部的高度不会来回跳,用户几乎感知不到布局变化。第三,失败重试要整行可点,不要只让文字可点。把InkWell铺满整个56高度,点击区域大,触发才顺手。
4.4 一张状态矩阵把六种页面形态对齐
把前面所有状态整理成一张矩阵,很多组合上的问题一眼就能看出漏洞。
| 页面形态 | 触发时机 | 主体表现 | 底部/额外操作 | | 首屏加载中 | 进入页面并发起第一页请求 | 全屏loading或骨架屏 | 无 | | 首屏失败 | 第一页请求失败且列表为空 | 全屏错误提示 | 点击重试按钮,也可下拉重试 | | 正常列表 | 第一页加载成功 | 正常列表数据 | 无 | | 刷新中 | 用户下拉触发 | RefreshIndicator自带loading | RefreshIndicator接管 | | 加载更多中 | 滚动接近底部且hasMore为true | 列表尾部小圈加文字 | 无 | | 加载更多失败 | 下一页请求失败 | 列表尾部“失败重试” | 点击尾部重试 | | 没有更多 | hasMore为false后再次滚动 | 尾部“没有更多了” | 无 | | 空数据 | 第一页成功但items为空 | 居中空态图加文案 | 无 |
这张表最重要的是防止两个误判:一个是“加载更多失败”不等于“没有更多”,失败要能重试;另一个是“空数据”不等于“首屏失败”,不要因为一条数据都没有,就弹一个加载失败的页面出来。把这些页面形态明确下来之后,代码里每个分支都有对应的呈现,不会出现用户面对空白页不知道发生了什么的情况。
5. 在OpenHarmony真机上冒出来的三个实际问题
5.1 内容不满一屏时下拉完全没反应
第一个问题是我在模拟器上没发现,换到真机才暴露的:页面只有五六条数据,列表根本不满一屏,这时候下拉刷新怎么拉都没有反应。原因前面提过,RefreshIndicator要触发下拉,前提是内部Scrollable存在可滚动的空间。内容不满一屏时,ListView自带physics不允许向下回拉,手势全部被吞掉。
解决办法就是给所有需要下拉刷新的列表显式加上:
physics: const AlwaysScrollableScrollPhysics(),注意不只是ListView.builder,如果你用了CustomScrollView,同样要加。用了NestedScrollView时,因为它是双层滚动结构,除了给它本身设置,内层列表也要跟着设置。这个字段在Day4-6的实现里几乎处处都要用,我习惯一开始就写成常量,避免后面每个页面漏加。
5.2 滚动到底之后连续触发加载下一页
第二个问题是触发频率。有学员反馈:快速滚动到底部后,列表一下加载了好几页,看起来像“停不下来”。这个现象有两层原因。
第一层是滚动监听的多次触发。用户快速滑动时,extentAfter可能在短时间内连续几次小于200,每一次都会调用_loadMore。如果没有_loadingMore这个锁,就会发出多个重复的下一页请求。我代码里的防抖条件if (_loadingMore) return正是解决这个问题。
第二层是加载完成后的连带触发。加载更多成功时,setState让列表长度增加,但滚动位置还在原来的地方,距离新底部可能仍然小于200,于是又立即触发下一页。这一层其实不算Bug,它保证了用户一直在底部附近时能连续翻页。如果你不希望一次滚动就加载完所有数据,可以限制每次滚动最多加载两页,或者把pageSize调大一点。但需要先确认加载卡顿时到底是哪一层引起的,否则会误调参数。
调试时我建议在_loadMore入口打印当前页和extentAfter:
void _onScroll() { // 临时日志 debugPrint('extentAfter=${_scrollController.position.extentAfter}, ' 'page=$_page, hasMore=$_hasMore, loadingMore=$_loadingMore'); ... }日志打出来,重复触发是锁的问题还是阈值的问题,一目了然。
5.3 debug包和release包的滚动性能差异不能忽略
这第三个问题其实最影响最终判断。我在真机上用debug包调试到第4页、快200条数据时,滚动明显有点顿,第一反应是代码有问题。后来打了一个release包装上,同样的操作流畅很多。Flutter在OpenHarmony上的debug模式有很多额外检查,性能开销比标准Flutter的debug还要明显,所以调滚动体验一定要以release包为准。
如果列表项高度统一,另一个能明显提升滚动性能的参数是itemExtent:
ListView.builder( itemExtent: 72, ... )itemExtent的作用是告诉ListView每一项的高度都是72,列表就不需要逐个测量子项,布局计算量大幅减少。但要注意,这个参数只对“所有item高度完全一致”的情况适用。列表项高度不固定时,用了itemExtent会导致内容被裁剪或间隙错乱。那种情况可以考虑prototypeItem,传一个样例item Widget,让ListView按照样例来估算其他item的高度,也能减少一定布局成本。
6. 把Day4-6的成果串成一个可复用的清单页模板
6.1 State类骨架与生命周期
到这里,我把前面所有的讨论收敛到一个完整的State类骨架。这段代码包含了当前页所有关键状态、三个核心方法(首屏、刷新、加载更多)、footer枚举判断和列表构建方式。
class _TodoListPageState extends State<TodoListPage> { final _scrollController = ScrollController(); final _repository = TodoRepository(); static const _pageSize = 20; final List<TodoItem> _items = []; int _page = 1; bool _hasMore = true; bool _initialLoading = true; bool _initialFailed = false; bool _refreshing = false; bool _loadingMore = false; bool _loadMoreFailed = false; int _requestSerial = 0; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _loadFirstPage(); } @override void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } Future<void> _loadFirstPage() async { final serial = ++_requestSerial; setState(() { _initialLoading = true; _initialFailed = false; }); try { final result = await _repository.fetchPage(page: 1, pageSize: _pageSize); if (serial != _requestSerial || !mounted) return; setState(() { _items ..clear() ..addAll(result.items); _page = 1; _hasMore = result.hasMore; _initialLoading = false; }); } catch (_) { if (serial != _requestSerial || !mounted) return; setState(() { _initialLoading = false; _initialFailed = true; }); } } Future<void> _onRefresh() async { if (_loadingMore) return; _refreshing = true; try { await _loadFirstPage(); } finally { _refreshing = false; } } void _onScroll() { if (!_hasMore || _loadingMore || _refreshing || _initialLoading) return; if (_scrollController.position.extentAfter < 200) { _loadMore(); } } Future<void> _loadMore() async { if (_loadingMore || !_hasMore || _refreshing) return; final serial = ++_requestSerial; setState(() { _loadingMore = true; _loadMoreFailed = false; }); final nextPage = _page + 1; try { final result = await _repository.fetchPage(page: nextPage, pageSize: _pageSize); if (serial != _requestSerial || !mounted) return; setState(() { _page = nextPage; _items.addAll(result.items); _hasMore = result.hasMore; _loadingMore = false; }); } catch (_) { if (serial != _requestSerial || !mounted) return; setState(() { _loadingMore = false; _loadMoreFailed = true; }); } } ListFooter get _footer { if (_loadMoreFailed) return ListFooter.failed; if (_hasMore && _loadingMore) return ListFooter.loading; if (!_hasMore && _items.isNotEmpty) return ListFooter.noMore; return ListFooter.none; } Widget _buildList() { final footerCount = _footer == ListFooter.none ? 0 : 1; return ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), itemCount: _items.length + footerCount, itemBuilder: (context, index) { if (index < _items.length) return _buildItem(_items[index]); return _buildFooter(); }, ); } // build方法里根据 _initialLoading / _initialFailed / _items.isEmpty // 分别走 首屏loading / 错误重试 / 空态 / 正常列表 四个分支 }这套骨架的核心思路,是把“首屏”、“刷新”、“加载更多”三条数据链路通过统一序号和互斥锁串起来。你会发现它们其实共用同一个_requestSerial,谁发起了最新请求谁就说了算,所有过期响应都会被丢弃。这个设计在动手之前显得有点过度,但当你真的遇到旧响应覆盖新数据的问题时,就知道它有多值钱了。
6.2 分页清单页的验收自检清单
代码写完不是终点,能跑起来、能经得起各种操作组合,才算真正完成。我在Day6最后给训练营列了一份自检清单,每一条都在真机上过一遍:
- 首屏加载时显示全屏loading或骨架屏,而不是空白页。
- 首屏请求失败时显示可重试的错误页,下拉刷新也能触发重试。
- 下拉刷新过程中,转圈持续到数据真正返回。
- 刷新进行时,滚动触发下一页会被拦截,不发生并发请求。
- 滚动接近底部时自动加载下一页,加载期间footer显示小圈和文字。
- 加载更多过程中不会重复发请求。
- 加载更多失败后,尾部出现“点击重试”,点击后能重新请求同一页。
- 所有数据加载完,尾部显示“没有更多了”,并且之后不再触发新请求。
- 数据为空时显示居中空态,而不是一个空白列表。
- 置灰列表项高度不固定时,带有
itemExtent也能正确显示。
这份清单既是验收标准,也是排查问题的索引。哪一条命中了Bug,直接对应到对应的状态位和分支里去查,比对着日志盲猜要快得多。
6.3 最后分享一点个人体会
如果你从Day3一路做到这里,可能会发现,下拉刷新、上拉加载和数据加载提示本身都是非常成熟的能力,每个组件单独拎出来都有现成方案,真正的复杂度全在这几个状态互相咬合的地方:刷新和加载同时发生时谁说了算、失败重试怎么保证不重复请求、到底之后怎么不再发请求。我自己的习惯是把今天这套状态机存成一个模板,后面所有需求类似的分页页面都直接套这一套逻辑,只替换repository和列表项组件。最后再提醒一句:所有滚动手感和触发频率的问题,在模拟器上都会骗人,一定以OpenHarmony真机release包为准。把这条原则刻进项目里,后面能少走很多弯路。