
上拉加载下拉刷新听起来就是移动端列表页里最不起眼的一对交互但真要在开源鸿蒙OpenHarmony的 ArkUI 框架下把它做稳、做顺、做到跨端不飘我花了整整三天时间。训练营Day4~6这三天我把一个叫某开源资讯聚合Demo的模拟项目从静态列表改造成完整的分页数据流下拉重新拉取第一页上拉到末尾追加下一页中间还要处理加载中、没有更多、请求失败这些状态。这篇文章就完整复盘这三天是怎么拆解、落地、踩坑和修复的。内容按训练营的推进节奏来组织所有代码和思路都是我在实际项目里验证过、能直接抄的版本。如果你正准备在 ArkUI 里写列表交互或者已经从 List 组件起步、但被 onReachEnd 疯狂触发这类问题折腾过这篇应该能帮你少走不少弯路。1. 先说清楚Day4~6到底在做什么1.1 需求拆解不是绑两个事件就完事很多开发拿到下拉刷新、上拉加载的第一反应是给列表绑两个回调函数不就行了实际操作下来这远远不够。一个完整的、能上线的列表页至少包含四层能力。第一层是最基础的手势响应下拉到一定距离触发刷新上拉到底部触发加载更多。第二层是数据请求管理刷新要重新请求第一页、并且替换掉旧数据加载更多要在当前页码基础上加一、再把新数据追加到列表尾部。第三层是 UI 状态同步刷新动画什么时候开始转、什么时候停底部要不要显示加载中数据全拉完了要不要提示没有更多了。第四层是防错机制快速连续下拉、列表不满一屏时上拉自动触发、请求失败后状态怎么恢复这些细节不提前设计后面排查起来非常痛苦。训练营前几天我们已经在练基础组件到Day4做这个列表页时我一开始也想简单堆事件结果第一次跑起来就出了三个问题刷新动画一直转圈退不回去、滚动到底部 onReachEnd 连续触发了四五次、下拉和上拉的状态互相打架。所以Day4到Day6真正做的事其实是在搭一个状态模型让数据请求和 UI 表现始终同步。1.2 为什么选内置 Refresh 和 List 而不是手写ArkUI 里能用来做滚动列表的容器不少Scroll、List 都可以。网上也有人选择用 Scroll 自己监听 touch 事件、算下拉距离、做回弹动画这样自由度更高但我测试下来不建议在常规项目里这么做。原因有三点。一是手势边界问题比想象中复杂用户下拉时手指在屏幕上是连续移动的你得自己判断是滚动页面还是触发刷新还要处理按下位置、滑动方向、超过阈值后的回弹这些代码量不少而且不同设备触控灵敏度不一样很难调出手感统一的体验。二是动画和绘制性能系统级 Refresh 组件内部已经做过渲染层优化手写方案用 translate 偏移整个列表时低端机上很容易掉帧。三是跨平台适配成本我们做的是开源鸿蒙跨平台项目同样一套界面要跑在不同屏幕尺寸的设备上系统组件能直接适配安全区和横竖屏切换自定义方案这些都得自己算。所以方案定为Refresh 组件整体包住 List 组件Refresh 负责下拉手势和转圈动画List 负责滚动列表和到达底部的事件。这样语义清晰责任单一后续维护成本也最低。提示Scroll 不是不能用它适合做整页刷新或者结构不规则的滚动页面。如果内容本身就是数据条目组成的列表一定要用 List因为 List 有懒加载机制几百条数据也不会一次性全建节点。2. 核心组件与状态模型2.1 让状态流转先行UI 才不会乱飞我在第一天下午踩了最大的一个坑一边改数据、一边改 UI 标志位最后代码里到处是 if 判断逻辑严重乱套。后来我把整个列表页的状态整理成了一个简单的状态机所有流程都围绕它走。我定义了四种基础状态Idle无请求列表展示中。Refreshing下拉刷新请求进行中顶部转圈动画生效。LoadingMore上拉加载请求进行中底部出现加载占位。NoMore所有分页数据已全部加载完毕。再加一个 Error 状态用于请求失败时展示提示同时保留旧数据。实际项目里可能还要区分 Empty列表空的和 Error请求失败但核心是这三个状态不能同时为 true。我会用三个布尔变量组合表达分别是 isRefreshing、isLoading、hasMore。这三个变量的关系是铁律isRefreshing 和 isLoading 永远不能同时为 truehasMore 为 false 时不能再发起加载更多任何一次请求结束后必须把对应的标志位恢复为 false。这听起来像废话但训练营里几乎所有同学的 bug 都是因为某个分支忘了复位标志位。2.2 Refresh 组件的正确打开方式ArkUI 的 Refresh 组件本质是一个容器组件包在里面的内容会跟随下拉手势产生位移动画。它最核心的两个参数是 refreshing 和 onRefresh。Refresh({ refreshing: this.isRefreshing, onRefresh: () { this.handleRefresh() } }) { // 列表内容 }这里的 refreshing 是布尔值表示当前是否处于刷新状态。注意它并不是说你下拉动作触发了就自动变 true它只是一个受控状态你自己在 onRefresh 回调里把 isRefreshing 设为 true转圈动画才出现。很多新手卡在这个地方下拉松手后onRefresh 确实触发了但 refreshing 没置 true页面看起来没反应还有一种情况是 refreshing 一直为 true动画转个不停因为数据回来后忘了置 false。另一组写法是属性式Refresh() { // 列表内容 } .refreshing($$this.isRefreshing) .onRefresh(() { this.handleRefresh() })其中$$表示双向同步组件内部刷新结束时可以直接改回你的状态变量。具体用哪种其实不冲突我觉得构造参数形式更直观一眼就能看到这是个受控组件。2.3 List 组件的滚动事件与触发时机List 组件是整个方案的另一半。它提供了两个滚到边界的事件onReachStart 和 onReachEnd分别在滚动到顶部和底部附近时触发。我们上拉加载主要依赖 onReachEnd。List({ space: 12, scroller: this.scroller }) { // 列表项 } .onReachEnd(() { this.handleLoadMore() })你需要理解它的触发条件当列表滚动接近底部时触发但它不会自动杜绝重复触发。只要你还停留在底部区域或者加载完数据后列表高度没有超过屏幕它就可能反复触发。所以 onReachEnd 回调里必须做防重入判断这是每个上拉加载方案都绕不开的一道坎。我在实际测试中还发现onReachEnd 的触发阈值跟设备高度有关。列表内容太少、一屏没放满时组件初始化后就会直接处于到达底部状态onReachEnd 会被立即调用。这一点在第5部分会展开讲解决方案。3. 实操把功能彻底跑通3.1 页面骨架Refresh 包 List先把页面主体代码摆出来。这个版本是训练营Day5结束时能正常跑通的完整结构我在关键位置加了注释。Entry Component struct FeedListPage { State dataSource: ArrayItemData [] State isRefreshing: boolean false State isLoading: boolean false State hasMore: boolean true State currentPage: number 1 private pageSize: number 20 private scroller: Scroller new Scroller() build() { Column() { Refresh({ refreshing: this.isRefreshing, onRefresh: () { this.handleRefresh() } }) { List({ space: 12, scroller: this.scroller }) { ForEach(this.dataSource, (item: ItemData, index: number) { ListItem() { this.ItemRow(item) } }, (item: ItemData, index: number) (item.id.toString())) if (this.isLoading) { ListItem() { this.LoadingRow() } } else if (!this.hasMore this.dataSource.length 0) { ListItem() { this.NoMoreRow() } } } .onReachEnd(() { this.handleLoadMore() }) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) } } .width(100%) .height(100%) } Builder ItemRow(item: ItemData) { Column() { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .width(100%) Text(item.summary) .fontSize(14) .fontColor(#666666) .width(100%) .margin({ top: 6 }) } .padding(12) .backgroundColor(Color.White) .borderRadius(8) } Builder LoadingRow() { Row() { LoadingProgress() .width(24) .height(24) Text(加载中...) .fontSize(14) .fontColor(#999999) .margin({ left: 8 }) } .width(100%) .justifyContent(FlexAlign.Center) .padding({ top: 12, bottom: 12 }) } Builder NoMoreRow() { Text(没有更多数据了) .fontSize(14) .fontColor(#999999) .width(100%) .textAlign(TextAlign.Center) .padding({ top: 12, bottom: 12 }) } }这套骨架的精髓在于底部加载中和没有更多了不是独立写在列表外面的而是作为 ListItem 放在了 ForEach 之后。这样做的好处是它们会跟着列表一起滚动当加载完成后自然滚出屏幕不会遮挡内容也不会引起布局跳动。3.2 下拉刷新三行核心逻辑下拉刷新的逻辑可以拆成三步重置页码、请求第一页、替换数据源。代码如下private handleRefresh(): void { if (this.isRefreshing || this.isLoading) { return } this.isRefreshing true this.currentPage 1 this.fetchData(true) }这里有个重要细节我在进入刷新前判断了 isLoading。如果此时上拉加载还没结束就直接忽略刷新请求避免两个请求同时修改 dataSource造成数据错乱。fetchData 里模拟网络请求的部分如下private fetchData(isRefresh: boolean): void { // 模拟网络请求真实项目替换为 HTTP 调用即可 setTimeout(() { const newData this.generateData(this.currentPage, this.pageSize) if (isRefresh) { this.dataSource newData } else { this.dataSource this.dataSource.concat(newData) } this.hasMore newData.length this.pageSize this.isRefreshing false this.isLoading false }, 500) }生成模拟数据的 generateData 我简单封装了一下按页码生成不同标题便于观察private generateData(page: number, size: number): ArrayItemData { const list: ArrayItemData [] for (let i 0; i size; i) { const id (page - 1) * size i list.push({ id: id, title: 第 page 页 - 资讯 i, summary: 这是一条来自某一开源社区的模拟内容用于验证上拉加载和下拉刷新的分页逻辑。 }) } return list }refresh 流程的要点是刷新成功前旧的 dataSource 不要清空。有些同学喜欢在刷新开始时就执行this.dataSource []结果列表瞬间变空、白屏闪烁体验非常差。正确的做法是等新数据回来以后一次性整体替换。3.3 上拉加载onReachEnd 加防重入上拉加载的核心逻辑比刷新多一个防重入判断而且状态变量多了一个 isLoadingprivate handleLoadMore(): void { if (this.isLoading || this.isRefreshing || !this.hasMore) { return } this.isLoading true this.currentPage 1 this.fetchData(false) }防重入的写法很简单但背后的场景很实际。用户在底部连续快速上滑onReachEnd 可能在一秒内触发两三次如果每个事件都发一次网络请求就会同时请求第2页、第3页、第4页返回顺序一乱列表数据就串了。isLoading 这个闸门保证同一时间只有一个加载请求在跑。有点经验的人还会再叠一层容错记录最近一次请求的唯一标识当数据返回时仅当标识仍是本次请求时再去更新 dataSource。这个处理对弱网环境尤为重要后面第5部分我会单独讲。3.4 底部状态loading 与“没有更多了”底部状态是通过两个 if 条件渲染出来的。这里有个顺序问题我踩过必须先判断 isLoading再判断 !hasMore。因为加载完成后 hasMore 可能已经变 false但 isLoading 还在收尾过程中如果顺序反了底部会先显示没有更多了再跳成 loading视觉效果很怪。真实项目里没有更多了这个提示一般还要加条件仅当 dataSource.length 0 时显示。首屏请求如果就返回空数组你应该展示的是空态页面而不是一个孤零零的没有更多了。没有更多数据时我还习惯顺手把 List 的 onReachEnd 逻辑挡掉。也就是 handleLoadMore 里的!hasMore判断。否则列表虽然没内容变化但事件还是会不断触发浪费不必要的函数调用。4. 跨平台适配与性能细节4.1 单位与响应式适配开源鸿蒙跨平台开发里最基础的单位是 vpvirtual pixel它跟设备像素密度有关设计稿上的 px 需要除以密度换算。UI 内容宽度尽量用 vp列表间距、字体大小都建议直接写 vp 数值系统会自适应。横竖屏和不同窗口尺寸适配我用的方法是布局容器加自适应占比比如列表项里标题文本宽度设成100%内边距固定 12vp这样不管是手机还是平板视觉比例都不会太离谱。更精细的适配可以通过 MediaQuery 监听断点在宽屏设备上调整列表列数或内容留白。训练营这个 Demo 我先用单列列表因此重点只放在安全区避让上Refresh 和 List 都在 Column 容器内高度设满顶部的标题栏用系统组件自动避让状态栏底部没有自定义 tab所以不用额外处理底部导航条。4.2 大数据量LazyForEach 与稳定键值我上面的示例代码用的是 ForEach。训练营里老师专门提醒过ForEach 是直接遍历渲染列表条数超过一两百条时节点数量会明显拖慢滚动性能。工业级写法要换成 LazyForEach它只在列表项即将进入可视区时才会创建组件。LazyForEach 需要实现数据源的懒加载接口核心是三个方法getCount、getData、getKey。getData 返回对应索引的数据getKey 返回每条数据的唯一键。键值的稳定性特别重要如果键值在刷新或翻页后重复列表会复用错误的组件出现文字和图片对不上号的问题。我的经验是直接用服务端返回的 id 作为键值不要用索引。用索引当键值前面插入新数据时后面对应的组件会因为键值变化而重建性能反而更差。4.3 竞态处理快速操作下如何不串数据这三天里最有价值的一课是处理请求竞态。场景一用户下拉刷新刷新请求还没回来又快速上拉触发了加载更多。我的状态机里已经限制 isRefreshing 和 isLoading 互斥所以这种情况不会并发。但另一个场景更隐蔽用户先下拉刷新紧接着又下拉刷新第二次。第一次请求发出后第二次刷新已经把 currentPage 重置为 1如果第一次的请求晚回来它可能会用旧的 page 值为 1 的数据覆盖新数据实际上两者都是第一页危害不大。但如果用户先加载第2页然后马上刷新并重新加载第1页旧的第2页请求晚回来就会把新的第一页数据追加到列表末尾造成脏数据。针对竞态我的处理策略是请求序号机制。每次发起新请求前把 self.requestSeq 加一并把本次请求的序号记录下来响应回来时如果自己的序号不再是最新的就丢弃数据不更新 UI。这个方案在真实接口下成本很低但能挡住绝大多数乱序问题。private requestSeq: number 0 private fetchData(isRefresh: boolean): void { const seq this.requestSeq // 网络请求回调里判断 if (seq ! this.requestSeq) { return // 说明已有更新的请求丢弃这次结果 } // 正常更新数据源 }4.4 减少渲染负担Builder 与组件拆分列表项组件在滚动中会被频繁创建和销毁渲染函数的优化直接决定帧率。我在代码里用 Builder 把 ItemRow、LoadingRow、NoMoreRow 抽了出来好处是构建逻辑清晰、复用方便也能避免在 ListItem 里写一大串 UI。还有一个容易被忽略的点ListItem 不要包一层多余的 Column 再包内容。ListItem 本身就是布局容器直接在里面写 Column、Row 或者 Text 都没问题包太多层层级深了布局计算量会成倍增加滚动手感就会变沉。保持单层到两层嵌套是最理想的。5. 训练营最后一天问题排查与避坑清单5.1 刷新动画一直转退不回去训练营里至少有一半同学卡在这个问题上。表现是下拉松手后顶部一直转圈数据其实已经刷新完了但动画停不下来。原因基本都是 refreshing 这个受控状态没有在数据请求完成后置为 false。解决方案很简单在 fetchData 的所有出口统一把 isRefreshing 设置为 false而不要只在一个成功分支里重置。代码里我是放在 setTimeout 回调的最后一行成功和失败都走同一句复位逻辑。如果你的请求函数有多个 return 分支建议用 try-finally保证 finally 里复位状态。5.2 onReachEnd 连续触发请求发个不停这是上拉加载最常见的坑。onReachEnd 在滚动到底部后每次布局变化到接近底部时都可能再次触发。比如你加载完一页数据列表高度新增了一段但用户还停留在底部附近事件就再次触发然后无限加载下去。我的解决办法有三层。第一层是 handleLoadMore 的互斥与 hasMore 判断这个已经有了。第二层是在 List 加载完数据后如果用户确实还在底部系统还会继续触发这是正常的但如果它连续触发导致频繁 loading我会用一个底部加载时间戳保证两次加载请求间隔不低于800毫秒。第三层是当 hasMore 为 false 时直接禁用 onReachEnd这个前面也提到了。另外补充一个细节请求失败时isLoading 必须复位否则下一次滚动永远无法触发加载。我在失败回调里除了复位 isLoading 外还会把 currentPage 减回去保证下次加载不会跳过一页数据。5.3 列表不满一屏onReachEnd 立即触发这个问题非常隐蔽。当列表数据很少不够填满一个屏幕时List 一开始就处于到达底部状态onReachEnd 会在页面加载完成后立即触发导致自动加载两次、三次一直拉到填满屏幕为止。这个行为处理起来没有很优雅的通用解法我的方案是在 onReachEnd 回调里判断 Scroller 的当前偏移量。如果当前滚动偏移量接近于0且总内容高度小于可视高度说明列表不满一屏此时应该延迟到容器尺寸变化后再触发加载或者直接允许它加载——因为加载后如果还不满一屏继续触发其实是符合让列表填满内容的直觉的。真正要避免的是无限快速触发所以上面说的时间戳防抖在这里非常管用。5.4 刷新后列表跳动回顶部数据刷新完成后用户希望列表回到顶部或停留在当前阅读位置。默认情况下因为 dataSource 整体替换列表可能会跳变。我的处理是用 Scroller 控制。this.scroller.scrollToIndex(0, true)在刷新成功且 isRefresh 为 true 时滚动到顶部是合理的交互。但如果是加载更多就绝对不要调这个否则用户翻页到一半被拉回顶部体验非常糟糕。5.5 问题排查速查表问题现象可能原因解决要点刷新动画不退refreshing 未复位请求完毕的统一出口置 false用 finally 兜底上拉加载连续触发onReachEnd 重复回调isLoading 互斥、hasMore 判断、时间戳防抖列表不满一屏自动加载初始即到底部接受自动填充或延迟首屏加载配合防抖刷新与加载数据错乱请求竞态requestSeq 序号机制丢弃过期响应数据出现重复页码没有正确重置刷新时 currentPage 置 1失败时回退页码列表滚动卡顿ForEach 渲染全量节点换 LazyForEach控制嵌套层级刷新后列表跳顶加载更多时调用了滚动仅在刷新成功后 scrollToIndex(0)一些属于我的收尾经验说实话这三天最大的收获不是学会了 Refresh 和 List 的 API而是建立了一种先定状态、再写交互的思维。列表页交互看似简单真正麻烦的地方全在状态切换和时序竞争里。如果你一上来就想把两个事件绑完收工大概率会被各种边角问题追着跑。但只要你先把状态机画明白把 isRefreshing、isLoading、hasMore 之间的关系定死剩下的代码就是填空。还有一个小技巧分享给同行训练营最后我们把加载的模拟延迟从500毫秒调成了2000毫秒来测试结果同时暴露出竞态、重复触发、加载占位闪烁三个问题。所以你要是有类似需求测试的时候故意把响应时间拉长能比正常情况更快找到隐藏 bug。希望这篇复盘能帮你省下几个加班的夜晚。