在这里插入图片描述

每日一句正能量

“即使触不到满天星辰,也要奔跑在萤火之森。”
梦想或许遥不可及,但过程本身就值得奔赴。星辰是远方的理想,萤火是当下的微光——即便无法摘取天上的光芒,也要在身边的点点光亮中全力奔跑。真正的热爱,不是只看向终点,而是享受脚下的每一步。

摘要

在移动端应用中,下拉刷新(Pull-to-Refresh)上拉加载更多(Load-More) 是长列表交互的两大基石。前者让用户获取最新数据,后者让无限内容得以渐进呈现。HarmonyOS ArkTS 通过 Refresh 组件与 List.onReachEnd 提供了声明式的一等公民支持,但要在生产环境中实现零抖动、零白屏、高复用的刷新体验,仍需深入理解其状态机、数据懒加载机制与组件复用策略。

本文基于 HarmonyOS 6(API 23),从 Refresh 组件的状态机出发,系统讲解下拉刷新的完整实现链路;结合 LazyForEach + IDataSource 构建高性能长列表;通过 onReachEnd 与防抖机制实现稳健的上拉加载;最后给出空状态、错误重试、骨架屏等生产级优化方案,形成一套可落地的长列表工程化范式。


一、Refresh 组件:下拉刷新的状态机

Refresh 是 HarmonyOS NEXT 提供的下拉刷新容器组件,用于为 ListScrollGridWaterFlow 等可滚动内容添加下拉刷新能力。与手动监听 onTouch 手势实现刷新不同,Refresh 将手势识别、阈值判定、状态切换、动画反馈全部封装在框架层,开发者只需关注业务数据逻辑。

1.1 核心 API 速览

属性/回调 类型 说明
refreshing boolean 双向绑定刷新状态,控制 Loading 动画显示与隐藏
onRefreshing() () => void 松手且超过阈值后触发,执行业务刷新逻辑
onStateChange() (status) => void 监听 Refresh 内部状态流转,可用于自定义头部动画
builder CustomBuilder 自定义刷新头部 UI(可选,默认使用系统样式)

1.2 状态机流转

Refresh 组件内部维护了一套完整的状态机,开发者无需干预其流转,但理解其机制有助于排查异常行为:

在这里插入图片描述

状态 枚举值 含义
Inactive RefreshStatus.Inactive 初始闲置状态,手指未触碰
Drag RefreshStatus.Drag 手指下拉中,距离小于触发阈值
OverDrag RefreshStatus.OverDrag 下拉距离超过阈值,松手即触发刷新
Refresh RefreshStatus.Refresh 正在执行刷新,显示 Loading 动画
Done RefreshStatus.Done 刷新完成,执行回弹动画后回到 Inactive

关键认知onRefreshing 回调在 OverDrag → Refresh 的跃迁瞬间触发,而非手指按下时。这意味着框架已经帮开发者完成了「是否超过阈值」的判断,无需手动计算拖拽距离。


二、基础实现:Refresh + List 组合

2.1 最小可用示例

以下代码展示了下拉刷新与上拉加载的最小完整实现,覆盖数据模型、状态管理、事件处理的全链路:

interface NewsItem {
  id: string;
  title: string;
  summary: string;
  time: string;
  imageUrl: string;
}

@Entry
@Component
struct NewsListPage {
  @State dataList: NewsItem[] = [];
  @State isRefreshing: boolean = false;
  @State isLoading: boolean = false;
  @State hasMore: boolean = true;

  private pageNum: number = 1;
  private readonly pageSize: number = 10;

  aboutToAppear(): void {
    this.loadData(true);
  }

  // 数据请求(模拟网络)
  private async fetchData(page: number, isRefresh: boolean): Promise<NewsItem[]> {
    return new Promise((resolve) => {
      setTimeout(() => {
        const items: NewsItem[] = [];
        const base = (page - 1) * this.pageSize;
        for (let i = 1; i <= this.pageSize; i++) {
          const idx = base + i;
          items.push({
            id: `news_${idx}`,
            title: `新闻标题 ${idx}:HarmonyOS 生态持续繁荣`,
            summary: `这是第 ${idx} 条新闻的摘要内容,展示 ArkTS 声明式开发范式的强大能力...`,
            time: `2026-08-${String(idx % 30 + 1).padStart(2, '0')}`,
            imageUrl: ''
          });
        }
        resolve(items);
      }, 800);
    });
  }

