文章配图:在前三篇中,我们用  +  +  三

页面预览

前言

在前三篇中,我们用 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 路由框架。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