ArkUI 列表性能优化:LazyForEach 懒加载 + @Reusable 组件复用的正确姿势

前言

长列表几乎是每一款 HarmonyOS 应用都绕不开的性能战场:消息流、商品卡片流、订单列表、通讯录、搜索结果。只要数据量会突破三屏,技术方案里就一定会出现 LazyForEach 和 @Reusable。官方给出的两个关键词也没有错——懒加载负责"按需创建",把首次构建全部节点的耗时摊到滚动过程中;组件复用负责"重复使用",把滚动中反复创建、销毁节点的开销压到最低。

问题在于,官方文档是原理说明书,不是工程实现。当这两个能力落到一个"带网络分页、带局部交互状态、还可能存在多种卡片形态"的真实列表页上时,代码里会冒出三类几乎人人都会踩的坑:

  • 翻页掉帧。滑动时很流畅,一旦触底加载下一页就明显卡一下,前几页已经渲染好的节点被整批作废重建。这不是网络慢,也不是布局重,而是数据源的通知姿势错了。
  • 内容串位。给列表项标了 @Reusable 之后,上一条数据的展开态、选中态"跑"到了下一条;或者初始化逻辑写在 aboutToAppear 里,第二条数据进来时根本没有执行。
  • 底部错乱。往 LazyForEach 里塞了一个"加载中 / 没有更多了"的列表项,列表开始出现位置跳动、"没有更多了"重复出现甚至飘到中间。

这三类现象看起来互不相关,实际上各自对应一条明确的机制:第一类是把"增量通知"写成了"全量重建";第二类是把"复用组件实例"误当成"复用数据";第三类是把"结构性的常量"混进了"数据性的序列"。把这三条机制讲清楚,优化方案基本就唯一了。

本文基于一个真实的开发者提问展开。提问者问的是"ArkUI 自定义组件如何实现高性能的列表懒加载与复用",并且已经自己梳理出五个方向:懒加载、组件复用、缓存列表项、动态预加载、布局优化。方向完全正确——这些正是官方长列表最佳实践的主干。但方向正确不等于代码正确:从方向到可运行实现之间,有四个判定点决定了这份代码是"真的快",还是"看起来用了优化、实测收益归零"。下面把五个方向和四个判定点逐一拆开,最后给出数据源、复用子组件、页面三段可以直接编译运行的实现。

问题描述

先还原提问者的五个方向,并标注每个方向在工程实现中真正的"生死线"在哪里。

方向一,懒加载。 用 LazyForEach 替代 ForEach,配合自定义 IDataSource,由框架按可视区范围调用 getData() 取数、调用 itemGenerator 创建组件。这条方向本身没有争议,但它的收益是条件收益:只有当"数据变化时只通知变化的那部分"时,已经渲染的节点才不会被作废。一旦分页追加时发的是全量重载通知,懒加载省下来的创建开销会在每次翻页时被原样还回去,表现为"平时丝滑、翻页必卡"。

方向二,组件复用。 给列表项组件加 @Reusable,让划出屏幕的组件实例进入复用池,新数据进屏时优先复用而非重新构造。这条方向的真正难点不在"怎么开",而在"复用之后怎么办":组件实例被复用了,但它内部的状态变量并不会自动回到初值,而 aboutToAppear 这类生命周期也不会再触发。凡是"数据里没有、但 UI 上会变"的东西,在这里都是隐患。

方向三,缓存列表项。 即 List 的 cachedCount,它让屏幕外的若干条提前完成布局,减少快速滑动时的白屏。这里最常见的误解是把 cachedCount 当成"复用池大小",甚至认为调大它就能获得复用能力。实际上它只是预加载范围,与复用能力没有因果关系。

方向四,动态预加载。 根据历史任务耗时动态调整预取数量,让"数据请求"和"滚动速度"匹配。注意这条方向的前提是:LazyForEach 不会帮你请求下一页。它只从数据源按需取数,数据源里没数据它就是没数据。网络分页必须由业务自己实现,并且要处理触底回调被连续触发的问题。

方向五,布局优化。 减少嵌套层级、降低单条卡片的布局复杂度、避免过度绘制。这条方向的隐藏前提是:只有当懒加载还在生效时,布局优化才有意义。而懒加载失效最常见的两种写法——List 没有确定高度、List 被放进同方向的 Scroll 里——恰恰是最容易被忽略的。

