HarmonyOS应用开发实战:猫猫大作战-@State 数组项替换的浅观察陷阱、@Observed 数组深观察机制、ForEach 密钥与项替换的协

文章配图:@State 数组项替换的浅观察陷阱、@Observed 数组深观察机制、ForEach 密钥与项替换的协

页面预览

前言

上一篇我们用 @Observed + @ObjectLink 让「改猫咪内部属性」直接触发重渲染。但游戏中还有种操作——替换数组某一项:合并时把 cats[0] 替换成新等级的猫、升级时把 inventory[3] 换成新道具。this.cats[0] = newCat 这种数组项替换,浅观察的 @State 监听不到(引用没变),怎么破?

本篇以「猫猫大作战」合并时替换数组项为锚点,把 @State 数组项替换的浅观察陷阱@Observed 数组深观察机制ForEach 密钥与项替换的协同三种替换方式对比四大要点讲透。

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–43 篇。本篇是阶段二第十四篇。

一、场景拆解:合并替换数组项

回顾「猫猫大作战」合并逻辑(第 121、125 篇会专讲,此处简提):

// 来源:entry/src/main/ets/components/GameEngine.ets  tryMergeAt()
private tryMergeAt(x: number, y: number): boolean {
  const cat = this.board[y][x];
  if (!cat) return false;

  const sameLevelNeighbors: Cat[] = [];
  /* ... 找相邻同级猫 ... */

  if (sameLevelNeighbors.length >= GameConfig.MERGE_COUNT) {
    const newLevel = (cat.level + 1) as CatLevel;
    const newCat: Cat = new Cat(`cat_${this.catCounter++}`, newLevel, x, y, false);

    // 移除被合并的猫
    this.removeCat(cat);
    const toRemove = sameLevelNeighbors.slice(0, GameConfig.MERGE_COUNT);
    toRemove.forEach((c) => this.removeCat(c));

    // 放置新猫
    this.cats.set(newCat.id, newCat);
    this.board[y][x] = newCat;

    return true;
  }
  return false;
}

痛点:合并后 UI 层的 @State cats: Cat[] 怎么同步?

当前解法(第 31 篇):引擎返回新数组,UI 整体赋值 this.cats = this.gameEngine.getAllCats()——靠数组引用变化触发重渲染。但如果只想替换其中一项(this.cats[0] = newCat),浅观察监听不到。

@Observed 数组深观察的解法

// 1. Cat 是 @Observed class(第 43 篇)
@Observed
export class Cat { /* ... */ }

// 2. 数组也用 @Observed 标记?不,ArkUI 用「数组项是 @Observed 实例 + ForEach 密钥」自动深观察
// 3. 替换数组项时,密钥变化触发 ForEach diff
this.cats[0] = newCat;      // 替换项
// ForEach 用 cat.id 做密钥,旧 cat-0 销毁,新 cat 摆上

关键经验数组项替换靠「ForEach 密钥 diff」+「@Observed 项深观察」双机制——密钥变触发项销毁/新建,密钥同但内部属性变触发深观察。

二、@State 数组项替换的浅观察陷阱

2.1 浅观察只监听引用和增删

@State cats: Cat[] = [];

// ✅ 触发重渲染:引用变
this.cats = [...this.cats];

// ✅ 触发重渲染:增删
this.cats.push(newCat);
this.cats.splice(0, 1);

// ❌ 不触发重渲染:替换项(引用没变)
this.cats[0] = newCat;

// ❌ 不触发重渲染:改项内部属性
this.cats[0].y += 1;

机制@State 浅观察只监听数组引用变化增删——this.cats[0] = newCat 替换项,数组引用没变,长度没变,浅观察视为「没变」

2.2 当前解法:整体赋值

this.cats[0] = newCat;           // 替换项
this.cats = [...this.cats];      // 整体赋值触发

缺点:每次替换一项都要重建整个数组,N 只猫整体 diff,浪费

2.3 期望:只重渲染被替换的那项

this.cats[0] = newCat;           // 只想重渲染第 0 项
// 其他 N-1 只猫不动

这就是 @Observed 数组深观察的用武之地

三、ForEach 密钥与项替换的协同

3.1 ForEach 用密钥做 diff

