☰
HarmonyOS 6 List下拉刷新实战:Refresh组件与数据链路设计详解
2026/10/9 3:45:33 网站建设 项目流程

最近在做HarmonyOS 6的列表页改造,正好遇到了List列表下拉刷新这个需求。之前项目里列表都是静态数据,这次要接真实接口,还得支持用户手指下拉触发重新加载,踩了不少坑才把整个链路捋顺。我把这个List下拉刷新案例的完整实现思路、代码细节、参数调优和常见问题都整理在这篇文里,给正在做鸿蒙应用开发的朋友一个可以直接抄作业的参考。

这篇文章适合两类人看:一类是刚接触鸿蒙ArkUI开发,想搞明白List和Refresh怎么配合的新手;另一类是已经在用List做展示,但遇到刷新不触发、列表卡顿、数据重复等问题,想找排查思路的进阶者。我会从最基础的设计拆解开始讲,再给完整可运行的代码,最后把几个高频问题逐一说明白。

1. 整体设计思路拆解:下拉刷新到底在解决什么问题

1.1 下拉刷新的本质是“数据链路的重新激活”

很多新手第一次写下拉刷新,容易陷入一个误区:以为只要在列表上套一个Refresh组件就完事了。实际上下拉刷新的核心动作是重新发起一次数据请求,然后把返回的新结果替换掉旧数据,最后让列表回到正常滚动状态。UI上的下拉动画只是给了用户一个明确的视觉反馈:你触发了刷新,系统正在重新获取内容。

所以这个案例的第一层设计拆分是这样的:

  • 手势层:由Refresh组件负责监听手指的下拉动作,计算下拉距离,决定是否触发刷新。
  • 状态层:用一个布尔状态标记当前是否处于刷新中,这个状态同时驱动刷新动画的显示和收起。
  • 数据层:列表数据源需要支持整体替换,而不是在旧数据上做增量覆盖。
  • 视图层:List负责高效渲染数据,并要处理好刷新前后的滚动位置。

这四个层次缺一不可。我见过有人只用@State数组往里push数据,结果刷新一次列表多了一倍重复项;也有人没管刷新状态,导致Refresh组件永远转个不停。这些问题不是组件库的问题,是对数据链路理解不到位。

1.2 为什么在HarmonyOS 6里推荐用Refresh包List,而不是自己写手势

HarmonyOS 6的ArkUI组件体系里,下拉刷新有一个标准做法:Refresh组件包裹滚动组件。你可以包List,也可以包Scroll、Grid,只要你包在Refresh里面,它就能识别这个滚动区域的下拉手势。

为什么不推荐自己用Touch事件去计算手指位移?我刚开始也想自己写,因为总觉得自绘手势会更灵活。实际试下来发现几个问题:

  • 滚动容器会拦截手势,你很难准确区分“我在滚动内容”和“我在下拉刷新”。
  • 下拉回弹动画要自己实现物理效果,很难调到和系统一样顺手。
  • 刷新状态和滚动位置的关系需要手动同步,容易在刷新过程中出现列表跳动。

Refresh组件内部已经处理了这些基础逻辑。你只需要关注两件事:刷新时做什么,刷新后什么时候结束。这也是HarmonyOS推荐这种组合方式的原因——框架把通用能力封装好,开发者专心处理业务逻辑。

1.3 List组件在数据更新时的表现差异

List组件和普通的Column布局最大的区别是视图复用。HarmonyOS的List配合LazyForEach,只渲染当前可视区域内的ListItem,上下滑动时会回收和复用节点。这意味着你替换数据源时,List不会傻傻地全量重建所有item,而是检测到数据变化后,复用已有的节点,只更新内容。

这个特性对下拉刷新非常关键。如果你的数据源没有按规范生成稳定的key,或者直接在原数组上做无序遍历,List会认为数据发生了大范围变化,从而销毁重建大量项。这个过程肉眼看起来就是刷新时列表闪一下,甚至卡顿。

所以在设计阶段,我强烈建议给每个列表项定义一个稳定且唯一的id,并且在LazyForEach里用它作为key。这个细节直接决定了下拉刷新动画结束后的流畅度。

2. 核心代码实现:从工程搭建到刷新逻辑落地

2.1 工程准备与目录结构

本次案例我使用的是DevEco Studio最新版本,创建了一个空白的ArkTS工程。项目名可以随便取,但建议在entry模块下新建一个pages/RefreshListPage.ets,方便单独调试。

