Flutter OpenHarmony列表分页实践:刷新、加载与状态管理 1. 先理清楚列表三件套到底解决什么问题我在训练营里追到 Day4-6这一阶段的主题很明确把 Day3 已经跑通的列表页从“能显示”做到“能真正用起来”。所谓真正用起来就是用户随手一拉能刷新滑到底部能自动加载下一页加载中、加载失败、没有更多数据都有明确提示。这三个能力在 Flutter for OpenHarmony 的跨平台开发里几乎每个业务列表页都会用到但真做起来有不少细节需要注意。如果只从组件层面看事情很简单下拉刷新有 RefreshIndicator上拉加载可以用 ScrollController ListView加载提示也只是几个 if 分支。但实际把它们拼在一起会涉及状态管理、并发控制、异常恢复、滚动边界等一系列问题。Day3 我们搭出来的列表是静态的代码里只有一个ListView.builder和一个数据数组从 Day4 开始要把它变成真正的“可交互列表”这个跨越比想象中大。我用一个比较生活化的类比列表页就像一个小超市货架。下拉刷新是“理货员重新盘点货架”上拉加载是“仓库自动把下一批货送到货架尾端”加载提示则是“货架上的状态标签——正在上货、暂时缺货、已经没有更多了”。货架本身列表Day3 已经搭好Day4-6 要做的是装上这三套机制。1.1 为什么把加载状态单独拆出来很多人做列表页习惯只用一个isLoading布尔值。页面初始加载时是 loading加载更多时也是 loading于是底部转圈和整页转圈互相打架失败状态也没地方放。更麻烦的是空列表和加载失败都显示同一个“暂无数据”用户无法区分是真的没有内容还是网络出了问题。所以第一天Day4我先把状态改成两个维度整页状态initial / firstLoading / success / empty / error和加载更多状态idle / loadingMore / noMore / loadMoreError。这两个状态不能合并成一个枚举。原因很简单一个页面可以处于“首屏已加载底部正在加载更多”的中间态用单一枚举表达这种组合会让代码膨胀。分开之后每个 Widget 只关心自己那一段状态逻辑清晰得多。这个状态拆分看起来是小事但直接决定了后面所有逻辑怎么写。训练营里有同学一开始把loadingMore塞进整页状态导致底部加载转圈的时候整页又触发了一次 onRefresh两个请求同时打出去列表数据错乱。这个问题在后面 4.1 里会专门讲排查思路。1.2 上拉加载与下拉刷新不是对称功能很多跨端开发者习惯把“上拉加载”理解为“反向的下拉刷新”实际并不是。RefreshIndicator只处理下拉方向的 overscroll如果你想在列表底部也用它是不行的。上拉加载本质上要靠监听 ScrollController 的滚动位置判断当前是否接近maxScrollExtent然后去做“分页请求 追加数据”。除了实现机制不同两者还有一个本质区别下拉刷新是“重置型”操作分页号回到第一页删除旧数据用新数据替换整个列表上拉加载是“增量型”操作保留已有数据在末尾追加下一页。所以两者的请求参数、对数组的处理、异常后的恢复策略都不一样。很多列表翻车就是因为把这两个操作用同一个函数处理。比如用户正在上拉加载突然又下拉刷新刚才加载回来的第 2 页数据还没渲染完刷新就把列表清空了或者刷新过程中触发了触底加载两个请求交错返回最后列表内容是混乱的。正确做法是在代码里明确区分 refresh 路径和 loadMore 路径并且用互斥锁保证同一时间只能有一个分页请求在飞。2. 落地前的设计数据层与状态层怎么搭在写 UI 之前我花了不少时间设计数据层。Day3 的代码里数据是直接写在 State 里的一个ListItem接下来要支持分页、刷新、失败重试所有逻辑都绕着这个数组转不先定好数据结构后面肯定要反复改。2.1 分页模型和加载状态枚举我定义了一个泛型分页类用来统一表示“一页数据”class PagedListT { final ListT items; final int page; final int pageSize; final bool hasMore; const PagedList({ this.items const [], this.page 1, this.pageSize 20, this.hasMore false, }); PagedListT copyWith({ ListT? items, int? page, int? pageSize, bool? hasMore, }) { return PagedListT( items: items ?? this.items, page: page ?? this.page, pageSize: pageSize ?? this.pageSize, hasMore: hasMore ?? this.hasMore, ); } }为什么要把page、pageSize、hasMore都放在一个对象里因为加载更多时只需要把整个对象替换成新的而不是手动修改三个变量。刷新时也方便直接copyWith(items: newItems, page: 1)。这样做还有一个额外好处调试的时候只需要打印一个PagedList对象就能看到当前页码、数据量、是否还有更多。hasMore怎么计算如果接口返回了 total直接page * pageSize total如果没有 total可以看返回的 items 长度是否小于 pageSize小于说明到底了。我建议以后端返回的 hasMore 字段优先后端不返回再用本地判断兜底不要同时依赖两种方式否则容易出边界 bug。状态枚举用两个enum PageStatus { initial, loading, success, empty, error, } enum LoadMoreStatus { idle, loadingMore, noMore, loadMoreError, }这里的PageStatus.empty不是一个独立请求状态它是“success 且 items 为空”的派生状态但把它显式列出来Widget 里写空态分支会方便很多。error指首屏加载失败此时列表没有内容需要一个整页错误提示。注意如果首屏加载失败但上一次有缓存数据我更倾向保留旧数据并弹一个 SnackBar而不是直接切到 error这个取舍要看具体业务。2.2 状态管理的选型与边界Day4-6 的代码我仍然没有引入重量级状态管理框架用的就是一个ChangeNotifier加ListenableBuilder。原因很简单这个列表页的状态只有“一个页面 一个底部 footer”通过 StatefulWidget 的 setState 完全能撑住。引入 Provider 或 Riverpod 当然没问题但训练营后面会讲更复杂的场景现阶段最重要的是把分页逻辑本身想清楚而不是被框架带走。不过有一个原则必须坚持刷新、加载更多、失败重试必须走同一个 controller 或同一个 State 方法不能一个页面里有好几个地方各自维护items。否则会出现“下拉刷新已经成功但底部 footer 还拿着旧页码继续加载第 3 页”的情况。我在实现里用一个PagedListControllerT extends ChangeNotifier对外暴露refresh()和loadMore()两个方法内部维护PagedListT、PageStatus、LoadMoreStatus。Widget 只负责监听 controller不直接操作数组。这个设计在 Day5 加搜索框、Day6 加分类筛选的时候非常舒服因为只需要调用同一个refresh()列表会自动清空并加载第一页。需要说明的是loadMore()不应该是“用户一触发就立刻执行”。它必须被设计成幂等方法无论触发多少次在请求返回之前都只有一次有效调用。这个特性用锁实现后面代码里会有。2.3 给列表项一个稳定的 Key这个属于不踩一次不会注意的细节。ListView.builder默认是按 index 复用元素的如果 item 的 id 没有作为 Key 传给组件刷新后某些列表项的内部状态——比如展开/收起、选中态、图片加载状态——会串到别的 item 上。我在 Day5 做“下拉刷新后数据顺序变化”的测试时就遇到过明明数据已经换成新的但界面上某些卡片还显示着旧图片的情况。解决办法很简单return ListItemCard( key: ValueKey(item.id), item: item, );如果是带搜索结果的列表没有稳定 id 时可以用内容摘要的 hash 值比如ValueKey(${item.title}_${item.updatedAt})但注意不要用 index 当 Key。另外如果你希望切换 Tab 后还能恢复滚动位置可以给 ListView 本身加一个PageStorageKey(home_list)这和 item 的 Key 不是一回事不要混用。3. 动手实现下拉刷新、触底加载和状态提示状态和数据结构定好后UI 部分就顺了。这一节我会把三个能力的完整实现过一遍代码基于一个简化版页面只保留核心逻辑不考虑具体业务字段。3.1 先用 RefreshIndicator 把下拉刷新接上下拉刷新的标准写法是RefreshIndicator( onRefresh: _refresh, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, itemCount: itemCount, itemBuilder: itemBuilder, ), )这里有三点必须注意。第一onRefresh必须返回一个Future并且这个 Future 要等数据请求真正完成后再结束。如果你在里面直接调用一个没有 await 的异步函数并立刻 return指示器会瞬间收回用户根本看不清刷新过程。第二physics必须设为AlwaysScrollableScrollPhysics否则列表内容不满一屏时根本没有滚动能力下拉刷新手势不会被触发。第三RefreshIndicator的颜色在 OpenHarmony 的 Material 实现里不一定完全跟随主题色我习惯显式指定。具体刷新方法Futurevoid _refresh() async { if (_isRefreshing) return; _isRefreshing true; try { final result await _repository.fetchList(page: 1, pageSize: _pageSize); _controller.updatePagedList(result, reset: true); } catch (e) { // 保留旧数据仅提示刷新失败 ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(刷新失败请稍后重试)), ); } finally { _isRefreshing false; } }注意_isRefreshing这个锁。虽然RefreshIndicator正常情况下不会在转圈期间再次触发但手指快速下拉两次、或者刷新过程中又出现其他触发入口时没有锁还是会发出去重复请求。这个锁和 loadMore 的锁是分开的但我会在 loadMore 里也判断if (_isRefreshing) return防止两个请求同时改同一个数组。3.2 用 ScrollController 做触底加载触底加载的核心是一个滚动监听void _onScroll() { if (!_scrollController.hasClients) return; final position _scrollController.position; if (position.pixels position.maxScrollExtent - 200) { _loadMore(); } }这段逻辑很容易理解当滚动位置大于等于“最大滚动距离减去阈值”时触发加载。阈值 200 不是拍脑袋它约等于一到两个列表项的高度。如果阈值太大用户还没滑到底就开始加载体验上像“提前偷跑”如果阈值太小用户看到底部空白后才能加载会有明显停顿。固定高度列表项我习惯用 150-200不固定高度用 200 起步。_loadMore的完整版本Futurevoid _loadMore() async { if (_isLoadingMore || _isRefreshing) return; if (!_controller.hasMore) return; _controller.loadMoreStatus LoadMoreStatus.loadingMore; _notify(); try { final nextPage _controller.page 1; final result await _repository.fetchList( page: nextPage, pageSize: _controller.pageSize, ); _controller.appendPage(result); } catch (e) { _controller.loadMoreStatus LoadMoreStatus.loadMoreError; } finally { // 无论成功失败都解锁允许用户再次触发 _isLoadingMore false; } _notify(); }这里_isLoadingMore是方法级别的一个锁但要注意它和LoadMoreStatus.loadingMore的关系。锁是给代码用的状态是给 UI 用的两者要同步但不要混在一起。失败后把状态置为loadMoreError这时 footer 会显示“加载失败点击重试”点击重试直接调用同一个_loadMore()。还有一个细节加载更多成功后如果返回的列表长度小于 pageSize应该把hasMore置为 false并把LoadMoreStatus切成noMore。此时底部显示“没有更多了”而不是继续转圈。如果后端没有给 hasMore这个本地判断就是唯一依据。我见过有同学忘了更新 hasMore导致列表到底之后还在疯狂请求下一页这是上拉加载最常见的线上事故。3.3 加载提示、错误页和空态所有状态提示都放在一个_buildBody方法里根据 controller 的状态返回不同 WidgetWidget _buildBody() { final status _controller.pageStatus; if (status PageStatus.loading) { return const Center(child: CircularProgressIndicator()); } if (status PageStatus.error) { return ErrorRetryView(onRetry: _refresh); } if (status PageStatus.empty) { return EmptyView(onRefresh: _refresh); } return _buildList(); }但这里有个交互问题RefreshIndicator必须包住真正可滚动的区域而错误页、空态页面本身不是 ListView直接放在 RefreshIndicator 下面会无法下拉刷新。我的做法是统一用LayoutBuilder SingleChildScrollViewRefreshIndicator( onRefresh: _refresh, child: LayoutBuilder( builder: (context, constraints) { if (status PageStatus.empty || status PageStatus.error) { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: SizedBox( height: constraints.maxHeight, child: emptyOrErrorView, ), ); } return _buildList(); }, ), )这样空态和错误态也能通过下拉刷新恢复用户不需要去找按钮。这个细节在做列表页时很容易被忽略但直接影响体验。列表底部 footer 的构建逻辑Widget _buildFooter() { switch (_controller.loadMoreStatus) { case LoadMoreStatus.loadingMore: return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); case LoadMoreStatus.noMore: return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Center( child: Text(没有更多了, style: TextStyle(color: Colors.grey)), ), ); case LoadMoreStatus.loadMoreError: return InkWell( onTap: _loadMore, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Center(child: Text(加载失败点击重试)), ), ); case LoadMoreStatus.idle: return const SizedBox.shrink(); } }这里 footer 高度不能太高否则会占掉一屏太多空间。而且如果用LoadMoreStatus.noMore一直显示“没有更多了”会让列表看起来很啰嗦我一般会在滚动停止后延迟隐藏或者只保留一句灰色小字。不过训练营阶段统一显示方便调试。4. 真机/模拟器上的排查经验与避坑清单代码能跑通之后真正的坑都在真机调试和边界测试里。这一节把我在 Day4-6 遇到过的问题整理成一份速查清单很多细节是文档里不会写的。4.1 滚动触发多次、刷新按钮卡死怎么查第一个高发问题是触底加载被连续触发。常见原因有三个忘了加锁hasMore没有更新或者触底方法写在了build方法里。前两个上面已经讲过第三个比较隐蔽——有同学在itemBuilder里判断index items.length - 1时直接调用_loadMore()这样每次 build 都会触发因为 build 在 setState 后一定会再次执行结果就是无限请求。排查时我习惯先看日志在_loadMore()入口打印page、hasMore、_isLoadingMore三个值。如果 page 连续递增说明锁没生效如果 page 一直是 1说明 refresh 路径和 loadMore 路径冲突了。另一个技巧是给请求加一个requestId把每次发出去的请求编号记录下来能很快看出哪些请求是冗余的。第二个高发问题是RefreshIndicator一直转圈不收回。原因基本是onRefresh里的 Future 没有正常完成要么没有 return要么异步函数抛了异常但没被捕捉。我在 OpenHarmony 的模拟器上调试时遇到过网络请求超时后异常被上层吞掉导致 Future 永远处于 pending 状态的情况。解决办法是给_refresh和_loadMore都加上 try/catch/finally并且确保finally里把 loading 状态复位。宁可这次请求失败也不能让 UI 卡死。4.2 短列表和底部空隙的触发阈值短列表是第二个难点。当列表内容不足一屏或者刚好比一屏多一点时maxScrollExtent很小甚至为 0_onScroll可能一次都不会触发用户滑不到底部也就无法加载第一页之外的数据。这个问题在很多分页实现里都会出现不只是 Flutter。我的处理办法是在initState里用addPostFrameCallback检查一次WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) return; final position _scrollController.position; if (position.maxScrollExtent 0 || position.pixels position.maxScrollExtent - 200) { _loadMore(); } });这样即使列表短到不需要滚动也会自动尝试加载下一页。当然前提是hasMore为 true而且此时不是首屏加载中。另一种做法是给列表设置一个最小高度比如SizedBox(height: 600)让短列表也能滚动但这样会引入额外的空白我更喜欢主动检查的方式。关于触发阈值还要考虑 footer 的存在。如果列表底部有一个高度 60 的 footer加载圈那么maxScrollExtent - 200这个边界其实已经包含了 footer 的高度。用户可以滚动到 footer 上方时触发体验最自然。如果 footer 高度太大触发边界会离真正底部太远。所以我建议 footer 高度控制在 40-80 之间不要用大面积占位。4.3 鸿蒙环境下 Flutter 列表的注意事项在 OpenHarmony 设备上跑 Flutter 列表多数 UI 行为跟其他移动平台是一致的但有几个差别值得注意。滚动时如果列表项里有大量图片性能会比普通文本列表差很多。Day4-6 训练营的清单项目有很多卡片每张卡片都有头像和封面图我在模拟器上快速滚动时能看到明显的掉帧。优化手段有几个固定图片尺寸使用本地缓存能力列表项用const构造减少重建如果 item 高度固定给ListView.builder设置itemExtent这样滚动时不需要反复计算高度性能提升非常明显。OpenHarmony 的 Flutter 引擎对RefreshIndicator的滚动边缘反馈和其他平台有细微差异。某些版本上 Material 的 over-scroll glow 效果可能不太明显但功能是正常的不需要额外处理。如果发现下拉刷新的触发灵敏度不对检查physics是否被其他设置覆盖了而不是去修改 RefreshIndicator 的 displacement。另外在 OpenHarmony 调试时DartVM 的 hot reload 不一定每次都能生效尤其是改了原生插件的代码之后。所以纯 Dart 层的改动尽量先 hot reload涉及插件和依赖版本变更就老老实实重新编译不要浪费时间怀疑是缓存问题。4.4 数据重复、错乱和加载状态残留数据重复主要发生在刷新和加载更多并发的时候。刷新清空旧数据加载更多又把旧数据追加回来最终列表里出现相同 id 的 item。我的规避方式除了互斥锁还会在_refresh开头把LoadMoreStatus重置为idle并等刷新完成后再允许触底加载。同时在追加页面时根据 id 做一个简单的去重保护final oldIds _controller.items.map((e) e.id).toSet(); final validNewItems result.items .where((e) !oldIds.contains(e.id)) .toList();这只是一个保底方案真正的问题要从并发控制上解决不能依赖去重。加载状态残留也是常见问题。比如刷新成功后footer 还停留在loadMoreError点击重试又能加载但页面看起来很奇怪。所以刷新成功的回调里要把LoadMoreStatus重置为noMore或idle不能只改 items。我的原则是任何会改变列表数据的操作都必须同时维护PagedList、PageStatus、LoadMoreStatus三个量缺一不可。5. 沉淀一步把这套逻辑做成可复用的分页混合类到 Day6 结束时我已经不再满足于“这个页面能跑通”。因为训练营后面的内容肯定会涉及更多列表页如果每次都要复制一遍滚动监听、互斥锁、footer 状态效率太低了。所以我把核心逻辑抽成了一个PagedListMixinT在 State 里混入使用。mixin PagedListMixinT on StateT { late ScrollController scrollController; late PagedListControllerT controller; override void initState() { super.initState(); scrollController ScrollController(); scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) _checkInitialLoad()); } void _onScroll() { /* 监听逻辑 */ } override void dispose() { scrollController.dispose(); controller.dispose(); super.dispose(); } }混入之后新的列表页只需要继承这个 mixin、实现fetchPage(int page, int pageSize)方法UI 部分基本只需要写 itemBuilder 和 footer。后面如果要加“搜索后刷新”或“切换分类”直接调用controller.refresh()就可以了。我个人在实际操作中的体会是列表三件套的难点不在组件而在“状态边界”。你什么时候允许刷新、什么时候允许加载更多、失败之后用户怎么恢复这些决策直接决定代码质量。把状态梳理清楚之后具体用什么组件、什么状态管理框架都只是实现细节。最后再分享一个小技巧在开发阶段可以在 footer 里临时显示当前页码 item数量 hasMore比如第2页 · 共34条 · 还有更多。这样在模拟器上滑动列表时一眼就能看出分页逻辑是否正常。等一切稳定了再换回正式的“没有更多了”文案。这个小习惯帮我省掉了无数次打开抓包工具的时间你在做类似列表功能时也可以试试。