  // 加载数据(刷新或加载更多)
  private async loadData(isRefresh: boolean): Promise<void> {
    if (isRefresh) {
      this.isRefreshing = true;
      this.pageNum = 1;
    } else {
      if (this.isLoading || !this.hasMore) return;
      this.isLoading = true;
    }

    try {
      const data = await this.fetchData(this.pageNum, isRefresh);

      if (isRefresh) {
        this.dataList = data;  // 刷新:替换数据
      } else {
        this.dataList = this.dataList.concat(data);  // 加载更多:追加数据
      }

      this.hasMore = data.length >= this.pageSize;
      this.pageNum++;
    } catch (error) {
      console.error('数据加载失败:', error);
    } finally {
      this.isRefreshing = false;
      this.isLoading = false;
    }
  }

  @Builder
  newsItemBuilder(item: NewsItem) {
    Row() {
      Column() {
        Text(item.title)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')
        Text(item.summary)
          .fontSize(13)
          .fontColor('#666666')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 6 })
          .width('100%')
        Text(item.time)
          .fontSize(11)
          .fontColor('#999999')
          .margin({ top: 6 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ right: 12 })

      Image($r('app.media.ic_news_placeholder'))
        .width(80)
        .height(60)
        .borderRadius(4)
        .objectFit(ImageFit.Cover)
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
  }

  @Builder
  loadMoreFooter() {
    Row() {
      if (this.isLoading) {
        LoadingProgress()
          .width(20)
          .height(20)
          .color('#999999')
        Text('正在加载...')
          .fontSize(13)
          .fontColor('#999999')
          .margin({ left: 8 })
      } else if (!this.hasMore) {
        Text('—— 已经到底了 ——')
          .fontSize(13)
          .fontColor('#CCCCCC')
      }
    }
    .width('100%')
    .height(50)
    .justifyContent(FlexAlign.Center)
  }

  build() {
    Column() {
      Row() {
        Text('新闻资讯')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
      }
      .width('100%')
      .height(56)
      .padding({ left: 16 })
      .backgroundColor('#2C3E50')

      Refresh({ refreshing: $$this.isRefreshing }) {
        List({ space: 1 }) {
          ForEach(this.dataList, (item: NewsItem) => {
            ListItem() {
              this.newsItemBuilder(item)
            }
          }, (item: NewsItem) => item.id)  // key 生成器:业务唯一 ID

          // 底部加载更多
          ListItem() {
            this.loadMoreFooter()
          }
        }
        .width('100%')
        .layoutWeight(1)
        .divider({ strokeWidth: 1, color: '#F0F0F0' })
        .edgeEffect(EdgeEffect.Spring)
        .onReachEnd(() => {
          this.loadData(false);  // 触发加载更多
        })
        .nestedScroll({
          scrollForward: NestedScrollMode.SELF_FIRST,
          scrollBackward: NestedScrollMode.SELF_FIRST
        })
      }
      .onRefreshing(() => {
        this.loadData(true);  // 触发下拉刷新
      })
      .onStateChange((status: RefreshStatus) => {
        console.info(`Refresh 状态变化: ${status}`);
      })
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

2.2 代码要点解析

① 双向绑定语法 $$this.isRefreshing

Refresh 组件的 refreshing 属性支持 $$ 双向绑定。当刷新完成时,框架会自动将 isRefreshing 重置为 false,无需开发者手动置位。若使用单向绑定 : this.isRefreshing,则需在 finally 块中手动关闭。

onReachEnd 防抖保护

List.onReachEnd 在滚动到底部时触发,但快速滑动或列表项高度不均时可能连续触发多次。必须通过 isLoading 状态锁和 hasMore 边界判断实现防抖:

if (this.isLoading || !this.hasMore) return;

③ 数据更新原则:引用替换

ArkTS 的响应式系统基于引用比较。直接修改数组元素(this.dataList[0].title = 'xxx')不会触发 UI 更新,必须通过重新赋值数组引用实现:

// 刷新:替换引用
this.dataList = data;

// 加载更多:concat 生成新引用
this.dataList = this.dataList.concat(data);

三、架构全景:组件层级与事件流

在这里插入图片描述

从架构视角看,下拉刷新与上拉加载是一个**「事件驱动 + 状态管理 + 数据分层」**的协同系统:

  • 事件层Refresh 负责下拉手势识别,List.onReachEnd 负责触底检测,两者独立触发、互不干扰。
  • 状态层isRefreshingisLoadinghasMore 三个布尔状态构成互斥锁,防止并发请求。
  • 数据层:刷新操作执行「替换」(page = 1),加载更多执行「追加」(page++),数据流向清晰可预测。

四、高性能长列表:LazyForEach + IDataSource

当列表数据量超过百条时,ForEach 会在页面初始化时一次性创建所有组件节点,导致内存峰值飙升和启动耗时增加。HarmonyOS 提供了 LazyForEach 实现按需渲染——仅创建可视区域及其缓冲区的组件,滑出视口时自动销毁。

4.1 IDataSource 完整实现

export class NewsDataSource implements IDataSource {
  private list: NewsItem[] = [];
  private listeners: DataChangeListener[] = [];

  constructor(list: NewsItem[] = []) {
    this.list = list;
  }

  totalCount(): number {
    return this.list.length;
  }

  getData(index: number): NewsItem {
    return this.list[index];
  }

  // 注册监听器(框架调用)
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  // 业务方法:刷新数据
  refreshData(newList: NewsItem[]): void {
    this.list = newList;
    this.notifyDataReload();
  }

  // 业务方法:追加数据
  appendData(newList: NewsItem[]): void {
    const start = this.list.length;
    this.list = this.list.concat(newList);
    this.notifyDataAdd(start, newList.length);
  }

  // 通知框架数据已重新加载
  private notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    });
  }