回顾「猫猫大作战」猫咪渲染(第 15、43 篇):

ForEach(this.cats, (cat: Cat) => {
  CatItem({ cat: cat })
}, (cat: Cat) => cat.id)         // ← 密钥 cat.id

ForEach diff 机制

  1. 数组变化时,ForEach 用密钥对比旧新数组。
  2. 新增密钥 → 渲染新项。
  3. 删除密钥 → 销毁旧项。
  4. 密钥同但项变 → 复用项,传新数据(深观察触发内部刷新)。

3.2 项替换 = 删旧密钥 + 增新密钥

// 原 cats = [{ id: 'cat_0', level: 1, ... }, { id: 'cat_1', ... }]
// 合并:cat_0 和 cat_1 合并成新猫 cat_2
this.cats[0] = new Cat('cat_2', 2, 0, 5, false);    // 替换第 0 项
// 数组变成 [{ id: 'cat_2', ... }, { id: 'cat_1', ... }]

// ForEach diff:
// 旧密钥 ['cat_0', 'cat_1']
// 新密钥 ['cat_2', 'cat_1']
// 'cat_0' 删了 → 销毁旧 CatItem
// 'cat_2' 新增 → 渲染新 CatItem
// 'cat_1' 同 → 复用,不重渲染

关键经验项替换时密钥变(cat_0 → cat_2),ForEach 销毁旧项新建新项——只重渲染被替换的那项,其他不动。

3.3 密钥不变的项替换陷阱

// 假设替换时密钥不变(复用同一 id)
this.cats[0] = new Cat('cat_0', 2, 0, 5, false);    // id 还是 cat_0,但 level 变了
// 数组 [{ id: 'cat_0', level: 2, ... }, { id: 'cat_1', ... }]

// ForEach diff:
// 旧密钥 ['cat_0', 'cat_1']
// 新密钥 ['cat_0', 'cat_1']
// 密钥全同 → 复用所有 CatItem,不销毁不新建
// 但 cat_0 的 level 变了 → 靠 @ObjectLink 深观察触发内部刷新

两种情况对比

替换方式密钥变ForEach 行为触发机制
new Cat('cat_2', ...) 新 id销毁旧项 + 新建新项ForEach diff
new Cat('cat_0', ...) 同 id复用项@ObjectLink 深观察

关键经验密钥变靠 ForEach diff,密钥同靠 @ObjectLink 深观察——双机制覆盖所有替换场景。

四、实战:合并替换数组项

4.1 引擎合并后返回新数组

// 来源:entry/src/main/ets/components/GameEngine.ets
updateCats(): Cat[] {
  let hasMerge = false;

  const sortedCats = Array.from(this.cats.values())
    .filter(cat => cat.falling)
    .sort((a, b) => b.y - a.y);

  sortedCats.forEach((cat) => {
    /* ... 下落或着陆 ... */
  });

  if (hasMerge) {
    this.checkAllMerges();      // 合并会改 cats Map
  }

  return Array.from(this.cats.values());   // 返回新数组快照
}

4.2 UI 层整体赋值(当前稳定解法)

// 来源:entry/src/main/ets/pages/Index.ets  主循环
this.gameLoopTimer = setInterval(() => {
  if (this.gameState !== GameState.PLAYING) return;
  this.cats = this.gameEngine.updateCats();    // 整体赋值新数组
  this.score = this.gameEngine.getScore();
  this.combo = this.gameEngine.getCombo();
}, 100);

机制:引擎返回新数组引用,UI 整体赋值 this.cats = ...引用变触发 ForEach 全 diff。ForEach 用密钥 cat.id 对比旧新:

  • 落的猫 id 没变 → 复用,靠 @ObjectLink 深观察刷新移动的 position。
  • 合并的猫 id 删了 → 销毁旧 CatItem。
  • 新合并的猫 id 新增 → 渲染新 CatItem。

关键经验整体赋值 + ForEach 密钥 diff + @ObjectLink 深观察 = 三重协同——引用变触发 diff,密钥变触发增删,密钥同深观察内部。

4.3 进阶:只替换数组项不整体赋值

如果想优化性能,不整体赋值,只替换变化的项:

// 假设引擎暴露 mergeAt(index) 返回新猫
const newCat = this.gameEngine.mergeAt(0);
this.cats[0] = newCat;           // 替换第 0 项
// ⚠️ 浅观察不触发,要手动触发

两种手动触发方式

// 方式 A:整体赋值(简单但浪费)
this.cats[0] = newCat;
this.cats = [...this.cats];

// 方式 B:用 @Observed 数组深观察(进阶)
// ArkUI 的 @State 数组本身不深观察项替换
// 要靠 ForEach 密钥 diff,必须让数组引用变
// 所以实际上方式 A 是当前最稳定的解法

实战经验当前 ArkUI V1 的 @State 数组不支持「项替换深观察」——必须整体赋值让引用变,ForEach 才 diff。V2 的 @ObservedV2 数组才支持项替换深观察(第 50 篇会讲)。

五、三种替换方式对比

5.1 方式 A:整体赋值(推荐,本项目)

this.cats = this.gameEngine.getAllCats();      // 整体赋值新数组
优点缺点
简单稳定N 只猫整体 diff
ForEach 自动 diff性能 N 大时浪费
兼容 V1 V2

5.2 方式 B:增删 API

this.cats.splice(0, 1, newCat);    // 删第 0 项并插入 newCat
优点缺点
显式增删API 略繁琐
触发浅观察(增删)仍是整体 diff

5.3 方式 C:项替换 + 整体赋值

this.cats[0] = newCat;             // 替换
this.cats = [...this.cats];        // 整体赋值触发
优点缺点
语义清晰(先改再触发)两步操作
ForEach diff仍是整体 diff

5.4 对比表

方式代码触发性能推荐
A 整体赋值this.cats = getAll()引用变✅ 本项目
B splicethis.cats.splice(0,1,newCat)增删增删场景
C 替换+赋值this.cats[0]=newCat; this.cats=[...this.cats]引用变语义清晰

关键经验当前 V1 三种方式本质都是「让数组引用变」触发 ForEach diff——性能差异微乎其乎,方式 A 最简洁。

六、ForEach 密钥的实战坑

6.1 密钥必须唯一

// ❌ 错误:密钥不唯一,ForEach diff 错乱
ForEach(this.cats, (cat: Cat) => {
  CatItem({ cat: cat })
}, (cat: Cat) => cat.level)        // ← 用 level 做密钥
// 两只 level=1 的猫密钥都 '1',冲突!

// ✅ 正确:密钥唯一
ForEach(this.cats, (cat: Cat) => {
  CatItem({ cat: cat })
}, (cat: Cat) => cat.id)           // ← id 唯一

6.2 密钥用坐标会踩坑

// ❌ 错误:用坐标做密钥,移动时密钥变导致销毁新建
ForEach(this.cats, (cat: Cat) => {
  CatItem({ cat: cat })
}, (cat: Cat) => `${cat.x}_${cat.y}`)   // ← 坐标做密钥
// 猫从 (0,5) 移到 (0,6),密钥 '0_5' → '0_6'
// ForEach 销毁旧 CatItem + 新建新 CatItem,失去移动动画连续性

// ✅ 正确:用不变的 id 做密钥
ForEach(this.cats, (cat: Cat) => {
  CatItem({ cat: cat })
}, (cat: Cat) => cat.id)           // ← id 不随坐标变
// 移动时密钥同,复用 CatItem,靠 @ObjectLink 深观察刷新 position,动画连续

关键经验密钥用不变的字段(id),别用会变的字段(坐标、等级)——否则移动/升级都变成销毁新建,失去动画连续性。第 15 篇讲过这个坑。

6.3 合并时密钥的正确处理

// 合并:cat_0 和 cat_1 合并成新猫
// 引擎内部:
this.removeCat(cat_0);          // cats Map 删 cat_0
this.removeCat(cat_1);          // cats Map 删 cat_1
this.cats.set('cat_2', newCat); // cats Map 加 cat_2

// UI 整体赋值后 ForEach diff:
// 旧密钥 ['cat_0', 'cat_1', 'cat_3', ...]
// 新密钥 ['cat_2', 'cat_3', ...]
// 'cat_0' 'cat_1' 销毁,'cat_2' 新建,'cat_3' 复用

