HarmonyOS鸿蒙Next中LazyForEach列表刷新后数据错乱?IDataSource的key管理和DataChangeListener踩坑

HarmonyOS鸿蒙Next中LazyForEach列表刷新后数据错乱?IDataSource的key管理和DataChangeListener踩坑 做了一个商品列表页,用 LazyForEach + IDataSource 实现分页加载,遇到这些情况:

  1. 上拉加载第二页数据后,列表里出现了第一页某些 item 的重复显示,实际数据没重复
  2. 删除列表中间某一项商品,结果紧接着下面那项也消失了,滑动一下才恢复正常
  3. 下拉刷新整个列表(清空后重新填充),列表短暂白屏闪一下再出来,有时候还能看到旧数据残影
9 回复

尊敬的开发者,您好,
本地使用DevEco Studio 6.1.1 Release和API 23设备未复现您的问题,对于问题1、2建议开发者排查下键值生成函数生成的键值是否唯一,获取数据和删除数据后是否通过onDataAdd或者onDataDelete通知组件数据有变动,问题3建议通过替换新数组的方式实现数据的刷新,开发者可以参考下以下示例,或者为了进一步分析您的问题请提供下您的设备版本信息和可复现问题的demo,感谢您的理解与支持。
本地demo:

export class BasicDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];
  protected dataArray: T[] = [];

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

  // 获取指定索引的数据
  getData(index: number): T {
    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);
    }
  }

  // 通知数据重新加载
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    });
  }

  // 通知数据添加
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    });
  }

  // 通知数据删除
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index);
      listener.onDataReloaded();
    });
  }

  // 通知数据变化
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataChange(index);
    });
  }
}

export class PageDataSource<T> extends BasicDataSource<T> {
  // 追加单条数据
  pushData(data: T): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  // 批量追加数据
  pushDataList(dataList: T[]): void {
    const startIndex = this.dataArray.length;
    this.dataArray.push(...dataList);
    for (let i = 0; i < dataList.length; i++) {
      this.notifyDataAdd(startIndex + i);
    }
  }

  // 清空并重新设置数据
  clearAndSetData(dataList: T[]): void {
    this.dataArray = [...dataList];
    this.notifyDataReload();
  }

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

  // 清空数据
  clearData(): void {
    this.dataArray.splice(0, this.dataArray?.length)
  }

  // 根据条件查找索引
  findIndex(predicate: (item: T) => boolean): number {
    return this.dataArray.findIndex(predicate);
  }

  // 获取所有数据
  getAllData(): T[] {
    return this.dataArray;
  }
}

// 数据模型
interface ItemData {
  id: number;
  title: string;
  content: string;
}

@Entry
@Component
struct Index {
  // 数据源
  private dataSource: PageDataSource<ItemData> = new PageDataSource<ItemData>();
  private scroller: Scroller = new Scroller();
  // 分页状态
  @State currentPage: number = 1;
  @State pageSize: number = 20;
  @State isLoading: boolean = false;
  @State isRefreshing: boolean = false;
  @State hasMore: boolean = true;

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

  // 加载第一页数据
  async loadFirstPage(): Promise<void> {
    this.isLoading = true;
    try {
      const data = await this.fetchData(1);
      this.dataSource.clearAndSetData(data);
      this.currentPage = 1;
      this.hasMore = data.length >= this.pageSize;
    } catch (error) {
      console.error('加载失败:', error);
    } finally {
      this.isLoading = false;
    }
  }

  // 下拉刷新
  async onRefresh(): Promise<void> {
    if (this.isRefreshing) {
      return;
    }
    this.isRefreshing = true;
    try {
      const data = await this.fetchData(1);
      this.dataSource.clearAndSetData(data);
      this.currentPage = 1;
      this.hasMore = data.length >= this.pageSize;
    } catch (error) {
      console.error('刷新失败:', error);
    } finally {
      this.isRefreshing = false;
    }
  }

