Administrator
发布于 2026-09-03 / 3,015 阅读
0
0

鸿蒙高效列表渲染:LazyForEach 原理、缓存策略与长列表极限场景

系列文章《鸿蒙开发从入门到专家》第 52 篇 · 高级

上一篇讲启动优化,这篇讲列表渲染。两者并列应用启动后最常见的性能瓶颈。

LazyForEach 是 ArkUI 提供的懒加载列表组件,只在可视区域内渲染列表项,屏幕外的组件不创建。听起来很美好,但实际项目里 400 条数据还是会卡。问题出在缓存策略、数据源实现和组件复用上。

一、LazyForEach 的渲染机制

先搞清楚 LazyForEach 到底做了什么。

普通 ForEach 会在 build 时一次性创建所有列表项组件。100 条数据就创建 100 个组件实例,1000 条就 1000 个。内存爆炸,渲染卡顿。

LazyForEach 的做法是只渲染可视区域内的 n 条数据,外加缓存区域的 cachedCount 条。当用户滑动时,滑出屏幕的组件被回收,新进入可视区域的组件被复用。

渲染过程:

  1. 首次加载:请求 n + cachedCount 条数据,屏幕上显示 n 条。

  2. 用户滑动:缓存区域的组件从屏外进入可视区,直接渲染,不需要重新创建组件。

  3. 继续滑动:屏外缓存的列表项数量少于 cachedCount 时,触发新一轮数据加载,预加载 cachedCount 条。

  4. 双向滑动:上下两个方向分别缓存 cachedCount 条数据。

默认 cachedCount 为 1,几乎等于没缓存。

二、数据源实现

LazyForEach 要求传入一个实现了 IDataSource 接口的数据源。这是最容易出错的地方。

import { LazyForEach } from '@kit.ArkUI';

interface IDataChangeListener {
  onDataAdded(index: number): void;
  onDataMoved(from: number, to: number): void;
  onDataDeleted(index: number): void;
  onDataChanged(index: number): void;
}

interface IDataSource {
  totalCount(): number;
  getData(index: number): Object;
  registerDataChangeListener(listener: DataChangeListener): void;
  unregisterDataChangeListener(listener: DataChangeListener): void;
}

class SimpleDataSource implements IDataSource {
  private data: string[] = [];
  private listeners: DataChangeListener[] = [];

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

  getData(index: number): string {
    return this.data[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    this.listeners.push(listener);
  }

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

  // 数据操作时必须通知监听器
  pushData(item: string): void {
    const index = this.data.length;
    this.data.push(item);
    this.listeners.forEach(listener => {
      listener.onDataAdded(index);
    });
  }

  reloadData(newData: string[]): void {
    this.data = newData;
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    });
  }
}

关键点:pushData 时必须调用 listener.onDataAdded(index),否则列表不会更新。这是新手最常犯的错误——数据源数组变了,UI 不刷新。

三、cachedCount 怎么设

cachedCount 不是越大越好。它直接影响内存占用和滑动流畅度。

cachedCount

内存占用

滑动流畅度

适用场景

0~1

最低

快速滑动有白屏

大图/视频列表

5~10

适中

平衡

普通图文列表

n/2(一屏条数)

较高

最流畅

纯文本短列表项

经验公式:cachedCount = 一屏可见项数 / 2。如果一屏显示 8 条,cachedCount 设 4。

但如果列表项包含网络图片加载,可以适当增大 cachedCount,给图片更多预加载时间。如果列表项包含视频或大图,减小 cachedCount 控制内存。

@Entry
@Component
struct ListPage {
  private dataSource: SimpleDataSource = new SimpleDataSource();

  aboutToAppear(): void {
    for (let i = 0; i < 1000; i++) {
      this.dataSource.pushData(`Item ${i}`);
    }
  }

  build() {
    List() {
      LazyForEach(this.dataSource, (item: string) => {
        ListItem() {
          Text(item)
            .width('100%')
            .height(60)
            .textAlign(TextAlign.Center)
        }
      }, (item: string) => item)
    }
    .cachedCount(5)
    .height('100%')
  }
}

第三个参数是 keyGenerator,用于组件复用。返回唯一 key 帮助框架判断哪些列表项可以复用组件。不写也行,但性能会差一些。

四、动态预加载:Prefetcher

API 12 引入了 Prefetcher 机制,比 cachedCount 更智能。cachedCount 是固定数量的预渲染,Prefetcher 能自适应调整预取范围、优先加载可视区域、在快速滚动时智能取消不必要任务。

import { LazyForEachOperation, IDataSourcePrefetching } from '@kit.ArkUI';

class PrefetchDataSource implements IDataSourcePrefetching {
  private data: string[] = [];

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

  getData(index: number): string {
    return this.data[index];
  }

  // Prefetcher 调用:预取数据
  prefetch(index: number): void {
    // 提前发起网络请求或解码图片
    if (index < this.data.length) {
      // 预取逻辑
    }
  }

  // 取消预取(快速滑动时框架会取消不必要任务)
  cancelPrefetch(index: number): void {
    // 取消正在进行的预取
  }
}

Prefetcher 的优势在于"预取"和"预渲染"分离:预取只加载数据(如发网络请求),预渲染才创建组件。快速滑动时先预取,滑动减速后再预渲染,避免无意义的组件创建销毁。

