HarmonyOS鸿蒙Next应用开发中如何实现海量数据的虚拟无限列表?

HarmonyOS鸿蒙Next应用开发中如何实现海量数据的虚拟无限列表? 在鸿蒙应用开发中,遇到需要展示上万条数据的列表场景时,直接渲染所有数据会导致页面卡顿、内存占用过高,甚至出现 ANR 问题,想知道如何实现只加载可见区域数据的虚拟无限列表,既能流畅滚动,又能高效处理海量数据?

5 回复

可以看下官网的可复用循环渲染Repeat:

  • Repeat根据容器组件的有效加载范围(屏幕可视区域+预加载区域)加载子组件。当容器滑动/数组改变时,Repeat会根据父容器组件的布局过程重新计算有效加载范围,并管理列表子组件节点的创建与销毁。Repeat通过组件节点更新/复用从而优化性能表现,详细描述见节点更新/复用能力说明

previewableImage

相关文档:【Repeat使用指南】

更多关于HarmonyOS鸿蒙Next应用开发中如何实现海量数据的虚拟无限列表?的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


一般来说在鸿蒙开发中实现海量数据的虚拟无限列表,核心思路是只渲染可见区域的列表项,通过滚动事件动态更新可见数据,避免一次性加载所有数据造成性能问题。我们来看一下原理:

一、原理解析

虚拟列表的核心思想:虚拟列表(Virtual List)的本质是 “按需加载”,即只渲染当前视口可见的列表项,通过计算可视区域的起始索引(startIndex),动态截取对应范围内的数据进行渲染,而非渲染全部数据。

设定visibleCount(可见项数量),确定每次需要渲染的列表项个数;

通过startIndex(起始索引)控制当前渲染数据的范围,滚动时更新startIndex,从而切换渲染的列表项。

滚动交互原理:通过监听列表的触摸事件(TouchEvent),判断滑动方向(上滑 / 下滑),并更新startIndex:

上滑时,startIndex增加,加载后续数据;

下滑时,startIndex减少,加载前面数据;

限制startIndex的范围(0 到totalItems - visibleCount),避免越界。

数据复用原理:每次滚动仅更新visibleItems数组(当前可见数据),而非重新创建所有列表项组件,减少组件创建和销毁的开销,提升性能。

二、关键代码实现

状态管理:用@State装饰visibleItems(当前可见数据)和startIndex(起始索引),状态变化触发 UI 刷新,实现列表项的动态更新。

初始化可见数据:在aboutToAppear生命周期中调用loadVisibleItems,初始化加载第一屏可见数据。

加载可见数据:

private loadVisibleItems(): void {
  this.visibleItems = [];
  // 从startIndex开始,加载visibleCount条数据
  for (let i: number = this.startIndex; i < this.startIndex + this.visibleCount; i++) {
    if (i < this.totalItems) { // 避免超出总数据量
      this.visibleItems.push({
        id: i + 1,
        content: this.generateRandomContent() // 生成随机内容模拟数据
      });
    }
  }
}

触摸事件处理:

.onTouch((event: TouchEvent) => {
  if (event.type === TouchType.Move) {
    const touchY = event.touches[0].y;
    // 上滑(y坐标变小):加载后续数据
    if (touchY < 300 && this.startIndex < this.totalItems - this.visibleCount) {
      this.startIndex += 1;
      this.loadVisibleItems();
    }
    // 下滑(y坐标变大):加载前面数据
    else if (touchY > 100 && this.startIndex > 0) {
      this.startIndex -= 1;
      this.loadVisibleItems();
    }
  }
})

三、完整代码

// 使用唯一类型名称,避免冲突
interface InfiniteListItem {
  id: number;
  content: string;
}

@Entry
@Component
struct InfiniteScrollList {
  [@State](/user/State) visibleItems: InfiniteListItem[] = [];
  [@State](/user/State) startIndex: number = 0;
  private readonly totalItems: number = 100000; // 模拟海量数据
  private readonly visibleCount: number = 10;

  aboutToAppear(): void {
    this.loadVisibleItems();
  }

