行业资讯
HarmonyOS开发实战:笔友-ForEach 列表渲染键值策略与 Diff 算法
前言在 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 LazyForEach6.1 核心差异维度ForEachLazyForEach渲染策略一次性渲染所有项按需渲染可见项适用数据量 100 项100 项以上内存占用全部加载仅加载可见项 缓存滚动性能数据量大时下滑数据量大时流畅实现复杂度简单需要实现 DataSource6.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.netHarmonyOS ForEach 渲染控制https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-foreachHarmonyOS LazyForEach 渲染控制https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-lazyforeachHarmonyOS List 组件https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-components/ListHarmonyOS Grid 组件https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-components/GridHarmonyOS 高性能编程实践https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-high-performance-programmingHarmonyOS 组件复用开发实践https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-component_reuseHarmonyOS Reusable 组件复用https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-reusableHarmonyOS 状态管理概述https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-overview
郑州网站建设
网页设计
企业官网