ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenHarmony上Flutter列表三件套:下拉刷新、上拉加载与状态管理实战

OpenHarmony上Flutter列表三件套:下拉刷新、上拉加载与状态管理实战

最近在做一个跨端平板上运行的列表页,设备系统是 OpenHarmony,技术栈定的 Flutter。需求本身不复杂:一个信息流列表,要支持下拉刷新、滚动到底自动加载下一页,并且每一个加载动作都要有明确的状态提示——正在加载、没有更多、加载失败可重试。说白了就是移动端最常见的“列表三件套”。

但真正把这三样在 OpenHarmony 上全部跑顺,比想象中耗时间。最大的感受是:Flutter 的跨端能力确实省事,但平台差异永远藏在细节里,尤其是下拉刷新的回弹物理效果、滚动监听的触发时机、底部提示组件在不同分辨率下的显示位置,这些坑不实际跑一遍真发现不了。这次的实现代号是“04-06”,目标是做一个可以复用到后续多个页面的通用列表组件。这篇就当一次踩坑记录,把设计思路、完整代码、OpenHarmony 适配要点一次讲清楚。

1. 先把需求和场景理清楚:什么样的列表才算“能打”

1.1 这次要实现的目标

列表页面在 App 里属于“看起来简单、做起来啰嗦”的模块。这个项目里的实际场景是:用户打开页面后,需要先看到首屏数据,接着不断上滑浏览,数据快到底时自动加载下一页,同时用户随时可以下拉刷新让列表回到最新状态。

围绕这个场景,我给自己定了几条硬性要求:

  • 首屏加载要有状态:要么显示加载动画,要么显示骨架屏,不能白屏干等。
  • 上拉加载要有明确触发点:滚动到距底部一定距离时自动拉取下一页,而不是精确到底才触发。
  • 加载过程要有防重入机制:一次只能有一个加载请求在跑,避免滚动监听连续触发十几个请求。
  • 底部要有状态提示:加载中显示转圈,没有更多数据显示“已经到底了”,加载失败显示“点击重试”。
  • 空列表、异常网络这些边界状态也要有独立呈现,不能简单复用“没有更多”的提示。

这些要求拆开看都不难,但组合在一起,就需要先有一个清晰的状态模型。Flutter 的列表本质上是一个高度自定义的滚动视图,数据、状态、UI 三者的同步关系如果捋不顺,后面调 bug 会非常痛苦。

1.2 为什么选择 Flutter 而不是其他方案

这个项目开始之前其实有另一个方案:直接基于 OpenHarmony 的 ArkUI 开发,用里面的 Scroll 组件和 Refresh 组件也能实现类似功能。但最终选了 Flutter,理由很实际。

第一,项目里同一套页面还要覆盖其他操作系统终端,Flutter 可以一套 Dart 代码编译到多个平台,业务逻辑、状态管理、网络层全部复用,不需要为每个平台单独维护一套 UI 代码。第二,Flutter 的渲染引擎是自绘的,列表项的布局、动画、滚动效果在 OpenHarmony 上依然保持一致的像素级表现,这一点对 UI 联调很重要。第三,Flutter 生态里关于列表加载、刷新、状态管理的成熟方案很多,开发效率确实高。

当然代价也存在:OpenHarmony 上的 Flutter 运行时和传统移动平台有一些细微差异,滚动物理效果、手势冲突处理、字体渲染等都需要真机验证。这部分我放到后面第 4 节专门讲。

2. 状态机与分页模型:先设计清楚再写代码

2.1 列表状态机设计

列表页最常见的坑是状态混乱:加载中、刷新中、加载更多、无更多、错误态,这些状态如果只用几个 bool 变量拼,用不了多久就会陷入“互相覆盖”的泥潭。

我的做法是引入一个显式的状态机。整个列表页面的状态可以分成两层:

一层是页面级状态,包括首次加载中、加载成功、空列表、加载失败。另一层是加载更多状态,属于局部状态,包括空闲、加载中、没有更多、加载失败。

在 Dart 里我直接用枚举来定义加载更多状态:

