开头还是得从那个让我印象很深的版本迭代说起。
前阵子公司一个鸿蒙应用要升级,业务方提了个需求:商品列表要从单页展示改成无限滚动,而且要求滚动过程不能有明显卡顿、不能闪白。项目早期用的是 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 刷新”的方式在某些场景下反而更直接;如果主要是整页刷新、增量追加,那 Repeat 的数组思维更顺手。
第三,团队能不能接受行为变化。Repeat 的 item 复用机制和 LazyForEach 不完全一样,可能出现 item 内部状态保持不一致的问题。如果团队没有心理准备,迁移到一半遇到诡异 Bug,很容易推翻重来。
我当时是把这三点写在排期文档里跟团队对齐过,确认可行之后才正式动工的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Repeat 与 LazyForEach 的工作机制差异:迁移前必须搞清楚的底层逻辑
2.1 数据源模型差异:接口实现 vs 纯数组
这一点在最前面的代码里已经能看出来,但值得单独展开讲。
LazyForEach 的数据源模型是“接口驱动”的。它要求你实现 IDataSource,本质上是在告诉框架:我这个数据源的长度是多少、某个 index 的数据是什么、数据的变更如何通知你。这套设计的特点是灵活,但是繁琐。它允许你在 getData 里做懒加载,比如数据本身也在按需拉取,那就可以做到“数据懒加载 + 组件懒加载”双重懒加载。
Repeat 的数据源模型是“数组驱动”的。它直接接收一个数组,框架通过监听数组本身的变化来感知数据更新。好处是心智模型简单——你不用再写那一堆接口,改数组就是改数据。但代价是你得遵守状态管理的规则:数组要放在 @State 等被观察的容器里,直接修改数组项的属性不一定能触发 UI 更新。
我画了个简单的对比表,方便你直观感受:
| 对比项 | LazyForEach | Repeat |
|---|---|---|
| 数据源要求 | 实现 IDataSource 接口 | 传入数组 |
| 数据变更方式 | 手动调用 listener 回调 | 依赖状态管理感知 |
| 新增一条数据 | 写 addData 方法,内部 notify | this.arr.push / splice |
| 修改单条数据 | 更新数据后 notifyDataChange(index) | 重建数组项或使用 @Observed |
| 代码量 | 偏多 | 精简 |
| 适用版本 | API 7 起 | API 12 起 |
2.2 更新触发机制差异:手动通知 vs 状态管理感知
LazyForEach 的刷新链路是:调用数据源的增删改方法 -> 遍历 listeners -> 触发对应回调 -> 框架拿到回调后更新对应 index 的组件。这个过程是“显式”的,开发者对每一次刷新都有精确控制,代价是漏一次通知 UI 就不刷新。
Repeat 的刷新链路是:修改 @State 数组 -> 状态管理框架感知到数组变化 -> 通知 Repeat 更新。这个过程是“隐式”的,少写很多代码,但前提是你的修改方式必须能被状态管理捕获。
这里有一个非常关键的细节:在 ArkUI 的状态管理里,@State 修饰的数组,直接修改数组某一项可能不会触发刷新。比如你的商品列表里每个 item 是个 Product 对象,你在某个事件回调里写了:
typescript复制this.products[index].price = 99;
这种写法在 Repeat 里常常不生效,因为框架没有检测到 this.products 这个数组本身的变化。正确的做法是新建一个数组,或者新建一个对象替换掉数组项:
typescript复制const newProduct = new Product(...);
this.products.splice(index, 1, newProduct);
// 或者
this.products = this.products.map((item, i) => i === index ? newProduct : item);
如果你确实想保留“直接改对象属性就能刷新”的体验,那就需要给 Product 类加上 @Observed,然后在子组件里用 @ObjectLink 去装饰对应属性。这个属于状态管理进阶用法,但迁移时非常容易碰到,我建议提前了解。
2.3 key 生成机制差异:必选 vs 可选,以及背后的复用逻辑
LazyForEach 的 keyGenerator 我印象里一直是强烈建议提供的。不传的话,框架没法区分哪个 item 对应哪条数据,复用时容易出现状态串号。
Repeat 的 keyGenerator 是可选的。如果你不传,它会按 index 来管理 item;传了,就按 key 来管理 item。听起来只是“多传一个参数”的区别,实际上对行为影响巨大。
按 index 管理意味着:数组中间插入一条数据,后面的所有 item 都会被重新创建——因为它们的索引都变了。如果你 item 里有输入框、有切换开关这类内部状态,用户可能正在输入,突然被顶掉,体验直接崩坏。按 key 管理则不同,只要 key 不变,组件会被复用,内部状态能保持住。
我的建议很明确:只要列表会出现增删操作,就必须传 keyGenerator,而且 key 要基于数据本身的唯一标识来生成,不要用 index 拼接。这个经验是踩过坑换来的,后面专门有一节讲这个坑。
2.4 缓存与复用策略差异
在 List 里用 LazyForEach 时,cachedCount 是在 List 上配置的,这个设置控制了可视区域前后多渲染多少个 item,让你快速滑动时不用等组件创建。Repeat 也支持 cachedCount,因为说到底还是跑在 List 的布局体系里。
但两者的 item 复用策略有区别。LazyForEach 的复用粒度是“组件的整个子树”,它会把滚出可视区域的 item 打包缓存,滚回来时再复用。Repeat 在初始化时会对 itemGenerator 生成的组件树做一个模板提取,把结构相同的 item 抽象成模板,然后通过模板创建和复用,复用粒度更细,开销更小。这也是为什么 Repeat 在长列表场景下会有性能优势。
不过要拿到这个优势,有个前提:item 的结构要足够规整。如果你的 item 里写了很多复杂的 if/else 分支,每次渲染的结构差异很大,模板提取的效果会打折扣。
3. 逐步迁移实操:从数据源重构到组件替换的完整过程
3.1 第一步:数据源重构为 @State 数组
迁移的第一步是把 ProductDataSource 这个类彻底删掉,把数据放进组件里并用 @State 修饰:
typescript复制@Entry
@Component
struct ProductList {
@State products: Product[] = [];
aboutToAppear(): void {
this.loadNextPage();
}
loadNextPage(): void {
// 模拟分页加载
const newItems: Product[] = [];
for (let i = this.products.length; i < this.products.length + 30; i++) {
newItems.push(Product.mock(i));
}
this.products = this.products.concat(newItems);
}
}
这里有个细节:分页追加数据时,我用了 this.products = this.products.concat(newItems),而不是 this.products.push(...newItems)。原因就是前面说的,@State 数组需要“赋值一个新数组”才能保证 UI 刷新,原地 push 虽然在数组层面是对的,但状态管理不一定感知得到。如果你用 push 也能刷,那可能跟具体版本有关,但最稳妥的做法还是给数组重新赋值。
3.2 第二步:替换 LazyForEach 为 Repeat
这一步看起来只是换个标签,但要注意的参数名和函数签名是一样的:
typescript复制List({ space: 12 }) {
Repeat(this.products, (item: Product, index: number) => {
ListItem() {
ProductItem({ product: item, index: index })
}
}, (item: Product) => item.id)
}
.cachedCount(5)
.onReachEnd(() => {
this.loadNextPage();
})
替换完成之后,IDataSource 相关代码可以全部删掉了。整个页面文件大概能少写六七十行样板代码,这个立竿见影。
但要提醒一句:Repeat 的 itemGenerator 里,第二个参数 index 在某些回调场景下可能是 undefined 吗?我实际测试下来,在 List 的 Repeat 里正常遍历时 index 都是有的,但文档里把它标成了可选参数。如果你写的 item 组件里依赖 index 做一些样式判断,建议加一个兜底:
typescript复制Repeat(this.products, (item: Product, index?: number) => {
ListItem() {
ProductItem({ product: item, index: index ?? 0 })
}
}, (item: Product) => item.id)
虽然很多场景下不传 index 问题不大,但做个防御总比线上崩溃强。
3.3 第三步:数据操作的迁移
原来在 ProductDataSource 里写的 addProduct、updateProduct、deleteProduct,现在要全部改成对 @State 数组的操作。我直接列一个对照表,方便你照着改:
| 原操作 | LazyForEach 时代写法 | Repeat 时代写法 |
|---|---|---|
| 追加一条 | dataSource.addProduct(item) | this.products = [...this.products, item] |
| 删除一条 | dataSource.deleteProduct(index) | this.products.splice(index, 1) |
| 修改全部 | dataSource.updateProducts(list) | this.products = list |
| 修改单条 | dataSource.updateProduct(index, item) | this.products = this.products.map((ele, i) => i === index ? item : ele) |
以“修改单条”为例,实际业务里最典型的就是商品价格变了、库存变了。用 LazyForEach 时你要更新数据源里的对象,然后调 notifyDataChange(index)。用 Repeat 后,我用的是 map 返回新数组的方式,让状态管理能够比较清楚地感知到变化。
如果你的数据项本身用了 @Observed 装饰,子组件里用 @ObjectLink 接收,那就更简单了,直接改 item.price = 99 就会刷新。但这样做的前提是数据类要设计成可观察的,对已有代码入侵比较大,我迁移时没有采用,而是统一用“重建数组项”的方式。
3.4 第四步:事件回调与参数透传
列表 item 里通常会有按钮、点击事件,迁移时这些回调要重新梳理一遍。
以前 LazyForEach 的 itemGenerator 里写子组件传参,和 Repeat 是一样的,都是 ProductItem({ product: item, index: index })。但原来的数据源封装了操作数据的逻辑,比如 dataSource.updateProduct(index, newItem),现在这些逻辑要放到页面组件里来,通过闭包传给子组件。
我当时的做法是把操作函数定义在页面组件里,然后传给子组件:
typescript复制@Entry
@Component
struct ProductList {
@State products: Product[] = [];
handlePriceChange(index: number, newPrice: number): void {
this.products = this.products.map((item, i) => {
if (i === index) {
const newItem = Product.clone(item);
newItem.price = newPrice;
return newItem;
}
return item;
});
}
build() {
List() {
Repeat(this.products, (item: Product, index?: number) => {
ListItem() {
ProductItem({
product: item,
index: index ?? 0,
onPriceChange: (price: number) => this.handlePriceChange(index ?? 0, price)
})
}
}, (item: Product) => item.id)
}
}
}
有一点要注意:闭包里的 index 在 Repeat 复用 item 时可能会有延迟更新的情况,也就是 UI 上显示的 item 是复用的,但闭包捕获的 index 可能还是上一次的值。我遇到过一次点击事件传了错误的 index,排查半天发现是闭包捕获问题。解决办法是优先用 item 里的唯一标识(比如 item.id)来定位数据,而不是用 index 去数组里找。后来我把 handlePriceChange 的签名改成了 handlePriceChange(id: string, newPrice: number),在里面先 findIndex 拿到 index,再更新数组,问题就消失了。
3.5 第五步:完整示例——通讯录列表迁移
到这里,一个完整的迁移示例其实已经成型了。我再给一个简化版通讯录列表的完整代码,这个可以当成模板直接改:
typescript复制@Observed
class Contact {
id: string;
name: string;
phone: string;
constructor(id: string, name: string, phone: string) {
this.id = id;
this.name = name;
this.phone = phone;
}
}
@Entry
@Component
struct ContactListPage {
@State contacts: Contact[] = [];
aboutToAppear(): void {
const list: Contact[] = [];
for (let i = 0; i < 100; i++) {
list.push(new Contact(`id_${i}`, `联系人${i}`, `1380000${String(i).padStart(4, '0')}`));
}
this.contacts = list;
}
deleteContact(id: string): void {
this.contacts = this.contacts.filter(item => item.id !== id);
}
addContact(): void {
const newContact = new Contact(
`id_${Date.now()}`,
`新增联系人${this.contacts.length}`,
'13900000000'
);
this.contacts = [...this.contacts, newContact];
}
build() {
Column() {
Button('新增联系人')
.onClick(() => this.addContact())
.margin(16)
List({ space: 8 }) {
Repeat(this.contacts, (item: Contact, index?: number) => {
ListItem() {
Row() {
Column() {
Text(item.name).fontSize(16).fontWeight(FontWeight.Bold)
Text(item.phone).fontSize(14).fontColor('#666')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Button('删除')
.onClick(() => this.deleteContact(item.id))
}
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
}
}, (item: Contact) => item.id)
}
.cachedCount(5)
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}
这个示例里,key 用的是 item.id,增删数据走的是数组重建,删除时用 id 定位而不是 index,这些细节都是前面踩坑总结出来的,直接套用一般不会出问题。
4. 迁移后最容易踩的坑:key稳定性、状态丢失与滚动错乱
4.1 坑一:key 生成函数拼接了 index,导致组件重建闪烁
迁移完之后,我第一个遇到的问题是列表滚动时偶发闪烁,尤其是快速滑动的时候,item 的图片会闪一下,感觉像是组件被销毁重建了。
排查了半天,发现 key 生成函数写的是:
typescript复制(item: Product, index?: number) => `${index}_${item.id}`
这个写法表面上看没问题,index 加上 id 肯定唯一。但问题是:Repeat 在滚动复用 item 时,index 会变化。一个 item 滑出可视区域再滑回来,它的 index 可能从 5 变成了 8,key 就变了,组件就要重新创建,于是闪烁。
修复很简单,key 里去掉 index,只用数据本身的唯一标识:
typescript复制(item: Product) => item.id
这里的关键认知是:key 不是用来生成“唯一值”的,而是用来标识“哪条数据对应哪个组件”。它必须对数据本身稳定,不能随位置变化。位置变化会导致 key 变化,key 变化就触发重建,性能反而下降。
4.2 坑二:item 内部输入框焦点丢失
列表里有一个 item 包含 TextInput,用户正在编辑,滚动一下再回来,输入框的焦点没了,输入的文本也丢了。
这个坑的本质跟 4.1 一样,是 key 不稳定导致的组件重建。但因为 TextInput 的焦点状态是内部状态,组件一重建,焦点自然就丢。即使 key 当时写得没问题,也要检查是不是 item 内部某个条件分支让组件树结构发生了变化,导致复用失败。
排查方法我后面会详细说。这里先说结论:输入框焦点丢失,优先查 key,然后查 item 内部结构是否稳定。
4.3 坑三:数据项属性更新后 UI 不刷新
迁移后最隐蔽的问题之一:某个操作改了数组里一个对象的属性,UI 毫无反应。比如:
typescript复制this.products[index].price = 99;
在 LazyForEach 时代,只要你最后调了 notifyDataChange(index),UI 就会刷新,因为通知是显式的。但 Repeat 依赖状态管理感知数组变化,上面这种写法改的是数组项内部的属性,状态管理框架没有感知到“数组变了”,于是不刷新。
解决方案有三种:
第一种,改完后重建数组项,这是最通用、最推荐的做法:
typescript复制this.products = this.products.map((item, i) => {
if (i === index) {
return { ...item, price: 99 };
}
return item;
});
第二种,给数据类加 @Observed,子组件用 @ObjectLink 接收,这样直接改属性就能刷新。注意 @ObjectLink 不能用在 Repeat 的 itemGenerator 里直接构造子组件时?我测试时是能用的,但有一个要求:数据项必须是被 @Observed 修饰的类,用 @ObjectLink 接收时需要配合 @Observed 的实例。这个方案对数据模型侵入性强,不太适合快速迁移。
第三种,把数据项改成普通对象数组,每次更新整个数组。如果列表数据量不大,这种方式最省事,但要考虑性能。
我实际用的是第一种为主,只有高频更新的数据类才考虑上了 @Observed。
4.4 坑四:列表中间插入数据后滚动偏移一眼可见
用 Repeat 时,如果列表中间插入了一条数据,后面的 item 理论上应该整体下移。但有时候你会看到滚动位置没有自动稳住,而是出现明显的跳变,或者停留在之前的位置,导致当前屏幕内容错位。
这个问题的根源是 List 的滚动位置是基于 item 索引或偏移量计算的。中间插入数据后,后面的 item 索引变了,滚动位置却不认识这种变化。
解决办法是给 List 绑定一个 Scroller,在插入数据后主动调整滚动位置:
typescript复制private scroller: Scroller = new Scroller();
// 插入数据后
this.contacts = ...
this.scroller.scrollToIndex(index, true, ScrollAlign.START);
但如果用户当前没有在滚动,插数据时突然调 scrollToIndex,反而会打断浏览体验。我给自己的方案是:只有当列表滚动到底部触发分页时,才追加数据;正常情况下不允许从中间插入。如果业务上必须从中间插,那就得自己记录滚动位置,插入后恢复,逻辑比较复杂。
4.5 踩坑排查的完整链路:一个输入框焦点丢失问题的定位过程
第 4.2 节的坑,标准的排查链路是什么样的?我把当时的全过程写出来,给你一个可复现的思路。
第一步,先看 key。在 Repeat 的 keyGenerator 里加一个日志,打印每次生成 key 时传入的 item.id 和 index。滚动列表,观察同一个 id 的 key 有没有变化。如果变了,问题就在 key 不稳。
第二步,如果 key 是稳的,再看 item 的内部结构。Repeat 的模板复用要求 item 的结构一致。当时我的 item 里有一个 if 判断:当某个字段为空时显示占位符,否则显示正常内容。这个字段在滚动过程中可能从空变成非空,组件结构就变了,复用失败,输入框被重建。
第三步,用 DevEco Studio 的 ArkUI Inspector 工具查看组件树。滚动到问题 item,点击查看它的组件树节点 ID。如果两次滚回来节点 ID 不一样,说明确实重建了。如果节点 ID 一样但焦点还是丢,那问题就在 TextInput 本身的状态管理上,比如你给 TextInput 绑定的 TextController 没有持久化。
第四步,检查 item 组件是否加了 @Reusable 以及复用的生命周期处理。Repeat 的 item 复用会触发 aboutToReuse 之类的回调,如果你在 aboutToReuse 里重置了输入框内容,那即使组件没重建,内容也会被重置。
最后我的问题其实出在第二步和第三步结合的地方:item 结构不稳定导致组件重建,TextInput 状态整个没了。修复方式是让 item 在字段为空和不为空时保持同一套组件结构,用属性控制显隐,而不是用 if/else 切换不同分支的结构。改完之后,焦点丢失问题再没出现过。
5. 性能验证与结论:迁移到底值不值得
5.1 测试方案与指标
迁移完不是就完事了,得用数据说话。我在真机上跑了对比测试,方案是同一个列表,分别用旧版 LazyForEach 代码和迁移后的 Repeat 代码,用同样的数据量、同样的操作路径来对比。
指标选了四个:
- 冷启动到列表首屏渲染完成的耗时。
- 快速滚动 100 条 item 的平均帧率。
- 内存占用峰值。
- item 内部交互(比如点击收藏)的响应延迟。
数据量分别测了 1000 条和 5000 条两种。测试机型是一台中端档位的鸿蒙设备,用 DevEco Studio 的 Profiler 工具和 HiLog 打点记录数据。
5.2 实测数据与对比
测试结果如下表:
| 指标 | 1000条 LazyForEach | 1000条 Repeat | 5000条 LazyForEach | 5000条 Repeat |
|---|---|---|---|---|
| 首屏渲染耗时(冷启动) | 约 320ms | 约 265ms | 约 380ms | 约 290ms |
| 快速滚动平均帧率 | 约 52fps | 约 57fps | 约 46fps | 约 54fps |
| 内存峰值 | 约 210MB | 约 188MB | 约 356MB | 约 305MB |
| 收藏按钮响应延迟 | 约 12ms | 约 9ms | 约 18ms | 约 11ms |
这个数据比我预想的还要明显一些。首屏渲染变快,主要是 Repeat 的模板复用减少了组件创建的开销;滚动帧率和内存的提升,则得益于复用粒度更细,减少了频繁创建销毁对象的压力。
性能提升之外,代码量的减少也是实打实的。整个页面从 180 多行缩到了 100 行左右,删掉了一个完整的数据源类。后续改需求时,团队反馈明显轻松了。
5.3 我的结论与适用场景建议
基于这次迁移,我的结论是:如果你的列表数据超过 500 条、item 结构相对规整、并且你们用的 HarmonyOS 版本支持 API 12 及以上,那么从 LazyForEach 迁到 Repeat 是值得的。
但如果你的列表数据量很小,或者 item 内部结构非常碎片化、依赖大量 if/else 切换,那 Repeat 的模板复用优势发挥不出来,迁移收益有限,还可能引入状态管理的适配成本。
此外,如果你的项目还有很多运行在不支持 API 12 的老设备上,那 Repeat 就用不了,得继续用 LazyForEach。迁移前先确认好最低支持版本,别白忙活一场。
6. 迁移之外的思考:Repeat 的适用边界与后续演进
6.1 Repeat 适合什么,不适合什么
一段时间用下来,我觉得 Repeat 最适合的场景有这么几个:
适合一,动态增删的长列表。数据频繁变化,但 item 结构稳定,Repeat 的 key 机制能保证 UI 正确更新,模板复用又能保住性能。
适合二,复杂的 item 单元。item 里有多层嵌套布局、图片、交互组件,Repeat 的复用机制能减少重复创建开销,对滚动手感提升很明显。
适合三,团队协作的项目。删掉 IDataSource 后,新同学看代码的成本低了很多,只要懂数组、懂 @State,基本就能上手。
不适合的场景也有:
不适合一,item 结构变化极大的列表。比如同一个列表里,不同 item 的布局完全不一样,模板复用优势没了,还可能因为结构不一致触发更多重建。
不适合二,需要精细控制局部刷新的场景。LazyForEach 的 notifyDataChange(index) 可以只刷一个 item,Repeat 如果数据更新方式不得当,可能整页刷新,反而更慢。
不适合三,需要兼容老版本的场景。API 12 以下的设备只能望洋兴叹。
6.2 与前端框架列表渲染的心智模型对照
如果你写过 Vue 或者 React,会发现 Repeat 的心智模型跟前端框架的列表渲染几乎一样。Vue 里 v-for 加 :key,React 里 key props,Repeat 也是这个思路:给数据一个稳定标识,框架基于标识做最小化更新。
区别在于 ArkUI 的渲染流程是走组件树的,没有 Virtual DOM,所以 Repeat 会直接操作真实组件节点。这让它的复用策略更像 Flutter 的 SliverChildBuilderDelegate 或者 SwiftUI 的 List,核心思路都是“只渲染可见区域,复用滑出区域的组件”。
这么一对照,思路就清晰了很多:你在前端项目里给列表 key 的经验,在鸿蒙里完全适用;你在 SwiftUI 里遇到的列表滑动问题,在 Repeat 里大概率也会遇到。
6.3 可以继续深挖的方向:har 封装、面试亮点与性能优化
迁移完 Repeat 之后,我又往下踩了几个方向,供你参考。
第一个是组件复用封装。既然 Repeat 这么好用,可以把一些通用列表封装成 har 包,内部用 Repeat 实现,对外暴露 @Prop 或普通参数接收数据数组。团队里其他模块直接引用,不用关心底层是 Repeat 还是 LazyForEach。之前做 har 封装时,我习惯把列表的滚动加载、空态、错误态都包含进去,一个组件解决整页列表需求。
第二个是面试层面的价值。鸿蒙面试里经常问“LazyForEach 和 Repeat 有什么区别”。经过这次迁移,你能答出来的点会非常具体:数据源模型不同、更新机制不同、key 管理不同、复用粒度不同、适用场景不同。如果再配合一个实际的性能数据,这个回答比背概念有说服力得多。
第三个是性能优化。Repeat 虽然好,但不是银弹。item 里的图片加载、事件处理、状态管理,仍然需要逐个优化。我现在的习惯是先跑 Profiler 看瓶颈在哪,再决定优化方向。如果 item 复用正常,但滚动手感还是差,那问题大概率不在渲染层,而在数据准备或者图片解码上。
从 LazyForEach 迁到 Repeat 这段时间,最大的感受是:一个组件好不好,光看文档是体会不到的,得真正把它放到业务里去跑、去踩坑、去优化,才能理解设计者的良苦用心。Repeat 把数据源模型简化了,但把状态管理的责任交给了开发者。这意味着你需要对 ArkUI 的 @State、@Observed、@ObjectLink 有更清晰的理解,这本身也是一种进阶。
最后分享一个小技巧:迁移完以后,不要急着删掉旧的 LazyForEach 代码,先在 Git 里留一个分支,跑一周线上数据对比,确认没有异常再清代码。我这次迁移在正式删除旧代码前,留了大概两周的观察期,期间确实发现了一个只会在真机高频滚动时出现的复用问题,幸好有旧分支可以对照排查,不然定位起来会更费劲。
