
HarmonyOS6 发布之后我第一个翻的组件就是 List 的下拉刷新。原因很简单列表是绝大多数应用的骨架而刷新则是列表的命门。资讯流要刷新、消息页要刷新、商品列表要刷新几乎每个带列表的页面都躲不开这个交互。网上关于下拉刷新的文章不少但很多还停留在早期的写法上——直接给 List 绑一个 onRefresh 回调或者用 Scroll 自定义偏移量硬做。这套思路放到 HarmonyOS6 里会出现各种水土不服状态不归位、刷新时机错乱、嵌套滚动互相抢甚至直接编译报错。这篇文章我不会只丢一段能跑的代码而是把我自己从零搭一个资讯列表 下拉刷新 上拉加载更多的完整过程拆开讲包括为什么这么设计、哪些坑必须绕开、实测中哪些细节最容易被忽略。适合正在做列表页、资讯流或者消息列表的开发者尤其是从旧版本迁移过来、想搞清楚 HarmonyOS6 新交互逻辑的同学。1. HarmonyOS6 的下拉刷新别再用旧 API 硬搬了1.1 从 onRefresh 到 Refresh 组件到底改了什么早期开发者在 ArkUI 里做下拉刷新最常见的手段是给List或Scroll加onRefresh事件配合一个自定义的刷新头。这套方案在当时的版本里能用但它有几个天生问题刷新头的位置要靠绝对定位或者offset手动计算松手回弹的动画要自己处理刷新状态和列表滚动状态的联动也非常脆弱。一旦列表项高度不固定或者页面里还有搜索框、Tab 栏偏移量的计算很容易出偏差最后表现出来就是刷新头卡在半空或者闪烁。HarmonyOS6 把下拉刷新做成了独立的Refresh容器组件。这个思路和很多前端框架的做法一致把刷新容器和列表内容解耦你负责告诉容器当前是否在刷新容器负责处理手势、回弹、动画这些脏活累活。对比下来代码的复杂度下降非常明显但代价是你得先理解它的状态流转逻辑否则会出现在错误的时机刷新、刷新结束但 UI 不回弹这类问题。对比项旧方案自定义 onRefreshHarmonyOS6 方案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 Promisevoid((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 渲染和事件转发。这样改起来非常快而且不容易在多个页面之间复制代码时漏掉某个状态判断。如果你当前只是做一个简单列表不一定要引入那么重的设计但至少要把互斥和状态复位这两条底线守住下拉刷新才能真正稳定可靠。