鸿蒙 ArkUI 深水区:LazyForEach 大列表虚拟滚动,1 万条数据滑得飞的秘密

写在前面

如果你写 ArkUI 写过购物车、消息列表、聊天记录之类的页面,大概率遇到过这个场景:

列表里 1 千条数据,你用 ForEach 渲染,滑得滑得生不如死——每滑一格卡半秒,滑到底还要白屏三秒。用户当场给你一星。
你查文档发现 ForEach 是「全量渲染」,1 千条全部构造 DOM 节点,内存爆炸、渲染卡死。文档说「大列表用 LazyForEach」,你点进去发现要写 IDataSource 接口、注册监听者、手动通知增删改——比 ForEach 复杂十倍,一脸懵。

这是「全量渲染」和「虚拟滚动」的分水岭。鸿蒙 ArkUI 给的答案是 LazyForEach + IDataSource——只渲染可视区 + 缓冲区,离屏自动销毁,1 万条数据滑得飞。

本文就用一个真机可跑的「1 万条数据 + 增删改」demo,把 LazyForEach + IDataSource 从「听名字一脸懵」讲到「下个项目直接抄」。代码托管在 AtomGit,文末有链接,真机实拍截图作证。

适合人群:写过 ArkUI、被 ForEach 大列表卡死折磨过的同学。
不适合人群:还在学 @State 的同学——出门左转看我的入门篇。


一、先讲清楚:LazyForEach 到底是啥

一句话:LazyForEach 是虚拟滚动的列表渲染器,只渲染可视区 + 缓冲区,离屏销毁。

对比 ForEach

维度 ForEach LazyForEach
渲染策略 全量渲染,N 条全部构造 DOM 虚拟滚动,只渲染可视区 + 缓冲区
数据源 直接传数组 必须传 IDataSource 实现
增删改 改数组重新渲染 IDataSource 的 notify 方法局部渲染
1 万条性能 卡死 滑得飞
何时用 小列表(< 100 条) 大列表(≥ 100 条)

一句话决策:小列表用 ForEach,大列表用 LazyForEach


二、动手:一个 1 万条数据的虚拟滚动 demo

2.1 数据模型

class Item {
  id: number = 0
  label: string = ''
  Item() {}
  set(id: number, label: string): Item {
    this.id = id
    this.label = label
    return this
  }
}

class + 工厂方法 set,不用 interface 字面量——ArkTS 强约束 arkts-no-untyped-obj-literals,且 LazyForEach 的 keyGenerator 要稳定的 key,class 实例带 id 字段正好做 key。

2.2 IDataSource 实现:ListDataSource

LazyForEach 不直接读数组,要通过 IDataSource 接口读写。这个接口要你实现 4 个方法:

class ListDataSource implements IDataSource {
  private items: Item[] = []
  private listeners: DataChangeListener[] = []

  constructor(count: number) {
    for (let i = 0; i < count; i++) {
      this.items.push(new Item().set(i, `${i + 1} 条数据`))
    }
  }

  // ① 总数:LazyForEach 问「这条数据有几条」
  totalCount(): number { return this.items.length }

  // ② 取一条:LazyForEach 问「第 index 条是啥」
  getData(index: number): Item { return this.items[index] }

  // ③ 注册监听者:LazyForEach 挂上来,你增删改要通知它
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener) }
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const idx = this.listeners.indexOf(listener)
    if (idx >= 0) { this.listeners.splice(idx, 1) }
  }

  // ④ 增删改后通知所有监听者,LazyForEach 自动局部重渲染
  addItem(item: Item): void {
    this.items.push(item)
    for (const l of this.listeners) { l.onDataAdd(this.items.length - 1) }
  }
  deleteItem(index: number): void {
    if (index < 0 || index >= this.items.length) { return }
    this.items.splice(index, 1)
    for (const l of this.listeners) { l.onDataDelete(index) }
  }
}

4 个接口方法的职责:

方法 职责 一句话理解
totalCount() 返回总数 「LazyForEach 问数据有几条」
getData(index) 取第 index 条 「LazyForEach 问第 N 条是啥」
register/unregister 监听者管理 「LazyForEach 挂上来/摘下去」
notifyXxx 增删改通知 「数据变了通知 LazyForEach 局部重渲染」

