一、为什么必须掌握LazyForEach?

上一节我们学习的ForEach,核心逻辑是全量创建组件,即便配合List组件使用,当数据量超过100条甚至更多时,对组件数据的增删查改依然会触发组件的销毁与重建,性能表现较差。严重时会导致页面打开慢、内存占用暴涨、滑动列表卡顿掉帧,甚至应用崩溃。

而LazyForEach就是鸿蒙针对长列表场景推出的专属性能优化方案之一,它的核心价值用一句话概括:按需创建组件、自动回收资源,用极低的内存占用实现千条/万条数据的流畅滑动

二、LazyForEach 三大核心底层原理

LazyForEach的所有用法、规则、坑点,都来自这三个核心原理,必须先吃透底层逻辑。示例代码作为定义验证

2.1 按需渲染+自动回收机制

这是LazyForEach高性能的核心来源:

  • 首次渲染:页面打开时,仅计算滚动容器(List/Grid等)的可视区域,再加上cachedCount设置的预加载条数,只创建这部分组件,其余数据完全不处理;
  • 滑动渲染:列表向上滑动时,仅创建即将划入屏幕的组件;向下滑动时,预加载下方的组件;
  • 自动回收:组件完全滑出可视区域后,不会立即销毁,框架会在主线程空闲时,自动销毁这些组件、回收内存,保证应用内存始终处于低占用状态。

2.2 键值(Key)机制

LazyForEach完全依赖键值判断组件的创建、复用、销毁,是保证渲染正确的核心:

  • 键值的作用:给每个列表项一个唯一的“身份证”,框架通过这个身份证识别哪个数据对应哪个组件;
  • 键值的要求:唯一性(每个数据的键值不能重复)、稳定性(数据不变,键值绝对不能变);
  • 键值的默认规则:如果不自定义键值,框架会用viewId + '-' + index生成键值,仅和索引绑定,数据增删后索引变化,会导致渲染错乱,不推荐使用默认键值

2.3 数据监听器机制

LazyForEach不会监听数据源数组的直接变化,必须通过DataChangeListener监听器,主动通知框架数据发生了什么变化,框架才会更新UI。

  • 简单说:你改了数组里的数据,必须告诉框架“我新增了一条、删除了一条、修改了一条”,框架才会对应更新组件;
  • 直接修改数组、直接给数据源重新赋值,都不会触发UI刷新,甚至会导致渲染异常。

2.4 核心语法说明

LazyForEach的语法固定为三个参数,缺一不可:

LazyForEach(
  dataSource: IDataSource, // 必选:实现了IDataSource的数据源
  itemGenerator: (item: T, index?: number) => void, // 必选:组件生成函数
  keyGenerator?: (item: T, index?: number) => string // 强烈建议必选:键值生成函数
)

三、LazyForEach 基础使用

3.1 前置要求

LazyForEach必须配合支持懒加载的滚动容器使用,鸿蒙仅支持5个容器:ListListItemGroupGridSwiperWaterFlow,最常用的是List

重要规范:数据增删改查提供了两种通知方式,onDatasetChange批量操作接口 与 onDataxxx单操作接口不可混用,否则会导致渲染异常甚至程序崩溃。

3.2 步骤1:实现基础数据源

我们需要先实现IDataSource接口,封装一个通用的数据源类,负责管理数据、注册监听器、通知框架数据变化。
这里我们直接封装一个可复用的泛型数据源,支持任意数据类型,后续项目可以直接用。核心方法必须实现,其他方法可根据需求扩展。

// datasource/BaseDataSource.ets
// 通用泛型数据源,支持任意数据类型
export class BaseDataSource<T> implements IDataSource {
  // 存储数据监听器(框架自动注册)
  private listeners: DataChangeListener[] = [];
  // 实际存储的数据源数组
  private dataList: T[] = [];

  // ========== 必须实现的4个核心方法 ==========
  // 1. 返回数据总条数,框架用来计算列表总长度
  totalCount(): number {
    return this.dataList.length;
  }

  // 2. 返回对应索引的数据,框架渲染组件时会调用
  getData(index: number): T {
    return this.dataList[index];
  }

