如果你用 ForEach 渲染 1000 条数据,ArkUI 会在首次渲染时创建 1000 个组件节点——即使屏幕上只能看到 10 条。这会导致首帧渲染延迟、滚动掉帧、内存飙升。LazyForEach 解决了这个问题:只创建屏幕可见区域的组件节点,滑出屏幕的节点被回收复用。本文用它构建一个 80 件商品的"创意市集",展示懒加载的完整实现模式。


一、ForEach 为什么不够用?

ForEach 是 ArkUI 中最常用的列表渲染方式,但它有一个根本性的性能问题——全量渲染

// ForEach 会把所有 1000 个产品一次性创建为组件节点
ForEach(this.allProducts, (product: Product) => {
  ListItem() { /* 商品卡片 */ }
})

当你调用 ForEach 时,框架遍历整个数组,为每个元素创建对应的 UI 节点。数组有 1000 条,就创建 1000 个节点;有 10000 条,就创建 10000 个节点。

这带来的三个问题:

  1. 首帧渲染延迟:创建 1000 个组件节点需要时间,用户从点击到看到内容之间有明显的延迟。
  2. 滚动掉帧:虽然 List 组件有内置的可见性裁剪(不显示屏幕外节点),但 1000 个节点占用的内存是实实在在的,GC 压力大。
  3. 内存占用高:每个节点都持有状态、样式、事件处理器等数据,1000 个节点轻松占用数十 MB 内存。

LazyForEach 解决了这三个问题:它只创建屏幕可见的几个节点(通常 10~15 个),当用户滑动时,滑出屏幕的节点被回收,滑入屏幕的位置创建新节点。无论数据有 100 条还是 100000 条,组件节点数量始终维持在 ~15 个左右。


在这里插入图片描述

二、LazyForEach 核心 API

2.1 基本语法

LazyForEach(
  dataSource: IDataSource,        // 数据源
  itemGenerator: (item: T) => void,  // 组件生成器
  keyGenerator: (item: T) => string  // 唯一键生成器
)

ForEach 的最大区别:第一个参数不是数组,而是一个实现了 IDataSource 接口的对象。

2.2 IDataSource 接口

interface IDataSource {
  totalCount(): number;                                    // 数据总数
  getData(index: number): any;                            // 按索引获取数据
  registerDataChangeListener(listener: DataChangeListener): void;    // 注册监听器
  unregisterDataChangeListener(listener: DataChangeListener): void;  // 注销监听器
}

框架通过这 4 个方法与数据源交互:先调用 totalCount() 知道有多少条,然后根据需要调用 getData(index) 获取特定索引的数据。

registerDataChangeListener 是连接数据源和 UI 的桥梁——当数据变化时(增、删、改),你需要通过 listener 通知框架刷新对应的 UI。

2.3 DataChangeListener:通知框架数据变更

interface DataChangeListener {
  onDataReloaded(): void;              // 数据全部刷新
  onDataAdd(index: number): void;      // 在 index 位置新增了数据
  onDataChange(index: number): void;   // index 位置的数据被修改
  onDataDelete(index: number): void;   // index 位置的数据被删除
  onDataMove(from: number, to: number): void;  // 数据从 from 移动到了 to
}

这些方法不是让你手动调用的——它们是框架传入的回调对象。当你的数据发生变化时,你通过 listener 的这些方法告诉框架"哪里的数据变了",框架就知道只更新受影响的那几个 UI 节点。

2.4 最简单的 IDataSource 实现

class ProductDataSource implements IDataSource {
  private dataArray: Product[] = [];
  private listeners: DataChangeListener[] = [];

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

  getData(index: number): Product {
    return this.dataArray[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);
    }
  }

  // 自定义方法:设置数据并通知框架刷新
  setData(data: Product[]): void {
    this.dataArray = data;
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded();
    });
  }
}

核心思路:

  • dataArray 是实际存储数据的数组
  • listeners 是框架注册的监听器列表(通常只有 1 个)
  • setData() 是我们自定义的方法,更新数据后调用 onDataReloaded() 通知框架

框架收到 onDataReloaded() 后,会重新调用 totalCount()getData(),只创建可见区域的组件节点。


在这里插入图片描述

三、LazyForEach vs ForEach:关键区别

