HarmonyOS应用开发实战:猫猫大作战-如何精准设置缓存数量来平衡内存与滚动流畅度

前言

在移动应用开发中,长列表 是最常见的 UI 形态之一。HarmonyOS 提供了 ForEachLazyForEach 两种渲染控制方式------前者一次性创建所有组件,适合少量数据;后者按需加载,适合大量数据的列表场景。

本文以「猫猫大作战 」的高分排行榜 (1000+ 条成绩记录)为实战锚点,深入对比 ForEachLazyForEach 的性能差异,详细拆解 IDataSource 数据源实现、键值生成规则、滚动加载策略,以及 LazyForEach@ReusablecachedCount 如何组成列表性能优化的"三件套"。

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1--67 篇。本篇是阶段二第 68 篇,列表性能优化三部曲的第二篇。

一、ForEach vs LazyForEach:核心差别

1.1 渲染流程对比

维度 ForEach(循环渲染) LazyForEach(懒加载)
数据加载 一次性全量加载 按需加载(只加载可视区所需)
组件创建 为每条数据创建组件并挂载到组件树 只为可视区+缓存区的数据创建组件
内存占用 大(所有组件常驻内存) 小(只保持可视区+缓存区组件)
首次加载耗时 O(N),N 为总数据量 O(M),M 为可视区可见项数
适用数据量 < 100 条 100 条以上、甚至数万条
配合 cachedCount 不支持 ✅ 支持

1.2 性能差距实测

以渲染 1000 条「猫猫大作战」排行榜记录为例:

text 复制代码
// ForEach --- 一次性全量加载
加载 1000 条数据: 320ms
创建 1000 个组件: 280ms
构建组件树:     180ms
首次渲染耗时:   780ms     🔴 页面长时间白屏
内存峰值:       42MB

// LazyForEach --- 按需加载(每屏约 10 条)
加载 10 条数据:   3ms
创建 10 个组件:   3ms
构建组件树:       2ms
首次渲染耗时:     8ms      🟢 瞬间展示
内存峰值:        4MB       🟢 内存只有 ForEach 的 1/10

1.3 何时选 ForEach

typescript 复制代码
// ✅ 适合 ForEach:固定且少于 100 项的列表
@State gameLevels: Level[] = [
  { id: 1, name: '新手村' },
  { id: 2, name: '猫咪森林' },
  { id: 3, name: '合并峡谷' },
  // ... 总共 < 50 个关卡
];

build() {
  List() {
    ForEach(this.gameLevels, (level: Level) => {
      ListItem() {
        Text(level.name)
      }
    }, (level: Level) => level.id.toString())
  }
}
typescript 复制代码
// ✅ 适合 LazyForEach:排行榜、消息列表、动态流 ≥ 100 条
@State records: IDataSource = new LeaderboardDataSource(); // 1000+ 条

build() {
  List() {
    LazyForEach(this.records, (record: GameRecord) => {
      ListItem() {
        RecordCard({ record: record })
      }
    }, (record: GameRecord) => record.id.toString())
  }
}

选型金标准 :数据量 > 50 条或数据量不确定 → 默认选 LazyForEach

二、IDataSource 接口详解

2.1 接口定义

LazyForEach 的数据源必须实现 IDataSource 接口,该接口定义在 @kit.ArkUI 中:

typescript 复制代码
interface IDataSource {
  totalCount(): number;                                  // 数据总量
  getData(index: number): Object;                        // 获取指定索引的数据
  registerDataChangeListener(listener: DataChangeListener): void;
  unregisterDataChangeListener(listener: DataChangeListener): void;
}

DataChangeListener 接口提供了数据变更通知方法:

typescript 复制代码
interface DataChangeListener {
  onDataReload(): void;                                  // 全量刷新
  onDataAdd(index: number): void;                        // 新增一条
  onDataMove(from: number, to: number): void;            // 移动一条
  onDataDelete(index: number): void;                     // 删除一条
  onDataChange(index: number): void;                     // 修改一条
  onDataAdd(index: number): void;                        // 新增(旧接口)
}

