资讯动态

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

发布时间:2026/10/16 11:23:58 来源:尧图企业网站定制
最近在做一个跨端平板上运行的列表页设备系统是 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的回调签名是Futurevoid Function()如果你的刷新逻辑没有返回完整的 Future下拉刷新动画就会提前消失或者一直转圈不消失。第三在 OpenHarmony 上我建议把物理滚动效果固定为ClampingScrollPhysics。不同平台默认的ScrollPhysics不同Android 风格是边缘固定iOS 风格是回弹。OpenHarmony 设备上的表现介于两者之间如果完全跟随平台默认值下拉手势的灵敏度和回弹幅度会不稳定。显式指定AlwaysScrollableScrollPhysics ClampingScrollPhysics之后刷新手势的表现会更可预期。刷新逻辑本身很简单但要注意请求状态与 UI 的同步Futurevoid _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添加监听第二种是用NotificationListenerScrollUpdateNotification。两种本质相同我习惯用ScrollController因为它的 API 更直接也方便在页面销毁时释放监听。完整实现class _InfiniteListState extends StateInfiniteList { final ScrollController _scrollController ScrollController(); final ListItemModel _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(); } } Futurevoid _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()),如果依然不稳定则使用ScrollNotificationStack来自绘下拉刷新动画。这里我不推荐把整个下拉组件做得很重因为项目后期大概率要换设计风格。更实用的做法是定义一个RefreshTriggerMode支持RefreshIndicator和自定义两种模式用配置开关切换。自定义刷新反馈的核心思路是通过NotificationListenerScrollNotification监听用户的过度滚动距离然后使用Transform.translate来移动整个列表内容配合AnimatedOpacity显示箭头或刷新图标。NotificationListenerScrollNotification( 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 列表希望这篇文章能帮你少踩几个坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