ForEach LazyForEach
数据源 Array<T> IDataSource 接口实现
渲染策略 全量创建组件节点 按需创建,约 15 个节点
首帧性能 数据多时慢 与数据量无关,始终快
滚动性能 数据多时掉帧 与数据量无关,始终流畅
内存占用 O(n),n = 数据量 O(1),约 15 个节点
代码复杂度 极简(一行代码) 中等(需实现 IDataSource)

选择建议

  • 数据 < 50 条:ForEach 即可,简单直接
  • 数据 50~200 条:可选用 LazyForEach,但差异不明显
  • 数据 > 200 条:强烈推荐 LazyForEach,性能差异明显
  • 数据 > 1000 条:必须用 LazyForEach,ForEach 会导致明显卡顿

四、Demo:创意市集

本 Demo 构建一个创意市集商品目录——80 件商品跨越 5 个分类,使用 LazyForEach 按需渲染,顶部横向分类筛选。

页面结构

ProductCatalogPage (~190行)
├── Header(标题 + 商品总数)
├── 分类筛选栏(横向滚动的 6 个分类按钮)
│   └── 全部 / 数码 / 图书 / 服饰 / 家居 / 美食
├── List
│   └── LazyForEach(dataSource)
│       └── ListItem × N
│           └── 商品行(图标 + 名称 + 分类标签 + 价格 + 销量)
└── onClick → 商品详情弹窗 + 加入购物车

ProductDataSource 实现

class ProductDataSource implements IDataSource {
  private dataArray: Product[] = [];
  private listeners: DataChangeListener[] = [];

  totalCount(): number { return this.dataArray.length; }
  getData(index: number): Product { return this.dataArray[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); }
  }

  setData(data: Product[]): void {
    this.dataArray = data;
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded();
    });
  }
}

setData() 是核心方法——它接收一个新数组(可能是筛选后的结果),替换内部数据,然后通过 listener.onDataReloaded() 通知框架"数据全部变了,请重新按需渲染"。

分类筛选与数据刷新

applyFilter(category: string): void {
  this.selectedCategory = category;
  const filtered = category === '全部' ? [...ALL_PRODUCTS] :
    ALL_PRODUCTS.filter((p: Product) => p.category === category);
  this.dataSource.setData(filtered);
  this.productCount = filtered.length;
}

筛选逻辑:

  1. 如果选"全部",展示所有 80 件商品
  2. 如果选特定分类,用 filter() 筛选出该分类的商品
  3. 调用 dataSource.setData() 更新 LazyForEach 的数据源

每次切换分类,只有 ~15 个可见的商品卡片被重新创建,而不是全部 80 个。

分类按钮的选中态

Text(cat)
  .fontColor(this.selectedCategory === cat ? Color.White : AppColors.TEXT_SECONDARY)
  .fontWeight(this.selectedCategory === cat ? FontWeight.Medium : FontWeight.Regular)
  .backgroundColor(this.selectedCategory === cat ? AppColors.PRIMARY : '#F0F0F0')

选中:蓝色填充 + 白色文字;未选中:灰色填充 + 灰色文字。视觉上非常清晰。

商品分类标签

每个商品行右侧有一个彩色分类标签:

getCatColor(category: string): string {
  switch (category) {
    case '数码': return '#1677FF';  // 蓝色
    case '图书': return '#52C41A';  // 绿色
    case '服饰': return '#FAAD14';  // 橙色
    case '家居': return '#FF7A45';  // 红色
    case '美食': return '#EB2F96';  // 粉色
    default: return AppColors.PRIMARY;
  }
}

不同分类用不同颜色标记,用户扫一眼就能区分商品类型。

三个交互点

  1. 分类筛选 — 顶部 6 个分类按钮,切换后 LazyForEach 自动刷新
  2. 商品点击 — 弹窗展示详情 + "加入购物车"按钮
  3. 滚动浏览 — 80 件商品流畅滚动(LazyForEach 保证性能)

在这里插入图片描述

五、完整代码

import { AppColors, BorderRadius, FontSize, Spacing } from '../common/Constants';
import { promptAction } from '@kit.ArkUI';

class Product {
  id: number;
  name: string;
  price: number;
  category: string;
  icon: string;
  sold: number;