  // 上拉加载更多
  async onLoadMore(): Promise<void> {
    if (this.isLoading || !this.hasMore || this.isRefreshing) {
      return;
    }
    this.isLoading = true;
    try {
      const nextPage = this.currentPage + 1;
      const data = await this.fetchData(nextPage);
      if (data.length > 0) {
        this.dataSource.pushDataList(data);
        this.currentPage = nextPage;
        this.hasMore = data.length >= this.pageSize;
      } else {
        this.hasMore = false;
      }
    } catch (error) {
      console.error('加载更多失败:', error);
    } finally {
      this.isLoading = false;
    }
  }

  // 删除数据
  deleteItem(item: ItemData): void {
    const index = this.dataSource.findIndex((data) => data.id === item.id);
    if (index >= 0) {
      this.dataSource.deleteData(index);
    }
  }

  // 模拟数据获取请求
  async fetchData(page: number): Promise<ItemData[]> {
    return new Promise((resolve) => {
      setTimeout(() => {
        const data: ItemData[] = [];
        const startId = (page - 1) * this.pageSize + 1;
        const now = new Date();
        for (let i = 0; i < this.pageSize; i++) {
          data.push({
            id: startId + i,
            title: `标题 ${startId + i}`,
            content: `这是第 ${startId + i} 条数据的内容`
          });
        }
        if (page > 5) {
          resolve([]);
        } else {
          resolve(data);
        }
      }, 800);
    });
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Text('标题')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
      }
      .width('100%')
      .height(56)
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#FFFFFF')

