在移动应用开发中,长列表的渲染性能和交互体验是决定应用品质的关键因素。HarmonyOS 的 ArkUI 框架为长列表场景提供了 LazyForEach 数据懒加载、Refresh 下拉刷新和 onReachEnd 触底加载等核心能力。本文将结合一个完整的「新闻列表」Demo,带你实战这些技术的组合使用。

一、效果预览

本文 Demo 包含以下功能:

  • LazyForEach 实现数据懒加载,仅渲染可视区域的列表项

  • 下拉刷新,模拟网络请求获取最新数据

  • 触底加载更多,自动请求分页数据

  • SwipeAction 侧滑操作,支持左滑删除

  • 加载状态 Toast 提示

列表主页 下拉刷新 侧滑删除
▲ 侧滑删除 ▲ 列表主页 ▲ 下拉刷新

二、核心概念速览

API 作用 关键点
LazyForEach 按需渲染列表项,只创建可见区域内的组件 必须配合 IDataSource 接口实现数据源
IDataSource 定义数据源接口 需实现 totalCount()getData()registerDataChangeListener() 等方法
Refresh 下拉刷新容器 包裹 List,通过 $$refreshing 双向绑定刷新状态
onReachEnd 列表滚动到底部回调 触发加载更多逻辑
cachedCount 预加载屏幕外 N 条 item 防止快速滚动时白屏
ListItem.swipeAction 侧滑操作 常用于删除、置顶等场景

三、完整代码

以下代码为完整的 @Entry 页面,包含数据源、列表渲染、下拉刷新、加载更多和侧滑删除功能。

// pages/Index.ets
// 鸿蒙 LazyForEach + 下拉刷新 + 加载更多 实战 Demo
// API 24 Release (HarmonyOS 6.1.1) 适用

import { promptAction } from '@kit.ArkUI';

// ============================================================
// 数据模型
// ============================================================

class NewsItem {
  id: number;
  title: string;
  summary: string;
  source: string;
  time: string;
  imageColor: string; // 模拟封面图颜色

  constructor(id: number, title: string, summary: string,
    source: string, time: string, imageColor: string) {
    this.id = id;
    this.title = title;
    this.summary = summary;
    this.source = source;
    this.time = time;
    this.imageColor = imageColor;
  }
}

// ============================================================
// 模拟数据生成器
// ============================================================

const NEWS_TITLES: string[] = [
  'HarmonyOS 7 开发者预览版发布',
  'ArkUI 声明式 UI 最佳实践总结',
  'DevEco Studio 6.1.1 新特性解读',
  'TaskPool 多线程性能优化指南',
  'Navigation 路由导航深度解析',
  'ArkWeb 混合开发踩坑记录',
  'Camera Kit 相机开发完整指南',
  'AppGallery Connect 上架流程',
  'Audio Kit 音频播放架构设计',
  'Form Kit 服务卡片开发实战',
];

const NEWS_SUMMARIES: string[] = [
  '本次更新带来了全新的 Material Design 视觉风格,API 26 新增了多个系统级 Kit 能力...',
  '从 V1 到 V2 状态管理迁移过程中,需要重点关注 @ObservedV2 和 @Trace 的配合使用...',
  '新增了 Hot Reload for C++、ComMemory 内存分析、strictCheckerOnly 快速语法检查等实用功能...',
  '使用 TaskPool.execute 替代 Worker 处理短时并发任务,可以降低线程创建开销约 60%...',
  'NavPathStack 提供了 pushPath、pop、replacePath 等完整的路由栈操作,配合 navDestination 实现灵活跳转...',
  '在 Web 组件中使用 javaScriptProxy 实现 ArkTS 与 JS 的互相调用,需要注意线程安全问题...',
  '通过 CameraInput + PreviewOutput + PhotoOutput 构建完整的拍照流程,支持前后摄像头切换...',
  '应用上架前需要完成 App Signing、Content Rating、Privacy Policy 等配置,审核周期通常 1-3 个工作日...',
  'AVSession 是后台音频播放的必需组件,缺少它系统会在应用退后台时强制暂停音频播放...',
  '服务卡片开发需要 FormExtensionAbility + 卡片 UI 页面配合,支持 1×2、2×2、2×4 等多种尺寸...',
];

const SOURCES: string[] = [
  '华为开发者官网', '鸿蒙技术社区', 'DevEco 博客',
  'ArkUI 团队', '开发者日报', 'HarmonyOS 周刊',
];

const COLORS: string[] = [
  '#007DFF', '#FF6B35', '#00B578', '#FF8F1F',
  '#0A59F7', '#E84026', '#6B3DE8', '#1EA89E',
];