  // 3. 注册数据监听器,框架自动调用,不用手动管
  registerDataChangeListener(listener: DataChangeListener): void {
    if (!this.listeners.includes(listener)) {
      this.listeners.push(listener);
    }
  }

  // 4. 注销数据监听器,框架自动调用,不用手动管
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const index = this.listeners.indexOf(listener);
    if (index >= 0) {
      this.listeners.splice(index, 1);
    }
  }

  // 新增数据
  pushData(data: T): void {
    this.dataList.push(data);
    this.notifyDataAdd(this.dataList.length - 1)
  }

  // 删除指定索引的数据
  deleteData(index: number): void {
    if (index < 0 || index >= this.dataList.length) return;
    this.dataList.splice(index, 1);
    this.notifyDataDelete(index)

  }

  // 修改指定索引的数据
  updateData(index: number, newData: T): void {
    if (index < 0 || index >= this.dataList.length) return;
    this.dataList.splice(index,1,newData);
    this.notifyDataChange(index)
  }

  // 批量操作数据(新增/删除/修改/移动一次性完成,性能最优)
  notifyDatasetChange(operations: DataOperation[]): void {
    this.listeners.forEach(listener => {
      listener.onDatasetChange(operations);
    });
  }

  batchDelete(start: number, count: number): void {
    // 1. 空数据直接返回
    if (this.dataList.length === 0) return;
    // 2. start 不能小于 0
    if (start < 0) start = 0;
    // 3. start 不能超过数组最大索引
    if (start >= this.dataList.length) return;
    // 4. count 不能小于 1
    if (count < 1) return;
    // 5. 确保删除范围不越界(核心)
    const maxDeleteCount = this.dataList.length - start;
    if (count > maxDeleteCount) {
      count = maxDeleteCount;
    }
    // 执行删除
    this.dataList.splice(start, count);
    // 批量操作
    // this.notifyDatasetChange([{
    //   type: DataOperationType.DELETE,
    //   index: start,
    //   count: count
    // }]);
    // 全局刷新
    this.reloadAll()

  }
  // 通知LazyForEach组件需要重载所有子组件
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    });
  }

  // 通知LazyForEach组件需要在index对应索引处添加子组件
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
      // listener.onDatasetChange([{type: DataOperationType.ADD, index: index}]);
    });
  }

  // 通知LazyForEach组件在index对应索引处数据有变化,需要重建该子组件
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
       listener.onDataChange(index);
       // listener.onDatasetChange([{type: DataOperationType.CHANGE, index: index}]);
    });
  }

  // 通知LazyForEach组件需要在index对应索引处删除该子组件
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index);
      // listener.onDatasetChange([{type: DataOperationType.DELETE, index: index}]);
    });
  }

  // 通知LazyForEach组件将from索引和to索引处的子组件进行交换
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to);
      // listener.onDatasetChange([{type: DataOperationType.EXCHANGE, index: {start: from, end: to}}]);
    });
  }

  // 移动某一条数据到某一个位置
  moveData(from: number, to: number): void {
    if (from < 0 || from >= this.dataList.length || to < 0 || to >= this.dataList.length) return;
    const items = this.dataList.splice(from, 1);
    this.dataList.splice(to, 0, ...items);
    this.notifyDataMove(from,to)
  }
  // 全量刷新列表(非必要不使用,会重建所有组件)
  reloadAll(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
      // listener.onDatasetChange([{type: DataOperationType.RELOAD}]);

    });
  }

  // 获取全部数据
  getAllData(): T[] {
    return this.dataList;
  }
}

3.3 步骤2:定义数据模型

// model/GoodsInfo.ets
import { util } from "@kit.ArkTS";

// 定义商品数据类型
@Observed
export class GoodsInfo {
  // 商品唯一ID,用来做键值,readonly 保证不可变,确保键值稳定
  readonly id: string;
  @Track name: string;
  @Track price: number;

  constructor(name: string, price: number, id: string = util.generateRandomUUID(true)) {
    this.name = name;
    this.price = price;
    this.id = id;
  }
}

3.4 步骤3:创建列表项组件

封装一个独立的列表项组件,方便后续做组件复用。重点@Reusable 用于开启组件复用,大幅提升长列表滑动性能。