五、组件复用:ReusableCustomNode

列表项复杂时(比如每个项包含图片+标题+副标题+操作按钮),反复创建和销毁组件开销很大。ArkUI 提供了组件复用机制:

@Reusable
@Component
struct ArticleItem {
  @State item: ArticleData = new ArticleData();

  aboutToReuse(params: Record<string, Object>): void {
    this.item = params['item'] as ArticleData;
  }

  build() {
    Row() {
      Image(this.item.coverUrl)
        .width(80)
        .height(80)

      Column() {
        Text(this.item.title)
          .fontSize(16)
          .maxLines(1)
        Text(this.item.summary)
          .fontSize(12)
          .maxLines(2)
      }
      .layoutWeight(1)

      Image($r('app.media.ic_arrow'))
        .width(16)
        .height(16)
    }
    .padding(12)
  }
}

使用 @Reusable 装饰器后,组件被回收时会进入复用池,而不是被销毁。新的列表项需要创建组件时,框架先从复用池取,通过 aboutToReuse 更新数据,跳过完整的组件创建流程。

在列表中使用:

List() {
  LazyForEach(this.dataSource, (item: ArticleData) => {
    ListItem() {
      ArticleItem({ item: item })
    }
  }, (item: ArticleData) => item.id)
}
.cachedCount(5)

六、长列表极限场景

6.1 万条数据

10000 条数据 + LazyForEach + cachedCount(5) + 组件复用 = 可以跑。实测在 API 12 的模拟器上,首次进入页面加载 15 条(可视 8 条 + 缓存 7 条),滑动帧率稳定。

但有几个注意点:

keyGenerator 必须稳定。 如果 keyGenerator 返回的 key 不稳定(比如用 index 做 key),删除/插入数据时组件复用会出错。

// 正确:用数据唯一 ID
(item: ArticleData) => item.id

// 错误:用数组 index
(item: ArticleData, index: number) => index.toString()

避免在闭包中捕获大对象。 itemGenerator 是一个闭包,如果在里面引用外部的大数组,即使组件被回收,大对象也无法被 GC 回收。

// 有泄漏风险
const largeData = loadLargeData();
LazyForEach(source, (item) => {
  processWith(largeData); // largeData 被闭包持有
});

// 正确做法:把大数据拆小后传入数据源

6.2 分页加载

下拉加载更多数据时,往数据源追加数据并通知监听器:

class PageDataSource implements IDataSource {
  private data: ArticleData[] = [];
  private listeners: DataChangeListener[] = [];
  private loading: boolean = false;

  totalCount(): number {
    return this.data.length + (this.loading ? 1 : 0); // +1 给加载中占位
  }

  getData(index: number): ArticleData | string {
    if (index >= this.data.length) return 'loading';
    return this.data[index];
  }

  appendData(newData: ArticleData[]): void {
    const startIndex = this.data.length;
    this.data = this.data.concat(newData);
    this.listeners.forEach(listener => {
      listener.onDatasetChange([{
        type: DataOperationType.ADD,
        index: startIndex,
        count: newData.length
      }]);
    });
  }

  // ... registerDataChangeListener / unregisterDataChangeListener 省略
}

在 List 组件中监听到达底部:

List() {
  LazyForEach(this.dataSource, (item: ArticleData | string) => {
    ListItem() {
      if (typeof item === 'string') {
        Text('加载中...')
      } else {
        ArticleItem({ item: item })
      }
    }
  }, (item: ArticleData | string) => {
    return typeof item === 'string' ? 'footer' : item.id;
  })
}
.onReachEnd(() => {
  this.loadMore();
})

七、性能对比数据

在模拟器上对 1000 条纯文本列表的测试结果:

方案

首次渲染(ms)

滑动帧率

内存增量

ForEach(全量渲染)

3200

<30fps

~180MB

LazyForEach + cachedCount(1)

45

58fps

~12MB

LazyForEach + cachedCount(5)

52

60fps

~20MB

LazyForEach + cachedCount(5) + @Reusable

50

60fps

~15MB

LazyForEach + Prefetcher

48

60fps

~18MB

ForEach 全量渲染在 1000 条数据时直接卡死 3 秒以上,内存暴涨。LazyForEach 配合合理的 cachedCount 和组件复用,能保持稳定帧率和可控内存。

小结

LazyForEach 的核心收益是只渲染可视区域 + 缓存区的组件。要用好它:

  1. 正确实现 IDataSource,数据变化时通知监听器。

  2. 根据列表项复杂度调 cachedCount,简单项设 5-10,复杂项设 3-5。

  3. 复杂列表项用 @Reusable 装饰器做组件复用。

  4. 大数据量场景用稳定 keyGenerator,避免闭包捕获大对象。

  5. API 12 以上考虑 Prefetcher 做智能预加载。

下篇讲动画系统。animateTo 和 animation 怎么选、曲线怎么配、复杂交互场景怎么编排。

参考阅读:

  • 第 8 篇:鸿蒙性能优化实战(列表渲染部分)

  • 第 7 篇:鸿蒙自定义组件与动画

  • 官方文档:LazyForEach、IDataSource


评论