

前言
上一篇我们用 @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 机制:
- 数组变化时,ForEach 用密钥对比旧新数组。
- 新增密钥 → 渲染新项。
- 删除密钥 → 销毁旧项。
- 密钥同但项变 → 复用项,传新数据(深观察触发内部刷新)。
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 splice | this.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);
九、调试技巧
console.info打 cats.length 和密钥:合并后 log,追项数和密钥变化。- 合并后猫不消失排查:检查引擎是否 removeCat 旧猫;检查 ForEach 密钥是否唯一。
- 移动不连续排查:检查密钥是否用了坐标(会变的字段);检查 @ObjectLink 是否深观察。
- DevEco ArkUI Inspector:查看 ForEach 子项和密钥绑定。
十、性能与最佳实践
- 当前 V1 数组项替换靠整体赋值——
this.cats = getAll()让引用变触发 ForEach diff。 - ForEach 密钥用不变字段——id 唯一且不随坐标/等级变,移动/升级连续。
- 密钥变 = 销毁新建——合并时删旧 id 加新 id,自然销毁旧项新建新项。
- 密钥同 = 复用深观察——移动时 id 不变,复用 CatItem,靠 @ObjectLink 刷新 position。
- 合并时引擎删旧加新——removeCat 旧猫 + set 新猫,ForEach diff 正确。
- V2 数组支持项替换深观察——@ObservedV2 数组直接监听
arr[i]=newCat(第 50 篇会讲)。
总结
本篇我们从数组项替换切入,掌握了**@State 数组浅观察陷阱(项替换不触发)**、ForEach 密钥 diff 协同(密钥变销毁新建,密钥同深观察)、三种替换方式对比(整体赋值最简)、密钥用不变字段的实战坑四大要点,并给出了合并替换数组项的完整代码。核心要点:V1 项替换靠整体赋值触发 diff;密钥用 id 不用坐标;合并删旧加新;密钥变销毁新建密钥同深观察。
下一篇我们将拆解批量状态更新——单次 re-render 的优化。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- 「猫猫大作战」项目源码:本仓库
entry/src/main/ets/pages/Index.ets、entry/src/main/ets/components/GameEngine.ets、entry/src/main/ets/components/GameTypes.ets - ForEach 循环渲染与密钥官方指南
- ArkUI @Observed 深观察官方指南
- ArkUI 数组与列表性能最佳实践
- 开源鸿蒙跨平台社区
- HarmonyOS 开发者官方文档首页
- 系列索引:本仓库
articles/INDEX.md

321

被折叠的 条评论
为什么被折叠?



