HarmonyOS应用开发实战:猫猫大作战-IDataSource 数据源接口、LazyForEach 按需调用机制、DataChangeListen

📅 2026/7/28 22:31:58
HarmonyOS应用开发实战:猫猫大作战-IDataSource 数据源接口、LazyForEach 按需调用机制、DataChangeListen
前言上一篇我们用Reusable让 ListItem 离屏入复用池省组件实例。但ForEach数据源仍是全量遍历——100 条记录调 100 次(record) {...}。如果数据源是「从数据库懒加载」「网络分页拉取」的全量遍历会卡死。HarmonyOS 提供了LazyForEach——只对可视区 cachedCount 范围的索引调dataSource.getData(index)真正按需渲染。本篇以「猫猫大作战」战绩历史改造为 LazyForEach 为锚点把IDataSource 数据源接口、LazyForEach 按需调用机制、DataChangeListener 监听器、与 Reusable 搭配最优解四大要点讲透。提示本系列不讲 ArkTS 基础语法与环境搭建假设你已跟完第 1–47 篇。本篇是阶段二第十八篇。一、场景拆解大列表按需渲染回顾「猫猫大作战」战绩历史第 47 篇List() { ForEach(this.history, (record: GameRecord) { ListItem() { RecordItem({ record: record }) } }, (record: GameRecord) record.id) } .cachedCount(5)痛点ForEach全量遍历 100 条记录——每条都调一次回调、读一次this.history[i]。如果数据源是「1000 条数据库查询」「网络分页」全量遍历会卡 UI 线程。LazyForEach 的解法// 1. 实现 IDataSource 接口 class HistoryDataSource implements IDataSource { private records: GameRecord[] []; totalCount(): number { return this.records.length; } getData(index: number): GameRecord { return this.records[index]; } // ... } // 2. LazyForEach 按需调用 getData List() { LazyForEach(this.dataSource, (record: GameRecord) { ListItem() { RecordItem({ record: record }) } }, (record: GameRecord) record.id) } .cachedCount(5) // 只对可视区 8 个 cached 10 个 18 个索引调 getData不全量遍历关键经验LazyForEach 按需调用数据源 配合 Reusable 复用池——前者省数据遍历后者省组件实例。二、IDataSource 数据源接口2.1 接口四方法import { IDataSource, DataChangeListener } from kit.ArkUI; class HistoryDataSource implements IDataSource { private records: GameRecord[] []; private listeners: DataChangeListener[] []; // 1. 总数 totalCount(): number { return this.records.length; } // 2. 按索引取数据 getData(index: number): GameRecord { return this.records[index]; } // 3. 注册监听器LazyForEach 内部调 registerDataChangeListener(listener: DataChangeListener): void { this.listeners.push(listener); } // 4. 注销监听器 unregisterDataChangeListener(listener: DataChangeListener): void { this.listeners this.listeners.filter(l l ! listener); } }四方法拆解方法调用者作用totalCount()LazyForEach知道总条数算滚动范围getData(index)LazyForEach按需取可视区某条数据registerDataChangeListenerLazyForEach注册监听数据变时通知unregisterDataChangeListenerLazyForEach注销监听组件销毁时关键经验IDataSource 是「数据源契约」——LazyForEach 不直接读数组而通过这四方法按需访问。2.2 数据填充方法class HistoryDataSource implements IDataSource { private records: GameRecord[] []; private listeners: DataChangeListener[] []; totalCount(): number { return this.records.length; } getData(index: number): GameRecord { return this.records[index]; } registerDataChangeListener(listener: DataChangeListener): void { this.listeners.push(listener); } unregisterDataChangeListener(listener: DataChangeListener): void { this.listeners this.listeners.filter(l l ! listener); } // 自定义填充数据并通知监听器 reloadData(records: GameRecord[]): void { this.records records; // 通知所有监听器数据刷新 this.listeners.forEach(l l.onDataReloaded()); } // 自定义追加数据 appendData(records: GameRecord[]): void { const startIndex this.records.length; this.records.push(...records); // 通知监听器从 startIndex 开始新增了 records.length 条 this.listeners.forEach(l l.onDatasetChange(startIndex, records.length)); } }实战经验IDataSource 四方法是外自定义 reloadData/appendData 通知监器——数据变时调listener.onDataReloaded()或onDatasetChangeLazyForEach 才会重 diff。三、LazyForEach 按需调用机制3.1 只调可视区 cachedCountList() { LazyForEach(this.dataSource, (record: GameRecord) { ListItem() { RecordItem({ record: record }) } }, (record: GameRecord) record.id) } .cachedCount(5) // 假设 dataSource 总数 1000可视区显示 8 个 // LazyForEach 只对索引 [0, 12] 调 getData可视 8 cached 上 5 // 滚动到索引 50 时只对 [45, 57] 调 getData // 始终不全量遍历 1000 条机制LazyForEach 读totalCount()知道总条数 1000算滚动范围。读可视区 cachedCount 范围的索引调getData(index)取数据。回调(record) ListItem() {...}渲染项。滚动时新进入范围的索引才调 getData离屏的不销毁Reusable 复用池。关键经验LazyForEach 永不全量遍历——只调可视区附近的 getData1000 条也只调 18 次。3.2 与 ForEach 对比维度ForEachLazyForEach数据遍历全量 N 次可视区 cached数据源普通数组IDataSource 接口适合N 20数据简单N ≥ 100数据复杂网络分页❌ 全量卡死✅ 按需拉取数据库❌ 全量查✅ 按需查3.3 LazyForEach Reusable 双剑合璧Reusable Component export struct RecordItem { /* ... */ } List() { LazyForEach(this.dataSource, (record: GameRecord) { ListItem() { RecordItem({ record: record }) } }, (record: GameRecord) record.id) } .cachedCount(5) **协同** - **LazyForEach**按需调 getData省数据遍历。 - **Reusable**离屏 ListItem 入复用池省组件实例。 **关键经验****LazyForEach Reusable 是长列表最优解**——前者省数据遍历后者省组件实例1000 条也流畅。 ## 四、DataChangeListener 监听器 ### 4.1 监听器接口 ts interface DataChangeListener { onDataChange(startIndex: number, endIndex: number): void; // 范围内数据变 onDatasetChange(startIndex: number, appendCount: number): void; // 追加 onDataReloaded(): void; // 全刷新 onDatasetRemoved(startIndex: number, endIndex: number): void; // 范围内删除 }四回调拆解回调触发时机LazyForEach 行为onDataReloaded数据全刷新重新 diff 所有可视区onDatasetChange追加数据渲染新增范围内的项onDataChange范围内数据变重渲染该范围项onDatasetRemoved范围内删除销毁该范围项4.2 实战追加分页数据// 场景滚到底部拉取下一页 class HistoryDataSource implements IDataSource { /* ... 四方法 ... */ private currentPage: number 0; private pageSize: number 20; // 拉取下一页 async loadNextPage(): Promisevoid { this.currentPage; const newRecords await fetch(/api/history?page${this.currentPage}size${this.pageSize}); const startIndex this.records.length; this.records.push(...newRecords); // 通知监听器从 startIndex 追加了 newRecords.length 条 this.listeners.forEach(l l.onDatasetChange(startIndex, newRecords.length)); } } // 滚到底部触发 this.scroller.onScroll((xOffset, yOffset) { if (this.scroller.isAtEnd()) { this.dataSource.loadNextPage(); // 异步拉取并通知 } })关键经验追加数据调onDatasetChange——LazyForEach 收到通知才渲染新增项不然不知道有新数据。4.3 实战全刷新// 场景下拉刷新 async refresh(): Promisevoid { this.currentPage 1; const allRecords await fetch(/api/history?page1size${this.pageSize}); this.records allRecords; // 通知监听器全刷新 this.listeners.forEach(l l.onDataReloaded()); }关键经验全刷新调onDataReloaded——LazyForEach 收到通知重新 diff 所有可视区。五、实战战绩历史改造 LazyForEach5.1 创建 HistoryDataSource新建entry/src/main/ets/components/HistoryDataSource.etsimport { IDataSource, DataChangeListener } from kit.ArkUI; import { GameRecord } from ./GameTypes; export class HistoryDataSource implements IDataSource { private records: GameRecord[] []; private listeners: DataChangeListener[] []; // IDataSource 四方法 totalCount(): number { return this.records.length; } getData(index: number): GameRecord { return this.records[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const index this.listeners.indexOf(listener); if (index 0) { this.listeners.splice(index, 1); } } // 自定义初始加载 reloadData(records: GameRecord[]): void { this.records records; this.listeners.forEach(l l.onDataReloaded()); } // 自定义追加 appendData(records: GameRecord[]): void { const startIndex this.records.length; this.records.push(...records); this.listeners.forEach(l l.onDatasetChange(startIndex, records.length)); } // 自定义删除某条 removeAt(index: number): void { if (index 0 || index this.records.length) return; this.records.splice(index, 1); this.listeners.forEach(l l.onDatasetRemoved(index, index)); } }5.2 改造 HistoryPage 用 LazyForEach修改entry/src/main/ets/pages/HistoryPage.etsimport { GameRecord } from ../components/GameTypes; import { RecordItem } from ../components/RecordItem; import { HistoryDataSource } from ../components/HistoryDataSource; import { Scroller } from kit.ArkUI; Component export struct HistoryPage { private dataSource: HistoryDataSource new HistoryDataSource(); private scroller: Scroller new Scroller(); aboutToAppear() { // 模拟加载 100 条记录到数据源 const records: GameRecord[] []; for (let i 0; i 100; i) { records.push({ id: record_${i}, score: 500 Math.floor(Math.random() * 2000), formatTime: ${Math.floor(Math.random() * 10)}:${Math.floor(Math.random() * 60).toString().padStart(2, 0)}, maxCombo: Math.floor(Math.random() * 10) 1, mergeCount: Math.floor(Math.random() * 50) 10, highestLevel: 1, date: 2026-07-${(20 - Math.floor(i / 10)).toString().padStart(2, 0)} }); } this.dataSource.reloadData(records); // 填充数据源 } build() { Column() { // 标题栏 Row() { Text(战绩历史).fontSize(20).fontWeight(FontWeight.Bold).fontColor(#2C3E50) } .width(100%).height(56) .padding({ left: 16, right: 16 }) .justifyContent(FlexAlign.Start).alignItems(VerticalAlign.Center) // 战绩列表LazyForEach Reusable本篇重点 List({ scroller: this.scroller }) { LazyForEach(this.dataSource, (record: GameRecord) { ListItem() { RecordItem({ record: record }) // Reusable 子组件 } }, (record: GameRecord) record.id) } .width(100%) .layoutWeight(1) .cachedCount(5) // 预渲染 5 个 .divider({ strokeWidth: 1, color: #ECF0F1, startMargin: 16, endMargin: 16 }) } .width(100%).height(100%) .backgroundColor(#FFFFFF) } }5.3 改造对比维度ForEach 版第 47 篇LazyForEach 版本篇数据遍历全量 100 次按需 18 次数据源普通数组State historyIDataSource 实例1000 条性能卡顿全量遍历流畅按需 18 次网络分页❌ 难支持✅ appendData 拉取数据变通知整体赋值自动调 listener 显式适合N 100N ≥ 100六、踩坑提示6.1 忘调 listener 通知// ❌ 错误改了 records 没通知LazyForEach 不刷新 reloadData(records: GameRecord[]): void { this.records records; // 忘了 this.listeners.forEach(l l.onDataReloaded()); } // LazyForEach 不知道数据变还显示旧的 // ✅ 正确改后通知 reloadData(records: GameRecord[]): void { this.records records; this.listeners.forEach(l l.onDataReloaded()); // 通知 }6.2 getData 越界// ❌ 错误不校验越界LazyForEach 传超范围索引崩 getData(index: number): GameRecord { return this.records[index]; // index 超范围时 undefined } // ✅ 正确校验或保证不越界 getData(index: number): GameRecord { if (index 0 || index this.records.length) { return {} as GameRecord; // 兜底返回空对象 } return this.records[index]; } ### 6.3 LazyForEach 用在非 List ts // ❌ 错误Column 内用 LazyForEach无按需逻辑退化为全量 Column() { LazyForEach(this.dataSource, (record) { RecordItem({ record: record }) }, (record) record.id) } // ✅ 正确List 内用 List() { LazyForEach(this.dataSource, (record) { ListItem() { RecordItem({ record: record }) } }, (record) record.id) }.cachedCount(5)6.4 密钥不唯一// ❌ 错误密钥冲突LazyForEach diff 乱 LazyForEach(this.dataSource, (record) { ListItem() { RecordItem({ record: record }) } }, (record) record.score.toString()) // 多条同得分冲突 // ✅ 正确密钥唯一 LazyForEach(this.dataSource, (record) { ListItem() { RecordItem({ record: record }) } }, (record) record.id) // id 唯一 ## 七、调试技巧 1. **console.info 在 getData 内**log index追哪些索引被调用应只有可视区附近。 2. **console.info 在 reloadData**log records.length追数据填充。 3. **不刷新排查**检查是否调了 listener 通知检查 totalCount 是否正确。 4. **DevEco Profiler 看 getData 调用次数**应远少于 totalCount证明按需。 ## 八、性能与最佳实践 1. **N ≥ 100 用 LazyForEach**——按需调 getData不全量遍历。 2. **实现 IDataSource 四方法**——totalCount/getData/register/unregister。 3. **数据变必调 listener 通知**——onDataReloaded/onDatasetChange/onDataChange/onDatasetRemoved。 4. **LazyForEach Reusable 最优解**——前者省数据遍历后者省组件实例。 5. **getData 校验越界**——LazyForEach 可能传超范围索引兜底返回。 6. **网络分页用 appendData onDatasetChange**——滚到底部拉取下一页追加通知。 ## 九、三种列表方案终极对比 | 方案 | 数据遍历 | 组件实例 | 数据源 | 适合 | |------|----------|----------|--------|------| | ForEach | 全量 N 次 | 全量 N 个 | 普通数组 | N 20数据简单 | | Reusable ForEach | 全量 N 次 | 可视区 cached | 普通数组 | N 20-100数据简单 | | **LazyForEach Reusable** | **按需 18 次** | **可视区 cached** | **IDataSource** | **N ≥ 100数据复杂/分页** | **关键经验****N 20 ForEachN 20-100 ReusableForEachN ≥ 100 LazyForEachReusable**——按数据规模选方案。 ## 总结 本篇我们从 LazyForEach 大列表切入掌握了**IDataSource 四方法接口**、**按需调用 getData 机制**、**DataChangeListener 四回调通知**、**与 Reusable 搭配最优解**四大要点并给出了战绩历史改造 LazyForEach 的完整代码。核心要点**LazyForEach 按需调 getData 不全量遍历数据变必调 listener 通知N ≥ 100 搭 Reusable 最优网络分页用 appendData**。 下一篇我们将做 V1 局限总结——V1 与 V2 差异。 如果这篇文章对你有帮助欢迎点赞、收藏⭐、关注你的支持是我持续创作的动力 --- **相关资源** - 「猫猫大作战」项目源码本仓库 entry/src/main/ets/components/、entry/src/main/ets/pages/ - [ArkUI LazyForEach 懒加载官方指南](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-syntax-lazyforeach) - [IDataSource 接口官方文档](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-rendering-control-syntax-lazyforeach) - [DataChangeListener 监听器官方文档](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-rendering-control-syntax-lazyforeach) - [ArkUI 长列表性能最佳实践](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/bpta-list-performance) - [开源鸿蒙跨平台社区](https://openharmonycrossplatform.csdn.net) - [HarmonyOS 开发者官方文档首页](https://developer.huawei.com/consumer/cn/doc/) - 系列索引本仓库 articles/INDEX.md