function generateMockNews(startId: number, count: number): NewsItem[] {
  const items: NewsItem[] = [];
  for (let i = 0; i < count; i++) {
    const id = startId + i;
    items.push(new NewsItem(
      id,
      NEWS_TITLES[id % NEWS_TITLES.length],
      NEWS_SUMMARIES[id % NEWS_SUMMARIES.length],
      SOURCES[id % SOURCES.length],
      `${Math.floor(Math.random() * 60)}分钟前`,
      COLORS[id % COLORS.length]
    ));
  }
  return items;
}

// ============================================================
// IDataSource 实现(LazyForEach 的数据源适配器)
// ============================================================

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

  // 获取总数据量
  totalCount(): number {
    return this.dataArray.length;
  }

  // 获取指定位置的数据
  getData(index: number): NewsItem {
    return this.dataArray[index];
  }

  // 追加数据(用于加载更多)
  appendData(items: NewsItem[]): void {
    this.dataArray.push(...items);
    this.notifyDataReload();
  }

  // 重置数据(用于下拉刷新)
  resetData(items: NewsItem[]): void {
    this.dataArray = items;
    this.notifyDataReload();
  }

  // 删除数据
  deleteData(index: number): void {
    this.dataArray.splice(index, 1);
    this.notifyDataReload();
  }

  // 获取所有数据(用于调试或进一步处理)
  getAllData(): NewsItem[] {
    return this.dataArray;
  }

  // 注册数据变更监听
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  // 注销数据变更监听
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const idx = this.listeners.indexOf(listener);
    if (idx >= 0) {
      this.listeners.splice(idx, 1);
    }
  }

  // 通知所有监听器:数据已重新加载
  private notifyDataReload(): void {
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataReloaded();
    }
  }

  // ======== DataChangeListener 接口必需实现的方法 ========
  // 当前方法名(API 21+)
  // onDataReloaded() — 上面已实现
  // onDataAdd / onDataDelete / onDataChange / onDataMove — 下面实现

  onDataAdd(index: number): void {
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataAdd(index);
    }
  }

  onDataDelete(index: number): void {
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataDelete(index);
    }
  }

  onDataChange(index: number): void {
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataChange(index);
    }
  }

  onDataMove(from: number, to: number): void {
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataMove(from, to);
    }
  }

  // ======== 废弃方法名(API 21 仍需实现,否则编译报错)========
  onDataAdded(index: number): void {
    this.onDataAdd(index);
  }

  onDataDeleted(index: number): void {
    this.onDataDelete(index);
  }

  onDataChanged(index: number): void {
    this.onDataChange(index);
  }

  onDataMoved(from: number, to: number): void {
    this.onDataMove(from, to);
  }

  onDatasetChange(dataOperations: DataOperation[]): void {
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDatasetChange(dataOperations);
    }
  }
}

// ============================================================
// 列表项组件
// ============================================================

@Component
struct NewsListItem {
  @Prop item: NewsItem;