enum LoadMoreStatus { idle, // 空闲,可以触发加载 loading, // 正在加载下一页 noMore, // 没有更多数据 error, // 加载失败 }

为什么不用 bool?因为 bool 只能表达两个状态,而加载更多的真实行为有四种。比如加载失败之后,底部提示要显示“点击重试”,此时如果继续上滑,应该允许重新尝试,而不是一直卡在 error 里;再比如没有更多数据之后,不应该再触发网络请求,这个状态也需要跟 idle 区分开。

页面级状态我用一个顶层状态对象持有:

class ListPageState { final bool isFirstLoading; final bool isRefreshing; final LoadMoreStatus loadMoreStatus; final String? errorMessage; }

每次变更状态都通过一个统一的方法派发,UI 层只根据当前状态渲染对应视图。这样无论用户是下拉刷新、上拉加载,还是点击错误重试,最终都会落回到这套状态模型里,不会出现某个操作把其他操作的状态覆盖掉的情况。

2.2 分页参数与接口约定

列表的分页设计需要前后端约定一套稳定的协议。我这次用的是最常见的分页参数模型:

  • 请求参数:page(页码,从 1 开始)、pageSize(每页数量)
  • 响应字段:list(当前页数据)、total(总条数)或者hasMore(是否还有下一页)

项目中我采用了hasMore作为判断依据,因为前端不需要知道总条数,只需要知道“还有没有下一页”。如果后端只返回total,前端可以这样计算是否还有更多:

bool get hasMore => list.length < total;

分页接口的约定尽量保持简单:第一页的page传 1,上拉加载时page自增,下拉刷新时重置为 1。这里有一个容易忽略的点:加载更多和下拉刷新用的是同一个拉取方法,但语义不同。加载更多是把新数据追加到列表尾部,刷新则是用新数据整体替换列表。所以至少需要一个参数来区分动作类型。

我习惯在数据层封装一个fetchList({required int page, required int pageSize}),返回值里带上list和hasMore。UI 层再根据当前动作决定是“追加”还是“替换”。

2.3 数据源与 Repository 隔离

为了让列表组件可以复用到多个业务页面,我没有把网络请求直接写在 Widget 里,而是通过 Repository 模式隔离数据源。

简单说,Widget 只依赖一个抽象的ListRepository接口,接口内部可以是 HTTP 请求、本地 JSON、或者数据库查询。这样在后续接入真实接口时,只需要替换对应的 Repository 实现,列表组件本身完全不用改。

这里也顺便说一下加载提示的位置。数据加载提示不应该只停留在“列表底部转圈”,它应该是一个完整的用户反馈链路:

  • 首屏请求发起:显示居中加载动画或骨架屏。
  • 加载完成:渲染列表内容。
  • 空数据:显示“暂无内容”图标和文案。
  • 加载失败:显示错误文案和重试按钮。
  • 加载更多:底部显示“正在加载”的细条形提示。
  • 没有更多:底部显示“已经到底了”文案。

我会在下一节把每个环节的代码实现展开。

3. 逐块实现:下拉刷新、上拉加载、状态提示的完整代码

3.1 下拉刷新:RefreshIndicator 的正确打开方式

Flutter 自带的下拉刷新组件是RefreshIndicator,它和ScrollView组合使用,用户从列表顶部继续下拉时触发回调。基础用法如下:

RefreshIndicator( onRefresh: _onRefresh, color: const Color(0xFF3478F6), backgroundColor: Colors.white, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()), itemCount: _list.length, itemBuilder: (context, index) { return _buildListItem(_list[index]); }, ), )

这里有几个关键点。

第一,ListView必须设置AlwaysScrollableScrollPhysics,否则当列表内容不满一屏时,用户无法下拉,刷新手势根本不会触发。这个坑在新手开发里出现频率极高。

第二,onRefresh必须返回一个Future,并且该 Future 要在刷新动作完成时才结束。RefreshIndicator的回调签名是Future<void> Function(),如果你的刷新逻辑没有返回完整的 Future,下拉刷新动画就会提前消失,或者一直转圈不消失。

第三,在 OpenHarmony 上,我建议把物理滚动效果固定为ClampingScrollPhysics。不同平台默认的ScrollPhysics不同,Android 风格是边缘固定,iOS 风格是回弹。OpenHarmony 设备上的表现介于两者之间,如果完全跟随平台默认值,下拉手势的灵敏度和回弹幅度会不稳定。显式指定AlwaysScrollableScrollPhysics + ClampingScrollPhysics之后,刷新手势的表现会更可预期。

刷新逻辑本身很简单,但要注意请求状态与 UI 的同步:

Future<void> _onRefresh() async { if (_state.isRefreshing) return; _setState(state.copyWith(isRefreshing: true)); try { final result = await _repository.fetchList(page: 1, pageSize: _pageSize); setState(() { _list = result.list; _page = 1; _hasMore = result.hasMore; _state = _state.copyWith( isRefreshing: false, isFirstLoading: false, loadMoreStatus: LoadMoreStatus.idle, ); }); } catch (e) { setState(() { _state = _state.copyWith( isRefreshing: false, errorMessage: '刷新失败,请稍后重试', ); }); } }