关键经验合并时引擎删旧 id 加新 id,ForEach 自然销毁旧项新建新项——这就是「合并动画」的本质(旧猫消失 + 新猫出现)。

七、完整代码:合并替换数组项

// 来源:entry/src/main/ets/pages/Index.ets(@Observed 改造后)
import { Cat, CatLevel, GameConfig, CatConfig, GameState, ComboInfo } from '../components/GameTypes';
import { GameEngine } from '../components/GameEngine';
import { GameHUD } from '../components/GameHUD';
import { CatItem } from '../components/CatItem';
import { PauseOverlay } from '../components/PauseOverlay';

@Entry
@Component
struct Index {
  @State @Watch('onGameStateChange') gameState: GameState = GameState.IDLE;
  @State score: number = 0;
  @State cats: Cat[] = [];                          // Cat 是 @Observed class
  @State combo: ComboInfo = { count: 0, multiplier: 1, lastMergeTime: 0 };
  @State nextCatLevel: CatLevel = CatLevel.SMALL;
  @State highScore: number = 0;
  @State gameTime: number = 0;
  @State maxCombo: number = 0;
  @State mergeCount: number = 0;
  @State highestLevel: CatLevel = CatLevel.SMALL;

  @Provide('gameState') providedGameState: GameState = this.gameState;
  @Provide('score') providedScore: number = this.score;

  private gameEngine: GameEngine = new GameEngine();
  private gameLoopTimer: number = -1;
  private spawnTimer: number = -1;
  private timeTimer: number = -1;
  private readonly cols: number[] = [0, 1, 2, 3, 4];
  private readonly rows: number[] = [0, 1, 2, 3, 4, 5, 6, 7];

  startGame() {
    this.clearTimers();
    this.gameEngine.reset();
    this.gameState = GameState.PLAYING;
    this.score = 0;
    this.cats = [];
    this.gameTime = 0;
    this.combo = { count: 0, multiplier: 1, lastMergeTime: 0 };
    this.nextCatLevel = this.gameEngine.getNextCatLevel();

    // 主循环:整体赋值 cats 数组(本篇重点)
    this.gameLoopTimer = setInterval(() => {
      if (this.gameState !== GameState.PLAYING) return;
      this.cats = this.gameEngine.updateCats();      // 整体赋值触发 ForEach diff
      this.score = this.gameEngine.getScore();
      this.combo = this.gameEngine.getCombo();
      if (this.gameEngine.isGameOver()) { this.endGame(); }
    }, 100);

    this.spawnTimer = setInterval(() => {
      if (this.gameState !== GameState.PLAYING) return;
      if (this.gameEngine.getCatCount() < GameConfig.MAX_CATS) {
        this.gameEngine.autoSpawnCat();
        this.cats = this.gameEngine.getAllCats();    // 整体赋值
        this.nextCatLevel = this.gameEngine.getNextCatLevel();
      }
    }, GameConfig.SPAWN_RATE);

    this.timeTimer = setInterval(() => {
      if (this.gameState === GameState.PLAYING) { this.gameTime++; }
    }, 1000);
  }

  handleColumnClick(column: number) {
    if (this.gameState !== GameState.PLAYING) return;
    if (this.gameEngine.dropCat(column)) {
      this.cats = this.gameEngine.getAllCats();      // 整体赋值
      this.nextCatLevel = this.gameEngine.getNextCatLevel();
    }
  }

  /* pauseGame / resumeGame / endGame / clearTimers / formatTime / aboutToDisappear / onGameStateChange 等略 */

