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

发布时间:2026/7/28 22:32:30
HarmonyOS应用开发实战:猫猫大作战-@State 数组项替换的浅观察陷阱、@Observed 数组深观察机制、ForEach 密钥与项替换的协 前言上一篇我们用ObservedObjectLink让「改猫咪内部属性」直接触发重渲染。但游戏中还有种操作——替换数组某一项合并时把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.idForEach 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_2ForEach 销毁旧项新建新项——只重渲染被替换的那项其他不动。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 diffnew 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 只猫整体 diffForEach 自动 diff性能 N 大时浪费兼容 V1 V2—5.2 方式 B增删 APIthis.cats.splice(0, 1, newCat); // 删第 0 项并插入 newCat优点缺点显式增删API 略繁琐触发浅观察增删仍是整体 diff5.3 方式 C项替换 整体赋值this.cats[0] newCat; // 替换 this.cats [...this.cats]; // 整体赋值触发优点缺点语义清晰先改再触发两步操作ForEach diff仍是整体 diff5.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 做密钥 // 两只 level1 的猫密钥都 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 加新 idForEach 自然销毁旧项新建新项——这就是「合并动画」的本质旧猫消失 新猫出现。七、完整代码合并替换数组项// 来源entry/src/main/ets/pages/Index.etsObserved 改造后 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 diff8.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.etsForEach 循环渲染与密钥官方指南ArkUI Observed 深观察官方指南ArkUI 数组与列表性能最佳实践开源鸿蒙跨平台社区HarmonyOS 开发者官方文档首页系列索引本仓库articles/INDEX.md