刷新时把loadMoreStatus重置为idle很重要。因为刷新之后列表整体替换,原本“没有更多”的判断已经失效,必须重新进入可加载状态。

3.2 上拉加载:ScrollController 的监听时机与触发距离

上拉加载的本质是:监听滚动位置变化,当滚动位置接近列表底部时,自动执行加载下一页。

实现方式有两种。第一种是用ScrollController添加监听,第二种是用NotificationListener<ScrollUpdateNotification>。两种本质相同,我习惯用ScrollController,因为它的 API 更直接,也方便在页面销毁时释放监听。

完整实现:

class _InfiniteListState extends State<InfiniteList> { final ScrollController _scrollController = ScrollController(); final List<ItemModel> _list = []; int _page = 1; final int _pageSize = 20; bool _hasMore = true; LoadMoreStatus _loadMoreStatus = LoadMoreStatus.idle; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _loadFirstPage(); } void _onScroll() { // 防止组件尚未挂载到 Viewport 时访问 position if (!_scrollController.hasClients) return; // 正在加载或者没有更多时,不再触发 if (_loadMoreStatus == LoadMoreStatus.loading || _loadMoreStatus == LoadMoreStatus.noMore) { return; } // 触发距离:距底部还剩 80 逻辑像素时开始加载 final position = _scrollController.position; if (position.extentAfter < 80) { _loadMore(); } } Future<void> _loadMore() async { setState(() { _loadMoreStatus = LoadMoreStatus.loading; }); try { final nextPage = _page + 1; final result = await _repository.fetchList(page: nextPage, pageSize: _pageSize); setState(() { _page = nextPage; _hasMore = result.hasMore; _list.addAll(result.list); _loadMoreStatus = result.hasMore ? LoadMoreStatus.idle : LoadMoreStatus.noMore; }); } catch (e) { setState(() { _loadMoreStatus = LoadMoreStatus.error; }); } } @override void dispose() { _scrollController.dispose(); super.dispose(); } }

触发距离的值需要根据实际场景调整。我这边列表项比较高,单条卡片大约 120 逻辑像素,所以把触发距离设在 80,相当于用户快看到最后一个完整卡片时,加载已经悄悄开始了。如果列表项高度比较小,比如纯文本行只有 40 像素,触发距离可以设到 120~160,保证有足够的提前量。

有一个很容易踩的坑:position.extentAfter < 80这个条件会在滚动过程中被多次触发,所以_loadMoreStatus必须在函数开头立刻切到loading。我用的是setState同步切换,这样在同一个事件循环里,即使_onScroll再次被调用,也会因为状态是loading而直接 return。否则会出现“列表底部突然连续闪烁多次加载转圈,然后一次性追加了好几页数据”的现象。

页面销毁时记得调用_scrollController.dispose(),否则监听器泄漏会导致内存问题。如果页面中如果有多个列表组件并存的场景,最好在dispose里把滚动位置一并保存,佐证。

3.3 尾部加载提示:loading、noMore、error 三种状态

列表底部的加载提示是用户感知“分页加载”最直接的地方。我封装了一个通用的底部状态 Widget,配合LoadMoreStatus枚举渲染不同内容。

Widget _buildListFooter() { switch (_loadMoreStatus) { case LoadMoreStatus.loading: return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); case LoadMoreStatus.noMore: return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: Text( '已经到底了', style: TextStyle(fontSize: 12, color: Color(0xFF999999)), ), ), ); case LoadMoreStatus.error: return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Center( child: GestureDetector( onTap: _loadMore, child: const Text( '加载失败,点击重试', style: TextStyle(fontSize: 12, color: Color(0xFF3478F6)), ), ), ), ); case LoadMoreStatus.idle: return const SizedBox.shrink(); } }

把这个 footer 加进ListView.builder的 itemCount 里:

itemCount: _list.length + 1, itemBuilder: (context, index) { if (index == _list.length) { return _buildListFooter(); } return _buildListItem(_list[index]); },

这里有一个设计细节:idle状态时 footer 是空组件,但 itemCount 依然多算了一个,意味着列表底部始终保留了这一项的空间。这样做的目的是避免加载状态切换时列表高度突变,导致滚动位置跳动。很多开发者在这里直接不渲染 footer,导致 loading 状态出现时列表突然变高,用户正盯着列表底部,视觉上会“跳一下”。