新手最容易忘的就是 notify——只改数组不通知,界面不动,以为装饰器没装上。

2.3 主页面:LazyForEach 装在 List

@Entry
@Component
struct Index {
  // 1 万条数据(ForEach 直接卡死,LazyForEach 滑得飞)
  private dataSource: ListDataSource = new ListDataSource(10000)
  @State counter: number = 0

  build() {
    Column({ space: 12 }) {
      Text('LazyForEach 虚拟滚动 Demo').fontSize(22).fontWeight(FontWeight.Bold).margin({ top: 16 })

      // 操作区:增删数据,LazyForEach 自动局部重渲染
      Row({ space: 10 }) {
        Button('+ 一条').backgroundColor('#007DFF').fontColor('#fff').height(36)
          .onClick(() => {
            this.counter++
            this.dataSource.addItem(new Item().set(10000 + this.counter, `新增第 ${this.counter}`))
          })
        Button('删末条').backgroundColor('#FF4D4F').fontColor('#fff').height(36)
          .onClick(() => { this.dataSource.deleteItem(this.dataSource.totalCount() - 1) })
      }

      // LazyForEach 虚拟滚动:滑到哪渲染哪,离屏销毁
      // keyGenerator 用 id 做复用 key
      List({ space: 8 }) {
        LazyForEach(this.dataSource, (item: Item) => {
          ListItem() {
            Row({ space: 12 }) {
              Stack({ alignContent: Alignment.Center }) {
                Text(`${item.id}`).fontSize(12).fontColor('#fff')
              }.width(36).height(36).backgroundColor('#007DFF').borderRadius(18)

              Text(item.label).fontSize(16).fontColor('#222').layoutWeight(1)
              Text('›').fontSize(22).fontColor('#bbb')
            }
            .width('100%').padding(12).backgroundColor('#fff').borderRadius(8)
          }
        }, (item: Item) => `item-${item.id}`)
      }
      .height('78%').scrollBar(BarState.Auto)
      .divider({ strokeWidth: 1, color: '#F0F0F0', startMargin: 12, endMargin: 12 })
    }
    .padding(16).backgroundColor('#F5F6F8').height('100%').width('100%')
  }
}

LazyForEach 三个参数:

  1. dataSourceIDataSource 实例
  2. itemGenerator:每条数据的 UI 模板(ListItem 里写)
  3. keyGenerator:每条数据的复用 key(用 item.id 拼,稳定唯一)

三、这段代码的三个关键点

IDataSourceLazyForEach 的「数据门面」

LazyForEach 不直接读你的数组,所有读写都过 IDataSource。这隔离了数据存储实现——你后面可以把数组换成数据库分页查询,IDataSource 接口不变,LazyForEach 调用不变。

notifyXxx 是局部重渲染的触发器

addItem(item: Item): void {
  this.items.push(item)
  for (const l of this.listeners) { l.onDataAdd(this.items.length - 1) }
  //                                  ↑↑↑↑↑ 忘了这行界面不动
}

只改数组不调 notifyLazyForEach 不知道数据变了,界面不动。这是新手第二坑(以为装饰器没装上,其实是忘通知)。

DataChangeListener 的通知方法对照表:

方法 触发啥 何时调
onDataAdd(index) 第 index 处新增 末尾追加/中间插入
onDataDelete(index) 第 index 处删除 删一条
onDataChange(index) 第 index 处改了 改一条内容
onDataReload() 全量重载 大批改后整体刷
onDataMove(from, to) 从 from 挪到 to 排序/拖拽换位

keyGenerator 是复用 key,稳定唯一

}, (item: Item) => `item-${item.id}`)

key 用 item.id 拼,稳定唯一——同一 id 的数据复用同一 DOM 节点,滑回去不重新构造。如果 key 用 index(数组下标),删一条所有 key 错位,会全量重渲染,虚拟滚动失效。这是新手第三坑。


四、真机实拍:1 万条数据滑得飞

