开头还是得从那个让我印象很深的版本迭代说起。
前阵子公司一个鸿蒙应用要升级,业务方提了个需求:商品列表要从单页展示改成无限滚动,而且要求滚动过程不能有明显卡顿、不能闪白。项目早期用的是 LazyForEach,当时选它是因为官方文档说它是“懒加载”的,能解决长列表渲染性能问题。但真正把列表数据涨到几千条、item 里再加几个图片和交互组件之后,问题一个接一个浮出来:数据源要实现 IDataSource 那一堆接口,写起来啰嗦不说,新来的同事光是搞清楚 DataChangeListener 的几种回调就花了两天;改一条数据要手动调 notifyDataChange,漏一次 UI 就不刷新,排查起来极其痛苦。
正好当时 Repeat 组件已经可以在真机上跑了,我就决定把列表这一块整体迁过去。这篇文章就是这次迁移的完整记录,从动机、原理差异、实操步骤到踩坑排查,全程都是我在真机上验证过的。
如果你正在用 LazyForEach,或者项目里刚好有类似的列表性能需求,这篇文章应该能帮你少走不少弯路。
1. 迁移的起点:项目里 LazyForEach 遇到了什么问题
1.1 最初的代码与三个典型痛点
先看一下我们最初的实现。那是一个典型的分页商品列表,数据源来自服务端,需要在滚动到底部时加载下一页。用 LazyForEach 的实现长这样:
typescript复制class ProductDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
private products: Product[] = [];
totalCount(): number {
return this.products.length;
}
getData(index: number): Product {
return this.products[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);
}
}
addProduct(product: Product): void {
this.products.push(product);
this.listeners.forEach(listener => {
listener.onDataAdd(this.products.length - 1);
});
}
updateProduct(index: number, product: Product): void {
this.products[index] = product;
this.listeners.forEach(listener => {
listener.onDataChange(index);
});
}
}
@Entry
@Component
struct ProductList {
private dataSource = new ProductDataSource();
aboutToAppear(): void {
for (let i = 0; i < 30; i++) {
this.dataSource.addProduct(Product.mock(i));
}
}
build() {
List() {
LazyForEach(this.dataSource, (item: Product, index: number) => {
ListItem() {
ProductItem({ product: item, index: index })
}
}, (item: Product) => item.id)
}
.cachedCount(5)
}
}
这段代码跑起来确实比普通的 ForEach 流畅,因为 LazyForEach 只渲染可视区域附近的 item,不会一次性创建几千个组件。但长期维护下来,三个痛点越来越明显:
第一个痛点是 IDataSource 接口的样板代码太多。真实项目里每个列表都要写一个数据源类,totalCount、getData、registerDataChangeListener、unregisterDataChangeListener 四个方法必写,增删改还要自己遍历 listeners 逐个回调。列表多了以后,这些代码基本是复制粘贴,换个数据类型又是一个新类。
第二个痛点是数据变更通知特别容易漏。页面里某个操作改了商品价格,如果不记得调 updateProduct 里的 listener.onDataChange(index),界面就纹丝不动。更麻烦的是,如果同时改了多个 index,还得小心翼翼地逐个通知,顺序错了甚至会闪一下。这种“数据变了但 UI 没变”的问题,在联调阶段排查起来非常耗时间。
第三个痛点是新人的上手成本。团队里新来的同学第一次看 IDataSource 这套东西,都会问同样的几个问题:为什么不能直接传数组?DataChangeListener 为什么有 onDataAdd、onDataDelete、onDataChange 这么多种回调?什么场景该用哪一种?我每次都要解释一遍。
1.2 Repeat 是“更好的 LazyForEach”吗
一开始我也以为 Repeat 只是 LazyForEach 的语法糖版本,把 IDataSource 换成数组、内部自动做懒加载。但真正仔细读文档、跑 Demo 之后才发现,这个理解太粗了。
Repeat 确实是 API 12 起推出的新列表渲染组件,它的基本写法和 ForEach 那套特别像:
typescript复制Repeat(this.products, (item: Product, index: number) => {
ListItem() {
ProductItem({ product: item, index: index })
}
}, (item: Product) => item.id)
关键区别在于:Repeat 不再要求数据源实现 IDataSource 接口,直接传一个数组就行。而且它内部有自己的一套模板复用逻辑,能够在 item 复用时比 LazyForEach 更激进,减少组件树的重复创建和销毁。官方说法是它“适用于长列表场景,且对列表项复用进行了增强”。
但“更好”是有条件的。Repeat 的数据变化依赖状态管理框架来感知,如果你的数据操作习惯是“直接改对象属性”,那 Repeat 反而可能让你感觉更难用——因为 UI 不会自动刷新。这一点很多人迁移的时候会踩坑,后面我会专门讲。
1.3 迁移前需要明确的三个问题
在动手改代码之前,我建议你先想清楚这三件事,能省掉后面的大半麻烦:
第一,数据量级到底有多大。如果你的列表只有十来条数据,用 ForEach 都没问题,LazyForEach 和 Repeat 的差异对你来说没有意义,迁移纯属浪费工时。我们是几千条起步、后续可能上万条,才有必要做这种迁移。
第二,数据的变更模式是什么。列表数据是只读展示,还是经常增删、排序、局部修改?如果是经常局部修改单条数据的某个字段,LazyForEach 那种“精确通知单个 index 刷新”的方