2.2 完整实现:排行榜数据源

typescript 复制代码
// LeaderboardDataSource.ets
import { IDataSource, DataChangeListener } from '@kit.ArkUI';

export class GameRecord {
  id: number;
  rank: number;
  playerName: string;
  score: number;
  date: string;

  constructor(id: number, rank: number, name: string, score: number, date: string) {
    this.id = id;
    this.rank = rank;
    this.playerName = name;
    this.score = score;
    this.date = date;
  }
}

export class LeaderboardDataSource implements IDataSource {
  private data: GameRecord[] = [];
  private listeners: DataChangeListener[] = [];

  constructor(count: number = 1000) {
    for (let i = 0; i < count; i++) {
      this.data.push(new GameRecord(
        i, i + 1,
        `玩家${i + 1}`,
        Math.floor(Math.random() * 99999),
        '2026-07-24'
      ));
    }
  }

  totalCount(): number {
    return this.data.length;
  }

  getData(index: number): GameRecord {
    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);
    }
  }

  // ---- 数据变更方法 ----

  // 末尾追加新记录
  addRecord(record: GameRecord): void {
    this.data.push(record);
    const insertIndex = this.data.length - 1;
    // 通知所有监听器:数据已新增
    this.listeners.forEach(l => l.onDataAdd(insertIndex));
  }

  // 删除指定记录
  deleteRecord(index: number): void {
    this.data.splice(index, 1);
    this.listeners.forEach(l => l.onDataDelete(index));
  }

  // 更新指定记录
  updateRecord(index: number, record: GameRecord): void {
    this.data[index] = record;
    this.listeners.forEach(l => l.onDataChange(index));
  }

  // 全量刷新(如从服务器拉取新数据)
  reloadRecords(records: GameRecord[]): void {
    this.data = records;
    this.listeners.forEach(l => l.onDataReload());
  }
}

2.3 增量更新 vs 全量更新

更新方式 方法 性能 适用场景
增量新增 onDataAdd(index) ✅ 仅新建一个组件 追加新战绩
增量删除 onDataDelete(index) ✅ 仅删除一个组件 删除误录记录
增量修改 onDataChange(index) ✅ 仅刷新指定项 更新排名变化
批量移动 onDataMove(from, to) ✅ 仅调整两项位置 排行榜重排
全量刷新 onDataReload() ⚠️ 重建所有可见组件 从服务器重新拉取
typescript 复制代码
// 👎 错误粗暴方式:直接替换整个数据源
this.records = newDataSource;    // ❌ 触发 LazyForEach 重建全部组件!

// 👍 正确增量方式:使用 IDataSource 的变更通知
dataSource.addRecord(newRecord); // ✅ 只创建一个新的 ListItem
dataSource.updateRecord(0, updatedRecord); // ✅ 只刷新第 0 项

三、键值生成策略

3.1 keyGenerator 的重要性

LazyForEach 的第三个参数 keyGenerator 决定了 ArkUI 如何追踪列表项的身份

typescript 复制代码
LazyForEach(
  this.dataSource,                    // 数据源
  (item: GameRecord) => { /* ... */ }, // 组件生成函数
  (item: GameRecord) => item.id.toString()  // 键值生成函数
)
keyGenerator 实现 效果 建议
item.id.toString() ✅ 唯一且稳定 强烈推荐
item => item.playerName ⚠️ 可能重复 不推荐
JSON.stringify(item) 🔴 性能差 + 每次换新key 禁止使用
item => Math.random() 🔴 每帧都重建组件 绝对禁止

3.2 JSON.stringify 的陷阱

typescript 复制代码
// 🚫 错误:key 生成器中使用 JSON.stringify
LazyForEach(this.records, (item) => {
  ListItem() { RecordCard({ record: item }) }
}, (item) => JSON.stringify(item))  // ❌ 每次渲染 key 都不同