  constructor(id: number, name: string, price: number, category: string,
    icon: string, sold: number) {
    this.id = id;
    this.name = name;
    this.price = price;
    this.category = category;
    this.icon = icon;
    this.sold = sold;
  }
}

class ProductDataSource implements IDataSource {
  private dataArray: Product[] = [];
  private listeners: DataChangeListener[] = [];

  totalCount(): number { return this.dataArray.length; }
  getData(index: number): Product { return this.dataArray[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); }
  }

  setData(data: Product[]): void {
    this.dataArray = data;
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded();
    });
  }
}

const CATEGORIES: string[] = ['全部', '数码', '图书', '服饰', '家居', '美食'];

function generateProducts(): Product[] {
  const digitalNames = ['降噪耳机Pro', '机械键盘RGB', '超宽显示器', '无线鼠标',
    '蓝牙音箱', '智能手表', '平板支架', '充电宝', 'USB集线器', '摄像头', '麦克风', '数位板'];
  const digitalIcons = ['🎧', '⌨️', '🖥️', '🖱️', '🔊', '⌚', '📱', '🔋', '🔌', '📷', '🎤', '✍️'];
  const bookNames = ['设计模式', '代码整洁之道', '深入理解计算机系统', '算法导论',
    '人月神话', '重构', '程序员修炼之道', '领域驱动设计', '微服务架构',
    'SRE工作手册', '架构整洁之道', '软技能'];
  const bookIcons = ['📖', '📚', '💻', '📐', '📕', '🔧', '🛠️', '🏗️', '☁️', '📋', '🏰', '💡'];
  const fashionNames = ['运动跑鞋', '牛仔夹克', '帆布背包', '太阳镜', '羊绒围巾',
    '真皮钱包', '棒球帽', '帆布鞋', '休闲T恤', '羊毛衫'];
  const fashionIcons = ['👟', '🧥', '🎒', '🕶️', '🧣', '👛', '🧢', '👟', '👕', '🧶'];
  const homeNames = ['香薰蜡烛', '多肉盆栽', '挂墙时钟', '收纳盒', '桌垫',
    '抱枕', '地毯', '台灯', '花瓶', '置物架'];
  const homeIcons = ['🕯️', '🪴', '⏰', '📦', '🟫', '🛏️', '🏠', '💡', '🏺', '🗄️'];
  const foodNames = ['手工巧克力', '精品咖啡豆', '有机绿茶', '进口蜂蜜', '日式抹茶',
    '坚果礼盒', '黑松露酱', '橄榄油', '意面套装', '和牛牛排'];
  const foodIcons = ['🍫', '☕', '🍵', '🍯', '🍃', '🥜', '🖤', '🫒', '🍝', '🥩'];

  const buildProducts = (names: string[], icons: string[], category: string,
    startPrice: number, startSold: number, startId: number): Product[] => {
    const products: Product[] = [];
    for (let i = 0; i < names.length; i++) {
      products.push(new Product(startId + i, names[i],
        startPrice + Math.floor(Math.random() * 500), category, icons[i],
        startSold + Math.floor(Math.random() * 5000)));
    }
    return products;
  };

  let all: Product[] = [];
  all = all.concat(buildProducts(digitalNames, digitalIcons, '数码', 199, 1200, 1));
  all = all.concat(buildProducts(bookNames, bookIcons, '图书', 29, 800, 100));
  all = all.concat(buildProducts(fashionNames, fashionIcons, '服饰', 59, 600, 200));
  all = all.concat(buildProducts(homeNames, homeIcons, '家居', 39, 400, 300));
  all = all.concat(buildProducts(foodNames, foodIcons, '美食', 19, 300, 400));
  return all;
}

const ALL_PRODUCTS: Product[] = generateProducts();

@Entry
@Component
struct ProductCatalogPage {
  @State selectedCategory: string = '全部';
  @State productCount: number = 0;
  private dataSource: ProductDataSource = new ProductDataSource();

  aboutToAppear(): void {
    this.applyFilter('全部');
  }

  applyFilter(category: string): void {
    this.selectedCategory = category;
    const filtered = category === '全部' ? [...ALL_PRODUCTS] :
      ALL_PRODUCTS.filter((p: Product) => p.category === category);
    this.dataSource.setData(filtered);
    this.productCount = filtered.length;
  }