// components/GoodsListItem.ets
import { GoodsInfo } from "../model/GoodsInfo";

// @Reusable 开启组件复用,提升长列表滑动性能
@Component
export struct GoodsListItem {
  @ObjectLink goods: GoodsInfo;

  // 事件回调定义
  onDelete?: (goods: GoodsInfo) => void;
  onTop?: (goods: GoodsInfo) => void;
  onPriceChange?: (goods: GoodsInfo) => void;

  // 生命周期日志
  aboutToAppear(): void {
    console.info(`[GoodsListItem] 组件创建 -> ${this.goods.name}`);
  }

  aboutToDisappear(): void {
    console.info(`[GoodsListItem] 组件消失 -> ${this.goods.name}`);
  }
   
  aboutToRecycle(): void {
    console.info(`[GoodsListItem] 组件回收 -> ${this.goods.name}`);
  }

  // 复用时更新数据
  // 注意:在aboutToReuse中对@Link、@ObjectLink等自动更新的状态变量赋值,可能触发不必要的组件刷新,无需手动处理
  // aboutToReuse(params: Record<string, ESObject>): void {
  //   console.info(`[GoodsListItem] 组件复用 -> 名字:${params.goods.name} -> 价格:${params.goods.price}`);
  // }

  build() {
    Row({ space: 12 }) {
      Text(this.goods.name)
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .flexGrow(1)

      Text(`¥${this.goods.price}`)
        .fontSize(16)
        .fontColor('#FF4400')
        .fontWeight(FontWeight.Bold)

      // 涨价按钮
      Text("涨价10元")
        .fontSize(14)
        .fontColor(Color.White)
        .padding(10)
        .backgroundColor('#007AFF')
        .borderRadius(4)
        .onClick(() => {
          console.info(`[GoodsListItem] 点击涨价 -> ${this.goods.name}`);
         
         if (this.onPriceChange) {
            // 方式1: 直接使用状态管理变量 局部精准修改数据 this.goods.price += 10。父组件仅做刷新,这样会分散逻辑。
            // 方式2: 父组件修改价格和通知数据变动逻辑放在一起。
            
            // 注意天坑来了:this.onPriceChange(this.goods); 通过回调把goods传递到父组件,但是代理对象Proxy会被解包变成普通对象Object,父组件修改goods子属性装饰器无法监听到数据变化。
           
           // 解决方案: const goods = this.goods; 创建临时变量,此时临时变量引用相同的地址,通过回调传递给父组件代理对象Proxy不会被解包。
            const goods = this.goods;
            this.onPriceChange(goods);
          }
        })

      // 上移按钮
      Text("上移")
        .fontSize(14)
        .fontColor(Color.White)
        .padding(10)
        .backgroundColor('#FF9500')
        .borderRadius(4)
        .onClick(() => {
          console.info(`[GoodsListItem] 点击上移 -> ${this.goods.name}`);
          if (this.onTop) {
            const goods = this.goods;
            this.onTop(goods);
          }
        })

      // 删除按钮
      Text("删除")
        .fontSize(14)
        .fontColor(Color.White)
        .padding(10)
        .backgroundColor('#FF3B30')
        .borderRadius(4)
        .onClick(() => {
          console.info(`[GoodsListItem] 点击删除 -> ${this.goods.name}`);
          if (this.onDelete) {
            const goods = this.goods;
            this.onDelete(goods);
          }
        })
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#F7F8FA')
    .borderRadius(8)
    .margin({ bottom: 8 })
  }
}

3.5 步骤4:实现懒加载列表页面

// pages/LazyForEachDemo.ets
import { GoodsListItem } from '../components/GoodsListItem';
import { GoodsInfo } from '../model/GoodsInfo';
import { BaseDataSource } from '../datasource/BaseDataSource';

@Entry
@Component
struct Index {
  // 初始化数据源,指定数据类型为GoodsInfo
  private goodsDataSource: BaseDataSource<GoodsInfo> = new BaseDataSource<GoodsInfo>();
  // 预加载条数:可视区域上下各预加载5条,滑动更流畅,默认值为5
  private cachedCount: number = 5;