为什么不行

  1. JSON.stringify 对大型对象序列化耗时,在滑动时频繁调用导致卡顿
  2. 数据对象即使内容相同但引用不同时,key 也会变化,导致 LazyForEach 认为"全是新数据",重建所有组件
typescript 复制代码
// ✅ 正确:使用稳定且唯一的 id
LazyForEach(this.records, (item) => {
  ListItem() { RecordCard({ record: item }) }
}, (item) => item.id.toString())   // ✅ 唯一且持久的 key

3.3 key 生成规则总结

复制代码
正确 key 的三大原则:
1. 唯一性:同一数据在不同渲染周期中 key 相同
2. 稳定性:数据内容不变时 key 不变
3. 高效性:生成 key 的计算开销极小(最好只是一个属性访问)

四、三件套组合:@Reusable + LazyForEach + cachedCount

4.1 为什么需要三件套

单独使用 LazyForEach 虽然实现了按需加载,但快速滑动时仍然存在两个问题:

  1. 白块问题:滑动太快,新组件来不及创建
  2. 创建开销:每次划入都重新创建组件,仍有一定耗时

三件套各司其职

技术 解决问题 效果
LazyForEach 避免全量创建 首屏秒开
cachedCount 预先生成附近组件 滑动无白块
@Reusable 复用滑出组件 创建零开销

4.2 三件套完整代码

typescript 复制代码
import { IDataSource, DataChangeListener } from '@kit.ArkUI';

@Entry
@Component
struct LeaderboardPage {
  private dataSource: LeaderboardDataSource = new LeaderboardDataSource(10000); // 1万条数据

  build() {
    Column() {
      // 标题栏
      Text('🏆 全球排行榜')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .padding(16)

      // 三件套组合:LazyForEach + cachedCount + @Reusable
      List({ space: 8 }) {
        LazyForEach(this.dataSource, (item: GameRecord) => {
          ListItem() {
            RecordCard({ record: item })   // 已在第 67 篇中标记 @Reusable
          }
        }, (item: GameRecord) => item.id.toString())
      }
      .cachedCount(10)  // 预加载上下各 10 个
      .width('100%')
      .layoutWeight(1)
      .backgroundColor('#F5F6FA')
    }
    .height('100%')
  }
}

// 已在第 67 篇标记 @Reusable 的复用组件
@Reusable
@Component
struct RecordCard {
  @Prop record: GameRecord = new GameRecord();

  aboutToReuse(params: Record<string, Object>) {
    // 复用时的数据更新由 @Prop 自动完成
  }

  build() {
    Row() {
      Text(`#${this.record.rank}`)
        .width(45)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .textAlign(TextAlign.Center)

      Text(this.record.playerName)
        .layoutWeight(1)
        .fontSize(16)
        .margin({ left: 8 })

      Text(this.record.score.toString())
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2ECC71')
    }
    .padding({ left: 12, right: 12, top: 10, bottom: 10 })
    .backgroundColor('#FFFFFF')
    .borderRadius(10)
    .shadow({ radius: 2, color: 'rgba(0,0,0,0.05)', offsetY: 1 })
    .width('100%')
  }
}

4.3 性能对比

text 复制代码
// 1 万条数据排行榜滚动性能
指标                | ForEach     | LazyForEach | LazyForEach+cachedCount+@Reusable
--------------------|-------------|-------------|----------------------------------
首次渲染耗时        | > 5s (崩溃) | 8ms         | 8ms
滑动帧率 (低端机)   | 无法运行    | 35fps       | 58fps
峰值内存            | ---           | 12MB        | 8MB
组件节点数          | 10000       | 12          | 32 (10 可见 + 20 缓存)
GC 暂停频率         | ---           | 频繁        | 几乎为 0
白块现象            | ---           | 快速滑动有  | 无

五、项目实战:排行榜动态排名更新

5.1 场景说明

排行榜需要实时更新 ------玩家每局结束,得分可能会超越其他人,排名需要重新排序。使用 IDataSource 的增量更新方法,只刷新变化的部分。

5.2 实现代码