  build(): void {
    Column() {
      Text('虚拟无限列表')
        .fontSize(24)
        .margin({ top: 50, bottom: 20 });

      Stack({ alignContent: Alignment.TopStart }) {
        // 列表容器
        Column()
          .width('90%')
          .height(this.visibleCount * 60)
          .backgroundColor('#eee')
          .borderRadius(10)
          .clip(true);

        // 可见项
        Column() {
          ForEach(this.visibleItems, (item: InfiniteListItem) => {
            Text(`Item ${item.id}: ${item.content}`)
              .fontSize(16)
              .padding(20)
              .width('100%')
              .backgroundColor(Color.White)
              .margin({ bottom: 5 })
              .borderRadius(5);
          })
        }
        .width('90%')
        .onTouch((event: TouchEvent) => {
          if (event.type === TouchType.Move && event.touches[0].y < 300) {
            // 上滑加载更多
            if (this.startIndex < this.totalItems - this.visibleCount) {
              this.startIndex += 1;
              this.loadVisibleItems();
            }
          } else if (event.type === TouchType.Move && event.touches[0].y > 100) {
            // 下滑加载更多
            if (this.startIndex > 0) {
              this.startIndex -= 1;
              this.loadVisibleItems();
            }
          }
        });
      }

      Text(`当前显示: ${this.startIndex + 1} - ${this.startIndex + this.visibleCount}`)
        .fontSize(14)
        .margin({ top: 20 });
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
    .alignItems(HorizontalAlign.Center);
  }

  // 仅加载可见区域数据
  private loadVisibleItems(): void {
    this.visibleItems = [];
    for (let i: number = this.startIndex; i < this.startIndex + this.visibleCount; i++) {
      if (i < this.totalItems) {
        this.visibleItems.push({
          id: i + 1,
          content: this.generateRandomContent()
        });
      }
    }
  }

  // 生成随机内容
  private generateRandomContent(): string {
    const chars: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let content: string = '';
    for (let i: number = 0; i < 10; i++) {
      content += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return content;
  }
}

四、实现效果

cke_31202.png

这个问题非常简单:

LazyForEach为开发者提供了基于数据源渲染出一系列子组件的能力。具体而言,LazyForEach从数据源中按需迭代数据,并在每次迭代时创建相应组件。当在滚动容器中使用了LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会销毁并回收组件以降低内存占用。

本文档依次介绍了LazyForEach的基本用法、高级用法和常见问题,开发者可以按需阅读。在首次渲染小节中,给出了简单的示例,可以帮助开发者快速上手LazyForEach的使用。

说明

在大量子组件的的场景下,LazyForEach与缓存列表项、动态预加载、组件复用等方法配合使用,可以进一步提升滑动帧率并降低应用内存占用。最佳实践请参考长列表加载丢帧优化

使用限制

  • LazyForEach必须在容器组件内使用,仅有ListListItemGroupGridSwiper以及WaterFlow组件支持数据懒加载(可配置cachedCount属性,即只加载可视部分以及其前后少量数据用于缓冲),其他组件仍然是一次性加载所有的数据。支持数据懒加载的父组件根据自身及子组件的高度或宽度计算可视区域内需布局的子节点数量,高度或宽度的缺失会导致部分场景懒加载失效。
  • LazyForEach依赖生成的键值判断是否刷新子组件,键值不变则不触发刷新。
  • 容器组件内只能包含一个LazyForEach。以List为例,不建议同时包含ListItem、ForEach、LazyForEach,不建议同时包含多个LazyForEach。
  • LazyForEach在每次迭代中,必须创建且只允许创建一个子组件;即LazyForEach的子组件生成函数有且只有一个根组件。
  • 生成的子组件必须是允许包含在LazyForEach父容器组件中的子组件。
  • 允许LazyForEach包含在if/else条件渲染语句中,也允许LazyForEach中出现if/else条件渲染语句。
  • 键值生成器必须针对每个数据生成唯一的值,如果键值相同,将导致键值相同的UI组件渲染出现问题。
  • LazyForEach必须使用一个数据变化监听器DataChangeListener对象进行更新(具体参数使用参考LazyForEach),重新赋值第一个参数dataSource会导致异常;dataSource使用状态变量时,状态变量改变不会触发LazyForEach的UI刷新。
  • 为了高性能渲染,使用DataChangeListener对象的onDataChange方法更新UI时,需要生成不同于原来的键值来触发组件刷新。
  • LazyForEach和@Reusable装饰器一起使用能触发节点复用。使用方法:将@Reusable装饰在LazyForEach列表的组件上,见列表滚动配合LazyForEach使用
  • LazyForEach和@ReusableV2装饰器一起使用能触发节点复用。详见@ReusableV2装饰器指南文档中的在LazyForEach组件中使用
  • LazyForEach的子节点在离开可视区域和预加载区域时,不会立即被析构或回收,LazyForEach会在空闲时析构或回收这些节点。

基本用法

设置数据源

为了管理DataChangeListener监听器和通知LazyForEach更新数据,开发者需要使用如下方法:首先实现LazyForEach提供的IDataSource接口,将其作为LazyForEach的数据源,然后管理监听器和更新数据。

为实现基本的数据管理和监听能力,开发者需要实现IDataSource的totalCountgetDataregisterDataChangeListenerunregisterDataChangeListener方法,具体请参考BasicDataSource示例代码。当数据源变化时,通过调用监听器的接口通知LazyForEach更新,具体请参考数据更新。

键值生成规则

在LazyForEach循环渲染过程中,系统为每个item生成一个唯一且持久的键值,用于标识对应的组件。键值变化时,ArkUI框架将视为该数组元素已被替换或修改,并基于新的键值创建新的组件。

LazyForEach提供了参数keyGenerator,开发者可以使用该函数生成自定义键值。如果未定义keyGenerator函数,ArkUI框架将使用默认的键值生成函数:(item: Object, index: number) => { return viewId + ‘-’ + index.toString(); }。viewId在编译器转换过程中生成,同一个LazyForEach组件内的viewId一致。

键值应满足以下条件。

  1. 键值具有唯一性,每个数据项对应的键值互不相同。
  2. 键值具有一致性,数据项不变时对应的键值也不变。

上述条件保证LazyForEach正确、高效地更新子组件,否则可能存在渲染结果异常、渲染效率降低等问题。

组件创建规则

在确定键值生成规则后,LazyForEach的第二个参数itemGenerator函数会根据组件创建规则为数据源的每个数组项创建组件。组件的创建包括两种情况:LazyForEach首次渲染和LazyForEach非首次渲染的数据更新。

首次渲染

使用LazyForEach时,开发者需要提供数据源、键值生成函数和组件创建函数。开发者需保证键值生成函数为每项数据生成不同的键值。

在LazyForEach首次渲染时,会根据上述键值生成规则为数据源的每个数组项生成唯一键值并创建相应的组件。

对于预加载区域内的节点,若创建耗时较长,框架会分帧执行创建任务。

/** BasicDataSource代码见文档末尾BasicDataSource示例代码: string类型数组的BasicDataSource代码 **/
class MyDataSource extends BasicDataSource {
  private dataArray: string[] = [];
  public totalCount(): number {
    return this.dataArray.length;
  }
  public getData(index: number): string {
    return this.dataArray[index];
  }
  public pushData(data: string): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }
}
@Entry
@Component
struct MyComponent {
  private data: MyDataSource = new MyDataSource();
  aboutToAppear() {
    for (let i = 0; i <= 20; i++) {
      this.data.pushData(`Hello ${i}`);
    }
  }
  build() {
    List({ space: 3 }) {
      LazyForEach(this.data, (item: string) => {
        ListItem() {
          Row() {
            Text(item).fontSize(50)
              .onAppear(() => {
                console.info(`appear: ${item}`);
              })
          }.margin({ left: 10, right: 10 })
        }
      }, (item: string) => item)
    }.cachedCount(5)
  }
}

在HarmonyOS鸿蒙Next中,可通过ArkUI的LazyForEach组件实现虚拟无限列表。该组件仅渲染可视区域内的数据项,结合List或Grid容器使用,能高效处理海量数据。开发者需实现数据源接口,提供数据项和总数,并绑定到LazyForEach。

在HarmonyOS Next中实现海量数据的虚拟无限列表,核心是使用LazyForEach与列表组件(如List)配合数据懒加载机制。以下是关键实现方案:

1. 核心组件与API