提问者没有明说、但实践中最容易翻车的三个具体现象是:

  1. 触底加载后列表"闪"一下,滚动位置轻微回跳。 根因通常是 notifyDataReload() 触发了全量 key 比对,或底部状态项参与了 LazyForEach 的 key 空间。
  2. 复用后内容张冠李戴。 第 20 条显示的是第 5 条的展开状态;或者图片加载回调回来时把 A 的缩略图渲染到了 B 上。
  3. "没有更多了"出现两次,或出现在列表中间。 底部状态项如果由 LazyForEach 生成,它的 key 会随着 totalCount() 变化而整体位移,框架判定为"旧项消失、新项出现",于是重复创建节点、位置错乱。

与之对应的四个错误预期是:认为"追加数据后调 notifyDataReload 就是刷新";认为"aboutToAppear 会在每次复用时执行";认为"reuseId 应该用数据 id 才能让框架对上号";认为"加了 LazyForEach 就一定懒加载"。这四条,每一条都会让优化收益归零。

细节解析

一、分页追加必须用增量通知,notifyDataReload() 会让懒加载收益归零

先看一段流传极广的代码,网上大量 LazyForEach 示例都是这么写的:

appendData(items: ItemData[]): void {
  this.dataArray.push(...items)
  this.notifyDataReload()   // 问题就在这一行
}

notifyDataReload() 最终调用的是 DataChangeListener 的 onDataReloaded(),它的语义是**“数据源整体被替换,请全部重新加载”**。框架收到这个通知后,会认为旧的数据集合已经整体失效,需要对列表进行一次完整的 key 比对:所有已有列表项节点的 key 都要重新参与匹配,无法匹配的部分作废重建。

于是出现一个很讽刺的结果:你在触底加载第 3 页时,前两页共 40 条已经渲染好的、本来完全不需要动的节点,会被重新走一遍匹配流程,其中相当一部分会被销毁重建。懒加载辛辛苦苦省下来的创建与布局开销,在每一次翻页时又原样还了回去。这就是"滑动很流畅,一到翻页就掉帧"的典型成因——不是网络慢,不是渲染慢,是数据结构层面的通知粒度错了。

正确做法是发增量操作,让框架只处理真正变化的那一段:

  • 追加:onDataAdd(index);批量追加用 onDatasetChange([{ type: DataOperationType.ADD, index: start, count: n }])
  • 删除:onDataDelete(index)
  • 单条修改:onDataChange(index)
  • 位置交换 / 移动:对应 EXCHANGE / MOVE 操作,移动用 onDataMove(from, to)
  • 整体替换:onDataReloaded(),这是它唯一该出现的场景

这里有两个容易写错的细节。

第一,通知的 index 必须是数据落位之后的真实下标。正确顺序是"先把数据放进数组,再按它最终所在的下标发通知"。如果先通知后插入,或者插入位置和通知下标不一致,框架会按错误的位置去取数,表现出来就是某一条数据短暂错位、或者新数据被渲染成了旧内容。

第二,逐条通知和批量通知要分清场景。分页追加一页 20 条时,逐条调用 20 次 onDataAdd 会产生 20 次通知,每次都触发一轮增量处理;而 onDatasetChange 携带一个 ADD(index, count) 操作,框架可以把它当成一次事务处理,通知次数从 20 降到 1。在页数多、追加频繁的场景(如自动翻页、瀑布流增量补齐)里,这个差异是可观测的。推荐的做法是:单条插入用 onDataAdd,整页追加用 onDatasetChange。

第三,一个反直觉但必须记住的点:"重新加载"和"重新请求"是两件事。onDataReloaded() 只是告诉框架"数据集合变了、请重看一遍",它自己不发网络请求。所以切换筛选条件时,正确顺序是"先拿到新数据、替换数据数组、再发 onDataReloaded()",而不是相反。顺序颠倒会出现"通知发完了、数组还是旧的"这种最尴尬的状态。

二、@Reusable 的四个真实坑

@Reusable 的机制本身很好理解:组件划出屏幕一定范围后进入 CustomNode 缓存,由 RecycleManager 按复用标识分组形成复用池;新数据需要上屏时优先从池中取同组实例,把新数据绑上去,而不是重新构造。真正难的是"复用之后"。以下四条是实践中最常见的翻车点。

