前言

上周我在一个数据大屏的工程里填列表,随手往里塞了一千条数据,一滚动就开始掉帧,卡片边缘能看到明显的锯齿感。第一反应是“是不是动画开太多了”,关掉几个动画还是卡。后来才想明白,问题根本不在动画,在于这一千条数据被一次性全部创建成了组件——屏幕上一屏只能看到十几条,剩下九百多条也在白白占着渲染的预算。

HarmonyOS 6 的 ArkUI 里,解决这个问题最直接的方式就是 LazyForEach。这篇我把踩坑过程完整记下来:先复现“全量创建”是怎么卡的,再看 LazyForEach 为什么能按需创建,最后用一个小实验把两种写法的差距直接量化出来。工程代码是完整的,你在 DevEco Studio 6.1.1 里建一个 Empty Ability 工程,把 Index.ets 换成文末的完整代码,就能在模拟器上亲自点着玩。

一、一千条数据放进去,为什么会卡

先看最朴素的写法:用一个 Scroll 包一个 Column,里面用 ForEach 遍历数据,每条数据渲染成一个卡片。代码看着很干净:

Scroll() {
  Column({ space: 8 }) {
    ForEach(this.items, (item: Item) => {
      this.row(item)
    }, (item: Item) => item.id.toString())
  }
}

问题就出在 ForEach 的语义上:它会一次性遍历全部数据,把每一个数据项都创建成对应的组件。一千条数据,就是一千个 Row、一千个 Text、一千个圆角背景,全部进组件树。组件树越大,布局和渲染要算的东西越多,滚动的时候每一帧都要重新计算可见区域内的布局,自然就卡了。

这里有个关键点:用户一屏只能看到十几条数据,剩下的几百条在屏幕外面,用户根本看不见,但它们照样被创建、照样参与布局计算。这些“看不见的组件”就是性能的大头。

数据量小的时候感觉不明显,几十条、一百条,ForEach 全量创建也够快。一旦到几百上千条,特别是每条数据里还有图片、复杂样式的时候,卡顿就藏不住了。我这次是一千条纯文本卡片,已经能明显感觉到掉帧,要是每条再带个图,会更难受。

二、LazyForEach 是怎么“偷懒”的

LazyForEach 和 ForEach 最大的区别,就是“按需创建”:它不会一上来把所有数据全部变成组件,而是只创建当前可见区域内的那几条,滚动到哪,就取哪条的数据来创建。滚过去的组件离开可视区之后,框架还能回收复用,把预算留给当前屏幕上的内容。

要实现这个“按需取数”,LazyForEach 需要配一个数据源,实现 IDataSource 接口。这个接口一共四个方法,语义很直白:

  • totalCount():告诉列表一共有多少条数据
  • getData(index):列表要创建第 index 条时来取数据
  • registerDataChangeListener():注册数据变化监听
  • unregisterDataChangeListener():注销监听

getData 是整个机制的核心:它只在框架真正需要创建某个位置的组件时才会被调用。换句话说,getData 被调用的次数,基本就等于“实际创建的组件数”。这正是我们后面做实验量化对比的抓手——数一数 getData 被调了多少次,就知道两种写法到底各创建了多少组件。

除了数据源,LazyForEach 的第三个参数 keyGenerator 也要给一个稳定且唯一的 key。它用来在数据变化时定位组件,key 写得不稳定,列表就可能出现错位、闪烁这类奇怪问题。这个我后面在踩坑那节会再展开。

三、实验设计:两个列表、一把开关、一个计数器

光讲原理没有说服力,我做了个小实验,把两种写法的差距直接摆在界面上:

  • 固定生成一千条测试数据
  • 页面上一把开关,可以在 List + LazyForEach 和 Scroll + ForEach 两种模式之间来回切换
  • 顶部实时显示“已创建组件:xx / 1000”,这个数字就是 getData 被调用的次数

ForEach 模式切过去,计数器会直接跳成 1000,因为一百个组件一次性全建了;LazyForEach 模式切过去,计数器只会停在可视区能容纳的数量附近,滚动的时候才慢慢往上涨。同样的数据、同样的卡片样式,唯一区别就是渲染方式,数字会说明一切。

环境用的是固定的一套:DevEco Studio 6.1.1 Release(Build #6.1.1.300),HarmonyOS 6.1.1 Release SDK(API Version 24),模拟器是 MateBook Pro 2in1(3120×2080 的 14.2 英寸屏),工程模板 Empty Ability。全程只改一个 Index.ets 文件。

四、数据模型和数据源

先定义一个最简单的数据模型,每条数据就三个字段:编号、标题、描述:

class Item {
  id: number;
  title: string;
  desc: string;

  constructor(id: number, title: string, desc: string) {
    this.id = id;
    this.title = title;
    this.desc = desc;
  }
}

然后是 LazyForEach 的数据源。除了实现 IDataSource 的四个方法,我给它加了一个 requestedCount 计数器,每次 getData 被调用就加一,方便把“实际创建了多少组件”暴露给界面:

class ItemDataSource implements IDataSource {
  private items: Item[] = [];
  private listeners: DataChangeListener[] = [];
  requestedCount: number = 0;

  init(items: Item[]): void {
    this.items = items;
  }

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

  getData(index: number): Item {
    // 每被 UI 请求一次,说明要创建一个 ListItem
    this.requestedCount++;
    return this.items[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);
    }
  }
}