在开始写代码前,先检查两件事:

  • 工程的compileSdkVersion是否支持你当前使用的Refresh API。HarmonyOS 6对应的SDK版本相对较新,Refresh组件的枚举和事件回调参数名可能和你之前看到的旧教程有差异,以你本地的API检查为准。
  • 是否开启了状态管理V2。HarmonyOS 6推荐使用更严格的类型状态管理,但我这个案例为了兼容性,还是用了标准的@State装饰器,跑起来没有任何问题。

2.2 页面骨架:Refresh组件包住List

先看页面最外层结构,我用的是下面这段代码:

@Entry @Component struct RefreshListPage { @State items: Array<ItemData> = []; @State isRefreshing: boolean = false; private pageIndex: number = 1; build() { Refresh({ refreshing: this.isRefreshing }) { List({ space: 12 }) { LazyForEach(this.items, (item: ItemData) => { ListItem() { Row() { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) Text(item.desc) .fontSize(13) .fontColor('#666666') } .padding(16) .width('100%') .backgroundColor(Color.White) .borderRadius(12) } }, (item: ItemData) => item.id) } .padding(16) .layoutWeight(1) } .width('100%') .height('100%') .onRefresh(() => { this.loadData(true); }) .onStateChange((status: RefreshStatus) => { if (status === RefreshStatus.REFRESHING) { this.isRefreshing = true; } else if (status === RefreshStatus.IDLE) { this.isRefreshing = false; } }) } }

这里有几个要点需要说明。

第一,Refresh组件的refreshing参数控制的是刷新状态。正常情况下你需要在刷新结束时把它设回false,但如果你用onRefresh回调去触发数据加载,并且配合onStateChange来同步状态,那么当Refresh组件自己回到IDLE状态时,isRefreshing会被正确复位。这两个回调是配合使用的,缺一个都容易出状态错乱。

第二,List的space属性控制项间距,我设置为12,视觉上比较紧凑。如果你需要更复杂的分组样式,可以改成ListItemGroup,但本案例不涉及。

第三,layoutWeight(1)是让List占满父容器剩余高度。如果你把List放在Column里,别忘了这个设置,否则列表底部会有空白区域,下拉手势的响应区间也不对。

2.3 数据源定义和模拟网络请求

接下来是数据部分,我创建了一个ItemData类,用来表示列表项。为了模拟真实接口,我封装了一个loadData方法,内部用setTimeout模拟网络延迟。

class ItemData { id: string; title: string; desc: string; constructor(id: string, title: string, desc: string) { this.id = id; this.title = title; this.desc = desc; } }

数据加载方法:

