同样是一千多条数据,放进 List 与直接把所有子组件一次性创建出来,开发体验可能差别很大。真正棘手的地方又不在“滚动是否顺滑”这一个观察上:在列表开头插入条目时,为什么已有行可能被错误复用?删除一项后,为什么数据数组已经变短,界面还留着旧内容?如果用户勾选的是某个业务条目,而组件只记住了行号,排序之后它是否仍然表示同一个东西?

这篇用一个独立的本地物资盘点应用“物盒清单”回答这些问题。它与此前的巡检工单、网络诊断、能耗看板和预约表单都不是同一个项目。示例启动时生成 1200 条物资记录,列表允许“在顶部新增”“删除首项”和“点击单项切换已盘点”。重点不在设计花哨界面,而在把 IDataSource 数据源、DataChangeListener 通知机制、LazyForEach 的稳定键,以及行内可观察状态串成可解释的增量更新过程。

大清单数据源与增量渲染主题封面

一、理解懒加载的前提:数据与组件不是同一个东西

ForEach 在初次渲染时会为数组中的项目创建相应子组件,数据量增大后,初始化和组件管理开销可能随之提高。LazyForEach 适合放在滚动容器中,按可见范围等条件按需创建条目,并在条目离开必要范围后回收相应组件。这里的“懒”指 UI 子组件生成策略,并不意味着它会自动分页请求服务器,也不意味着 1200 条业务对象根本没有被创建。示例数据仍常驻内存,只是 UI 不必同时把所有行挂进渲染树。

然而,LazyForEach 无法盯住普通数组的每次插入和删除。官方要求数据源实现 IDataSource 的核心方法:totalCount()、getData()、registerDataChangeListener() 和 unregisterDataChangeListener()。数组更新之后,再通过监听器告知 ArkUI 对应位置发生了变化。这个顺序绝不能反过来:例如调用 onDataDelete(0) 之前,必须先把数据源中的首项删除,否则渲染期间读到的数量和元素会不一致。

业务键也要单独设计。数组行号会随着插入删除变化,但记录 ID 不应该因此变化。在“物盒清单”中,每条记录有 id、name、checked 三个字段。id 从 BOX-0001 这样的稳定值生成,不从当前 index 推导;name 表示展示标题,checked 表示盘点状态。列表在开头插入新条目后,原有物资仍保留原来的 ID。

二、实现可通知的数据源,不让页面直接改数组

建议在 entry/src/main/ets/model/CatalogSource.ets 定义业务模型与数据源。通过 @Observed 让记录本身可被行组件观察;数据源内部持有私有数组,仅暴露明确的 addAtFront()、removeFirst() 和读取接口。这既让通知与变更靠近,也避免页面有时调用通知、有时忘记调用通知。

// entry/src/main/ets/model/CatalogSource.ets
@Observed
export class CatalogItem {
  id: string;
  name: string;
  checked: boolean;
  constructor(id: string, name: string, checked: boolean = false) {
    this.id = id;
    this.name = name;
    this.checked = checked;
  }
}

export class CatalogSource implements IDataSource {
  private items: CatalogItem[] = [];
  private listeners: DataChangeListener[] = [];
  private nextId: number = 1;

  constructor(count: number) {
    for (let i = 0; i < count; i++) {
      this.items.push(this.makeItem());
    }
  }

  private makeItem(): CatalogItem {
    const seq: number = this.nextId++;
    const id: string = `BOX-${seq.toString().padStart(4, '0')}`;
    return new CatalogItem(id, `盘点物资 ${seq.toString().padStart(4, '0')}`);
  }

  totalCount(): number { return this.items.length; }
  getData(index: number): CatalogItem { return this.items[index]; }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) === -1) {
      this.listeners.push(listener);
    }
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const index: number = this.listeners.indexOf(listener);
    if (index !== -1) { this.listeners.splice(index, 1); }
  }

  addAtFront(): void {
    this.items.unshift(this.makeItem());
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataAdd(0);
    });
  }
  removeFirst(): boolean {
    if (this.items.length === 0) { return false; }
    this.items.shift();
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataDelete(0);
    });
    return true;
  }
}

nextId 由数据源维持,不使用 totalCount() 充当新 ID。假如先删除 BOX-0001 再插入数据,按数量生成 ID 就可能产生冲突。我们的构造器创建初始 1200 条,第一次新增应分配 BOX-1201,再删再增仍会继续递增。这个单进程示例无需数据库;真正要跨重启保持 ID 唯一,还必须把序列号或 ID 策略纳入持久化设计。

DevEco Studio 中的 CatalogSource 与长列表页面

三、让行内状态和列表结构各管各的刷新

业务对象的 checked 不属于列表结构变化。若每次点击一行都强行重建整个列表,就把懒加载的优势消耗在无意义更新上。这里选择 @Observed + @ObjectLink:列表组件负责决定某个 ID 的行是否存在、位于什么位置;行组件负责观察该对象内部字段的改变。这样“勾选物资”不需要再发出 onDataAdd() 或 onDataDelete()。

// entry/src/main/ets/pages/CatalogPage.ets
import { CatalogItem, CatalogSource } from '../model/CatalogSource';