第一,aboutToAppear / aboutToDisappear 在复用时不会触发。 复用走的是 aboutToReuse(params),组件的构造参数会通过 params 传进来。这意味着所有"每次上屏都该做一遍"的事情——数据绑定、状态重置、埋点上报、图片请求——都必须写在 aboutToReuse 里。把初始化逻辑放在 aboutToAppear 里的代码,在复用池起作用之后就只会执行"第一次",后续所有复用实例都带着第一次的数据渲染,这是"复用后数据错乱"最底层的根因。相应地,组件进入复用池时会触发 aboutToRecycle(),适合做定时器清理、请求作废这类收尾动作。

第二,@State 变量在复用时保留上一次的值。 框架只负责把新的构造参数交给你,不会替你重置组件内部状态。所有"跟着数据变、或者由用户操作改变"的临时状态,都必须在大约 aboutToReuse 里重新赋初值:展开/收起、选中/收藏、动画进度、二次编辑的中间文本、局部 loading 标记、上一次异步请求回来的 URL 或图片源。因为这些值既不属于新数据,又确实残留着旧数据的痕迹。判断标准很简单:凡是"数据里没有、但 UI 上会变"的东西,都是隐患。

第三,aboutToReuse 里发起的异步回调会串位。 这是最隐蔽的一条。复用实例在等待图片下载、接口返回的过程中,很可能已经被复用于另一条数据了;等回调回来时,它把 A 的图片渲染到了 B 上。稳妥做法是给每次复用分配一个递增的 token(或在回调里核对 this.item.id 与发起时记录的 id),只有 token 仍然有效才写状态;aboutToRecycle() 里把 token 再自增一次,让所有在途请求彻底作废。

第四,reuseId 的语义是"区分布局形态",不是"区分数据"。 官方文档写得很清楚:不设置 reuseId 时,组件名会默认作为复用标识,所以同名组件天然共享一个复用池。手动设置 reuseId 的目的,是让"左图右文"和"纯文字"这两种结构各自成池,避免取到结构不匹配的实例后发生布局跳变。千万不要把每条数据的 id 当作 reuseId——那等于告诉框架"每条数据的布局都是独一无二的",复用池里永远取不到可用的实例,复用能力被彻底关闭,只剩下 @Reusable 这个装饰器的心理安慰。

另外两条容易被忽略的约束来自官方限制条件:@Reusable 组件需要布局在同一个父自定义组件下才能实现缓存复用;并且不建议在 @Reusable 组件内部再嵌套另一个 @Reusable 组件。前者意味着复用池的作用域是"父组件范围内",跨页面、跨父容器不会共享;后者意味着嵌套复用会让"谁负责重置状态"变得不可控,收益也远低于复杂度。

三、把"加载更多"放进 LazyForEach 会造成 key 空间位移

底部状态项(“加载中…”、“没有更多了”、“加载失败,点击重试”)几乎每个分页列表都要有。一个很自然、但代价很大的写法,是把它当成 LazyForEach 的最后一项:

LazyForEach(this.ds, (item: ItemData, index: number) => {
  ListItem() {
    if (index === this.ds.totalCount() - 1 && this.ds.totalCount() === 0) {
      this.FooterBuilder()
    }
  }
}, (item: ItemData) => item.id.toString())

即便写法各异,只要底部项被 LazyForEach 生成,问题就一定会出现:totalCount() 一变,整个 key 空间就位移了。假设当前已有 40 条数据、底部项是框架眼中索引为 40 的一项;翻页追加 20 条之后,这 40 条新数据要插在底部项之前,于是"功能上位置没变"的底部项,在框架看来变成了索引 60 的另一个 key。框架于是判定"索引 40 的旧项消失、索引 60 的新项出现",执行了一次删除 + 一次新增。表现出来就是列表闪烁、滚动位置回跳,严重时同一屏里出现两个底部项。

这条机制和 keyGenerator 的关系非常紧。keyGenerator 必须返回稳定、唯一、且与数据一一对应的业务 id,绝对不能用数组下标或随机数:用下标,删一条数据后面所有 key 全部错位,等于全量重建;用随机数,每次 build 都生成新 key,等于每帧全量重建。而在 key 稳定正确的前提下,把底部项移出 LazyForEach 才是彻底的解法。