  build() {
    Row({ space: 12 }) {
      // 左侧封面色块
      Row() {
        Text(this.item.title.charAt(0))
          .fontSize(20)
          .fontColor(Color.White)
          .fontWeight(FontWeight.Bold)
      }
      .width(72)
      .height(72)
      .borderRadius(8)
      .backgroundColor(this.item.imageColor)
      .justifyContent(FlexAlign.Center)

      // 右侧文本内容
      Column({ space: 6 }) {
        Text(this.item.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(this.item.summary)
          .fontSize(13)
          .fontColor('#666666')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .lineHeight(18)

        Row({ space: 8 }) {
          Text(this.item.source)
            .fontSize(11)
            .fontColor('#007DFF')
          Text(this.item.time)
            .fontSize(11)
            .fontColor('#999999')
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }
}

// ============================================================
// 主页面 @Entry
// ============================================================

@Entry
@Component
struct NewsListPage {
  @State isRefreshing: boolean = false;
  @State isLoading: boolean = false;
  @State hasMore: boolean = true;
  private dataSource: NewsDataSource = new NewsDataSource();
  private nextId: number = 10;
  private pageSize: number = 10;

  aboutToAppear(): void {
    // 初始加载第一页数据
    this.dataSource.resetData(generateMockNews(0, 10));
    this.nextId = 10;
  }

  // 下拉刷新
  private async onRefresh(): Promise<void> {
    this.isRefreshing = true;
    // 模拟网络请求延迟
    await this.delay(1500);
    // 模拟:获取最新数据插入到头部
    const freshData = generateMockNews(1000 + Math.floor(Math.random() * 1000), 3);
    const allData = freshData.concat(this.dataSource.getAllData());
    this.dataSource.resetData(allData);
    this.hasMore = true;
    this.isRefreshing = false;

    const promptAction = this.getUIContext().getPromptAction();
    promptAction.showToast({ message: `为您推荐了 ${freshData.length} 条新内容`, duration: 2000 });
  }

  // 加载更多
  private async loadMore(): Promise<void> {
    if (this.isLoading || !this.hasMore) {
      return;
    }
    this.isLoading = true;
    // 模拟网络请求延迟
    await this.delay(1000);

    const moreData = generateMockNews(this.nextId, this.pageSize);
    this.dataSource.appendData(moreData);
    this.nextId += this.pageSize;

    // 模拟总共 50 条数据后无更多
    if (this.nextId >= 50) {
      this.hasMore = false;
    }
    this.isLoading = false;
  }

  // 删除列表项
  private onDeleteItem(index: number): void {
    const item = this.dataSource.getData(index);
    this.dataSource.deleteData(index);

    const promptAction = this.getUIContext().getPromptAction();
    promptAction.showToast({ message: `已删除: ${item.title}`, duration: 2000 });
  }

  // 延迟函数
  private delay(ms: number): Promise<void> {
    return new Promise<void>((resolve: Function) => {
      setTimeout(() => { resolve(); }, ms);
    });
  }

  // 侧滑删除按钮构建器
  @Builder
  SwipeDeleteBtn(index: number) {
    Row() {
      SymbolGlyph($r('sys.symbol.trash'))
        .fontSize(20)
        .fontColor([Color.White])
    }
    .width(72)
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#FF4444')
    .borderRadius({ topRight: 12, bottomRight: 12 })
    .onClick(() => {
      this.onDeleteItem(index);
    })
  }

  build() {
    Column() {
      // 下拉刷新容器包裹 List
      Refresh({ refreshing: $$this.isRefreshing }) {
        List({ space: 10 }) {
          LazyForEach(this.dataSource, (item: NewsItem, index: number) => {
            ListItem() {
              NewsListItem({ item: item })
            }
            .swipeAction({
              end: {
                builder: () => { this.SwipeDeleteBtn(index); },
                actionAreaDistance: 72
              },
              edgeEffect: SwipeEdgeEffect.Spring
            })
          }, (item: NewsItem) => item.id.toString())

          // 底部加载状态
          ListItem() {
            Row() {
              if (this.isLoading) {
                LoadingProgress()
                  .width(20)
                  .height(20)
                  .color('#007DFF')
                Text('正在加载更多...')
                  .fontSize(13)
                  .fontColor('#999999')
                  .margin({ left: 8 })
              } else if (!this.hasMore) {
                Text('— 已经到底了 —')
                  .fontSize(13)
                  .fontColor('#CCCCCC')
              } else {
                Text('上拉加载更多')
                  .fontSize(13)
                  .fontColor('#CCCCCC')
              }
            }
            .width('100%')
            .height(50)
            .justifyContent(FlexAlign.Center)
          }
        }
        .width('100%')
        .height('100%')
        .padding({ left: 14, right: 14, top: 8 })
        .edgeEffect(EdgeEffect.Spring)
        .cachedCount(3)   // 预加载屏幕外 3 条,防止快速滚动白屏
        .onReachEnd(() => {
          // 触底加载更多
          this.loadMore();
        })
        .scrollBar(BarState.Auto)
        .alignListItem(ListItemAlign.Center)
      }
      .onRefreshing(() => {
        this.onRefresh();
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

四、代码拆解说明

4.1 IDataSource 数据源实现

LazyForEach 不直接操作数组,而是通过 IDataSource 接口代理数据访问:

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

  totalCount(): number {
    return this.dataArray.length;  // 告诉框架总数据量
  }

  getData(index: number): NewsItem {
    return this.dataArray[index];  // 按索引获取数据
  }
  // ...
}

关键规则:

  • totalCount()getData() 每次重渲染都会调用,必须保持 O(1) 复杂度
  • 数据变更后必须通过 listeners 回调通知框架,否则 UI 不会更新
  • API 21+ 必须同时实现新旧两套方法名(如 onDataReloadedonDataReloaded — 实际上废弃方法是 onDataAdded / onDataDeleted / onDataChanged / onDataMoved

4.2 LazyForEach 使用

LazyForEach(this.dataSource, (item: NewsItem, index: number) => {
  ListItem() { /* 渲染 item */ }
}, (item: NewsItem) => item.id.toString())

三个参数:

  1. 数据源IDataSource 实例
  2. 渲染函数:每个 item 的 UI 构建
  3. 键生成器:返回唯一 key(必须保证唯一性,否则会导致渲染错乱)

4.3 下拉刷新 Refresh 组件

Refresh({ refreshing: $$this.isRefreshing }) {
  List() { /* ... */ }
}
.onRefreshing(() => {
  this.onRefresh();   // 刷新回调
})
  • $$this.isRefreshing 是双向绑定语法,Refresh 组件结束时自动重置为 false
  • onRefreshing 回调中执行数据请求,请求完成后刷新状态会自动还原

4.4 触底加载 onReachEnd

List()
  .onReachEnd(() => {
    this.loadMore();   // 触发加载更多
  })

注意添加防抖保护

private async loadMore(): Promise<void> {
  if (this.isLoading || !this.hasMore) {
    return;   // 正在加载中或无更多数据,直接返回
  }
  this.isLoading = true;
  // ... 请求数据
  this.isLoading = false;
}

4.5 cachedCount 预加载

List()
  .cachedCount(3)   // 额外预加载 3 条屏幕外的 item

cachedCount 会在可视区域外提前创建 N 条 item,用户快速滚动时不易出现白屏。建议值:1-5,过大反而浪费内存。

4.6 侧滑删除 SwipeAction

swipeActionListItem 组件的属性,不能用在 Row 或其他容器上。在 @Entry 页面中定义 @Builder 方法,然后通过 swipeAction 绑定到 ListItem

// 在 @Entry 组件中定义侧滑按钮 Builder
@Builder
SwipeDeleteBtn(index: number) {
  Row() {
    SymbolGlyph($r('sys.symbol.trash'))
      .fontSize(20).fontColor([Color.White])
  }
  .width(72).height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#FF4444')
  .borderRadius({ topRight: 12, bottomRight: 12 })
  .onClick(() => { this.onDeleteItem(index); })
}

// 在 ListItem 上挂载
ListItem() {
  NewsListItem({ item: item })
}
.swipeAction({
  end: {
    builder: () => { this.SwipeDeleteBtn(index); },
    actionAreaDistance: 72,  // 滑动触发距离
  },
  edgeEffect: SwipeEdgeEffect.Spring  // 回弹效果
})

左滑后显示红色删除按钮,点击删除后调用 dataSource.deleteData() → 内部调用 notifyDataReload()LazyForEach 自动更新 UI。

五、性能优化要点

优化点 做法 效果
避免 item 层级过深 列表项组件控制在 3 层以内 减少布局计算耗时
使用 cachedCount 设置为 2-5 快速滚动不白屏
图片懒加载 onVisibleAreaChange 中加载图片 减少首屏内存占用
复用组件 ListItem 的组件加 @Reusable 约 69% 更快的组件创建
key 生成器保持稳定 使用 item.id.toString() 而非 index.toString() 避免列表项错位和闪烁
深色模式适配 颜色使用系统资源 $r('sys.color.xxx') 自动跟随系统主题

六、常见踩坑

  1. IDataSource 方法不全导致编译报错:API 21+ 必须同时实现新旧两套 DataChangeListener 方法名,参考上文完整实现。
  2. key 生成器用 index 导致删除错乱:删除第 0 条后,原来的第 1 条变成第 0 条,key 相同会导致组件复用错误。务必使用业务唯一 ID。
  3. onReachEnd 反复触发:不加 isLoading 防抖会在滚动到底部时连续触发多次请求。
  4. Refresh 与 List 的嵌套Refresh 必须直接包裹 List(或 Scroll/Grid),中间不能有其他容器。
  5. LazyForEach 不支持非 List/Grid/WaterFlow/Swiper 容器:不能在 Column 内直接使用 LazyForEach

七、运行效果

将此代码复制到 DevEco Studio 项目的 entry/src/main/ets/pages/Index.ets 中,运行后将看到:

  • 10 条新闻列表正常渲染
  • 下拉刷新:出现刷新指示器,模拟延迟后插入 3 条新数据并 Toast 提示
  • 触底加载:滚动到底部自动追加 10 条数据,底部显示「正在加载…」
  • 无更多数据:加载到 40+ 条后底部显示「已经到底了」
  • 左滑删除:列表项左滑出现红色删除按钮,点击后 item 动画消失并 Toast 提示
  • LoadingProgress:加载中状态显示旋转指示器

八、扩展方向

场景 实现方案
分组列表 + 粘性标题 使用 ListItemGroup + sticky(StickyStyle.Header)
瀑布流布局 List 替换为 WaterFlow + FlowItem
拖拽排序 使用 List.onItemDragStart + onItemDragMove
网络真实数据 @ohos/axios 请求分页接口,替换模拟数据
多选模式 NewsItemisSelected 字段 + Checkbox 组件
下拉刷新二级样式 Refresh 组件绑定 Prompt 提示文本

希望本文能帮助你快速掌握 LazyForEach 长列表开发的核心要点。完整代码经过 API 24 Release SDK 编译验证,可直接运行截图。

如果你对鸿蒙开发有任何疑问,欢迎下方咨询我们,一对一给你解答。

Logo

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

更多推荐