  // 页面加载时,初始化50条测试数据
  aboutToAppear(): void {
    for (let i = 0; i < 50; i++) {
      this.goodsDataSource.pushData(new GoodsInfo(`测试商品 ${i + 1}`, Math.floor(Math.random() * 100 + 1)));
    }
  }

  // 处理删除:通过ID查找真实索引,避免闭包index过时问题
  handleDelete(goods: GoodsInfo): void {
    const allData = this.goodsDataSource.getAllData();
    const index = allData.findIndex(item => item.id === goods.id);
    if (index !== -1) {
      this.goodsDataSource.deleteData(index);
    }
  }

  // 处理上移:通过ID查找真实索引
  handleMoveUp(goods: GoodsInfo): void {
    const allData = this.goodsDataSource.getAllData();
    const currentIndex = allData.findIndex(item => item.id === goods.id);
    // 如果不是第一条,则执行上移
    if (currentIndex > 0) {
      this.goodsDataSource.moveData(currentIndex, currentIndex - 1);
    }
  }

  // 处理价格修改:通过ID查找真实索引并通知刷新
  handlePriceChange(goods: GoodsInfo): void {
    const allData = this.goodsDataSource.getAllData();
    const index = allData.findIndex(item => item.id === goods.id);
    if (index !== -1) {
      // 方案1:子组件用@ObjectLink直接修改属性,这里仅做通知刷新(性能最优,局部刷新)
      
      // 方案2:父组件统一修改数据,再通知刷新(逻辑更集中)
      // goods.price += 10;
      this.goodsDataSource.notifyDataChange(index);
    }
  }

  build() {
    Column({ space: 12 }) {
      Text('LazyForEach 千条数据长列表示例')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 16 })

      List({ space: 8 }) {
        LazyForEach(
          this.goodsDataSource,
          (item: GoodsInfo, index: number) => {
            ListItem() {
              GoodsListItem({
                goods: item,
                onDelete: (goods) => this.handleDelete(goods),
                onTop: (goods) => this.handleMoveUp(goods),
                onPriceChange: (goods) => this.handlePriceChange(goods)
              })
            }
            // 【测试写法】ListItem直接绑定点击事件修改数据,这里拿到的也是代理对象可正常刷新
            // .onClick(()=>{
            //   item.price += 10;
            //   this.goodsDataSource.notifyDataChange(index);
            // })
          },
          // 第三个参数:键值生成函数,必须返回唯一稳定的值,这里用商品唯一id
          (item: GoodsInfo) => item.id
        )
      }
      // 设置预加载条数
      .cachedCount(this.cachedCount)
      .width('100%')
      .layoutWeight(1)
      .backgroundColor($r('sys.color.comp_background_list_card'))

      Row({ space: 16 }) {
        Button("新增一条数据")
          .onClick(() => {
            // 新增商品时,id由构造函数自动生成
            this.goodsDataSource.pushData(
              new GoodsInfo(`新增商品 ${this.goodsDataSource.totalCount() + 1}`, Math.floor(Math.random() * 100 + 1))
            );
          })
        Button("删除前五条")
          .onClick(() => {
            // 批量删除前五条数据
            this.goodsDataSource.batchDelete(0, 5);
          })
      }
      .padding({ bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('sys.color.point_color_checked'))
  }
}

四、数据更新核心操作(增删改查)

4.1 首次创建

初始化50条数据,通过控制台日志可看到,首屏仅创建了14条组件,其中可视区域9条,缓存区域5条,与cachedCount = 5的设置完全符合预期。

LazyForeach首次渲染

4.2 非首次创建

滚动列表:每向上滑动一行,观察日志可看到,向上滑动5行时,会连续输出5条组件创建日志,且没有销毁旧组件——这是因为缓存机制,cachedCount会预创建上下不可见区域的组件,此时刚好满足上下各5条的缓存要求。
LazyForeach渲染滑动屏幕

继续向上滑动一行:会创建第20条组件,同时销毁第1条组件,日志如下:

[GoodsListItem] 组件创建 (aboutToAppear) -> 商品: 测试商品 20
[GoodsListItem] 组件消失 (aboutToDisappear) -> 商品: 测试商品 1

4.3 删除数据

点击删除第一行,调用deleteData方法,内部通过onDataDelete通知框架,框架仅销毁对应索引的组件,更新后续组件的位置。

注意:键值必须唯一且固定,否则可能出现删除错乱、渲染异常的问题。

4.4 新增数据

点击「新增一条数据」,调用pushData方法,内部通过onDataAdd通知框架,框架仅在对应索引创建新组件,不会刷新整个列表。

注意:点击新增后,控制台不会立即输出组件创建日志,因为新增的数据不在可视区域内,只有滑动到底部时,才会创建对应组件。

4.5 修改数据