正确的放置方式有两种,按交互需求选:

  • 随内容滚动(推荐,体验更自然):把底部状态做成 List 里 LazyForEach 之后的静态 ListItem()。它不是 LazyForEach 的 itemGenerator 产物,不参与数据序列,totalCount() 的变化与它无关,key 空间自然不会被它扰动。
  • 固定在屏幕底部:把状态视图放在 List 的兄弟位置,用固定高度或 layoutWeight 占位。

两种方式的共同点只有一条,也是最本质的一条:底部状态绝对不能是 LazyForEach 的一项。

四、cachedCount、动态预加载、复用是三件独立的事

这三者在社区讨论里经常被混为一谈,必须分开理解。

cachedCount(n) 的作用是让屏幕外的 n 条列表项提前完成布局,目的是减少快速滑动时的白屏空档。它不是复用池大小,也不提供任何复用能力——复用能力只取决于"组件是否标了 @Reusable"以及"取实例时 reuseId 是否匹配"。把 cachedCount 调到 50 并不会让复用变强,只会让内存里同时驻留更多未上屏节点,甚至因为一屏内需要维护的节点变多而拖慢首帧。调参建议是从 2 到 5 起步,在真机上按列表项高度分档:卡片流、大图这类高列表项取小值(2~3),纯文本单行这类矮列表项可以适当放大。

动态预加载是第三件事,它优化的是数据侧的就绪时机:根据历史加载耗时,决定在距离触底还有多远时就开始发起下一页请求,让数据尽量在用户滑到之前就位。它和 cachedCount(渲染侧预布局)是两条正交的链路,需要分别调。同时它的前提仍然是我们反复强调的那条:LazyForEach 不会自动请求下一页,触底回调也可能被连续触发,因此分页逻辑里必须有一个 loading 锁。

把这三点归拢成一句话:@Reusable 决定"能不能复用",reuseId 决定"从哪个池里复用",cachedCount 决定"提前布局多少",动态预加载决定"提前要多少数据"。 四者各自独立,调其中一个都不会顺带改善另外三个。

五、V1 与 V2 的选型要提前定

状态管理 V1 与 V2 在复用能力上不是"新旧替代"关系,而是两套并行的装饰器体系,在同一个组件里不能混用。

V1 的写法是 @Reusable + aboutToReuse(params),数据需要手动从 params 搬进 @State 变量,状态重置也全部由开发者负责——这既是它容易踩坑的原因,也是它行为最可预测的地方。

V2 对应的装饰器是 @ReusableV2,用于修饰 @ComponentV2 组件。它最大的不同是数据不再需要手动搬运:列表项数据用 @Param 声明,框架复用时会把新的构造参数直接同步过去,aboutToReuse() 因此不再承担"把新数据赋给成员变量"的职责,开发者只需要在回调里处理本地 UI 状态的重置(V2 里本地状态用 @Local 声明)。V2 的另一个好处是数据模型本身可以写成 @ObservedV2 类、用 @Trace 修饰内部字段,对象内部的修改可以直接驱动 UI。

但有一个预期必须提前打消:换成 V2 并不能省掉 IDataSource。LazyForEach 要求的容器接口是框架层面的契约,totalCount()、getData()、registerDataChangeListener()、unregisterDataChangeListener() 这四件事无论 V1 还是 V2 都要自己实现;@Trace 能观察的是内置 Array / Map / Set 的 API 调用和属性整体赋值,它不会替你把 IDataSource 的通知链路接上。也就是说,本文第一节讲的增量通知纪律,在 V2 里一字不改地继续适用。

选型建议:新建项目、团队已经在用 V2,就用 @ReusableV2 + @Param + @Local;存量 V1 项目就老老实实把 aboutToReuse 的重置写全。不要在一个组件上同时标 @Reusable 和 @ReusableV2,也不要在 @ComponentV2 里使用 V1 装饰器——这类混用要么编译不过,要么在复用时出现难以定位的状态异常。

六、让懒加载失效的布局陷阱

最后补一条布局层面的硬约束,它是"我明明用了 LazyForEach 还是卡"的头号原因。

LazyForEach 的按需创建依赖框架能算出可视区范围,而可视区范围依赖 List 有一个确定的高度。如果 List 的高度是"由内容撑开"的(未约束高度、放在没有高度约束的容器里),框架无法判断什么在屏外,就会退化为一次性构建全部节点。同理,不要把 List 放进相同方向的 Scroll 里:外层 Scroll 会给内层无限高度,List 于是展开全部内容,可视区概念失效,懒加载彻底变成全量加载。

