HarmonyOS6 发布之后,我第一个翻的组件就是 List 的下拉刷新。原因很简单:列表是绝大多数应用的骨架,而刷新则是列表的命门。资讯流要刷新、消息页要刷新、商品列表要刷新,几乎每个带列表的页面都躲不开这个交互。网上关于下拉刷新的文章不少,但很多还停留在早期的写法上——直接给 List 绑一个 onRefresh 回调,或者用 Scroll + 自定义偏移量硬做。这套思路放到 HarmonyOS6 里会出现各种水土不服:状态不归位、刷新时机错乱、嵌套滚动互相抢,甚至直接编译报错。这篇文章我不会只丢一段能跑的代码,而是把我自己从零搭一个"资讯列表 + 下拉刷新 + 上拉加载更多"的完整过程拆开讲,包括为什么这么设计、哪些坑必须绕开、实测中哪些细节最容易被忽略。适合正在做列表页、资讯流或者消息列表的开发者,尤其是从旧版本迁移过来、想搞清楚 HarmonyOS6 新交互逻辑的同学。
1. HarmonyOS6 的下拉刷新:别再用旧 API 硬搬了
1.1 从 onRefresh 到 Refresh 组件:到底改了什么
早期开发者在 ArkUI 里做下拉刷新,最常见的手段是给List或Scroll加onRefresh事件,配合一个自定义的刷新头。这套方案在当时的版本里能用,但它有几个天生问题:刷新头的位置要靠绝对定位或者offset手动计算,松手回弹的动画要自己处理,刷新状态和列表滚动状态的联动也非常脆弱。一旦列表项高度不固定,或者页面里还有搜索框、Tab 栏,偏移量的计算很容易出偏差,最后表现出来就是刷新头卡在半空或者闪烁。
HarmonyOS6 把下拉刷新做成了独立的Refresh容器组件。这个思路和很多前端框架的做法一致:把"刷新容器"和"列表内容"解耦,你负责告诉容器当前是否在刷新,容器负责处理手势、回弹、动画这些脏活累活。对比下来,代码的复杂度下降非常明显,但代价是你得先理解它的状态流转逻辑,否则会出现在错误的时机刷新、刷新结束但 UI 不回弹这类问题。
| 对比项 | 旧方案(自定义 onRefresh) | HarmonyOS6 方案(Refresh 容器) |
|---|---|---|
| 刷新头位置 | 手动计算偏移量,易错 | 容器自动布局,无需关心 |
| 回弹动画 | 自己写动画曲线 | 内置回弹,可配参数 |
| 状态管理 | 自定义变量,难联动 | RefreshStatus 枚举,和组件状态绑定 |
| 嵌套滚动 | 容易和 List 抢手势 | 容器统一处理,冲突更少 |
| 扩展性 | 改样式成本高 | 支持自定义刷新头组件 |
1.2 一个反直觉的结论:刷新状态要自己控制
很多新手第一次接触Refresh组件时会有一个误解:以为只要把List丢进Refresh里,手指一拉松手,刷新就自动完成了。实际上不是这样。Refresh组件只负责"手势识别"和"动画表现",真正决定"数据有没有重新加载完"的是你的业务逻辑。你需要在合适的时机把绑定状态改成"刷新中",等数据请求回来后,再把状态改回"非刷新中"。这个状态不归位,刷新头就会一直转圈。
这一点和网页端的pull-to-refresh插件很类似——UI 只是反馈,数据生命周期必须由应用自己管理。理解了这一点,后面所有的问题都好解释了:为什么刷新会卡住?因为状态没复位;为什么刷新时机不对?因为你把请求放在了错误的事件里;为什么手势不触发?因为容器高度或者滚动位置不对。
2. 先搭一个 List 骨架:从数据源到列表项样式
2.1 数据模型与 Mock 数据准备
在碰刷新逻辑之前,我建议先把列表本身跑通。别一上来就接网络请求,否则你根本分不清是列表的问题还是刷新逻辑的问题。我的做法是先建一个数据模型,再用本地 Mock 数据模拟接口返回,等列表能正常滚动、正常渲染了,再引入刷新容器。
以资讯列表为例,一条列表项包含标题、摘要、发布时间、阅读数这几个字段。在 ArkTS 里直接用 class 定义模型比用 interface 更顺手,因为后续创建对象、比较对象都会方便一些。
// 资讯数据模型 class NewsItem { id: string; title: string; summary: string; publishTime: string; readCount: number; constructor(id: string, title: string, summary: string, publishTime: string, readCount: number) { this.id = id; this.title = title; this.summary = summary; this.publishTime = publishTime; this.readCount = readCount; } }Mock 数据我习惯放在一个单独的文件里,这样以后接真实接口时,只需要替换数据来源,不用动页面结构。生成方式也很简单:用数组存固定内容,或者写一个小循环生成带编号的假数据,方便观察刷新后列表内容是否有变化。
// 生成模拟资讯数据 function generateMockData(startId: number, count: number): NewsItem[] { const items: NewsItem[] = []; for (let i = 0; i < count; i++) { const id = startId + i; items.push(new NewsItem( `news_${id}`, `资讯标题 ${id}`, `这是第 ${id} 条资讯的摘要内容,用于测试列表滚动和下拉刷新效果。`, `2025-01-${String((i % 28) + 1).padStart(2, '0')}`, 100 + id * 37 )); } return items; }2.2 ForEach 渲染与 key 的生成原则
数据准备好了,接下来就是让List把数据渲染出来。HarmonyOS6 里List组件配合ListItem使用,循环渲染通常用ForEach。这里有一个非常关键的细节:ForEach的第三个参数是 key 生成器。
如果你不写 key,或者直接把数组下标拿来当 key,刷新后一旦数据顺序变了,ArkUI 的状态复用就会出现问题。比如某条数据的readCount在刷新后变了,但 UI 还显示旧值,或者滚动位置突然跳了一下,十有八九就是 key 不合适。
我推荐用数据的唯一标识id作为 key,而不是下标。这样即时数据排序变了,组件也能准确关联到对应的数据项。
List({ space: 12 }) { ForEach(this.newsList, (item: NewsItem) => { ListItem() { NewsCard({ item: item }) } }, (item: NewsItem) => item.id) } .padding(16) .layoutWeight(1)NewsCard是我单独抽出来的一个组件,用来展示单条资讯的结构。组件化之后,列表项的样式、事件都能独立维护,后续做刷新头、加载更多也都是在周边扩展,不会把列表项本身的代码越堆越乱。列表项里面我放了标题、摘要、底部信息栏三块,用Column做垂直布局,再用Row把时间和阅读数放在同一行。这一步不用写太复杂,只要保证一条列表数据的展示清晰、容易阅读即可。
3. 下拉刷新交互实战:状态绑定、回调时序与动画细节
3.1 Refresh 组件的状态流转
骨架搭好之后,真正的重头戏来了:用Refresh组件把List包起来。在 HarmonyOS6 里,基础写法是先把一个布尔类型的状态变量绑定到Refresh组件的refreshing参数上,再通过onRefreshing事件回调来处理刷新逻辑。
@State isRefreshing: boolean = false; Refresh({ refreshing: this.isRefreshing }) { List({ space: 12 }) { ForEach(this.newsList, (item: NewsItem) => { ListItem() { NewsCard({ item: item }) } }, (item: NewsItem) => item.id) } .padding(16) .layoutWeight(1) } .onRefreshing(() => { // 下拉达到阈值并松手后,进入刷新状态 this.isRefreshing = true; this.loadNewsList(true); })这个代码看着简单,但里面的时序问题非常多。onRefreshing虽然看起来像是一个"回调",但它在实际运行中是由手势和组件状态共同驱动的。你需要在回调里把isRefreshing置为true,让组件知道"现在正处于刷新中",然后启动数据请求。等请求返回后,再把isRefreshing置回false,组件才会收起刷新头、回弹到正常状态。
3.2 为什么刷新结束状态必须放在异步请求之后
我见过不少项目把isRefreshing = false放在请求发出去之后立刻执行,结果就是刷新头转了一下马上收回,数据其实还没回来。正确做法必须是等数据真正到了、界面已经拿到新内容,再关闭刷新状态。
我用一个简单的setTimeout来模拟网络请求,实际项目里替换成 HTTP 请求或者 RCP 请求即可。
async loadNewsList(isPullRefresh: boolean) { // 模拟网络请求耗时 await new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, 1200); }); // 下拉刷新:重置数据;上拉加载更多:追加数据 if (isPullRefresh) { this.newsList = generateMockData(1, 20); } else { const newItems = generateMockData(this.newsList.length + 1, 10); this.newsList = this.newsList.concat(newItems); } this.isRefreshing = false; }这里还有一个容易被忽略的点:async函数里如果抛异常,isRefreshing可能永远无法复位,下拉刷新就会一直转圈。所以一定要把状态复位放在finally里,或者单独做异常兜底。我的习惯是请求逻辑里加 try/catch/finally,保证无论成功失败,刷新状态都会被回收。
async loadNewsList(isPullRefresh: boolean) { try { // 请求数据 } catch (e) { // 异常处理:可以弹 toast,或者保留旧数据 } finally { this.isRefreshing = false; } }3.3 刷新头组件与动画参数调整
大部分场景下,用系统默认的刷新头就够了。但如果你想换掉默认的"转圈",HarmonyOS6 的Refresh也支持自定义刷新头。只需要在Refresh容器里放进一个自定义组件,并监听刷新状态变化。
这里我强烈建议先用默认样式跑通整个流程,再考虑自定义。原因是在没有完全理解状态流转之前,自定义刷新头会引入额外的变量,排查问题时容易分不清是业务逻辑的问题还是自定义样式的问题。默认样式下,你能非常直观地看到"拉下-松手-刷新中-完成回弹"这一整套流程,确认逻辑无误后,再动手换成自己的 UI。
如果你确实需要自定义,可以参考下面的思路:用一个@State变量记录当前的RefreshStatus,然后根据状态渲染不同的提示文案和图标。手势拉动过程的 offset 也可以接收,用来做"下拉距离越远图标越大"这类效果。要注意的是,自定义刷新头的动画更新频率很高,不要在状态变化里做太重的计算,否则滚动掉帧会很严重。
4. 上拉加载更多与下拉刷新共存的坑:状态机设计是核心
4.1 两个状态互相打架的经典问题
单做下拉刷新,逻辑其实还算简单。但真实列表页面几乎都要同时支持"下拉刷新"和"上拉加载更多",这个时候如果状态管理做得稀烂,马上会出现几个经典问题:刷新还没结束就触发了加载更多,加载更多还没回来却又刷了一次,刷新头还没收起但是新数据已经追加进去导致列表闪烁。
这些问题的根源都是同一个:你没有把"刷新中"和"加载中"当作两个独立的状态来管理。正确的做法是用一个状态机来约束互斥关系,而不是简单地让两个布尔变量自由切换。
我用的是下面这个简单的三态模型:
enum LoadState { Idle, Refreshing, LoadingMore }Idle表示空闲,Refreshing表示正在下拉刷新,LoadingMore表示正在加载下一页。在下拉刷新触发时,只有Idle状态才能进入Refreshing;上拉加载同理。这样就不会出现两个请求同时跑的情况。
4.2 代码实践:互斥状态下的真实流程
以这个状态机为基础,我再把列表页的完整逻辑整理一下。页面里维护两个状态变量:loadState负责互斥,newsList负责数据。列表滚到底部时触发onReachEnd,加载完成后追加数据。
@State loadState: LoadState = LoadState.Idle; @State newsList: NewsItem[] = generateMockData(1, 20);下拉刷新的回调里,先判断当前是否空闲,否则直接忽略这次刷新请求:
.onRefreshing(() => { if (this.loadState !== LoadState.Idle) { return; } this.loadState = LoadState.Refreshing; this.loadNewsList(true); })上拉加载更多的回调,同样要做状态判断:
.onReachEnd(() => { if (this.loadState !== LoadState.Idle) { return; } this.loadState = LoadState.LoadingMore; this.loadNewsList(false); })loadNewsList里的核心变化是:根据加载类型决定是重置列表还是追加列表,但不管是哪种,最后都必须把loadState重置为Idle。
async loadNewsList(isPullRefresh: boolean) { try { await this.fetchRemoteData(isPullRefresh); if (isPullRefresh) { this.newsList = this.tempData; } else { this.newsList = this.newsList.concat(this.tempData); } } catch (e) { // 处理异常 } finally { this.loadState = LoadState.Idle; } }这里我特意把网络请求和数据处理拆开了。实际项目中,请求失败时要保留旧数据,只在成功时才替换或追加,这样用户体验才稳。不要一进try就把列表清空,否则一旦请求失败,页面就变成一片空白。
4.3 分页参数与"没有更多"的判断
加载更多还需要考虑分页参数。最简单的做法是维护一个page变量,每次加载更多时加一。同时还需要一个hasMore标志位,当后端返回的数据条数少于单页条数时,说明到底了,此时不再触发加载更多。
@State page: number = 1; @State hasMore: boolean = true; async loadMoreNews() { if (!this.hasMore || this.loadState !== LoadState.Idle) { return; } this.loadState = LoadState.LoadingMore; const nextPage = this.page + 1; const newItems = await this.fetchNewsByPage(nextPage); if (newItems.length < 10) { this.hasMore = false; } this.newsList = this.newsList.concat(newItems); this.page = nextPage; this.loadState = LoadState.Idle; }在列表底部可以用一个简单判断来展示"加载中"或者"没有更多了"。这个状态的展示通常放在List的最后一个ListItem里,如果hasMore为 false,就显示一行灰色的提示文字。
5. 实测中容易踩的坑:从状态不归位到滚动冲突
5.1 刷新头一直转圈:首先检查状态复位
开发过程中最常撞上的问题就是刷新头一直转圈,怎么拉都收不回去。遇到这个现象,我的排查顺序非常固定:先看isRefreshing有没有在数据请求结束后被置为 false。如果代码路径里有提前 return、或者异常被吞掉,状态就永远不会复位。
还有一种隐蔽的情况:你在onRefreshing里异步调用loadNewsList,但这个函数在finally之前又抛了错,而且错误没有被捕获。ArkTS 里错误如果没有被正确处理,后续代码不会执行,状态自然卡住。所以我在每个异步操作里都会加完整的 try/catch/finally,目的不是处理错误本身,而是为了确保状态回收。
5.2 下拉触发区域变得很窄:注意 List 高度
Refresh组件要能正常识别下拉手势,需要它的子组件在纵向上有足够的可滚动区域。如果你把List放进了Flex或Column里,但没有给List设置layoutWeight(1)或者明确高度,列表可能只占很小的区域,导致只有触摸到那一小块区域才能触发刷新,看起来就像"刷新失效"。
遇到这种情况,第一反应不是改手势参数,而是检查布局是否铺满。我一般会在Refresh的外层用Column撑满整个页面,再给List高度的权重或者flex属性,保证列表区域足够大。你可以用Edge或Debug模式把组件边框描出来,能看到列表的实际占比,排查起来会直观很多。
5.3 嵌套滚动冲突:和 Tabs、Search 一起用时的处理
列表页里经常还有其他可滚动组件,比如顶部搜索框、分类 Tab。如果List外层套Refresh,但Tabs也在同一层级,手势上下滑动时就会出现抢滚动的情况。表现为:明明想滚动列表,却把整个页面拽下来了;或者下拉手势被 Tab 组件吞掉,刷新根本触发不了。
这类冲突没有绝对通用的配置,但我的经验是:尽量让List成为页面中唯一的主要滚动区域,其它区域用固定高度的组件,避免多层滚动嵌套。如果确实需要在Tabs里嵌List,可以尝试调整List的edgeEffect和nestedScroll相关参数,但要理解不同版本对这些参数的支持程度不同,建议以真机实测为准。
6. 性能与体验优化:列表项复用、图片懒加载与节流
6.1 列表项复用:别让每一项都重建
下拉刷新加加载更多跑通之后,列表页还有一个逃不掉的话题:性能。尤其是资讯流这种内容较多的列表,如果每一项都创建独立的图片资源、复杂的子组件,滚动时明显感觉到掉帧。
HarmonyOS6 的List和早期的Scroll方案相比,一个优势就是复用机制。你不需要手动管理缓存池,但需要保证列表项组件尽量"轻"。具体来说:
- 不要在
ListItem内部做耗时的同步计算,比如大量字符串拼接、数据格式化,尽量在数据准备阶段完成。 - 图片加载使用异步占位,避免阻塞主线程渲染。
- 复杂的布局层级能压平就压平,能用
Row/Column就别嵌套十层容器。
6.2 图片懒加载与缓存思路
在资讯列表里,图片通常是最大的性能消耗点。每一个列表项都有一张缩略图,如果页面初始化时一次性全部加载,不仅慢,还容易造成内存峰值。我的做法是:缩略图使用轻量占位图,真正可见时再加载真实图片。
HarmonyOS6 的Image组件本身就支持按需解码和占位图配置。你可以通过placeholder参数指定加载前的显示内容,再配合自定义的懒加载机制,在列表项真正进入可视区域时再触发网络图片加载。
这里我给一个简单的代码片段:
Image(this.item.imageUrl) .width('100%') .height(180) .objectFit(ImageFit.Cover) .borderRadius(12) .backgroundColor('#F1F3F5') .placeholder(this.loadingPlaceholder)placeholder可以传一个PixelMap或者本地资源。这样图片没加载出来之前,UI 上至少有一个占位区域,不会出现文字突然顶上去、图片突然铺开的跳动感。
6.3 手势节流:防止 onReachEnd 连续触发
onReachEnd这个事件有一个特点:当你快速滑动列表到底部时,它可能被连续触发多次。如果每次触发都发请求,等于同时发出好几个请求,用户体验和资源消耗都很差。
除了前面状态机的互斥,我还习惯加一个防抖:在触发加载之后记录时间戳,短时间内不重复触发。比如用lastLoadTime记录上次加载的时间,如果两次时间间隔小于 500ms,就直接返回。
private lastLoadTime: number = 0; onReachEndHandler() { const now = Date.now(); if (now - this.lastLoadTime < 500) { return; } this.lastLoadTime = now; this.loadMoreNews(); }其实状态机的互斥已经能挡住并发请求了,但加这个节流之后,连事件本身的频繁触发都会被压住,日志看起来更干净,排查问题也更舒服。
6.4 刷新反馈细节:触感与文案
最后聊一个容易被忽略但很影响体验的细节:刷新过程中的反馈。默认刷新头转圈,但用户往往更关心"刷新之后有什么变化"。我的做法是在刷新成功后,如果数据条数发生了变化,列表顶部会有一个轻微的提示,比如"已更新 12 条资讯"。这个提示不需要太复杂,用animateTo做透明度变化,或者让刷新头区域短暂显示一段文字即可。
触感方面,Refresh组件支持在状态切换时触发震动反馈。但这个功能比较依赖硬件和系统设置,不一定在所有机型上都有明显效果,所以不要把它作为核心反馈路径,只能作为增强体验的补充。
我自己在实际项目里把刷新互斥状态、分页参数、节流时间放在一个单独的"列表状态管理类"里,页面只负责 UI 渲染和事件转发。这样改起来非常快,而且不容易在多个页面之间复制代码时漏掉某个状态判断。如果你当前只是做一个简单列表,不一定要引入那么重的设计,但至少要把互斥和状态复位这两条底线守住,下拉刷新才能真正稳定可靠。