在 OpenHarmony 上,我用了一个小技巧:空 footer 也可以不占用空间,但为了保持稳定,我倾向于统一返回一个无高度的 SizedBox。实际测试下来,列表高度突变引起的滚动位置偏移在 OpenHarmony 上比在 Android 上更明显,所以宁可多一个空 item,也不要偷懒。

3.4 首屏加载与全局状态提示

首屏加载和后续的分页加载需要区分对待。首屏是整个页面生命周期里第一次数据请求,此时列表项为空,如果在列表底部显示 loading,用户看不到任何内容,体验是割裂的。这个阶段我采用页面级居中状态。

Widget _buildPageBody() { if (_state.isFirstLoading) { return const Center(child: CircularProgressIndicator()); } if (_list.isEmpty && _state.errorMessage != null) { return ErrorPlaceholder( message: _state.errorMessage!, onRetry: _retryFirstPage, ); } if (_list.isEmpty) { return const EmptyPlaceholder(message: '暂无内容'); } return RefreshIndicator( onRefresh: _onRefresh, color: const Color(0xFF3478F6), backgroundColor: Colors.white, child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()), itemCount: _list.length + 1, itemBuilder: (context, index) { if (index == _list.length) { return _buildListFooter(); } return _buildListItem(_list[index]); }, ), ); }

加载失败场景的处理要稍微细一点。如果首屏加载失败,我选择展示全屏错误占位,并提供重试按钮。这里不能只显示一句“网络异常”,还要让用户明确知道点击哪里可以恢复。错误占位组件内部包含图标、文案、重试按钮三个元素。

如果首屏已经成功,只是后续加载更多失败,则保留已有列表内容,只在底部出现“加载失败,点击重试”。两个层级的状态提示不能混用,否则用户会分不清是页面崩了还是分页挂了。

4. OpenHarmony 适配实录:我踩到的几个坑

4.1 下拉刷新在部分设备上回弹动画失效

这是我遇到的第一个问题。同样的代码在开发机上运行下拉刷新正常,换到另一台 OpenHarmony 设备上,RefreshIndicator的圆圈动画出现了明显的卡顿,甚至有时候下拉距离够了也不触发刷新。

排查后发现,问题出在滚动物理效果和平台的 overscroll 响应机制上。OpenHarmony 的 Flutter 适配层对边缘拖拽的处理和 Android 原生存在差异,部分设备默认接收到的滚动通知频率较低,RefreshIndicator的位移判断不够灵敏。

我的解决方案是显式指定物理效果,并且把RefreshIndicator换成自定义的下拉刷新反馈。

physics: const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()),

如果依然不稳定,则使用ScrollNotification+Stack来自绘下拉刷新动画。这里我不推荐把整个下拉组件做得很重,因为项目后期大概率要换设计风格。更实用的做法是:定义一个RefreshTriggerMode,支持RefreshIndicator和自定义两种模式,用配置开关切换。

自定义刷新反馈的核心思路是:通过NotificationListener<ScrollNotification>监听用户的过度滚动距离,然后使用Transform.translate来移动整个列表内容,配合AnimatedOpacity显示箭头或刷新图标。

NotificationListener<ScrollNotification>( onNotification: (notification) { if (notification is ScrollUpdateNotification) { final overscroll = notification.metrics.extentBefore - 0; _refreshProgress = overscroll / 80; } return false; }, child: ..., )

不过这种方式要自己处理很多边界逻辑:松手判定、回弹动画、刷新状态下的位移保持。如果只是追求可用,RefreshIndicator已经够;如果追求一致性,建议在真机上先验证两三种设备,再决定是否自定义。

4.2 上拉触发过于频繁导致列表抖动

第二个坑在滚动监听的触发频率上。extentAfter < 80这个条件看起来没问题,但在加载完成的瞬间,如果列表数据追加导致可滚动范围变大,extentAfter会突然增加。而如果加载完成前用户正好停留在底部附近,加载完成之后还需不需要继续加载?其实不需要。但如果_loadMoreStatus在setState后才置回idle,那么在这个间隙里旧值还是loading,不会重复触发。这点我在 3.2 节已经通过状态机解决了。

真正让我头疼的是另一个问题:列表项图片异步加载导致的高度变化。项目里的列表项包含网络图片,图片在加载完成后高度会发生变化,这种变化会直接改掉整个滚动范围。如果此时用户正好接近底部,extentAfter会忽大忽小,就可能出现“到底部触发加载 -> 图片加载完成高度变化 -> 再次触发加载”的循环。

解决办法是给列表项固定高度约束。我给图片区域设置了固定的宽高比,外部容器也固定了高度,确保列表项的高度在图片加载前后保持一致。另外一个思路是使用shimmer骨架占位,在图片还没加载完成时已经占用了最终尺寸,就不会触发滚动范围变化。