实践中的写法是:List 要么用 layoutWeight(1) 占据父容器的剩余空间,要么显式给一个确定高度('100%' 或具体数值)。这一步做对,前面所有关于通知粒度和复用的优化才真正落地;这一步做错,后面调什么都白搭。

示例代码

下面三段代码构成一个完整可编译的例子:数据源负责增量通知,复用子组件负责状态重置与异步防串位,页面负责分页加载、加载锁与底部状态。第四段给出 V2 写的对应版本,用于对照。

1. 数据源:分页追加走增量通知

// DataSource.ets —— 数据源:分页追加走增量通知,而不是全量重建
export class ItemData {
  id: number
  title: string
  desc: string

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

export class BasicDataSource implements IDataSource {
  private items: ItemData[] = []
  private listeners: DataChangeListener[] = []

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

  getData(index: number): ItemData {
    return this.items[index]
  }

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

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

  // 单条追加:先落数据,再按最终下标通知
  push(item: ItemData): void {
    const index: number = this.items.length
    this.items.push(item)
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataAdd(index)
    }
  }

  // 整页追加:一次事务通知,避免 N 次 onDataAdd
  pushRange(newItems: ItemData[]): void {
    if (newItems.length === 0) {
      return
    }
    const start: number = this.items.length
    for (let i = 0; i < newItems.length; i++) {
      this.items.push(newItems[i])
    }
    const op: DataAddOperation = {
      type: DataOperationType.ADD,
      index: start,
      count: newItems.length
    }
    const operations: DataOperation[] = [op]
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDatasetChange(operations)
    }
  }

  removeAt(index: number): void {
    if (index < 0 || index >= this.items.length) {
      return
    }
    this.items.splice(index, 1)
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataDelete(index)
    }
  }

  updateAt(index: number, item: ItemData): void {
    if (index < 0 || index >= this.items.length) {
      return
    }
    this.items[index] = item
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataChange(index)
    }
  }

  move(from: number, to: number): void {
    if (from < 0 || from >= this.items.length || to < 0 || to >= this.items.length) {
      return
    }
    const moved: ItemData = this.items.splice(from, 1)[0]
    this.items.splice(to, 0, moved)
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataMove(from, to)
    }
  }

  // 只有整体替换数据集时才用它:切换筛选条件、清空重载
  resetAll(list: ItemData[]): void {
    this.items = list
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDataReloaded()
    }
  }

  clear(): void {
    this.resetAll([])
  }
}

2. 复用子组件:状态重置与异步防串位

// ReusableItem.ets —— 可复用列表项:所有会变的状态都在 aboutToReuse 里收口
import { ItemData } from './DataSource'

@Reusable
@Component
export struct ReusableItem {
  @State item: ItemData = new ItemData(0, '', '')
  @State expanded: boolean = false
  @State liked: boolean = false
  @State thumbUri: string = ''
  // 每次复用自增,用于作废在途的异步回调
  private token: number = 0
  // 异步请求发起时锁定的数据 id
  private loadingId: number = -1

  // 复用时触发,aboutToAppear 不会触发
  aboutToReuse(params: Record<string, Object>): void {
    const next: ItemData = params.item as ItemData
    // 1. 先废掉上一个实例遗留的异步回调
    this.token++
    const currentToken: number = this.token
    // 2. 绑定新数据
    this.item = next
    this.loadingId = next.id
    // 3. 重置所有"数据里没有、但 UI 上会变"的本地状态
    this.expanded = false
    this.liked = false
    this.thumbUri = ''
    // 4. 发起本条数据自己的异步任务
    this.fetchThumb(next.id, currentToken)
  }

  // 进入复用池时触发,做收尾,同样要作废在途回调
  aboutToRecycle(): void {
    this.token++
    this.loadingId = -1
  }

  private fetchThumb(id: number, currentToken: number): void {
    setTimeout(() => {
      // 双保险:token 未变,且组件当前绑定的仍是发起时的那条数据
      if (currentToken !== this.token || this.item.id !== id || this.loadingId !== id) {
        return
      }
      this.thumbUri = `https://example.com/thumb/${id}.png`
    }, 120)
  }