      // 下拉刷新区域
      Refresh({ refreshing: $$this.isRefreshing }) {
        List({ space: 12, scroller: this.scroller }) {
          LazyForEach(this.dataSource, (item: ItemData, index: number) => {
            ListItem() {
              this.ListItemBuilder(item, index)
            }
            .swipeAction({
              end: {
                builder: () => {
                  this.DeleteButtonBuilder(item)
                },
                actionAreaDistance: 80,
                onAction: () => {
                  this.getUIContext()?.animateTo({ duration: 1000 }, () => {
                    this.deleteItem(item);
                  });
                }
              },
              edgeEffect: SwipeEdgeEffect.Spring
            })
          }, (item: ItemData) => item.id.toString())
          // 底部加载状态
          ListItem() {
            this.FooterBuilder()
          }
        }
        .width('100%')
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .edgeEffect(EdgeEffect.Spring)
        .cachedCount(5)
        .onScrollIndex((start: number, end: number) => {
          if (end >= this.dataSource.totalCount() - 3) {
            this.onLoadMore();
          }
        })
      }
      .onRefreshing(() => {
        setTimeout(() => {
          this.isRefreshing = false;
          this.onRefresh();
        }, 2000)
      })
      .refreshOffset(64)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // 列表项构建(支持左滑删除)
  @Builder
  ListItemBuilder(item: ItemData, index: number) {
    ListItem() {
      Row() {
        Column() {
          Row() {
            Text(item.title)
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor('#333333')
          }
          .width('100%')

          Text(item.content)
            .fontSize(14)
            .fontColor('#666666')
            .margin({ top: 8 })
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .padding(16)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(8)
      .margin({ left: 12, right: 12 })
    }
  }

  // 删除按钮构建
  @Builder
  DeleteButtonBuilder(item: ItemData) {
    Row() {
      // 删除按钮
      Column() {
        Text('删除')
          .fontSize(12)
          .fontColor('#FFFFFF')
          .margin({ top: 2 })
      }
      .width(80)
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#FF4D4F')
      .borderRadius({ topRight: 8, bottomRight: 8 })
      .onClick(() => {
        this.getUIContext()?.animateTo({ duration: 1000 }, () => {
          this.deleteItem(item);
        });
      })
    }
    .height('100%')
    .margin({ left: 12 })
  }

  // 底部状态构建
  @Builder
  FooterBuilder() {
    Row() {
      if (this.isLoading) {
        LoadingProgress()
          .width(24)
          .height(24)
          .color('#999999')
        Text('加载中...')
          .fontSize(14)
          .fontColor('#999999')
          .margin({ left: 8 })
      } else if (!this.hasMore) {
        Text('— 已经到底了 —')
          .fontSize(14)
          .fontColor('#CCCCCC')
      } else {
        Text('上拉加载更多')
          .fontSize(14)
          .fontColor('#999999')
      }
    }
    .width('100%')
    .height(60)
    .justifyContent(FlexAlign.Center)
  }
}

更多关于HarmonyOS鸿蒙Next中LazyForEach列表刷新后数据错乱?IDataSource的key管理和DataChangeListener踩坑的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


根因基本是 key 和通知方式用错了。LazyForEach 不是靠数组引用刷新,它靠 IDataSource + DataChangeListener 通知,再用 key 判断节点能不能复用。键值必须唯一,键值不变不会触发刷新;数据变化要通过 DataChangeListener 通知。相关文档:LazyForEach:数据懒加载

示例代码:

class Product {
  id: string = '';
  name: string = '';
  price: string = '';
  version: number = 0;

  constructor(id: string = '', name: string = '', price: string = '') {
    this.id = id;
    this.name = name;
    this.price = price;
  }
}

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

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

  public getData(index: number): Product {
    return this.products[index];
  }

  public registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  public unregisterDataChangeListener(listener: DataChangeListener): void {
    let index = this.listeners.indexOf(listener);
    if (index >= 0) {
      this.listeners.splice(index, 1);
    }
  }

  public appendPage(items: Product[]): void {
    let start: number = this.products.length;
    items.forEach((item: Product, index: number): void => {
      this.products.push(item);
      this.notifyDataAdd(start + index);
    });
  }

  public deleteAt(index: number): void {
    if (index < 0 || index >= this.products.length) {
      return;
    }

    this.products.splice(index, 1);
    this.notifyDataDelete(index);
  }

  public replaceAll(items: Product[]): void {
    this.products = items;
    this.notifyDataReload();
  }

  public updatePrice(index: number, price: string): void {
    if (index < 0 || index >= this.products.length) {
      return;
    }

    this.products[index].price = price;
    this.products[index].version += 1;
    this.notifyDataChange(index);
  }

  private notifyDataReload(): void {
    this.listeners.forEach((listener: DataChangeListener): void => {
      listener.onDataReloaded();
    });
  }

  private notifyDataAdd(index: number): void {
    this.listeners.forEach((listener: DataChangeListener): void => {
      listener.onDataAdd(index);
    });
  }

  private notifyDataDelete(index: number): void {
    this.listeners.forEach((listener: DataChangeListener): void => {
      listener.onDataDelete(index);
    });
  }

  private notifyDataChange(index: number): void {
    this.listeners.forEach((listener: DataChangeListener): void => {
      listener.onDataChange(index);
    });
  }
}

@Component
struct ProductItemView {
  product: Product = new Product();
  index: number = 0;

  build() {
    Row() {
      Column() {
        Text(this.product.name)
          .fontSize(16)
          .fontWeight(600)

        Text(`ID: ${this.product.id}`)
          .fontSize(12)
          .fontColor('#666666')
      }
      .layoutWeight(1)

      Text(this.product.price)
        .fontSize(16)
        .fontColor('#E64545')
    }
    .width('100%')
    .height(72)
    .padding({ left: 16, right: 16 })
    .backgroundColor(Color.White)
  }
}

@Entry
@Component
struct ProductListPage {
  private dataSource: ProductDataSource = new ProductDataSource();

  @State page: number = 0;
  @State message: string = '商品列表';

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

  private buildPage(page: number): Product[] {
    let items: Product[] = [];
    let start: number = page * 10;

    for (let i = 0; i < 10; i++) {
      let id: string = `product_${start + i}`;
      let name: string = `商品 ${start + i}`;
      let price: string = `¥${99 + start + i}`;
      items.push(new Product(id, name, price));
    }

    return items;
  }