private loadData(isPullRefresh: boolean) { if (this.isRefreshing) { return; } this.isRefreshing = true; // 模拟网络请求 setTimeout(() => { const newItems: ItemData[] = []; const base = this.pageIndex * 20; for (let i = 0; i < 20; i++) { newItems.push(new ItemData( `item-${base + i}`, `标题 ${base + i}`, `这是第 ${base + i} 条数据的描述信息` )); } if (isPullRefresh) { // 下拉刷新:整组替换 this.items = newItems; } else { // 上拉加载更多:追加 this.items = this.items.concat(newItems); } this.pageIndex++; // 注意:这里不能直接 this.isRefreshing = false, // 而是要等Refresh组件自己回到IDLE状态后,由onStateChange统一处理 }, 800); }

关于这段代码,有三点实战经验分享:

第一,刷新去重保护。我在方法开头判断了isRefreshing,如果当前已经在刷新,直接return。这个保护很重要,因为用户在刷新动画还没结束时会连续下拉,你不加判断就会同时发出多个请求,最后数据互相覆盖。

第二,整组替换和追加要分开。下拉刷新应该替换整个数组,上拉加载才是追加。我在代码里用isPullRefresh区分了这两种行为。如果你统一用concat,每下拉一次,后面就会多一截旧数据,这个bug非常隐蔽。

第三,刷新状态的复位位置。我故意没有在setTimeout内部把isRefreshing置为false,而是希望让实际的Refresh状态决定UI。如果网络请求早就返回了,但Refresh动画还没走完,强行把isRefreshing置false会导致动画突然中断,视觉上很生硬。

2.4 Refresh状态回调的细节处理

再单独讲讲onStateChange这个回调。它在Refresh组件状态变化时触发,最常见的状态值是IDLE和REFRESHING。我在回调里根据状态同步isRefreshing:

.onStateChange((status: RefreshStatus) => { if (status === RefreshStatus.REFRESHING) { this.isRefreshing = true; } else if (status === RefreshStatus.IDLE) { this.isRefreshing = false; } })

有人会问:既然onRefresh里已经能发请求了,为什么还要这个回调?因为onRefresh只负责通知你“刷新动作发生了”,但Refresh组件内部的动画是一个独立过程。如果网络请求在动画结束前就返回了,你需要知道动画什么时候真正结束,才能准确复位状态。

这个同步逻辑也可以更简单:完全依赖onRefresh,在请求回调里手动把isRefreshing设为false。但那样做的话,一旦网络特别快,会出现刷新动画只闪了一下就被打断的现象。我个人还是推荐用状态回调驱动UI,让系统的刷新动画完整走完,观感更自然。

3. 细节打磨:刷新体验的隐藏价值点

3.1 刷新阈值和回弹手感

Refresh组件默认有一个触发刷新所需的下拉距离阈值。我在案例中用的是默认值,实际项目里可以根据交互稿调整。

HarmonyOS的Refresh组件支持传入offset参数,用来控制下拉距离的基准值。你还可以提供一个自定义的Builder,在下拉过程中显示自定义动画。比如很多App在下拉时会有一个拉伸的图片或者带有进度百分比文字,这些都是通过Refresh的builder参数实现的。

这里给一个简单的自定义刷新头示例:

Refresh({ refreshing: this.isRefreshing, offset: 100 }) { List() { } } .builder(() => { Row() { Text(this.isRefreshing ? '正在刷新...' : '下拉刷新') .fontSize(14) .fontColor('#999999') } .height(60) })

需要注意,自定义builder的布局不要写成固定高度死值,最好根据下拉距离动态调整。否则会出现下拉距离超过builder高度后内容悬空的问题。实际调试时我习惯先跑一次,记录builder高度的实时值,再回到代码里调整间距。

3.2 空数据和接口失败的处理

下拉刷新不可能每次都成功,接口失败、返回空列表都要有对应状态。我在项目里增加了一个isEmpty的派生状态,用来显示空页面占位。

一个简单做法是:请求完成后判断this.items.length === 0,在List外面用if/else切换空态视图。这个和Refresh不冲突,因为Refresh依然可以包裹空态,这样即便列表没数据,用户也能通过下拉刷新重新加载。

接口失败场景更麻烦。我建议在请求失败时保留旧数据,而不是清空items。你可以在loadData里加try/catch,失败时弹一个Toast提示,然后什么都不改。这样用户的阅读位置不会丢,也不会因为刷新失败看到白屏。

3.3 上拉加载更多与下拉刷新的协同

一个列表页通常同时需要下拉刷新和上拉加载更多。List组件本身有onReachEnd回调,会在滚动到底部时触发。我把它接到同一个loadData方法里,但传参区别一下:

.onReachEnd(() => { this.loadData(false); })

这里要注意一个问题:如果列表内容不满一屏,onReachEnd会立即触发,导致页面刚进来就疯狂加载。解决办法是加一个是否还有更多数据的判断,没有更多时直接return。

if (!this.hasMore) { return; }

我在案例里没有展开这部分,但实际项目一定要考虑。单纯的下拉刷新demo可以不做,一旦接上分页,这个细节就是必踩的坑。

3.4 图片加载对刷新流畅度的影响

列表项里如果带图片,刷新时图片重新加载也会造成卡顿。HarmonyOS的Image组件会在数据变化时重新拉取资源,尤其是远程图片,处理不当就会在刷新动画过程中出现白块。

优化思路有两种:一是缩略图缓存,二是使用Image占位图,让图片列表项在数据替换时快速复用旧图,等新图加载完成再替换。这个案例里我避免引入图片,但实际项目中务必要考虑这个因素。列表的流畅度往往不是刷新组件的问题,而是item渲染的瓶颈。

4. 常见问题与排查技巧实录

4.1 下拉刷新完全不触发

如果Refresh包了List之后,下拉没有任何反应,优先检查三点:

  • 检查Refresh组件是否设置了正确的宽高,如果高度为0或者包裹内容被子容器溢出,手势区域可能根本不在屏幕上。
  • 检查List的edgeEffect阻尼效果。HarmonyOS默认会给List边缘加阻尼,需要把它设置为EdgeEffect.Spring或EdgeEffect.None,否则下拉手势有可能被边缘效果吃掉。
  • 检查是否在Refresh里直接添加了scrollable子组件时,内部手势发生冲突。可以把List的高度调小,或者关闭内部滚动,让Refresh直接响应。

我在调试这个问题时,最喜欢用HarmonyOS的HiLog打印手势回调,看onTouch到底有没有收到事件。先把问题范围缩小到“组件没有响应手势”还是“响应了但没触发回调”,再针对性地改。

4.2 刷新动画一直转圈停不下来

这个问题的本质是**refreshing参数没有在正确时机复位**。

最容易出现的情况是:你在onRefresh里发起了网络请求,请求成功后直接把isRefreshing设为false,但这时候Refresh组件的内部动画状态还没有回到IDLE,两者状态冲突。表现就是动画在转,但数据已经更新完毕。

我的建议是抛弃“请求成功就立刻停止刷新”的思路,改成由onStateChange统一控制。只要Refresh组件认为动画走完了,自然会把IDLE状态回传给你,你只需要在这个回调里同步isRefreshing就行。

如果接口实在太快,动画一闪而过,你可以主动加一个最小刷新时长,比如请求发起后至少等800ms再允许结束:

setTimeout(() => { this.isRefreshing = false; }, Math.max(800, Date.now() - this.refreshStartTime));

注意,这段代码要在请求成功之后调用,不能放在请求前面。

4.3 刷新后列表内容闪烁或跳动

这个问题很大概率来自LazyForEach的key不稳定。如果你的key用的是index,数据一旦变化,所有item的key都变了,List会强制重建所有可见项。正确做法是给每个数据项生成一个业务唯一的id,比如数据库主键、接口返回的id,绝对不要用数组下标。

另外一个隐藏问题是:更新this.items时,如果你是直接修改数组元素,而没有创建新数组,那@State装饰器可能无法感知变化。我上面的代码用的是this.items = newItems,直接赋值新数组,这样状态系统才能确认变化发生。如果你用this.items[0] = xxx这种写法,在某些情况下UI不会刷新。

4.4 快速下拉导致重复请求

我在前面已经提过用isRefreshing做保护,这里再展开一下。真实场景里,用户在刷新动画尚未结束时会再次下拉,如果不加保护,每一次下拉都会触发一次接口请求,最终多个请求返回后相互覆盖数据。

加保护的位置有两个:一是loadData入口判断,二是onRefresh回调里判断。我推荐两个都加,双保险。入口判断防止业务代码手动误触发,回调判断拦截用户连续手势。

下面是常见问题的速查表,方便你排查时快速定位:

现象可能原因解决方案
下拉无反应List边缘阻尼吃掉了手势设置EdgeEffect.Spring或None
刷新动画不停止refreshing参数未正确复位由onStateChange统一管理状态
刷新后列表闪烁LazyForEach使用index作为key改为业务唯一id
数据重复下拉刷新使用了追加逻辑区分刷新和加载更多两种更新方式
多次请求缺少状态保护在入口和回调中双重判断isRefreshing
下拉时页面跳动item高度不稳定固定item高度或使用缓存组件

4.5 性能排查思路:从列表下滑流畅度反推问题

如果刷新结束后列表滑动明显卡顿,不要急着怀疑Refresh组件。先打开HiLog看掉帧记录,再检查item里是否有复杂计算、大量嵌套布局、过多容器层级。

HarmonyOS的ArkUI对容器层级很敏感。一个ListItem里嵌套五层Row/Column,性能会断崖式下降。我的经验是列表项尽量扁平化,能用一个Row解决的就不要嵌套Stack和Column。另外,列表项的宽高要让系统能提前计算出来,尽量减少measure阶段的耗时。

下拉刷新本身只是一个触发机制,真正的性能瓶颈往往在item渲染上。这个案例里我们用的是简单文本列表,你看不出差异,但换成富文本、图片、视频封面混合布局时,优化差异会非常明显。

5. 写在最后的个人实操体会

这个List下拉刷新案例看起来不大,我前前后后却调了快两天。最开始觉得Refresh组件是个现成的容器,套上去就完事,结果被状态管理、数据更新策略和手势冲突轮番教育。我个人最大的体会是:下拉刷新不是一个UI功能,而是一条数据链路。UI只是表象,真正要设计的是刷新过程中状态怎么流转、数据怎么替换、用户怎么感知。

如果你正在实现类似功能,我的建议是:先不做任何自定义视觉效果,把最朴素的Refresh + List跑通。确认下拉、转圈、数据替换、停止动画这四个环节稳定之后,再逐步加入自定义刷新头、上拉加载、空态占位这些锦上添花的东西。不要一上来就堆代码,先想清楚你的数据源是被替换还是被追加,刷新开始后哪些操作应该被禁止,以及刷新结束后列表应该停留在什么位置。

最后分享一个小技巧:调试下拉刷新时,把模拟网络请求的延迟调到300ms和3000ms各跑一遍。300ms能验证状态复位是否及时,3000ms能暴露重复下拉、界面假死这类边界问题。这两个极端场景都过了,实际线上环境才会稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询