4.3 底部提示在某些分辨率下被系统手势区遮挡

OpenHarmony 设备上比较特殊的问题:底部导航条和手势区域会占据屏幕下方的一部分空间。如果用ListView.builder默认布局,列表最后一个 item(也就是我们的 footer)可能被手势区域遮挡,用户看不到“已经到底了”的提示。

解决方式有两种。第一种是给ListView添加padding,在底部额外留出一块安全区域:

padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 12),

第二种方案是用SafeArea包裹列表,但这会导致滚动背景区域也发生变化。我的实践是优先使用第一种,单独给底部 padding 增加安全间距。需要注意这个间距是运行时动态获取的,不同设备的padding.bottom值不一样,写死数值很容易在新设备上出问题。

4.4 长列表性能与内存占用

分页加载本质上是无限地往 ListView 里添加数据。如果不做任何优化,数据量到几千条时,即使 Flutter 的ListView.builder是懒加载的,内存也会慢慢上涨,滚动流畅度也会下降。

对于 OpenHarmony 设备的 Flutter 应用,我做了三件事。

第一,限制了单页请求条数。pageSize定在 20,不要贪多。一次性加载 50 条确实减少了请求次数,但列表项的计算、渲染、缓存压力会同步上来。20 条在绝大多数场景下是体验和性能的平衡点。

第二,对列表项做统一的尺寸约束。这一点前面提过,目的是避免滚动范围内出现动态高度抖动。另外所有子组件尽量保持轻量,局部重绘成本要控制。

第三,如果确实需要处理上万条数据的极端场景,建议改用SliverList配合SliverChildBuilderDelegate。它和ListView.builder的差异主要在于延迟创建子组件的范围更大,GC 压力更小。不过大部分业务列表到几千条已经够用,不必过度设计。

5. 实测效果与后续扩展思路

5.1 实测效果

把上面这套逻辑跑通之后,我在 OpenHarmony 设备上做了几轮测试。结果如下:

场景表现
首次进入页面居中加载动画约 300ms,加载完成后列表正常渲染
内容不足一屏下拉刷新依然可以触发
滚动接近底部自动加载下一页,底部显示细条形 loading
已无更多数据底部显示“已经到底了”,不再发起请求
加载更多失败底部显示“加载失败,点击重试”,其余列表数据不受影响
下拉刷新成功列表被新数据替换,加载更多状态重置

页面在连续滑动几百条数据时,滚动帧率稳定在 55~60 之间,没有出现掉帧或者内存明显增长的问题。

5.2 后续扩展思路

当前组件已经能覆盖大部分列表场景,但还有几个扩展方向可以继续做。

一是接入后端分页游标。如果后端的列表接口不用 page 而是用游标,只需要修改 Repository 层取数逻辑,UI 层状态机不需要变。

二是增加本地缓存。首次加载成功后将列表数据缓存到本地,下次进入页面时可以先用缓存渲染,再静默刷新,这样弱网环境下首屏也不会白屏。

三是把尾部错误重试的上滑触发做得更智能。现在错误之后只有点击重试,后续可以做成“滚回底部附近时自动重试一次”。

四是将整个“列表状态机 + 滚动监听 + 底部状态”抽成一个通用组件,通过泛型和 builder 承接不同业务的数据模型。我在这个项目里已经做了这一步,后续新增页面只需要配置repository和itemBuilder就能复用整套加载逻辑。

最后分享几个我自己的实践习惯

这类列表功能做完之后,我最深的一点体会是:不要等到功能写完再考虑平台适配。这次项目里,OpenHarmony 的滚动物理特性和 Android 很不一样,如果一开始不设好AlwaysScrollableScrollPhysics,后面很可能要为了几个无用刷新问题改一大堆代码。

另一个建议是:调试滚动类功能时,一定要用真机。模拟器上滚动手势和触发距离的表现和真实设备经常不一致,尤其 OpenHarmony 设备的屏幕比例和手势区域差异很大,完全依赖模拟器会在上线后才发现问题。

最后一个很实际的技巧:所有状态切换尽量集中管理,不要散落在多个setState里。我在这个项目里把页面状态和加载更多状态都收拢到一个 state 对象中,后面做错误埋点和用户行为分析时,只需要在状态变更入口打一个日志,就能还原完整的用户操作路径。

这套实现后续还可以继续优化,但核心思路已经稳定下来。如果你们也在做 OpenHarmony 上的 Flutter 列表,希望这篇文章能帮你少踩几个坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进