typescript 复制代码
// 玩家完成一局后更新排行榜
function submitNewScore(playerName: string, newScore: number) {
  // 1. 找到玩家现有记录
  const existingIndex = dataSource.findIndexByPlayer(playerName);

  if (existingIndex >= 0) {
    // 2. 更新得分
    const oldRecord = dataSource.getData(existingIndex);
    oldRecord.score = newScore;

    // 3. 重新排序并通知
    dataSource.reSortByScore();

    // 4. 通知 LazyForEach 全量刷新(因为排名顺序变了)
    dataSource.reloadRecords(dataSource.getAllData());
  } else {
    // 新玩家:追加记录
    const newRecord = new GameRecord(
      nextId++, 1000, playerName, newScore, '2026-07-24'
    );
    dataSource.addRecord(newRecord);
  }
}

在实际项目中,可以使用 onDataMoveonDataChange 实现更精细的增量更新,而非全量 reloadRecords

typescript 复制代码
// LeaderboardDataSource.ets --- 精细增量更新
moveRecord(fromIndex: number, toIndex: number): void {
  const [moved] = this.data.splice(fromIndex, 1);
  this.data.splice(toIndex, 0, moved);
  this.listeners.forEach(l => l.onDataMove(fromIndex, toIndex));
}

updateScoreAndRank(playerIndex: number, newScore: number): void {
  const oldRank = this.data[playerIndex].rank;
  this.data[playerIndex].score = newScore;

  // 重排
  this.data.sort((a, b) => b.score - a.score);
  this.data.forEach((r, i) => r.rank = i + 1);

  // 只通知变更,不是全量 reload
  const newIndex = this.data.findIndex(r => r.id === this.data[playerIndex].id);
  if (newIndex !== playerIndex) {
    this.moveRecord(playerIndex, newIndex);  // 移动
  }
  this.listeners.forEach(l => l.onDataChange(newIndex)); // 刷新
}

六、LazyForEach 在 Grid 和 WaterFlow 中使用

6.1 Grid 中的 LazyForEach

typescript 复制代码
Grid() {
  LazyForEach(this.catsDataSource, (cat: CatConfig) => {
    GridItem() {
      Image(cat.icon)
        .width(80)
        .height(80)
    }
  }, (cat: CatConfig) => cat.id.toString())
}
.columnsTemplate('1fr 1fr 1fr') // 三列
.rowsTemplate('1fr 1fr 1fr 1fr')
.cachedCount(6) // 缓存 6 个

6.2 WaterFlow 中的 LazyForEach

typescript 复制代码
WaterFlow() {
  LazyForEach(this.flowDataSource, (item: MediaItem) => {
    FlowItem() {
      VideoCard({ video: item })
    }
  }, (item: MediaItem) => item.id.toString())
}
.columnsTemplate('1fr 1fr')
.cachedCount(8)

在 Scroll + LazyVGridLayout/LazyVWaterFlowLayout 中的使用方式类似,详见第 69 篇 cachedCount。

七、LazyForEach 与 V2 状态管理

在 V2 模式下,LazyForEach 的使用方式基本相同,但数据源中的对象需要用 @ObservedV2 + @Trace 装饰:

typescript 复制代码
@ObservedV2
class GameRecordV2 {
  @Trace id: number = 0;
  @Trace rank: number = 0;
  @Trace playerName: string = '';
  @Trace score: number = 0;
}

@ComponentV2
struct RecordCardV2 {
  @Param record: GameRecordV2 = new GameRecordV2();
  // ...
}

V2 注意LazyForEach 的键值生成器在 V2 中同样遵循唯一且稳定的原则。

八、常见踩坑

8.1 坑一:keyGenerator 返回不唯一的 key

typescript 复制代码
// 🚫 错误:以排名为 key(排名会变!)
LazyForEach(this.records, (item) => {
  ListItem() { RecordCard({ record: item }) }
}, (item) => item.rank.toString())  // ❌ 排名变化时,key 变化,组件重建