  // 通知框架指定范围数据新增
  private notifyDataAdd(index: number, count: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index, count);
    });
  }
}

4.2 LazyForEach 使用规范

// 页面中使用 LazyForEach
@State newsData: NewsDataSource = new NewsDataSource();

// 在 List 中替换 ForEach
List({ space: 1 }) {
  LazyForEach(this.newsData, (item: NewsItem) => {
    ListItem() {
      this.newsItemBuilder(item)
    }
  }, (item: NewsItem) => item.id)  // key 必须用业务唯一 ID
}
.cachedCount(3)  // 预加载屏幕外 3 条,防止快速滚动白屏

在这里插入图片描述

4.3 LazyForEach 核心规则

规则 说明 违规后果
key 唯一性 必须使用业务唯一 ID(如 item.id),禁止使用 index 删除/插入时组件复用错位,UI 闪烁
禁止重赋值 dataSource 不能执行 this.newsData = new NewsDataSource() LazyForEach 监听器丢失,UI 不更新
必须通过监听器更新 调用 onDataAdd / onDataReloaded 等方法 直接修改内部数组框架感知不到
单根组件 LazyForEach 的 itemBuilder 必须且只能返回一个根组件 编译报错或运行时异常
配合 @Reusable 列表项组件添加 @Reusable 装饰器 组件节点复用,创建耗时降低约 69%

五、生产级优化:空状态、错误重试与骨架屏

基础实现只能应对「理想网络 + 正常数据」的场景。生产环境中,必须处理空数据、网络错误、加载过渡等边界情况。

5.1 状态机扩展

enum ListState {
  IDLE = 'idle',           // 初始/正常浏览
  REFRESHING = 'refreshing', // 下拉刷新中
  LOADING_MORE = 'loading_more', // 上拉加载中
  EMPTY = 'empty',         // 无数据
  ERROR = 'error',         // 加载失败
  NO_MORE = 'no_more'      // 已加载全部
}

@State listState: ListState = ListState.IDLE;

5.2 空状态与错误重试