requestedCount 是实验的关键。对 LazyForEach 来说,getData 只在框架要真正创建某个位置的组件时才被调用,所以这个计数就是“当前实际存在的组件数量”;而 ForEach 那套是另一个维度,我们直接用数据总量 1000 来对比。

五、列表、开关和计数器

页面主体用一个 @State useLazy 开关控制当前模式。约到开始前先造好一千条数据,再用一个 300ms 的定时器不停把 requestedCount 刷到界面上,这样滚动的时候计数器是实时变化的:

@State useLazy: boolean = true;
@State createdCount: number = 0;
private items: Item[] = [];
private source: ItemDataSource = new ItemDataSource();
private timerId: number = -1;

aboutToAppear() {
  for (let i = 0; i < 1000; i++) {
    this.items.push(new Item(i, `第 ${i + 1} 条数据`, '列表性能实战:1000 条也能流畅滚动'));
  }
  this.source.init(this.items);
  this.createdCount = this.source.requestedCount;
  this.timerId = setInterval(() => {
    this.createdCount = this.source.requestedCount;
  }, 300);
}

列表区按开关走两条分支:LazyForEach 用 List + ListItem,ForEach 用 Scroll + Column。卡片行渲染抽成了一个 @Builder row(),两种模式共用同一套样式,保证对比公平:

if (this.useLazy) {
  List({ space: 8 }) {
    LazyForEach(this.source, (item: Item) => {
      ListItem() {
        this.row(item)
      }
    }, (item: Item) => item.id.toString())
  }
  .width('100%')
  .layoutWeight(1)
} else {
  Scroll() {
    Column({ space: 8 }) {
      ForEach(this.items, (item: Item) => {
        this.row(item)
      }, (item: Item) => item.id.toString())
    }
  }
  .width('100%')
  .layoutWeight(1)
}

切开关的时候,把计数改成对应模式的语义:切到 LazyForEach 显示当前实际创建的条数,切到 ForEach 直接显示 1000,因为 ForEach 是一次性全量创建的。

六、完整代码——直接运行

下面是整个 Index.ets 的完整代码,和工程里跑的是同一个版本,拷进 Empty Ability 工程就能运行:

// 数据模型
class Item {
  id: number;
  title: string;
  desc: string;

  constructor(id: number, title: string, desc: string) {
    this.id = id;
    this.title = title;
    this.desc = desc;
  }
}

// LazyForEach 数据源:数据按需取,取到才创建对应组件
class ItemDataSource implements IDataSource {
  private items: Item[] = [];
  private listeners: DataChangeListener[] = [];
  requestedCount: number = 0;

  init(items: Item[]): void {
    this.items = items;
  }

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

  getData(index: number): Item {
    // 每被 UI 请求一次,说明要创建一个 ListItem
    this.requestedCount++;
    return this.items[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);
    }
  }
}

@Entry
@Component
struct Index {
  @State useLazy: boolean = true;
  @State createdCount: number = 0;
  private items: Item[] = [];
  private source: ItemDataSource = new ItemDataSource();
  private timerId: number = -1;

  aboutToAppear() {
    // 生成 1000 条测试数据
    for (let i = 0; i < 1000; i++) {
      this.items.push(new Item(i, `第 ${i + 1} 条数据`, '列表性能实战:1000 条也能流畅滚动'));
    }
    this.source.init(this.items);
    this.createdCount = this.source.requestedCount;
    // 定时把"实际创建组件数"刷到界面(LazyForEach 滚动时会持续增长)
    this.timerId = setInterval(() => {
      this.createdCount = this.source.requestedCount;
    }, 300);
  }

  aboutToDisappear() {
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }

  build() {
    Column() {
      // 顶栏
      Row() {
        Text('1000 条列表性能对比')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
        Blank()
        Text(this.useLazy ? 'LazyForEach' : 'ForEach')
          .fontSize(13)
          .fontColor(Color.White)
          .backgroundColor(this.useLazy ? '#2B5CE6' : '#FA8C16')
          .borderRadius(10)
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })

      // 统计 + 切换按钮
      Row({ space: 12 }) {
        Text(`已创建组件:${this.createdCount} / 1000`)
          .fontSize(13)
          .fontColor('#666666')
        Blank()
        Button(this.useLazy ? '切到 ForEach' : '切到 LazyForEach')
          .fontSize(12)
          .height(32)
          .backgroundColor('#2B5CE6')
          .onClick(() => {
            this.useLazy = !this.useLazy;
            if (this.useLazy) {
              this.createdCount = this.source.requestedCount;
            } else {
              // ForEach 会一次性创建全部子组件
              this.createdCount = 1000;
            }
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 8 })

      // 当前模式说明
      Text(this.useLazy ? 'LazyForEach:只创建可见项,滚动时按需取数' : 'ForEach:一次性创建全部子组件')
        .fontSize(12)
        .fontColor('#888888')
        .width('100%')
        .padding({ left: 16, bottom: 8 })

      // 列表区:两种实现方式切换
      if (this.useLazy) {
        List({ space: 8 }) {
          LazyForEach(this.source, (item: Item) => {
            ListItem() {
              this.row(item)
            }
          }, (item: Item) => item.id.toString())
        }
        .width('100%')
        .layoutWeight(1)
        .padding({ left: 16, right: 16 })
      } else {
        Scroll() {
          Column({ space: 8 }) {
            ForEach(this.items, (item: Item) => {
              this.row(item)
            }, (item: Item) => item.id.toString())
          }
          .width('100%')
        }
        .width('100%')
        .layoutWeight(1)
        .padding({ left: 16, right: 16 })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
  }

  @Builder
  row(item: Item) {
    Row() {
      Text(`${item.id + 1}`)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .backgroundColor(item.id % 2 === 0 ? '#4A80F0' : '#52C41A')
        .borderRadius(18)
      Column({ space: 2 }) {
        Text(item.title)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')
        Text(item.desc)
          .fontSize(12)
          .fontColor('#888888')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Text('>')
        .fontSize(14)
        .fontColor('#CCCCCC')
    }
    .width('100%')
    .height(64)
    .padding({ left: 12, right: 12 })
    .backgroundColor(Color.White)
    .borderRadius(12)
  }
}

七、运行效果:数字说话

在模拟器上跑起来,两边各点一次,差距非常直观:

  • 默认是 LazyForEach 模式:顶部“已创建组件”停在个位数,十四英寸屏一屏能显示十来个卡片,计数器就在这个量级附近。上下快速滚动,计数器才缓慢往上爬,滚过的组件离开可视区后会被回收,计数器基本保持稳定。
  • 点“切到 ForEach”:计数器瞬间跳到 1000,一千个卡片全部创建完成。这时候再上下滚动,明显能感觉到帧率下降,列表滚动没有 LazyForEach 模式顺滑。

同样是 1000 条数据、同一套卡片样式,唯一的区别就是“创建策略”:一个只创建看得见的,一个把一千条全部建出来。滚动体验的差距,就是这两种策略的成本差。

页面上的“已创建组件:xx / 1000”是刻意暴露给用户的细节。有了这个数字,LazyForEach 为什么快就不再是玄学,而是可以直接看到的:它创建的量级,始终只有你屏幕上需要的那一点点。

八、我在真实项目里踩过的坑

8.1 别在 getData 里做耗时操作

getData 会在滚动过程中被高频调用,如果在里面做网络请求、复杂计算或者字符串拼接,滚动就会一顿一顿的。数据源应该提前把数据准备好,getData 只做一件事:把第 index 条数据取出来返回。

8.2 keyGenerator 必须稳定且唯一

key 用来在数据变化时定位组件。如果 key 不稳定(比如用了随机数),列表刷新时组件会被错误复用,出现错位、内容串行这种诡异问题。数据里如果有 id,直接用 id 做 key 最稳。

8.3 子组件尺寸要确定

懒加载是按需创建的,子组件如果高度不确定,列表在滚动时可能反复调整布局,出现跳动。固定行高,或者让内容撑满确定的结构,体验才稳定。

8.4 LazyForEach 不只在 List 里能用

Grid、WaterFlow 这些滚动容器同样支持 LazyForEach。这次用的是列表,思路一样:凡是“数据多、一屏显示不下”的地方,都优先考虑懒加载。

8.5 再进一步:组件复用

LazyForEach 解决了“创建过多”的问题,如果卡片本身很重,还可以配合 List 的组件复用机制进一步优化。这篇先不展开,等后面单独写一篇列表性能深挖。

总结

这篇把一个很常见的列表卡顿问题拆到了底:卡顿来自 ForEach 的一次性全量创建,解决思路是 LazyForEach 的按需创建,然后用一个“已创建组件”计数器把差距量化出来——1000 对十几,差距摆在那里,滚动手感也摆在那里。

写这篇最大的收获是:列表性能问题,不要靠感觉去调,先想办法把“创建了多少组件”这种关键数字暴露出来,问题在哪、优化有没有效,一眼就清楚了。

参考资料

  1. LazyForEach:数据懒加载(官方开发指南)
  2. ForEach:循环渲染(官方开发指南)
  3. ArkUI 性能优化建议(官方)
  4. List 组件开发指南
Logo

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

更多推荐