  build() {
    Column() {
      Row() {
        Text('创意市集')
          .fontSize(FontSize.HEADLINE)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .layoutWeight(1)
        Text(`${this.productCount}`)
          .fontSize(FontSize.CAPTION)
          .fontColor('#FFFFFFAA')
      }
      .width('100%').height(52)
      .backgroundColor(AppColors.PRIMARY)
      .padding({ left: Spacing.XXL, right: Spacing.XXL })

      Row() {
        Scroll() {
          Row() {
            ForEach(CATEGORIES, (cat: string, index: number) => {
              Text(cat)
                .fontSize(FontSize.CAPTION)
                .fontColor(this.selectedCategory === cat ? Color.White :
                  AppColors.TEXT_SECONDARY)
                .fontWeight(this.selectedCategory === cat ? FontWeight.Medium :
                  FontWeight.Regular)
                .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                .borderRadius(9999)
                .backgroundColor(this.selectedCategory === cat ?
                  AppColors.PRIMARY : '#F0F0F0')
                .margin({ right: Spacing.SM })
                .onClick(() => { this.applyFilter(cat); })
            })
          }
          .padding({ left: Spacing.LG, right: Spacing.LG })
        }
        .scrollBar(BarState.Off).width('100%')
      }
      .width('100%')
      .padding({ top: Spacing.MD, bottom: Spacing.MD })
      .backgroundColor(Color.White)
      .border({ width: { bottom: 1 }, color: '#F0F0F0' })

      List() {
        LazyForEach(this.dataSource, (product: Product) => {
          ListItem() {
            Row() {
              Column() {
                Text(product.icon).fontSize(32)
              }
              .width(56).height(56)
              .borderRadius(BorderRadius.SM)
              .backgroundColor(this.getCatBgColor(product.category))
              .justifyContent(FlexAlign.Center)

              Column() {
                Row() {
                  Text(product.name)
                    .fontSize(FontSize.BODY)
                    .fontColor(AppColors.TEXT_PRIMARY)
                    .fontWeight(FontWeight.Medium)
                    .layoutWeight(1).maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(product.category)
                    .fontSize(10)
                    .fontColor(this.getCatColor(product.category))
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .backgroundColor(this.getCatBgColor(product.category))
                    .borderRadius(4)
                }
                .width('100%')
                Row() {
                  Text(`¥${product.price}`)
                    .fontSize(FontSize.MEDIUM)
                    .fontColor('#E74C3C')
                    .fontWeight(FontWeight.Bold)
                  Text(` 已售${product.sold}`)
                    .fontSize(FontSize.CAPTION)
                    .fontColor(AppColors.TEXT_DISABLED)
                }
                .width('100%').margin({ top: 2 })
              }
              .layoutWeight(1).margin({ left: Spacing.MD })
            }
            .width('100%')
            .padding({ left: Spacing.LG, right: Spacing.LG,
              top: Spacing.MD, bottom: Spacing.MD })
            .backgroundColor(Color.White)
            .onClick(() => {
              promptAction.showDialog({
                title: `${product.icon} ${product.name}`,
                message: `价格:¥${product.price}\n` +
                  `分类:${product.category}\n已售:${product.sold}` +
                  `\n\n商品详情请查看商品页面`,
                buttons: [{ text: '加入购物车', color: AppColors.PRIMARY },
                  { text: '关闭', color: AppColors.TEXT_TERTIARY }]
              });
            })
          }
        }, (product: Product) => product.id.toString())
      }
      .layoutWeight(1).scrollBar(BarState.Off).backgroundColor('#F5F6FA')
      .divider({ strokeWidth: 1, color: '#F0F0F0',
        startMargin: Spacing.LG, endMargin: Spacing.LG })
    }
    .width('100%').height('100%')
  }

  getCatColor(category: string): string {
    switch (category) {
      case '数码': return '#1677FF';
      case '图书': return '#52C41A';
      case '服饰': return '#FAAD14';
      case '家居': return '#FF7A45';
      case '美食': return '#EB2F96';
      default: return AppColors.PRIMARY;
    }
  }

  getCatBgColor(category: string): string {
    switch (category) {
      case '数码': return '#1677FF20';
      case '图书': return '#52C41A20';
      case '服饰': return '#FAAD1420';
      case '家居': return '#FF7A4520';
      case '美食': return '#EB2F9620';
      default: return AppColors.PRIMARY + '20';
    }
  }
}