@Builder
emptyView() {
  Column() {
    Image($r('app.media.ic_empty'))
      .width(120)
      .height(120)
      .opacity(0.5)
    Text('暂无数据')
      .fontSize(16)
      .fontColor('#999999')
      .margin({ top: 16 })
    Button('重新加载')
      .margin({ top: 24 })
      .onClick(() => {
        this.loadData(true);
      })
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
}

@Builder
errorView() {
  Column() {
    Image($r('app.media.ic_error'))
      .width(100)
      .height(100)
      .opacity(0.5)
    Text('加载失败,请检查网络')
      .fontSize(15)
      .fontColor('#E74C3C')
      .margin({ top: 12 })
    Button('点击重试')
      .type(ButtonType.Capsule)
      .margin({ top: 20 })
      .onClick(() => {
        this.loadData(true);
      })
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
}

5.3 骨架屏(Skeleton Loading)

骨架屏在数据加载前展示占位动画,可显著降低用户感知等待时间:

@Builder
skeletonItem() {
  Row() {
    Column() {
      Rectangle()  // 模拟标题占位
        .width('70%')
        .height(16)
        .fill('#E8E8E8')
        .borderRadius(4)
      Rectangle()  // 模拟摘要占位
        .width('90%')
        .height(12)
        .fill('#F0F0F0')
        .borderRadius(4)
        .margin({ top: 10 })
    }
    .layoutWeight(1)
    .margin({ right: 12 })

    Rectangle()  // 模拟图片占位
      .width(80)
      .height(60)
      .fill('#E8E8E8')
      .borderRadius(4)
  }
  .width('100%')
  .padding(16)
  .backgroundColor('#FFFFFF')
}

// 使用 shimmer 动画
@Builder
shimmerSkeleton() {
  Column() {
    ForEach([1, 2, 3, 4, 5], () => {
      this.skeletonItem()
    })
  }
  .width('100%')
  .opacity(0.7)
  .animation({
    duration: 1500,
    iterations: -1,
    curve: Curve.EaseInOut,
    playMode: PlayMode.Alternate
  })
}

六、进阶:与吸顶效果的协同(承接前序文章)

在第一百一十二篇文章中,我们深入探讨了吸顶效果的三种实现方案。当吸顶头部与下拉刷新共存时,需要特别注意事件分发优先级滚动偏移量计算

6.1 吸顶 + 下拉刷新的嵌套策略

当页面顶部存在吸顶 TabBar(如电商分类页),且整个页面支持下拉刷新时,推荐采用以下嵌套结构:

// 外层 Refresh 包裹整个页面内容
Refresh({ refreshing: $$this.isRefreshing }) {
  Scroll() {
    Column() {
      // Banner 区域(滚动时推出)
      BannerView()

      // 吸顶 TabBar
      StickyTabBar()

      // 内层列表(带独立上拉加载)
      List() {
        LazyForEach(this.dataSource, ...)
      }
      .onReachEnd(() => this.loadMore())
    }
  }
}
.onRefreshing(() => this.refreshAll())

6.2 关键配置

场景 配置 说明
外层 Scroll + 内层 List 外层 nestedScroll: PARENT_FIRST 下拉时外层 Scroll 先响应,保证 Refresh 手势不被拦截
吸顶 TabBar 高度固定 预留占位组件 防止吸顶瞬间内容跳动
刷新重置页码 onRefreshingpage = 1 刷新后从第一页重新加载

七、性能优化清单

优化项 实现方式 预期收益
懒加载 LazyForEach + IDataSource 仅渲染可视区,降低 80%+ 内存
组件复用 @Reusable 装饰列表项 组件创建耗时降低约 69%
预加载缓冲 cachedCount(3~5) 快速滚动无白屏
图片懒加载 onVisibleAreaChange 中加载 减少首屏内存和流量
分页控制 pageSize 控制在 10~20 条 平衡请求次数与单次数据量
防抖节流 isLoading + hasMore 状态锁 防止重复请求和空转
引用更新 concat / 重新赋值数组 确保响应式系统正确感知变化

八、完整实战效果

在这里插入图片描述

上图展示了三种典型状态:

  1. 下拉刷新中:手指下拉超过阈值,Refresh 头部显示 Loading 动画,业务层执行 page = 1 的数据请求。
  2. 正常浏览LazyForEach 按需渲染可视区列表项,cachedCount 预加载缓冲区内容,滚动流畅无白屏。
  3. 上拉加载更多:滚动到底触发 onReachEnd,底部显示加载提示,数据追加到列表尾部。

九、总结

本文围绕 HarmonyOS 6(API 23)的下拉刷新与上拉加载,构建了从基础实现到生产优化的完整技术体系:

  • Refresh 状态机:理解 Inactive → Drag → OverDrag → Refresh → Done 的流转机制,是排查刷新异常的基础。
  • LazyForEach 工程化IDataSource 的规范实现 + 业务唯一 key + @Reusable 组件复用,是长列表性能的核心保障。
  • 上拉加载稳健性onReachEnd 配合 isLoading / hasMore 双锁机制,可有效防止重复请求和空数据异常。
  • 生产级体验:空状态、错误重试、骨架屏三件套,是区分「Demo」与「产品」的关键细节。
  • 与吸顶效果协同:通过合理的嵌套结构和 nestedScroll 配置,下拉刷新可与吸顶头部无缝共存。

掌握本文所述的工程化范式,开发者即可在 HarmonyOS 应用中构建出媲美原生体验的高性能长列表。


转载自:https://blog.csdn.net/u014727709/article/details/163418917
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