  • LazyForEach:按需迭代数据源,仅创建可视区域及相邻区域的列表项组件,离开视窗的组件会被自动回收。
  • List/Grid:作为滚动容器,需设置listDirectionspace等属性优化布局性能。
  • CachedData(可选):通过@Provide/@Consume或全局状态管理缓存已加载数据,避免重复计算。

2. 数据源设计

  • 实现IDataSource接口,重写totalCount()getData(index)registerDataChangeListener()等方法。
  • 数据分页加载:监听滚动位置(onScroll事件或Scroll组件),接近列表底部时触发下一页数据加载。
  • 建议使用ArrayBuffer或对象池管理数据块,减少内存碎片。

3. 性能优化要点

  • 组件复用:为列表项组件设置稳定的key,避免重建。
  • 轻量级组件:列表项使用原生组件(TextImage)替代复杂嵌套,必要时通过@Reusable装饰器复用组件实例。
  • 图片处理:对网络图片使用Image组件的interpolation控制加载精度,或配合imageCacheCount限制缓存数量。
  • 滚动节流:高频滚动事件中避免同步计算,可通过TaskPool异步处理数据排序/过滤。

4. 示例代码框架

// 1. 实现数据源
class LargeDataSource implements IDataSource {
  private data: YourDataModel[] = [];
  
  totalCount(): number {
    return 10000; // 实际数据总量
  }
  
  getData(index: number): YourDataModel {
    return this.data[index];
  }
  
  registerDataChangeListener(listener: DataChangeListener): void {
    // 监听数据变化
  }
  
  unregisterDataChangeListener(listener: DataChangeListener): void {
    // 移除监听
  }
}

// 2. 列表组件
@Entry
@Component
struct VirtualListPage {
  private dataSource: LargeDataSource = new LargeDataSource();
  
  build() {
    List({ space: 5 }) {
      LazyForEach(this.dataSource, (item: YourDataModel) => {
        ListItem() {
          YourListItemComponent({ item: item }) // 轻量级自定义组件
        }
      }, (item: YourDataModel) => item.id.toString()) // 唯一key
    }
    .onScroll((scrollOffset: number) => {
      // 触发分页加载逻辑
    })
  }
}

5. 扩展场景处理

  • 动态高度列表:若列表项高度不固定,需提前计算并存储高度值,通过aboutToAppear回调动态更新布局。
  • 状态保持:结合@StorageLink或持久化存储,恢复滚动位置时直接定位到缓存数据区间。

注意事项

  • 避免在LazyForEach内直接执行耗时操作(如网络请求),数据预处理应在加载前完成。
  • 测试时需覆盖快速滚动、内存警告等边界场景,可通过DevEco Studio的Profiler工具监测内存及帧率。

此方案通过按需构建组件、分页加载和数据缓存,可支撑万级数据流畅滚动,内存占用保持稳定。

回到顶部