HarmonyOS应用开发实战:猫猫大作战-在前三篇中,我们用 `LazyForEach` + `@Reusable` + `cachedCount` 三

发布时间:2026/7/28 1:08:47
HarmonyOS应用开发实战:猫猫大作战-在前三篇中,我们用 `LazyForEach` + `@Reusable` + `cachedCount` 三 前言在前三篇中我们用LazyForEachReusablecachedCount三件套构建了高性能排行榜列表。所有这些能力都建立在IDataSource接口之上——它是LazyForEach的数据供给契约。但是多数教程只展示了最简单的数组包装一旦涉及分页加载、服务端同步、数据合并、增量更新等真实业务场景基础实现就不够用了。本文以「猫猫大作战」排行榜数据源为锚点从零实现一个企业级IDataSource——支持泛型、分页加载、服务端同步拉取、增量插入、搜索过滤、远程数据合并等高级能力。这也是阶段二的收官之作。提示本系列不讲 ArkTS 基础语法与环境搭建假设你已跟完第 1–69 篇。本篇是阶段二第 70 篇。一、IDataSource 基础回顾1.1 接口定义// kit.ArkUI 中的接口定义 interface IDataSource { totalCount(): number; getData(index: number): Object; registerDataChangeListener(listener: DataChangeListener): void; unregisterDataChangeListener(listener: DataChangeListener): void; } interface DataChangeListener { onDataReloaded(): void; onDataAdd(index: number): void; onDataMove(from: number, to: number): void; onDataDelete(index: number): void; onDataChange(index: number): void; }1.2 最简实现class SimpleDataSourceT implements IDataSource { private data: T[] []; private listeners: DataChangeListener[] []; constructor(data: T[]) { this.data data; } totalCount(): number { return this.data.length; } getData(index: number): T { return this.data[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (!this.listeners.includes(listener)) this.listeners.push(listener); } unregisterDataChangeListener(listener: DataChangeListener): void { const idx this.listeners.indexOf(listener); if (idx 0) this.listeners.splice(idx, 1); } // 增量方法 push(item: T): void { this.data.push(item); this.listeners.forEach(l l.onDataAdd(this.data.length - 1)); } removeAt(index: number): void { this.data.splice(index, 1); this.listeners.forEach(l l.onDataDelete(index)); } updateAt(index: number, item: T): void { this.data[index] item; this.listeners.forEach(l l.onDataChange(index)); } reload(data: T[]): void { this.data data; this.listeners.forEach(l l.onDataReloaded()); } }但实际项目中这个简单实现远远不够。下面我们从真实需求出发逐步增强它。二、项目实战排行榜数据源增强2.1 基础数据模型// types/GameRecord.ets export class GameRecord { id: number; rank: number; playerName: string; score: number; avatar: string; mergeCount: number; maxCombo: number; gameDate: string; constructor(id: number, rank: number, name: string, score: number, avatar: string, merge: number, combo: number, date: string) { this.id id; this.rank rank; this.playerName name; this.score score; this.avatar avatar; this.mergeCount merge; this.maxCombo combo; this.gameDate date; } }2.2 阶段一泛型数据源 分页加载真实场景下排行榜数据往往来自服务端分页 API不能一次性加载所有数据// datasource/PagedDataSource.ets import { IDataSource, DataChangeListener } from kit.ArkUI; import { hilog } from kit.PerformanceAnalysisKit; const TAG PagedDataSource; const DOMAIN 0xFF00; export class PagedDataSourceT implements IDataSource { protected data: T[] []; protected listeners: DataChangeListener[] []; private currentPage: number 0; private pageSize: number 20; private hasMore: boolean true; private isLoading: boolean false; totalCount(): number { return this.data.length; } getData(index: number): T { return this.data[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (!this.listeners.includes(listener)) this.listeners.push(listener); } unregisterDataChangeListener(listener: DataChangeListener): void { const idx this.listeners.indexOf(listener); if (idx 0) this.listeners.splice(idx, 1); } // 加载下一页由外部调用列表触底时触发 async loadNextPage(fetcher: (page: number, size: number) PromiseT[]): Promiseboolean { if (this.isLoading || !this.hasMore) return false; this.isLoading true; const nextPage this.currentPage 1; try { const newItems await fetcher(nextPage, this.pageSize); if (newItems.length 0) { this.hasMore false; return false; } const startIndex this.data.length; this.data.push(...newItems); this.currentPage nextPage; // 增量通知逐条通知 LazyForEach for (let i 0; i newItems.length; i) { this.listeners.forEach(l l.onDataAdd(startIndex i)); } hilog.info(DOMAIN, TAG, 加载第 ${nextPage} 页成功新增 ${newItems.length} 条); // 通知数据已变更适用于已经可见的项可能需要刷新 this.notifyDataChanged(); return true; } catch (err) { hilog.error(DOMAIN, TAG, 加载失败: ${JSON.stringify(err)}); return false; } finally { this.isLoading false; } } // 刷新下拉重新加载第一页 async refresh(fetcher: (page: number, size: number) PromiseT[]): Promisevoid { this.currentPage 0; this.hasMore true; const newItems await fetcher(1, this.pageSize); this.data newItems; this.currentPage 1; // 增量通知替代全量 reload避免组件重建闪烁 this.listeners.forEach(l l.onDataReloaded()); hilog.info(DOMAIN, TAG, 刷新完成共 ${this.data.length} 条); } // 是否还有更多数据 getHasMore(): boolean { return this.hasMore; } // 当前是否正在加载 getIsLoading(): boolean { return this.isLoading; } // 提供给子类覆盖的通知方法 protected notifyDataChanged(): void { /* 子类可按需实现 */ } }使用方式Entry Component struct LeaderboardPage { private dataSource new PagedDataSourceGameRecord(); private scroller: Scroller new Scroller(); aboutToAppear() { // 首次加载 this.dataSource.loadNextPage(this.fetchRankings.bind(this)); } // 服务端分页 API 封装 async fetchRankings(page: number, size: number): PromiseGameRecord[] { const resp await HttpRankingApi.getRankings(page, size); return resp.data.map((item, idx) new GameRecord( item.id, (page - 1) * size idx 1, item.playerName, item.score, item.avatar, item.mergeCount, item.maxCombo, item.gameDate )); } build() { Column() { List({ scroller: this.scroller }) { LazyForEach(this.dataSource, (item: GameRecord) { ListItem() { RecordCard({ record: item }) } }, (item: GameRecord) item.id.toString()) } .cachedCount(5) .width(100%) .layoutWeight(1) .onReachEnd(() { // 触底自动加载更多 this.dataSource.loadNextPage(this.fetchRankings.bind(this)); }) } .height(100%) } }2.3 阶段二搜索过滤数据源在排行榜基础上增加按玩家名搜索的能力// datasource/SearchableDataSource.ets export class SearchableDataSourceT extends PagedDataSourceT { private allData: T[] []; // 全量数据缓存 private keyword: string ; private filterFn: (item: T, keyword: string) boolean; constructor(filterFn: (item: T, keyword: string) boolean) { super(); this.filterFn filterFn; } // 注入全量数据实际项目中可能是从本地数据库加载 setAllData(data: T[]): void { this.allData data; this.applyFilter(); } // 设置搜索关键字 search(keyword: string): void { this.keyword keyword.trim().toLowerCase(); this.applyFilter(); } // 清空搜索 clearSearch(): void { this.keyword ; this.applyFilter(); } private applyFilter(): void { if (!this.keyword) { this.data [...this.allData]; } else { this.data this.allData.filter(item this.filterFn(item, this.keyword)); } this.listeners.forEach(l l.onDataReloaded()); } }使用方式Component struct SearchableRanking { private dataSource new SearchableDataSourceGameRecord( (item, keyword) item.playerName.toLowerCase().includes(keyword) ); build() { Column() { // 搜索框 TextInput({ placeholder: 搜索玩家名称... }) .onChange((value) { this.dataSource.search(value); }) .padding(8) .margin(8) List() { LazyForEach(this.dataSource, (item: GameRecord) { ListItem() { RecordCard({ record: item }) } }, (item: GameRecord) item.id.toString()) } .cachedCount(5) .width(100%) .layoutWeight(1) } .height(100%) } }三、通用泛型数据源基类综合前面的经验构建一个企业级生产可用的泛型数据源基类// datasource/BaseDataSource.ets import { IDataSource, DataChangeListener } from kit.ArkUI; import { hilog } from kit.PerformanceAnalysisKit; const TAG BaseDataSource; const DOMAIN 0xFF00; export class BaseDataSourceT implements IDataSource { protected data: T[] []; protected listeners: SetDataChangeListener new Set(); private lockReload: boolean false; // ---- IDataSource 接口实现 ---- totalCount(): number { return this.data.length; } getData(index: number): T { return this.data[index]; } registerDataChangeListener(listener: DataChangeListener): void { this.listeners.add(listener); } unregisterDataChangeListener(listener: DataChangeListener): void { this.listeners.delete(listener); } // ---- 批量变更方法 ---- // 全量替换数据 setData(newData: T[]): void { this.data newData; this.listeners.forEach(l l.onDataReloaded()); } // 追加多条 appendItems(items: T[]): void { const startIdx this.data.length; this.data.push(...items); for (let i 0; i items.length; i) { this.listeners.forEach(l l.onDataAdd(startIdx i)); } } // ---- 单条变更方法 ---- // 新增 add(item: T): number { const idx this.data.length; this.data.push(item); this.listeners.forEach(l l.onDataAdd(idx)); return idx; } // 插入到指定位置 insert(index: number, item: T): void { if (index 0 || index this.data.length) return; this.data.splice(index, 0, item); this.listeners.forEach(l l.onDataAdd(index)); } // 删除 remove(index: number): void { if (index 0 || index this.data.length) return; this.data.splice(index, 1); this.listeners.forEach(l l.onDataDelete(index)); } // 移除符合条件的第一个 removeFirst(predicate: (item: T) boolean): boolean { const idx this.data.findIndex(predicate); if (idx 0) { this.remove(idx); return true; } return false; } // 更新 update(index: number, item: T): void { if (index 0 || index this.data.length) return; this.data[index] item; this.listeners.forEach(l l.onDataChange(index)); } // 有条件地更新所有匹配项 updateAll(predicate: (item: T) boolean, updater: (item: T) T): void { for (let i 0; i this.data.length; i) { if (predicate(this.data[i])) { this.data[i] updater(this.data[i]); this.listeners.forEach(l l.onDataChange(i)); } } } // 移动 move(from: number, to: number): void { if (from to) return; const [item] this.data.splice(from, 1); this.data.splice(to, 0, item); this.listeners.forEach(l l.onDataMove(from, to)); } // 清空 clear(): void { this.data []; this.listeners.forEach(l l.onDataReloaded()); } // ---- 查询方法 ---- find(predicate: (item: T) boolean): T | undefined { return this.data.find(predicate); } findIndex(predicate: (item: T) boolean): number { return this.data.findIndex(predicate); } getAll(): readonly T[] { return this.data; } toArray(): T[] { return [...this.data]; } }变更方法对照表方法操作通知类型性能setData()全量替换onDataReloaded()⚠️ 重建所有可见组件appendItems()追加多条onDataAdd()× N✅ 仅新建组件add()单条追加onDataAdd()✅ 仅增一个insert()指定位置插入onDataAdd()✅ 仅增一个remove()删除onDataDelete()✅ 仅删一个update()修改onDataChange()✅ 仅刷新一个move()移动位置onDataMove()✅ 仅调整两个clear()清空onDataReloaded()✅ 移除所有子组件四、远程数据同步场景4.1 WebSocket 实时更新当排行榜需要在游戏进行中实时广播排名变化时// datasource/RealtimeDataSource.ets export class RealtimeDataSource extends BaseDataSourceGameRecord { private ws: WebSocket | null null; private reconnectTimer: number -1; // 连接 WebSocket 服务 connect(url: string): void { this.ws new WebSocket(url); this.ws.onopen () { hilog.info(DOMAIN, TAG, WebSocket 已连接); this.reconnectTimer -1; }; this.ws.onmessage (event: WebSocketMessageEvent) { const message JSON.parse(event.data as string); this.handleServerMessage(message); }; this.ws.onclose () { hilog.warn(DOMAIN, TAG, WebSocket 已断开5s 后重连); this.reconnectTimer setTimeout(() { this.connect(url); }, 5000); }; } private handleServerMessage(msg: { type: string; payload: any }): void { switch (msg.type) { case ranking_update: // 单条得分更新 const idx this.findIndex(r r.id msg.payload.id); if (idx 0) { const oldRank this.data[idx].rank; this.data[idx].score msg.payload.newScore; // 重新排序 this.data.sort((a, b) b.score - a.score); this.data.forEach((r, i) r.rank i 1); // 通知全量刷新因为排名顺序变化了 this.listeners.forEach(l l.onDataReloaded()); } break; case new_record: // 新纪录插入 const record new GameRecord( msg.payload.id, 0, msg.payload.playerName, msg.payload.score, msg.payload.avatar, msg.payload.mergeCount, msg.payload.maxCombo, msg.payload.gameDate ); this.data.push(record); this.data.sort((a, b) b.score - a.score); this.data.forEach((r, i) r.rank i 1); this.listeners.forEach(l l.onDataReloaded()); break; case ranking_full: // 全量排行榜数据 const records msg.payload.map((item: any) new GameRecord( item.id, item.rank, item.playerName, item.score, item.avatar, item.mergeCount, item.maxCombo, item.gameDate )); this.setData(records); break; } } disconnect(): void { this.ws?.close(); if (this.reconnectTimer 0) { clearTimeout(this.reconnectTimer); } } }4.2 数据合并策略当本地数据与服务端数据冲突时需要合并策略// 乐观更新 服务端确认 Entry Component struct LeaderboardPage { private dataSource new BaseDataSourceGameRecord(); // 玩家提交新得分 async submitScore(newScore: number): Promisevoid { // 1. 乐观更新立即在 UI 中更新 const playerId getPlayerId(); const idx this.dataSource.findIndex(r r.id playerId); if (idx 0) { const oldRecord this.dataSource.getData(idx); oldRecord.score newScore; this.dataSource.update(idx, oldRecord); } // 2. 发起服务端请求 try { const serverResult await RankingApi.submitScore(playerId, newScore); // 3. 服务端确认后用真实数据替换 const serverIdx this.dataSource.findIndex(r r.id serverResult.id); if (serverIdx 0) { this.dataSource.update(serverIdx, serverResult); } } catch (err) { // 4. 服务端失败 → 回滚 const rollbackIdx this.dataSource.findIndex(r r.id playerId); if (rollbackIdx 0) { const rollback await RankingApi.getPlayerRecord(playerId); this.dataSource.update(rollbackIdx, rollback); } } } }五、在 V2 状态管理中使用V2 模式下IDataSource同样适用ObservedV2 class GameRecordV2 { Trace id: number 0; Trace rank: number 0; Trace playerName: string ; Trace score: number 0; Trace avatar: string ; Trace mergeCount: number 0; Trace maxCombo: number 0; Trace gameDate: string ; } ComponentV2 struct LeaderboardPageV2 { private dataSource new PagedDataSourceGameRecordV2(); build() { Column() { List({ space: 8 }) { LazyForEach(this.dataSource, (item: GameRecordV2) { ListItem() { RecordCardV2({ record: item }) } }, (item: GameRecordV2) item.id.toString()) } .cachedCount(5) .width(100%) .layoutWeight(1) .onReachEnd(() { this.dataSource.loadNextPage(this.fetchPage.bind(this)); }) } .height(100%) } }V2 中关键区别数据源对象本身不需要装饰器但数据类需用ObservedV2Trace标记可观察属性这样更新后 UI 会自动刷新。六、IDataSource 设计模式总结6.1 架构图┌─────────── UI 层 ──────────┐ │ List / Grid / WaterFlow │ │ └── LazyForEach ──┘ │ └──────────┬─────────────────┘ │ IDataSource 契约 ┌──────────▼─────────────────┐ │ BaseDataSourceT │ ← 泛型基类提供完整 CRUD │ ├─ PagedDataSourceT │ ← 分页加载服务端分页 │ ├─ SearchableDataSource │ ← 搜索过滤 │ ├─ RealtimeDataSource │ ← WebSocket 实时更新 │ └─ CachedDataSource │ ← 磁盘缓存 服务端同步 └────────────────────────────┘6.2 选型指南场景推荐数据源说明本地数组 100 条BaseDataSource最简 CRUD服务端分页列表PagedDataSource触底自动加载需要搜索/过滤SearchableDataSource客户端过滤实时排名/消息RealtimeDataSourceWebSocket 推送离线优先服务端同步CachedDataSource本地存储 后台同步七、常见踩坑7.1 坑一通知错位// 错误先删除索引 2再删除索引 5 this.dataSource.remove(2); this.dataSource.remove(5); // ❌ 此时索引 5 已经变成原来的索引 6 // ✅ 正确从后往前删 this.dataSource.remove(5); this.dataSource.remove(2); // ✅ 从后往前索引不会偏移7.2 坑二register 和 unregister 不平衡每个LazyForEach使用数据源时都会register组件销毁时unregister。如果在页面退出后仍然调用数据源变更方法aboutToDisappear() { // 如果不清除引用后台 WebSocket 回调可能会操作已无监听器的数据源 this.dataSource.disconnect?.(); // ✅ 断开 WebSocket // 监听器由框架在 LazyForEach 销毁时自动 unregister }7.3 坑三频繁 onDataReloaded 导致闪烁// 错误每次小更新都调用 onDataReloaded this.data.sort((a, b) b.score - a.score); this.dataSource.setData(this.data); // ❌ onDataReloaded 重建所有可见组件闪烁 // ✅ 正确用 onDataMove onDataChange 精细更新 const fromIdx this.dataSource.findIndex(r r.id movedId); // ... 计算新位置 ... this.dataSource.move(fromIdx, toIdx); // ✅ 仅调整两个组件位置八、性能建议操作通知方式推荐场景增加 1 项onDataAdd新玩家加入批量增加循环onDataAdd分页加载下一页删除 1 项onDataDelete删除误录修改 1 项onDataChange更新得分移动 1 项onDataMove排名变化未跨页全量变更onDataReloaded下拉刷新 / 搜索不得已时优先使用增量通知onDataReloaded应尽量少用。九、阶段二总结#31–#70 回顾40 篇文章覆盖了状态管理、交互事件、动画体系、页面生命周期、列表性能优化五大子领域子专题文章范围核心技能状态管理 V1#31–#46State/Prop/Link/Provide/Observed/Watch状态管理 V2#47–#54Local/Param/Event/ObservedV2/Monitor/AppStorageV2动画体系#55–#60animateTo/属性动画/弹簧曲线/关键帧/共享元素网络与数据#61–#64HTTP/REST/JSON/SQLite页面生命周期#65–#66aboutToDisappear / onVisibleAreaChange列表性能优化#67–#70Reusable / LazyForEach / cachedCount / IDataSource下一篇预告第 71 篇开启阶段三页面架构 导航 弹窗从 EntryAbility 入口生命周期开始深入 Navigation 路由框架。如果这篇文章对你有帮助欢迎点赞、收藏⭐、关注你的支持是我持续创作的动力相关资源HarmonyOS LazyForEach 官方文档IDataSource API 参考懒加载优化最佳实践组件复用最佳实践开源鸿蒙跨平台社区第 67 篇Reusable 组件复用第 68 篇LazyForEach 按需渲染第 69 篇cachedCount 预加载