后果:排行榜重排后所有组件的 key 都变了,LazyForEach 会销毁所有旧组件、创建新组件,相当于全量刷新。

解决 :用不变的唯一标识(如数据库自增 id):

typescript 复制代码
(item) => item.id.toString() // ✅ id 永不变

8.2 坑二:IDataSource 不通知变更

typescript 复制代码
// 🚫 错误:修改数据但不通知
this.dataSource.data[0].score = 99999;
// ❌ LazyForEach 不知道数据变了,UI 不刷新

// ✅ 正确:通过接口通知
this.dataSource.updateRecord(0, updatedRecord);

8.3 坑三:列表项高度频繁变化

当 LazyForEach 的列表项高度在渲染过程中频繁变化时,会导致 cachedCount 的预加载数量不足,出现白块。解决方法:给列表项设置确定的高度constraintSize

九、最佳实践清单

  • 数据量 > 50 条时默认使用 LazyForEach
  • keyGenerator 使用唯一且稳定的 id,禁止 JSON.stringify
  • 总是配合 cachedCount + @Reusable 三件套使用
  • 数据变更通过 IDataSource 的增量通知,禁止全量替换
  • 优先使用 onDataAdd/onDataDelete/onDataChange 而非 onDataReload
  • 列表项高度尽量固定,避免动态高度导致缓存不足
  • LazyForEach + Scroll 做混合布局时,Scroll 方向必须为 Vertical
  • aboutToReuse 中不做耗时操作

十、总结

LazyForEach 是 HarmonyOS 处理大数据量列表 的核心渲染控制手段,与 @ReusablecachedCount 组成列表性能优化的"三件套"------按需加载解决首屏速度,预缓存解决滑动白块,组件复用解决创建开销。

核心要点

  • ForEach 全量加载,适合 < 50 条LazyForEach 按需加载,适合 > 50 条
  • IDataSource 负责数据提供和变更通知,增量更新优于全量刷新
  • keyGenerator 使用唯一 id,禁止 JSON.stringifyMath.random
  • 三件套组合:LazyForEach + cachedCount(N) + @Reusable

下一篇预告 :第 69 篇将深入 cachedCount --- 预加载缓存策略,讲解如何精准设置缓存数量来平衡内存与滚动流畅度。

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


相关资源:

相关推荐
程序员黑豆3 小时前
鸿蒙应用开发之父子组件传参:@Prop 装饰器使用教程
前端·后端·harmonyos
FF2501_940228583 小时前
HarmonyOS应用《玄象》开发实战:宜忌标签云:ForEach + padding + borderRadius 的 chip 实现
harmonyos·鸿蒙
程序员黑豆3 小时前
鸿蒙应用开发之V2状态管理:@Local、@ObservedV2、@Trace 使用教程
前端·后端·harmonyos
LEO111104 小时前
HarmonyOS应用开发实战:猫猫大作战-onHover 触发时机、HoverType 类型判定、与 onTouch 的差异、TV/PC 场景应用四
harmonyos·鸿蒙
程序员黑豆4 小时前
鸿蒙应用开发之@State 装饰器详解:从基本类型到 @Observed/@ObjectLink/@Track 嵌套监听
前端·harmonyos
慧海灵舟4 小时前
鸿蒙南向开发教程 Day 11:GPIO 按键中断与 LED 状态机
华为·harmonyos
LEO111104 小时前
HarmonyOS应用开发实战:猫猫大作战-HUD 拆成独立子组件、用 `@Prop` 接收父组件得分/时间为锚点,把 @Prop 声明与传递、单向只
harmonyos·鸿蒙
不肥嘟嘟右卫门4 小时前
鸿蒙原生ArkTS布局方式之Saturate饱和度调节布局深度解析
华为·harmonyos
yaoyaoxingzhe4 小时前
HarmonyOS应用开发实战:猫猫大作战-`ForEach` 的双层嵌套、`keyGenerator` 密钥、`width` 百分比响应式格子
harmonyos·鸿蒙