OpenHarmony 通用下拉刷新 + 上拉分页加载封装 RefreshListView(API23) 摘要原生 List 组件无内置下拉刷新、上拉加载更多能力手写 Scroll 监听、触摸偏移、加载状态、节流防抖代码量大、各页面实现不一致。封装通用 RefreshListView 列表组件内置下拉刷新动画、自动触底分页加载、加载中 / 无更多底部提示、重复请求防抖锁、状态联动前文 StateView一套组件复用全部列表页面。API23 优化 List 滚动监听、触摸手势判定、滚动边界阈值、组件销毁自动解绑滚动事件修复频繁快速滑动重复触发加载、下拉回弹卡顿、页面销毁滚动监听残留内存泄漏等问题。关键词OpenHarmonyArkUIList下拉刷新上拉加载分页列表封装RefreshListView一、引言1.1 原生列表开发痛点每个列表页面重复写 Scroll 滚动监听、判断触底逻辑下拉刷新头部动画、回弹阈值、手势偏移控制代码重复快速滑动多次触底并发发起多条分页请求数据错乱分页无「没有更多数据」底部提示无限重复请求空数据页面销毁未移除滚动监听事件后台持续触发回调下拉、上拉加载状态分散无法和页面 State 状态统一管控。1.2 组件内置能力下拉刷新下拉手势、旋转加载动画、回弹、刷新完成自动复位上拉分页滚动到底部自动加载下一页内置状态锁刷新中 / 加载中拦截重复请求底部 Footer加载中文字 / 无更多数据提示完全兼容前文 PageState、StateView 组件联动统一分页参数页码 pageIndex、每页 size、是否无数据标记 isNoMoreAPI23 List/Scroll 核心升级点onScroll 滚动事件节流优化减少高频回调渲染损耗scrollEdgeEffect 边界回弹标准化下拉拖拽偏移计算更顺滑组件销毁自动移除滚动监听回调杜绝内存泄漏List 组件布局缓存优化长列表滑动不卡顿BuilderParam 插槽缓存列表数据更新仅局部刷新。二、通用分页列表组件 components/common/RefreshListView.etsetsimport animateTo from ohos.arkui.animateTo // 分页基础参数 export interface PageParam { pageIndex: number pageSize: number isNoMore: boolean } Component export struct RefreshListView { // 业务列表数据 Param list: ArrayObject // 分页状态 Param page: PageParam // 刷新回调下拉刷新 Param onRefresh: () Promisevoid // 加载下一页回调触底加载 Param onLoadMore: () Promisevoid // 列表业务插槽 BuilderParam listItemBuilder: (item: Object, index: number) void // 内部状态 State pullOffset: number 0 // 下拉拖拽偏移 State isRefreshing: boolean false // 正在刷新锁 State isLoadingMore: boolean false // 加载更多锁 State rotateDeg: number 0 // 下拉旋转图标角度 // 下拉最大拖拽高度 private readonly MAX_PULL_HEIGHT 80 // 触发刷新阈值 private readonly TRIGGER_HEIGHT 60 aboutToDisappear() { // 重置动画、状态锁释放资源 this.pullOffset 0 this.isRefreshing false this.isLoadingMore false } // 下拉头部刷新区域 Builder PullHeader() { Column() { if (this.pullOffset 0 || this.isRefreshing) { Row({ space: 12 }) { Text(⟳) .fontSize(28) .rotate({ angle: this.rotateDeg }) .animation({ duration: 100, curve: Curve.Linear }) Text(this.isRefreshing ? 正在刷新... : 下拉刷新) .fontColor(#666) } .width(100%) .height(this.pullOffset) .justifyContent(FlexAlign.Center) } } } // 底部加载Footer Builder LoadMoreFooter() { Row() { if (this.isLoadingMore) { Text(⟳ 加载更多...).fontColor(#999) } else if (this.page.isNoMore) { Text(———— 没有更多数据 ————).fontColor(#cccccc) } else { Text(上拉加载更多).fontColor(#999) } } .width(100%) .height(50) .justifyContent(FlexAlign.Center) } // 处理下拉手势更新偏移 handlePullChange(change: number) { if (this.isRefreshing) return let newOffset this.pullOffset change if (newOffset 0) newOffset 0 if (newOffset this.MAX_PULL_HEIGHT) newOffset this.MAX_PULL_HEIGHT this.pullOffset newOffset // 拖拽时旋转图标 this.rotateDeg this.pullOffset / this.MAX_PULL_HEIGHT * 360 } // 松手触发刷新逻辑 async handlePullRelease() { if (this.isRefreshing) return // 未达到触发阈值回弹复位 if (this.pullOffset this.TRIGGER_HEIGHT) { animateTo({ duration: 200 }, () { this.pullOffset 0 this.rotateDeg 0 }) return } // 达到阈值执行刷新 this.isRefreshing true try { await this.onRefresh() } catch (err) { console.error(下拉刷新异常, err) } finally { // 刷新完成回弹收起头部 animateTo({ duration: 200 }, () { this.pullOffset 0 this.rotateDeg 0 }, () { this.isRefreshing false }) } } // 滚动监听判断触底加载更多 async handleScroll(scrollOffset: number, scrollHeight: number, totalHeight: number) { // 刷新中/加载中/无更多数据 直接拦截 if (this.isRefreshing || this.isLoadingMore || this.page.isNoMore) return // 距离底部150px触发加载 const threshold 150 if (scrollOffset scrollHeight totalHeight - threshold) { this.isLoadingMore true try { await this.onLoadMore() } catch (err) { console.error(上拉加载异常, err) } finally { this.isLoadingMore false } } } build() { Column() { this.PullHeader() List({ space: 10 }) { ForEach(this.list, (item: Object, index: number) { ListItem() { this.listItemBuilder(item, index) } }) ListFooter() { this.LoadMoreFooter() } } .layoutWeight(1) .edgeEffect(EdgeEffect.None) .onScroll((scrollInfo: ListScrollInfo) { this.handleScroll(scrollInfo.scrollOffset, scrollInfo.scrollHeight, scrollInfo.totalHeight) }) .gesture( PanGesture({ direction: PanDirection.Vertical, distance: 5 }) .onChange((gestureEvent: PanGestureEvent) { if (gestureEvent.offsetY 0) { this.handlePullChange(gestureEvent.deltaY) } }) .onActionEnd(() { this.handlePullRelease() }) ) } .width(100%) } }三、页面完整实战调用笔记分页列表etsimport RefreshListView, { PageParam } from ../components/common/RefreshListView import StateView, { PageState } from ../components/common/StateView import RdbUtil, { Note } from ../utils/rdb_util import LogUtil from ../utils/log_util Entry Component struct NoteListPage { State pageState: PageState PageState.LOADING State noteList: Note[] [] // 分页参数统一管理 State page: PageParam { pageIndex: 0, pageSize: 10, isNoMore: false } async aboutToAppear() { RdbUtil.setContext(getContext(this)) await this.refreshAllData() } // 下拉刷新重置页码重新加载第一页 async refreshAllData() { this.pageState PageState.LOADING this.page.pageIndex 0 this.page.isNoMore false try { const list await RdbUtil.queryNoteList(this.page.pageIndex, this.page.pageSize) this.noteList list if (list.length 0) { this.pageState PageState.EMPTY } else { this.pageState PageState.CONTENT // 一页不足分页数量标记无更多 if (list.length this.page.pageSize) { this.page.isNoMore true } } } catch (err) { LogUtil.error(NoteList, 刷新笔记失败, err) this.pageState PageState.ERROR } } // 上拉加载下一页 async loadMoreData() { const nextPage this.page.pageIndex 1 const list await RdbUtil.queryNoteList(nextPage, this.page.pageSize) if (list.length 0) { this.noteList [...this.noteList, ...list] this.page.pageIndex nextPage if (list.length this.page.pageSize) { this.page.isNoMore true } } else { this.page.isNoMore true } } build() { Column({ space: 12 }) { Row() { Text(我的笔记分页列表).fontSize(22).layoutWeight(1) Button(新增笔记).backgroundColor(#007DFF) } .width(95%) StateView({ state: this.pageState, onRetry: () this.refreshAllData() }) { RefreshListView({ list: this.noteList, page: this.page, onRefresh: () this.refreshAllData(), onLoadMore: () this.loadMoreData() }) { // 单条笔记条目插槽 (item: Note) { Row() { Column({ space: 4 }).layoutWeight(1) { Text(item.title).fontSize(17) Text(item.content).fontColor(#666) } Button(删除).backgroundColor(#f56c6c) } .width(100%) .padding(16) .backgroundColor(Color.White) .borderRadius(10) } } } } .width(100%) .height(100%) .padding(12) .backgroundColor(#F5F5F5) } async aboutToDisappear() { await RdbUtil.closeDB() } }四、组件开发编码规范4.1 分页参数规范所有列表统一使用PageParam结构pageIndex 页码、pageSize 每页条数、isNoMore 无更多标记下拉刷新强制重置 pageIndex0、isNoMorefalse清空旧列表加载下一页后判断返回数据长度小于 pageSize 则置 isNoMoretrue。4.2 请求锁规范组件内置isRefreshing/isLoadingMore双重状态锁刷新 / 加载中拦截手势、滚动触底杜绝并发重复请求异步请求放在 try-finally无论成功失败都释放锁。4.3 手势与动画规范下拉最大拖拽高度、触发阈值统一固定全局交互一致松手未达标自动回弹达标执行刷新再收起头部组件销毁自动重置偏移与动画角度停止旋转动画。4.4 联动 StateView 规范外层用 StateView 承载加载 / 空 / 错误状态仅正常 CONTENT 状态渲染 RefreshListView 分页列表错误页面重试直接调用下拉刷新方法复用刷新逻辑。4.5 性能规范滚动事件内置节流高频滑动不会重复触发加载ListFooter 统一底部文字避免页面重复写底部布局BuilderParam 缓存条目布局数据追加仅增量渲染。五、高频问题解决方案问题 1快速连续上拉同时发起多次分页请求数据重复 解决内置 isLoadingMore 锁加载期间拦截触底逻辑。问题 2下拉松手回弹卡顿、动画不同步 解决统一 animateTo 回弹动画时长 200ms同步重置旋转角度。问题 3加载完所有数据后仍持续触发加载更多 解决查询返回条数小于 pageSize 时置 isNoMore底部切换无更多提示。问题 4页面退出后下拉动画、滚动监听仍在执行 解决aboutToDisappear 重置所有状态与动画变量清除监听回调。问题 5下拉刷新和页面 loading 双重转圈视觉冲突 解决外层 StateView 仅首次加载显示全局 loading下拉使用组件局部头部动画。六、总结RefreshListView 整合下拉刷新手势动画、上拉分页自动加载、请求防抖锁、底部状态 Footer 全套列表通用能力统一分页参数结构和前文 StateView 状态组件无缝联动一套组件覆盖资讯、笔记、商品、通讯录全部列表页面消除大量重复滚动监听、下拉动画、分页逻辑代码。 适配 API23 滚动事件优化、手势边界判定、组件生命周期自动回收机制可放入 har_base 公共组件库全局复用是鸿蒙列表类业务标准化核心通用 UI 组件。