  private loadMore(): void {
    this.page += 1;
    this.dataSource.appendPage(this.buildPage(this.page));
    this.message = `已加载第 ${this.page + 1} 页`;
  }

  private refresh(): void {
    this.page = 0;
    this.dataSource.replaceAll(this.buildPage(0));
    this.message = '已刷新';
  }

  private deleteMiddle(): void {
    this.dataSource.deleteAt(5);
    this.message = '已删除第 6 项';
  }

  private updateOne(): void {
    this.dataSource.updatePrice(2, '¥199');
    this.message = '已修改第 3 项价格';
  }

  build() {
    Column() {
      Row({ space: 8 }) {
        Button('刷新')
          .onClick(() => {
            this.refresh();
          })

        Button('加载更多')
          .onClick(() => {
            this.loadMore();
          })

        Button('删除')
          .onClick(() => {
            this.deleteMiddle();
          })

        Button('改价')
          .onClick(() => {
            this.updateOne();
          })
      }
      .padding(12)

      Text(this.message)
        .fontSize(14)
        .fontColor('#666666')
        .padding({ left: 16, bottom: 8 })

      List({ space: 1 }) {
        LazyForEach(this.dataSource, (item: Product, index: number) => {
          ListItem() {
            ProductItemView({
              product: item,
              index: index
            })
          }
        }, (item: Product) => {
          return `${item.id}_${item.version}`;
        })
      }
      .width('100%')
      .layoutWeight(1)
      .cachedCount(6)
      .backgroundColor('#F5F5F5')
      .onReachEnd(() => {
        this.loadMore();
      })
    }
    .width('100%')
    .height('100%')
  }
}

你这三个现象可以这样排查:

  1. 第二页出现第一页重复:通常是 key 重复了,比如用了分页内下标 0、1、2,第二页又从 0 开始。key 必须用商品真实唯一 ID,不能用 index
// 错
(item: Product, index: number) => index.toString()

// 对
(item: Product) => item.id
  1. 删除中间项后下面一项也消失:常见原因是先通知再改数组,或者删除后仍然用 index 做 key。正确顺序是:先改数据,再 onDataDelete(index)
this.products.splice(index, 1);
listener.onDataDelete(index);
  1. 下拉刷新白屏/旧数据残影:不要先 clear 再一条条 onDataAdd。整页替换时直接换数组,然后 onDataReloaded()。如果刷新期间接口较慢,UI 上保留旧列表,加一个刷新状态,不要提前清空数据源。

这类“数据没重复但 UI 重复、删除错位、刷新残影”的问题,优先看 key 和 DataChangeListener 通知是否严格对应数据变化。

建议检查:

  1. getKey 不要用 index,要用后端 id 或业务唯一 id。
  2. 分页追加只通知新增区间,不要追加后又整体 reload。
  3. 删除中间项时先改数据源,再通知对应 index 的 delete,顺序不要反。
  4. 下拉刷新如果是清空后重填,最好替换数据源实例或明确通知 reload,避免旧节点复用。
  5. item 组件内部不要缓存 index,事件里用 id 找数据。

LazyForEach 的性能来自节点复用,key 不稳定时复用就会变成错位。先把 key 唯一且稳定这件事做对,后面的刷新问题会少很多。

这个问题一般不是 LazyForEach 自己乱了,更多是 IDataSource 的 key 和数据变化通知没有处理对

LazyForEach 和普通 ForEach 不一样,它会根据 key 做节点复用,如果 key 不稳定,就很容易出现你说的这些情况。

第一个,分页加载后出现重复显示:

重点看 getKey()

不要用 index 当 key,比如:

getKey(index){
  return index.toString()
}

分页以后数据插入,index变化,但是组件认为还是原来的节点,就会复用错。

建议直接用商品唯一 id:

getKey(index){
  return this.data[index].id
}

保证整个列表生命周期内唯一。

第二个,删除中间项导致下面一项一起消失:

这个也是 key 问题比较多。

比如:

删除前:

A(index0)

B(index1)

C(index2)

删除B以后:

A(index0)

C(index1)

如果 key 用 index,LazyForEach 会认为:

原来的 index1 还是那个组件,只是数据换了。

如果组件内部有状态,就可能出现错位。

第三个,下拉刷新白屏、残影:

一般是以下流程中出现问题:

clear数据 → reload → 加载新数据

中间有一瞬间数据源为空,所以 UI 会销毁再重建。

刷新建议:

不要先清空列表。

接口返回新数据后:

替换数据源

或者批量更新。

另外 IDataSource 里通知也要对应:

新增:

notifyDataAdd

删除:

notifyDataDelete

整体变化:

notifyDataReload

不要只是改数组,然后等 UI 自己感知。

这种商品列表场景比较推荐:

商品列表数据 → 唯一 id 做 key → 分页追加通知 → 刷新整体替换

基本可以避免 LazyForEach 的复用错乱。

希望可以帮到你~~~

我觉得这个是你key 的问题, 你的key 是否唯一

当LazyForEach数据源发生变化,需要再次渲染时,开发者应根据数据源的变化情况调用listener对应的接口,通知LazyForEach做相应的更新。LazyForEach的更新操作包括:添加数据、删除数据、交换数据、改变单个数据、改变多个数据以及精准批量修改数据,各使用场景示例详见开发文档:

https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-lazyforeach#数据更新

这类现象优先检查 key 和通知时机。LazyForEach 会按 key 复用子组件;如果 key 用 index、随机值或不唯一,分页、删除、刷新后就容易出现重复、错位或旧 UI 残影。

建议这样处理:

  1. LazyForEach 第三个参数使用业务唯一 id 作为 key,例如商品 id,不要使用 index。

  2. IDataSource 的 totalCount() 和 getData(index) 必须始终读取同一份数组;先更新数组,再调用对应 DataChangeListener。

  3. 追加第二页:从旧 length 开始逐条 onDataAdd(index),或 API 12+ 使用 onDatasetChange 批量通知。

  4. 删除中间项:先从数组删除,再 onDataDelete(index);批量删除要从大 index 到小 index,避免索引位移。

  5. 修改单项内容:替换对应数据后调用 onDataChange(index)。

  6. 下拉全量刷新不要先清空通知再填充通知,建议一次替换新数组后调用 onDataReloaded();如果新旧数据不是同一批,确保 key 随业务 id 改变,否则框架会复用旧 child。

LazyForEach数据错乱主因是getKey返回了不稳定标识(如index)或可变字段。刷新时必须保证key与数据项唯一绑定,且DataChangeListener按实际变更调用对应方法(notifyItemInserted/Removed/Changed),不能仅用notifyItemDataChange或全量刷新。另外,更新数据源后需同步通知,顺序错乱会导致缓存复用异常。

LazyForEach 的列表项复用依赖 IDataSource 提供的唯一且稳定的 key。你的问题大概率出在 key 的生成规则上:

  • 分页加载后出现重复 item:很可能第二页某些数据的 key 与第一页重复(比如用 index 当 key,或取到的字段有相同值),导致复用错乱。
  • 删除中间项后下面项消失:key 不稳定(如删除后重新生成 key),LazyForEach 无法正确映射旧 item 与新数据,复用视图时删错了位置。
  • 刷新白屏/残影:直接清空数据源再 setData 会触发整体重建,而不是增量刷新;没有正确调用 DataChangeListenernotifyDataDelete/Insert/Change 来通知 UI 局部更新,就会出现闪烁和残留。

正确做法是保证每条数据有全局唯一且无变化的 key(如商品 id),分页新增时用 notifyDataAdd,删除时用 notifyDataDelete,下拉刷新先 notifyDataClear 再通知插入,避免直接替换整个数据源实例。

回到顶部