  • 推荐方案:使用@ObjectLink在子组件直接修改属性,配合notifyDataChange通知刷新,可实现仅刷新使用了该属性的组件,性能更优,还能避免因组件重建导致的图片闪烁问题。

4.6 批量操作

调用batchDelete,通过onDatasetChange一次性通知框架删除范围,仅重建受影响的后续组件,避免多次通知导致的重复渲染,性能最优。

4.7 全量刷新

调用reloadAll,通过onDataReloaded通知框架重建所有组件,非必要不使用,会造成严重的性能损耗和屏幕闪烁。

4.8 局部刷新:避免组件全量重建

当我们只需要修改列表项的某一个属性时,不需要重建整个列表项,通过@Observed+@ObjectLink可以实现仅刷新使用了该属性的组件,大幅降低渲染开销。

五、高级优化核心

5.1 组件复用:减少创建销毁开销

通过@Reusable装饰器标记列表项为可复用,组件滑出屏幕后不会被销毁,而是放入缓存池;新的列表项进入屏幕时,直接复用缓存的组件,仅更新数据,大幅减少组件创建销毁的性能开销。

5.2 验证步骤

取消对@ReusableaboutToReuse的注释,观察控制台日志,会发现aboutToAppear调用次数大幅减少,组件销毁变成了回收,组件创建变成了复用。

5.3 不建议嵌套使用

@Reusable 不建议嵌套使用,会降低复用效率、增加内存占用与维护成本,还会导致缓存冗余、生命周期管理混乱。

六、高频踩坑与解决方案

6.1 数据修改后,组件不刷新

  • 原因1:没有自定义键值,默认键值仅和索引绑定,数据变化键值不变,框架认为组件不需要刷新;
  • 解决方案:自定义键值生成函数,绑定数据的唯一ID或稳定不变的内容;
  • 原因2:修改了数据,但没有调用notify方法通知框架;
  • 解决方案:所有数据修改,都必须通过数据源的方法,调用对应的notify通知。

6.2 删除数据后,渲染错乱,删错了组件

  • 原因:在 LazyForEachitemGenerator 中直接使用了闭包的 index 进行删除。当列表滑动或数据删除后,这个 index是未更新的;
  • 解决方案:通过数据的唯一 idgetAllData() 中查找当前真实索引(如代码中的 handleDelete 所示)。

6.3 列表滚动到底部加载更多时,屏幕闪烁

  • 原因:加载更多数据后,调用了reloadAll全量刷新,导致整个列表重建;
  • 解决方案:用onDataAddonDatasetChange精准通知新增的数据,不要全量刷新。

6.4 子组件回调修改数据后,UI不刷新

  • 原因@ObjectLink 包装的是响应式代理对象(Proxy Object)。当在子组件中直接将 this.goods 传递给父组件回调时,在传递过程中会对代理对象进行自动解包(Unwrap),使其变回普通的原始对象。此时,父组件修改的是普通对象,无法触发 @Observed 装饰器的响应式监听,导致UI不刷新。
  • 解决方案:在子组件回调触发前,先通过 const goods = this.goods; 将代理对象临时赋值给一个常量。这一操作会保留对代理对象的引用,再通过这个常量传递给父组件,从而保证父组件修改数据时能够被正确监听并触发 UI 更新。

七、仓库代码

  • 工程名称:LazyForEachBaseDemo
  • 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git

八、下节预告

下一节我们将利用LazyForEach改造之前微信联系人列表通过双重LazyForEach分别对列表的组和行进行懒加载,并完成侧滑菜单删除修改备注彻底掌握LazyForEach。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