@Component
struct CatalogRow {
  @ObjectLink item: CatalogItem;
  build() {
    Row({ space: 12 }) {
      Text(this.item.checked ? '✓' : '○')
        .fontSize(24).fontColor(this.item.checked ? '#0F987D' : '#8C9AA8')
      Column({ space: 4 }) {
        Text(this.item.name).fontSize(18).fontColor('#20344C')
        Text(this.item.id).fontSize(12).fontColor('#7E8CA0')
      }.alignItems(HorizontalAlign.Start)
    }
    .width('100%').padding(16).backgroundColor(Color.White)
    .borderRadius(14)
    .onClick(() => { this.item.checked = !this.item.checked; })
  }
}

@Entry
@Component
struct CatalogPage {
  private source: CatalogSource = new CatalogSource(1200);
  @State private count: number = 1200;

  build() {
    Column({ space: 14 }) {
      Text('物盒清单').fontSize(28).fontWeight(FontWeight.Bold)
        .width('100%').fontColor('#20344C')
      Text(`盘点条目 · ${this.count} 项`).fontSize(15)
        .width('100%').fontColor('#74869B')
      Row({ space: 12 }) {
        Button('顶部新增').onClick(() => {
          this.source.addAtFront();
          this.count = this.source.totalCount();
        })
        Button('删除首项').onClick(() => {
          if (this.source.removeFirst()) {
            this.count = this.source.totalCount();
          }
        })
      }.width('100%')
      List({ space: 10 }) {
        LazyForEach(this.source, (item: CatalogItem) => {
          ListItem() { CatalogRow({ item: item }) }
        }, (item: CatalogItem) => item.id)
      }
      .width('100%').layoutWeight(1).cachedCount(3)
    }
    .padding(18).backgroundColor('#F4F7FB')
    .width('100%').height('100%')
  }
}

LazyForEach 的第三个参数负责生成键,item.id 的唯一性至关重要。用 index.toString() 看似简单,但在顶部插入新记录时,每个旧条目都移动一个位置,index 已经不再是条目身份。相反,业务键 BOX-0001 无论位于第 1 行还是第 2 行,都依旧属于同一条物资。另一方面,稳定键并不替代数据变更通知:已经插入一个新数组元素,却忘记调用 onDataAdd(0),UI 未必知道列表结构已变;点击“删除首项”后调用通知,系统才会按对应位置维护已有渲染节点。

物盒清单 9:16 竖屏页面,展示新增条目与稳定编号

四、一次顶部插入,最能暴露索引方案的缺陷

假设初始可见的三项为 BOX-0001、BOX-0002、BOX-0003。点击“顶部新增”时,数据源先执行 unshift(),新项 ID 是 BOX-1201;随后 onDataAdd(0) 让 LazyForEach 更新列表结构。列表顺序变成 BOX-1201、BOX-0001、BOX-0002、BOX-0003。原来第一项依然有同一个 ID 和盘点状态,不会因为展示位置从第一行变成第二行就把“已盘点”附着到新项。

对于单行勾选,CatalogRow 通过 @ObjectLink 直接观察 CatalogItem.checked。这里不额外用 onDataChange() 触发强制重建,避免对同一更新路径重复通知。如果业务场景改成用一个普通不可观察对象完全替换某一条记录,则需要依照 LazyForEach 的 onDataChange() 语义及稳定键行为重新审查:尤其不能假定“发出变化通知”就等于“所有子属性一定按期望重绘”。官方对变更时键值重建与子属性观察有专门说明,需按目标 SDK 验证。

cachedCount(3) 只控制部分额外缓存行为,不能被解释成内存中永远只有三条数据,亦不代表固定数量的组件或性能提升百分比。页面 count 是可响应的条目数量,数据源 items 才是业务事实。此次把它们分开,目的是让列表结构更新与顶部计数同步;未来接入分页、搜索时还需区分“总条数”和“当前筛选后条数”。

稳定业务键与位置索引在顶部插入后的差异

五、验证顺序与边界:看得见的结果才有说服力

在 DevEco Studio 中新建 Stage 模型 ArkTS 工程,把模型放进 model/CatalogSource.ets,把页面放进 pages/CatalogPage.ets 并设为入口;本示例的数据全在本地构造,不需要网络权限。实际验证可以按顺序进行:打开页面确认总数为 1200;点击首项让它变成绿色对勾;在顶部新增一条,检查 BOX-1201 是否进入首行、原 BOX-0001 是否保持原勾选;删除首项后,检查列表总数和原有记录的相对顺序;快速上下滚动并继续勾选,确认页面可在反复回收与创建行组件后显示相应状态。

为进一步检查缓存和复用机制,可以在可控调试版本给行组件增加 aboutToAppear/aboutToDisappear 记录,但要注意生命周期触发本身不等同于真实帧率或内存收益。要比较 ForEach 和 LazyForEach 的性能,至少应固定数据集、设备、渲染内容与构建配置,在相同方法下采集构建耗时、滚动帧耗时和内存占用;不能凭一张页面截图就宣布“提升数倍”。当业务记录数量远超内存能力时,需要结合分页数据库、过滤索引和异步加载,而不是把 LazyForEach 当万能缓存层。

这个项目把三类责任明确分离:CatalogSource 管业务数组与结构通知,CatalogRow 管单行观察,CatalogPage 管页面布局和计数。它适合本地大量同构条目的清单、商品列表、文件目录;不适合直接拿来替代持久数据库事务或网络分页逻辑。真正稳定的交互,不是靠越来越多的刷新调用堆出来,而是靠业务身份稳定、数据变更顺序正确、观察边界清晰。

官方参考:LazyForEach API、LazyForEach 开发指南、ForEach API。

Logo

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

更多推荐