  @Builder
  GameView() {
    Column() {
      GameHUD()
      Column() {
        Row() { /* 预告区 */ }
        Stack() {
          Column() { /* 棋盘背景 ForEach rows × cols */ }

          // 猫咪渲染:@Observed Cat + ForEach 用 id 做密钥
          ForEach(this.cats, (cat: Cat) => {
            CatItem({ cat: cat })                    // @ObjectLink 深观察
          }, (cat: Cat) => cat.id)                   // ← 密钥用 id

          Row() {
            ForEach(this.cols, (col: number) => {
              Column()
                .width(GameConfig.CELL_SIZE)
                .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
                .backgroundColor('rgba(0,0,0,0)')
                .onClick(() => { this.handleColumnClick(col); })
            }, (col: number) => `click_${col}`)
          }
        }
        .width(GameConfig.BOARD_WIDTH * GameConfig.CELL_SIZE)
        .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
        .borderRadius(12).clip(true).backgroundColor('#D6EEF5')
      }.alignItems(HorizontalAlign.Center)

      Spacer()

      Row() { /* 底部控制栏 */ }
        .width('100%').padding({ left: 24, right: 24, bottom: 24, top: 12 })
    }
    .width('100%').height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#E8F4F8', 0.0], ['#D6EEF5', 0.5], ['#C9E8F2', 1.0]]
    })
    .alignItems(HorizontalAlign.Center)
  }

  /* MainMenuView / PauseOverlay 引用 / GameOverOverlay / StatItem 等略 */
}

八、踩坑提示

8.1 项替换后忘整体赋值

// ❌ 错误:替换项没整体赋值,浅观察不触发
this.cats[0] = newCat;           // 数组引用没变,不触发

// ✅ 正确:替换后整体赋值
this.cats[0] = newCat;
this.cats = [...this.cats];      // 引用变,触发 ForEach diff

8.2 密钥用会变的字段

// ❌ 错误:用坐标做密钥,移动时销毁新建
ForEach(this.cats, (cat: Cat) => { /* ... */ }, (cat: Cat) => `${cat.x}_${cat.y}`)

// ✅ 正确:用不变的 id
ForEach(this.cats, (cat: Cat) => { /* ... */ }, (cat: Cat) => cat.id)

8.3 密钥不唯一

// ❌ 错误:多只同级猫密钥都 level,冲突
ForEach(this.cats, (cat: Cat) => { /* ... */ }, (cat: Cat) => cat.level.toString())

// ✅ 正确:id 唯一
ForEach(this.cats, (cat: Cat) => { /* ... */ }, (cat: Cat) => cat.id)

8.4 合并忘删旧猫密钥

// ❌ 错误:引擎合并只加新猫,没删旧猫,密钥冲突
this.cats.set('cat_2', newCat);    // 加新
// 忘了 this.removeCat(cat_0); this.removeCat(cat_1);
// getAllCats 返回 [cat_0, cat_1, cat_2, ...],密钥都唯一但内容错乱

// ✅ 正确:合并时删旧加新
this.removeCat(cat_0);
this.removeCat(cat_1);
this.cats.set('cat_2', newCat);

九、调试技巧

  1. console.info 打 cats.length 和密钥:合并后 log,追项数和密钥变化。
  2. 合并后猫不消失排查:检查引擎是否 removeCat 旧猫;检查 ForEach 密钥是否唯一。
  3. 移动不连续排查:检查密钥是否用了坐标(会变的字段);检查 @ObjectLink 是否深观察。
  4. DevEco ArkUI Inspector:查看 ForEach 子项和密钥绑定。

十、性能与最佳实践

  1. 当前 V1 数组项替换靠整体赋值——this.cats = getAll() 让引用变触发 ForEach diff。
  2. ForEach 密钥用不变字段——id 唯一且不随坐标/等级变,移动/升级连续。
  3. 密钥变 = 销毁新建——合并时删旧 id 加新 id,自然销毁旧项新建新项。
  4. 密钥同 = 复用深观察——移动时 id 不变,复用 CatItem,靠 @ObjectLink 刷新 position。
  5. 合并时引擎删旧加新——removeCat 旧猫 + set 新猫,ForEach diff 正确。
  6. V2 数组支持项替换深观察——@ObservedV2 数组直接监听 arr[i]=newCat(第 50 篇会讲)。

总结

本篇我们从数组项替换切入,掌握了**@State 数组浅观察陷阱(项替换不触发)**、ForEach 密钥 diff 协同(密钥变销毁新建,密钥同深观察)三种替换方式对比(整体赋值最简)密钥用不变字段的实战坑四大要点,并给出了合并替换数组项的完整代码。核心要点:V1 项替换靠整体赋值触发 diff;密钥用 id 不用坐标;合并删旧加新;密钥变销毁新建密钥同深观察

下一篇我们将拆解批量状态更新——单次 re-render 的优化。

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


相关资源:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值