  build() {
    Row({ space: 12 }) {
      if (this.thumbUri !== '') {
        Image(this.thumbUri)
          .width(56)
          .height(56)
          .borderRadius(8)
          .objectFit(ImageFit.Cover)
      } else {
        Column()
          .width(56)
          .height(56)
          .borderRadius(8)
          .backgroundColor('#E8E8E8')
      }

      Column({ space: 4 }) {
        Text(this.item.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(this.item.desc)
          .fontSize(13)
          .fontColor('#999999')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .visibility(this.expanded ? Visibility.Visible : Visibility.None)
        Text(this.expanded ? '收起' : '展开')
          .fontSize(12)
          .fontColor('#007DFF')
          .margin({ top: 2 })
          .onClick(() => {
            this.expanded = !this.expanded
          })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Text(this.liked ? '★' : '☆')
        .fontSize(20)
        .fontColor(this.liked ? '#FF9500' : '#CCCCCC')
        .onClick(() => {
          this.liked = !this.liked
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(8)
  }
}

3. 页面:分页加载、触底锁、底部状态

// LongListPage.ets —— 页面:分页增量追加,底部状态不参与 LazyForEach 的 key 空间
import { BasicDataSource, ItemData } from './DataSource'
import { ReusableItem } from './ReusableItem'

@Entry
@Component
struct LongListPage {
  private ds: BasicDataSource = new BasicDataSource()
  private page: number = 0
  private readonly pageSize: number = 20
  private readonly maxPage: number = 50
  @State loading: boolean = false
  @State hasMore: boolean = true

  aboutToAppear(): void {
    this.loadNext()
  }

  loadNext(): void {
    // onReachEnd 会被连续回调,这个锁必须有,否则会重复请求、重复插数据
    if (this.loading || !this.hasMore) {
      return
    }
    this.loading = true
    setTimeout(() => {
      const list: ItemData[] = []
      for (let i = 0; i < this.pageSize; i++) {
        const id: number = this.page * this.pageSize + i + 1
        list.push(new ItemData(id, `标题 ${id}`, `第 ${id} 条数据的描述内容,用于观察滑出屏幕后的复用效果。`))
      }
      // 关键:整页追加走增量通知,已渲染的前两页完全不受影响
      this.ds.pushRange(list)
      this.page++
      this.hasMore = this.page < this.maxPage
      this.loading = false
    }, 300)
  }

  @Builder
  FooterArea() {
    Row() {
      if (this.loading) {
        LoadingProgress()
          .width(24)
          .height(24)
          .color('#007DFF')
        Text('加载中…')
          .fontSize(14)
          .fontColor('#999999')
          .margin({ left: 8 })
      } else if (!this.hasMore) {
        Text('没有更多数据了')
          .fontSize(14)
          .fontColor('#999999')
      } else {
        Text('上拉加载更多')
          .fontSize(14)
          .fontColor('#999999')
      }
    }
    .width('100%')
    .height(56)
    .justifyContent(FlexAlign.Center)
  }

  build() {
    Column() {
      List({ space: 8 }) {
        LazyForEach(this.ds, (item: ItemData) => {
          ListItem() {
            ReusableItem({ item: item })
              // reuseId 区分布局形态,不是区分数据;此处同一形态共用一个池
              .reuseId('normal')
          }
        }, (item: ItemData) => item.id.toString())
        // 底部状态:静态 ListItem,不是 LazyForEach 的产物,不参与 key 空间
        ListItem() {
          this.FooterArea()
        }
      }
      .width('100%')
      .layoutWeight(1)   // List 必须有确定高度,否则懒加载退化为全量加载
      .cachedCount(3)
      .onReachEnd(() => {
        this.loadNext()
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

4. 对照:V2 写法

// DataSourceV2.ets + ReusableItemV2.ets —— @ComponentV2 体系下的对照实现
import { ItemData } from './DataSource'

// V2 数据模型:@Trace 修饰字段,字段修改可直接驱动 UI
@ObservedV2
export class ItemV2 {
  id: number
  @Trace title: string
  @Trace desc: string

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

// 注意:@Trace 不会替我们实现 IDataSource,四个接口方法仍然要自己写
export class BasicDataSourceV2 implements IDataSource {
  private items: ItemV2[] = []
  private listeners: DataChangeListener[] = []

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

  getData(index: number): ItemV2 {
    return this.items[index]
  }

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

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

  // 增量通知纪律与 V1 完全一致
  pushRange(newItems: ItemV2[]): void {
    if (newItems.length === 0) {
      return
    }
    const start: number = this.items.length
    for (let i = 0; i < newItems.length; i++) {
      this.items.push(newItems[i])
    }
    const op: DataAddOperation = {
      type: DataOperationType.ADD,
      index: start,
      count: newItems.length
    }
    const operations: DataOperation[] = [op]
    for (let i = 0; i < this.listeners.length; i++) {
      this.listeners[i].onDatasetChange(operations)
    }
  }
}

// V2 复用组件:数据经 @Param 自动同步,aboutToReuse 只负责重置 @Local
@ReusableV2
@ComponentV2
export struct ReusableItemV2 {
  @Require @Param item: ItemV2 = new ItemV2(0, '', '')
  @Local expanded: boolean = false
  @Local liked: boolean = false

  aboutToReuse(): void {
    // 不需要搬运数据,@Param 已由框架更新;只重置本地 UI 状态
    this.expanded = false
    this.liked = false
  }

  build() {
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text(this.item.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Text(this.item.desc)
          .fontSize(13)
          .fontColor('#999999')
          .visibility(this.expanded ? Visibility.Visible : Visibility.None)
        Text(this.expanded ? '收起' : '展开')
          .fontSize(12)
          .fontColor('#007DFF')
          .onClick(() => {
            this.expanded = !this.expanded
          })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Text(this.liked ? '★' : '☆')
        .fontSize(20)
        .fontColor(this.liked ? '#FF9500' : '#CCCCCC')
        .onClick(() => {
          this.liked = !this.liked
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(8)
  }
}

总结

把整篇文章收敛成一份可执行的检查清单,它的顺序也基本就是性能收益从大到小的顺序。

第一,通知粒度是第一优先级,高于任何参数调优。 分页追加必须用 onDataAdd 或 onDatasetChange,绝不用 onDataReloaded();onDataReloaded() 只保留给"切换筛选、整体替换数据集"这一种场景。通知的 index 必须是数据落位后的真实下标,单条追加用 onDataAdd,整页追加用带 count 的 ADD 操作合成一次事务。把这一步做对,翻页掉帧这类问题基本就消失了。

第二,@Reusable 的成本不在"开",而在"复用之后"。 aboutToAppear 不会触发,初始化逻辑全部搬到 aboutToReuse;@State / @Local 里所有临时 UI 状态都要显式重置;异步回调要用 token 或数据 id 校验,aboutToRecycle 里作废在途请求;reuseId 只用来区分布局形态,默认取组件名,不要用数据 id。同时记住两条约束:复用池在父自定义组件范围内生效,不在 @Reusable 组件里再嵌套另一个 @Reusable 组件。

第三,"加载中 / 没有更多了"必须离开 LazyForEach。 把它做成 LazyForEach 的一项,会让 totalCount() 的变化扰动整个 key 空间,造成位置跳动与重复渲染。正确写法是 List 内、LazyForEach 之后的静态 ListItem(),或者 List 的兄弟节点。顺带把 keyGenerator 检查一遍:稳定、唯一、业务 id,不用下标、不用随机数。

第四,cachedCount、动态预加载、复用是三个独立维度。 cachedCount 只影响预布局范围,从 2 到 5 起步按列表项高度分档;动态预加载优化的是数据就绪时机,需要配 loading 锁防重复请求;复用能力只由 @Reusable 和 reuseId 决定。不要指望调其中任何一个顺带改善另外两个。

第五,布局约束是懒加载的前置条件。 List 必须有确定高度(layoutWeight(1) 或显式高度),并且不要放进同方向的 Scroll。这一条不满足,前面四条全部失效。

最后说一句 V1 / V2 的选择:@Reusable + aboutToReuse(params) 与 @ReusableV2 + @Param 是两套并行体系,不能混用;V2 省掉了数据搬运,也提供了 @ObservedV2 + @Trace 的对象级观察能力,但 IDataSource 的四件套和增量通知纪律一个都省不掉。选型要提前定,不要在一个组件上既标 @Reusable 又标 @ReusableV2。

长列表优化没有银弹,它是一组"每一环都不能写错"的纪律。懒加载负责少建,复用负责少销,增量通知负责不白建不白销——三者都对,列表才会真的顺。

Logo

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

更多推荐