前言
在 ArkUI 声明式开发范式中,列表渲染是最常见的 UI 模式之一。ForEach是 ArkUI 提供的核心列表渲染 API,它根据数据数组循环生成一组组件。但ForEach的键值生成策略直接决定了列表 Diff 算法的效率和正确性。
本文将以开源鸿蒙笔友通信应用 xiexin 的Index.ets和ReadLetterPage.ets为蓝本,详细剖析ForEach的键值策略、Diff 算法原理、常见陷阱,以及在不同场景下的最佳实践。
提示:本文假设你已经了解 ArkUI 声明式开发的基本概念。如果还不熟悉,建议先阅读前十四篇文章。
一、ForEach 的基本用法
1.1 ForEach 的三个参数
ForEach接受三个参数:
ForEach( arr: any[], // 数据数组 itemGenerator: (item: any, index?: number) => void, // 子项生成函数 keyGenerator?: (item: any, index?: number) => string // 键值生成函数 )1.2 xiexin 中的 ForEach 使用
xiexin 的Index.ets中多处使用了ForEach:
// 信箱 Tab:信件列表 ForEach(this.getSortedLetters(), (letter: Letter) => { this.LetterCard(letter) }, (letter: Letter) => letter.id.toString()) // 笔友 Tab:笔友列表 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.id.toString())1.3 ForEach 渲染流程
graph LR A[数据数组] -->|keyGenerator| B[生成键值表] B --> C[Diff 算法] D[已有 UI 树] --> C C --> E[更新 UI 树]二、键值生成策略
2.1 为什么需要键值
键值(Key)是 ForEach 进行 Diff 算法的依据。当数据数组发生变化时,ArkUI 通过键值来判断哪些项是“新增“、“删除“或“移动“的。
2.2 键值的选择
// 正确:使用唯一 ID (letter: Letter) => letter.id.toString() // 正确:使用数组索引(仅当数组不变时) (letter: Letter, index: number) => index.toString() // 错误:使用不稳定值 (letter: Letter) => letter.body.substring(0, 5) // 内容变化会导致键值变化 // 错误:使用固定字符串 (letter: Letter) => 'letter' // 所有项键值相同,Diff 失效2.3 xiexin 的键值策略
xiexin 使用letter.id.toString()和pal.id.toString()作为键值:
// 信件列表:使用 letter.id 作为键值 ForEach(this.getSortedLetters(), (letter: Letter) => { this.LetterCard(letter) }, (letter: Letter) => letter.id.toString()) // 笔友列表:使用 pal.id 作为键值 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.id.toString())这是推荐的做法,因为:
id是持久化的唯一标识,不会变化- 每个
id对应一个信件/笔友,永远不会重复 - 即使数组排序变化,键值不变,ArkUI 能正确复用组件
三、ForEach 的 Diff 算法
3.1 Diff 算法原理
当数据数组变化时,ForEach 的 Diff 算法执行以下步骤:
// 假设初始数组 const letters = [ { id: 1, title: '信1' }, { id: 2, title: '信2' }, { id: 3, title: '信3' } ]; // 更新后的数组 const newLetters = [ { id: 4, title: '信4' }, // 新增 { id: 1, title: '信1' }, // 保留 { id: 3, title: '信3' } // 保留 ];Diff 过程:
- 遍历旧键值表:
['1', '2', '3'] - 遍历新键值表:
['4', '1', '3'] - 发现
'2'不存在于新表中 → 删除{ id: 2 }的组件 - 发现
'4'不存在于旧表中 → 新增{ id: 4 }的组件 '1'和'3'都存在 → 复用组件,更新数据
3.2 键值对 Diff 的影响
// 情况 1:键值稳定,Diff 高效 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.id.toString()) // 稳定键值 // 情况 2:键值不稳定,Diff 低效 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => Math.random().toString()) // 每次重新生成键值,所有组件销毁重建| 键值策略 | Diff 效果 | 组件复用 |
|---|---|---|
| 唯一 ID | 精确 diff | 全部复用 |
| 数组索引 | 头部插入准确 | 尾部插入准确 |
| 随机值 | 全部销毁重建 | 不复用 |
| 相同值 | 无法 diff | 全部复用(但数据错乱) |
四、ForEach 与 getSortedLetters()
xiexin 的Index.ets中有一个有趣的设计:getSortedLetters()方法对信件进行排序,然后把排序后的结果传给ForEach:
// 排序信件 - 待回信置顶 private getSortedLetters(): Letter[] { const sorted: Letter[] = [...this.letters]; sorted.sort((a: Letter, b: Letter) => { // 待回信的排前面 const aUrgent = (!a.isSender && a.status === LetterStatus.WAIT_REPLY) ? 0 : 1; const bUrgent = (!b.isSender && b.status === LetterStatus.WAIT_REPLY) ? 0 : 1; if (aUrgent !== bUrgent) { return aUrgent - bUrgent; } return b.createdAt - a.createdAt; }); return sorted; } // ForEach 中使用排序后的数组 ForEach(this.getSortedLetters(), (letter: Letter) => { this.LetterCard(letter) }, (letter: Letter) => letter.id.toString())4.1 排序对 Diff 的影响
当@StorageProp('letters')变化时,getSortedLetters()返回一个新数组,但letter.id键值不变,所以:
- 排序导致的数组顺序变化 → 键值表变化 → ArkUI 重新排列组件
letter.status变化导致排序优先级变化 → 键值顺序变化 → ArkUI 移动组件- 新增信件 → 新增键值 → ArkUI 新增组件
由于 xiexin 使用letter.id作为键值,排序变化不会影响组件复用率。
4.2 展开运算符的拷贝
[...this.letters]创建了一个新数组,这是必要的,因为sort()会修改原数组:
// 错误:直接排序会修改 @StorageProp 数组 this.letters.sort(...); // 修改了 AppStorage 中的源数据 // 正确:先拷贝再排序 const sorted: Letter[] = [...this.letters]; sorted.sort(...); // 不影响源数据五、ForEach 的常见陷阱
5.1 键值重复
// 错误:键值重复 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => 'same_key') // 所有项的键值相同 // 错误:键值可能重复 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.name) // 同名笔友会导致键值重复5.2 键值不稳定
// 错误:键值不稳定 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => Date.now().toString()) // 每次渲染键值不同5.3 在 ForEach 中修改数组
// 错误:在 ForEach 中直接修改数组 ForEach(this.penPals, (pal: PenPal) => { Button('删除').onClick(() => { this.penPals.splice(0, 1); // 直接修改数组,会引发渲染问题 }) }, (pal: PenPal) => pal.id.toString()) // 正确:创建新数组 ForEach(this.penPals, (pal: PenPal) => { Button('删除').onClick(() => { this.penPals = this.penPals.filter((p: PenPal) => p.id !== pal.id); }) }, (pal: PenPal) => pal.id.toString())5.4 ForEach 与 @Builder 的配合
// 正确:在 ForEach 中调用 @Builder 函数 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.id.toString()) // 错误:在 ForEach 中使用独立组件时,需要传递 @ObjectLink ForEach(this.penPals, (pal: PenPal) => { PenPalCard({ pal: pal }) // 组件形式 }, (pal: PenPal) => pal.id.toString())六、ForEach vs LazyForEach
6.1 核心差异
| 维度 | ForEach | LazyForEach |
|---|---|---|
| 渲染策略 | 一次性渲染所有项 | 按需渲染可见项 |
| 适用数据量 | < 100 项 | 100 项以上 |
| 内存占用 | 全部加载 | 仅加载可见项 + 缓存 |
| 滚动性能 | 数据量大时下滑 | 数据量大时流畅 |
| 实现复杂度 | 简单 | 需要实现 DataSource |
6.2 选型决策
// 数据量小(< 100 项)→ ForEach ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.id.toString()) // 数据量大(> 100 项)→ LazyForEach LazyForEach(this.penPalsDataSource, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.id.toString())七、ForEach 的渲染性能优化
7.1 避免在 ForEach 中执行耗时操作
// 反例:在 itemGenerator 中执行耗时计算 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) // 耗时的计算 }, (pal: PenPal) => pal.id.toString())7.2 使用 @Builder 拆分
// 优化:把 UI 拆分到 @Builder 中 ForEach(this.penPals, (pal: PenPal) => { this.PenPalCard(pal) // @Builder 函数 }, (pal: PenPal) => pal.id.toString())7.3 减少不必要的 ForEach 重渲染
// 反例:每次渲染都创建新数组 ForEach(this.getSortedLetters(), (letter: Letter) => { this.LetterCard(letter) }, (letter: Letter) => letter.id.toString()) // 优化:缓存排序结果 private sortedLetters: Letter[] = []; private getSortedLetters(): Letter[] { if (this.sortedLetters.length !== this.letters.length) { this.sortedLetters = [...this.letters]; this.sortedLetters.sort(/* ... */); } return this.sortedLetters; }八、ForEach 在 xiexin 中的性能分析
8.1 当前数据量
xiexin 当前的数据量:
- 笔友列表:3 个(mock 数据)
- 信件列表:4 个(mock 数据)
在这种数据量下,ForEach的性能完全不是问题。但考虑未来扩展:
- 笔友列表:最多 50 个
- 信件列表:可能几百个
8.2 性能瓶颈预测
当信件列表增长到 1000+ 时,ForEach可能面临以下问题:
- 首帧渲染慢:一次性渲染 1000 个组件
- 内存占用高:1000 个组件的 UI 树
- 滚动卡顿:每次重渲染都需要遍历所有组件
8.3 优化建议
// 当前(适用于小数据量) ForEach(this.getSortedLetters(), (letter: Letter) => { this.LetterCard(letter) }, (letter: Letter) => letter.id.toString()) // 优化(适用于大数据量) LazyForEach(this.lettersDataSource, (letter: Letter) => { this.LetterCard(letter) }, (letter: Letter) => letter.id.toString())九、ForEach 的扩展实践
9.1 嵌套 ForEach
// 嵌套 ForEach 渲染二级列表 @Builder renderPenPalGroup(group: { stage: string, pals: PenPal[] }) { Column({ space: 12 }) { Text(group.stage).fontSize(18).fontWeight(FontWeight.Bold) ForEach(group.pals, (pal: PenPal) => { this.PenPalCard(pal) }, (pal: PenPal) => pal.id.toString()) } } // 外层 ForEach ForEach(this.penPalGroups, (group: { stage: string, pals: PenPal[] }) => { this.renderPenPalGroup(group) }, (group: { stage: string, pals: PenPal[] }) => group.stage)9.2 多列 ForEach
// 使用 Grid 实现多列渲染 Grid() { ForEach(this.penPals, (pal: PenPal) => { GridItem() { this.PenPalCard(pal) } }, (pal: PenPal) => pal.id.toString()) } .columnsTemplate('1fr 1fr') .columnsGap(12) .rowsGap(12)十、从 xiexin 看 ForEach 设计
xiexin 的ForEach使用体现了“简单、正确“的原则:
- 使用唯一 ID 作为键值:
letter.id.toString()和pal.id.toString() - 通过
@Builder函数渲染子项:保持itemGenerator简洁 - 排序在外部完成:
getSortedLetters()先排序再传给 ForEach - 不修改原数组:
[...this.letters]拷贝后再排序
这些做法虽然简单,但确保了 ForEach 的正确性和可维护性。
总结
本文详细剖析了 HarmonyOS ArkUI 的ForEach列表渲染机制,重点讲解了键值生成策略、Diff 算法原理、常见陷阱,以及在不同场景下的最佳实践。
理解 ForEach 的关键是把握“一个核心、三个原则“:一个核心是键值策略,键值决定了 Diff 的效率和正确性;三个原则是唯一性、稳定性、永久性——键值必须唯一、稳定且永久不变。
下一篇文章我们将深入LazyForEach懒加载,剖析长列表的性能优化实践。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源
- 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
- HarmonyOS ForEach 渲染控制:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-foreach
- HarmonyOS LazyForEach 渲染控制:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-lazyforeach
- HarmonyOS List 组件:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-components/List
- HarmonyOS Grid 组件:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-components/Grid
- HarmonyOS 高性能编程实践:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-high-performance-programming
- HarmonyOS 组件复用开发实践:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-component_reuse
- HarmonyOS @Reusable 组件复用:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-reusable
- HarmonyOS 状态管理概述:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-overview