六、常见面试题 / 踩坑点

6.1 LazyForEach 的 keyGenerator 为什么是必须的?

LazyForEach(this.dataSource, (product: Product) => { ... },
  (product: Product) => product.id.toString())  // ← 这个参数是必须的

keyGenerator 是 LazyForEach 的第三个参数,它告诉框架每个数据项的唯一标识。当数据变化时,框架通过 key 来判断哪些组件可以复用、哪些需要重建。

如果 keyGenerator 返回重复的值,框架会混淆两个不同的数据项,导致 UI 显示错误(两个卡片显示相同内容)。

如果 keyGenerator 返回的 key 在数据变化前后不匹配,框架会认为所有数据都变了,全部重建——这会让 LazyForEach 退化为"一次性创建所有节点",丢失懒加载的性能优势。

最佳实践:使用数据实体的唯一 ID(通常是数据库主键)。

6.2 IDataSource 的监听器需要保存多个吗?

通常情况下,registerDataChangeListener 只会被调用一次——框架注册一个监听器。所以你只需要用一个数组保存即可。在实际开发中,很少出现多个监听器的场景,但按接口规范用数组实现是最安全的做法。

6.3 LazyForEach 只能用在 List 中吗?

不。LazyForEach 可以用于任何支持滚动的容器:List、Scroll、Grid、WaterFlow 等。核心条件是容器必须支持"按需创建节点"——而滚动容器天然支持,因为它们知道当前可见区域的边界。

6.4 如何给 LazyForEach 数据源动态新增 / 删除数据?

// 新增
addData(item: Product): void {
  this.dataArray.push(item);
  this.listeners.forEach((listener: DataChangeListener) => {
    listener.onDataAdd(this.dataArray.length - 1);
  });
}

// 删除
deleteData(index: number): void {
  this.dataArray.splice(index, 1);
  this.listeners.forEach((listener: DataChangeListener) => {
    listener.onDataDelete(index);
  });
}

关键点:增/删后必须通过 listener 通知框架。onDataAdd(index) 告诉框架"在 index 位置插入了一个新项",框架只创建这一项的 UI 节点,不影响其他已有节点。

6.5 数据刷新(如筛选)用 onDataReloaded 还是逐一通知?

如果数据全部变了(筛选、排序、重新加载),用 onDataReloaded() 一次性通知。
如果只是个别项变了(修改一个属性、删除一项),用对应的精确通知方法(onDataChangeonDataDelete 等)。

不要为了"简单"在所有场景都用 onDataReloaded()——这会让 LazyForEach 重建所有可见节点,失去增量更新的优势。


七、总结

LazyForEach 是 ArkUI 中处理大量数据列表的核心方案。它的设计哲学很清晰:框架管理"何时创建/何时回收",开发者管理"数据是什么/数据怎么变"。

1. 组件节点数量从 O(n) 降到 O(1)。 无论数据是 100 条还是 10000 条,屏幕上始终只有约 15 个组件节点。这意味着首帧加载时间、滚动帧率、内存占用都不随数据量增长而恶化。

2. IDataSource 是连接数据与 UI 的桥梁。 它不是一个"最佳实践"建议,而是一个硬性接口——实现了这 4 个方法(totalCount、getData、registerListener、unregisterListener),LazyForEach 就能工作。setData 是自定义方法,不是接口要求。

3. 数据变更通知决定 UI 刷新粒度。 onDataReloaded() 全量刷新,onDataAdd/Delete/Change 增量刷新。选对通知方法,能让 LazyForEach 在增删改场景下也只重建受影响的节点。

LazyForEach 特别适合以下场景:

  • 商品列表/商品目录(分页加载 + 筛选 + 懒渲染)— 本文 Demo
  • 社交媒体 Feed(无限滚动 + 动态增删)
  • 文件管理器(成千上万的文件列表)
  • 通讯录/联系人(按字母分组的大量联系人)
  • 任何数据量 > 200 条且需要流畅滚动的列表

LazyForEach 的代码比 ForEach 多一些(需要实现 IDataSource),但这个"多一点"的代价换来的是:无论数据量多大,列表始终像原生一样流畅。

Logo

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

更多推荐