我把这个 demo 装到真机上跑(鸿蒙 6.1.1.125, API 24),1 万条数据,下面这张是真机实拍,没有任何 P 图。

整体效果:标题 + 操作区(+一条/删末条)+ 列表区(可见第 1、2、3 条数据,圆形序号 + label + ›),1 万条数据滑得飞:

LazyForEach 虚拟滚动 demo 真机整体效果

重点看画面:列表区每条是「圆形序号 + label + ›」结构,序号 0、1、2 依次显示「第 1 条数据」「第 2 条数据」「第 3 条数据」——这就是 LazyForEach 只渲染可视区 + 缓冲区的结果,1 万条数据后台待命,滑到哪渲染哪,离屏销毁,内存不爆。


五、LazyForEach vs ForEach:啥时候用哪个

新手最容易纠结的问题:既然 LazyForEach 性能好,还要 ForEach 干啥?

维度 ForEach LazyForEach
数据源 直接数组 IDataSource 实现
性能 全量渲染,N 条全 DOM 虘拟滚动,只渲染可视区
增删改 改数组重新渲染 调 notify 局部渲染
代码量 多(要写 IDataSource
何时用 < 100 条小列表 ≥ 100 条大列表

一句话决策:小列表用 ForEach 省代码,大列表用 LazyForEach 省性能。不要啥都往 LazyForEach 塞,IDataSource 写起来比 ForEach 多三倍代码,小列表用 ForEach 就够。


六、常见坑(都是血泪)

症状 解法
改数组不调 notify 界面不动 增删改后必须调 onDataAdd/onDataDelete/onDataChange
keyGeneratorindex 删一条全量重渲染 用稳定唯一的 id 做 key,不用数组下标
IDataSource 忘写 register/unregister 监听者漏挂通知不到 4 个接口方法都要实现,不能省
getData 返回裸 Object 编译报错 返回显式 class 实例,不要裸对象字面量
小列表用 LazyForEach 代码臃肿性能没差 < 100 条用 ForEach,别过度工程
LazyForEach 期望跨页面状态 跨页面不响应 跨页面用 AppStorageLazyForEach 只本组件

七、完整代码仓库

本文所有代码都已托管到 AtomGit,欢迎 clone、提 issue、点 star:

🔗 仓库地址:https://atomgit.com/JaneConan/arkui-lazyforeach

仓库包含:

  • 完整的「1 万条数据 + 增删改」虚拟滚动 demo 工程
  • Index.ets 主页面(LazyForEach + 操作区)
  • ListDataSource IDataSource 实现(4 接口方法 + 增删改 notify)
  • Item 数据模型 + 工厂方法 set
  • 可直接用 DevEco Studio 打开运行

八、下一步该学什么?

跑通这个 demo 之后,你的 ArkUI 大列表性能就入门了。这是 ArkUI 系列第九篇,十篇凑齐只差最后一篇——下一篇讲动画体系。建议按这个顺序往下:

  1. 动画体系(下一篇):animateTo 显式动画、animation 隐式动画、CustomDialog 入场
  2. List + Scroller 控制滚动:编程式滚到某条、监听滚动事件、下拉刷新
  3. Grid/WaterFlow 网格虚拟滚动LazyForEach 在网格里的用法
  4. 大数据分页加载IDataSource 配合网络分页,滑到底自动加载下一页

写在最后

LazyForEach + IDataSource 的本质,是**「数据存储」和「数据渲染」的隔离**——IDataSource 管「数据怎么存」,LazyForEach 管「数据怎么渲染」,两者通过接口通讯。这个思想在前端圈叫虚拟列表/Windowing,在鸿蒙圈叫 LazyForEach/IDataSource,名字不同灵魂相通。

一旦你开始用虚拟滚动思维写大列表,你会发现大部分「大列表卡死」的需求,都是 IDataSource 实现的自然结果。1 万条数据滑得飞,内存不爆,用户五星。

代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手滑一遍 1 万条数据感受下飞。

跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀


作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-lazyforeach
协议:Apache-2.0,随便用,别告我

Logo

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

更多推荐