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


前言
在前三篇中,我们用 LazyForEach + @Reusable + cachedCount 三件套构建了高性能排行榜列表。所有这些能力都建立在 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 SimpleDataSource<T> 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 PagedDataSource<T> 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) => Promise<T[]>): Promise<boolean> {
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) => Promise<T[]>): Promise<void> {
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 PagedDataSource<GameRecord>();
private scroller: Scroller = new Scroller();
aboutToAppear() {
// 首次加载
this.dataSource.loadNextPage(this.fetchRankings.bind(this));
}
// 服务端分页 API 封装
async fetchRankings(page: number, size: number): Promise<GameRecord[]> {
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 SearchableDataSource<T> extends PagedDataSource<T> {
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 SearchableDataSource<GameRecord>(
(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 BaseDataSource<T> implements IDataSource {
protected data: T[] = [];
protected listeners: Set<DataChangeListener> = 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 BaseDataSource<GameRecord> {
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 BaseDataSource<GameRecord>();
// 玩家提交新得分
async submitScore(newScore: number): Promise<void> {
// 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 PagedDataSource<GameRecordV2>();
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 中关键区别:数据源对象本身不需要装饰器,但数据类需用
@ObservedV2+@Trace标记可观察属性,这样更新后 UI 会自动刷新。
六、IDataSource 设计模式总结
6.1 架构图
┌─────────── UI 层 ──────────┐
│ List / Grid / WaterFlow │
│ └── LazyForEach ──┘ │
└──────────┬─────────────────┘
│ IDataSource 契约
┌──────────▼─────────────────┐
│ BaseDataSource<T> │ ← 泛型基类,提供完整 CRUD
│ ├─ PagedDataSource<T> │ ← 分页加载(服务端分页)
│ ├─ SearchableDataSource │ ← 搜索过滤
│ ├─ RealtimeDataSource │ ← WebSocket 实时更新
│ └─ CachedDataSource │ ← 磁盘缓存 + 服务端同步
└────────────────────────────┘
6.2 选型指南
| 场景 | 推荐数据源 | 说明 |
|---|---|---|
| 本地数组(< 100 条) | BaseDataSource |
最简 CRUD |
| 服务端分页列表 | PagedDataSource |
触底自动加载 |
| 需要搜索/过滤 | SearchableDataSource |
客户端过滤 |
| 实时排名/消息 | RealtimeDataSource |
WebSocket 推送 |
| 离线优先+服务端同步 | 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–#46 | @State/@Prop/@Link/@Provide/@Observed/@Watch |
| 状态管理 V2 | #47–#54 | @Local/@Param/@Event/@ObservedV2/@Monitor/AppStorageV2 |
| 动画体系 | #55–#60 | animateTo/属性动画/弹簧曲线/关键帧/共享元素 |
| 网络与数据 | #61–#64 | HTTP/REST/JSON/SQLite |
| 页面生命周期 | #65–#66 | aboutToDisappear / onVisibleAreaChange |
| 列表性能优化 | #67–#70 | @Reusable / LazyForEach / cachedCount / IDataSource |
下一篇预告:第 71 篇开启 阶段三:页面架构 + 导航 + 弹窗,从 EntryAbility 入口生命周期开始,深入 Navigation 路由框架。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
更多推荐


所有评论(0)