HarmonyOS应用开发实战:猫猫大作战-被动批量(同帧)、主动批量(batchUpdate)、跨帧批量的陷阱、批量与深观察的协同

📅 2026/7/28 0:48:45 👁️ 阅读次数 📝 编程学习
HarmonyOS应用开发实战:猫猫大作战-被动批量(同帧)、主动批量(batchUpdate)、跨帧批量的陷阱、批量与深观察的协同

前言

第 32 篇我们讲过「同帧批量更新」——一个回调里改多个@State,ArkUI 合并成一次重渲染。但那是「被动批量」——靠回调天然同帧。实战中还有「主动批量」场景:连续多次逻辑操作改 state,要强制合并成一次重渲染,避免中途触发浪费。HarmonyOS 提供了batchUpdate机制(V2)和「手动批」套路(V1)。

本篇以「猫猫大作战」结束游戏时连续改 5 个 state 为锚点,把被动批量(同帧)主动批量(batchUpdate)跨帧批量的陷阱批量与深观察的协同四大要点讲透。

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

一、场景拆解:endGame 连续改 5 个 state

回顾「猫猫大作战」endGame(第 33、36 篇):

// 来源:entry/src/main/ets/pages/Index.ets endGame() { this.gameState = GameState.GAME_OVER; // 改 1 this.maxCombo = this.gameEngine.getMaxCombo(); // 改 2 this.mergeCount = this.gameEngine.getMergeCount(); // 改 3 this.highestLevel = this.gameEngine.getHighestLevel(); // 改 4 if (this.score > this.highScore) { this.highScore = this.score; // 改 5 } this.clearTimers(); }

核心问题:5 个@State连续改,ArkUI 重渲染几次?

答案1 次——因为都在endGame同一个调用栈(同帧),ArkUI 收集所有变更,下一 VSync 只 build() 一次。这是第 32 篇讲的「被动批量」。

但有种进阶坑

endGame() { this.gameState = GameState.GAME_OVER; // 改 1 this.clearTimers(); // 调方法,可能内部又改 state this.maxCombo = this.gameEngine.getMaxCombo(); // 改 2 }

clearTimers()内部如果改了@State(比如清空cats),仍然同帧批量——只要都在endGame调用栈内,不论调多少层方法,都合并。

二、被动批量:同帧合并

2.1 同帧收集机制

endGame() { this.gameState = GameState.GAME_OVER; // 标记 1 this.maxCombo = 5; // 标记 2 this.mergeCount = 10; // 标记 3 this.highestLevel = CatLevel.BIG; // 标记 4 this.highScore = 1500; // 标记 5 } // 调用栈结束 → 下一 VSync → 一次 build() 所有脏组件

机制

  1. ArkUI 在每次@State赋值时标记依赖组件为脏不立即重渲染
  2. 当前 JS 调用栈结束(endGame return)。
  3. 下一个 VSync 帧,一次性 build() 所有脏组件

关键经验「同调用栈」=「同帧」=「批量」——不论改多少 state、调多少层方法,只要在一个调用栈内都合并。

2.2 跨帧多次重渲染的浪费

// ❌ 错误:用 setTimeout 分次改,跨帧多次重渲染 endGame() { this.gameState = GameState.GAME_OVER; setTimeout(() => { this.maxCombo = 5; }, 0); // 下一帧 setTimeout(() => { this.mergeCount = 10; }, 0); // 再下一帧 setTimeout(() => { this.highScore = 1500; }, 0); // 再下一帧 } // 4 次重渲染(gameState + maxCombo + mergeCount + highScore 各一次)

对比

方式重渲染次数性能
同帧批量(endGame 内连续改)1 次✅ 最优
setTimeout(0) 分次4 次❌ 浪费 4 倍

2.3 被动批量的覆盖范围

endGame() { this.gameState = GameState.GAME_OVER; this.clearTimers(); // 内部改 state,仍同帧 this.helperA(); // 内部改 state,仍同帧 this.helperB(); // 内部改 state,仍同帧 this.highScore = 1500; } // 全部在 endGame 调用栈内,1 次重渲染 helperA() { this.maxCombo = 5; } helperB() { this.mergeCount = 10; } clearTimers() { /* 可能改 cats 等 */ }

关键经验被动批量覆盖整个调用栈——不论嵌套多少层方法,只要起点是同一个用户/定时器回调,都合并。

三、主动批量:batchUpdate

3.1 什么时候需要主动批量

被动批量已经覆盖大部分场景,但有种情况要主动批量——异步操作中途改 state

async endGame() { this.gameState = GameState.GAME_OVER; // 改 1(本次帧) const stats = await this.fetchStats(); // 异步等待 this.maxCombo = stats.maxCombo; // 改 2(下一帧) this.mergeCount = stats.mergeCount; // 改 3(再下一帧) } // gameState 单独一次重渲染,maxCombo+mergeCount 同帧一次,共 2 次

痛点await让调用栈断开,后续改 state 跨帧,无法被动批量。

3.2 batchUpdate 主动合并

HarmonyOS V2 提供batchUpdate主动批量:

import { batchUpdate } from '@kit.ArkUI'; async endGame() { this.gameState = GameState.GAME_OVER; // 改 1(本次帧) const stats = await this.fetchStats(); // 异步等待 // 主动批量:后续改合并成一次 batchUpdate(() => { this.maxCombo = stats.maxCombo; // 改 2 this.mergeCount = stats.mergeCount; // 改 3 this.highestLevel = stats.highestLevel; // 改 4 if (stats.score > this.highScore) { this.highScore = stats.score; // 改 5 } }); } // gameState 一次重渲染,batchUpdate 内 4 个改合并一次,共 2 次

拆解

片段含义
batchUpdate(() => { ... })主动批量装饰器,回调内所有 state 改动合并
回调内this.xxx = ...改的 state 都标记,但延迟到 batchUpdate 结束才刷新

关键经验batchUpdate 用于「异步后连续改 state」场景——把跨帧的改动强制合并成一次重渲染。

3.3 batchUpdate vs 被动批量

维度被动批量batchUpdate
触发同调用栈自动手动包回调
覆盖同步连续改异步后连续改
V1/V2V1 V2 都支持V2 专属
推荐同步场景异步场景

实战经验同步连续改用被动批量(不包 batchUpdate),异步后连续改用 batchUpdate

四、跨帧批量的陷阱

4.1 await 后的改动天然跨帧

async loadData() { this.loading = true; // 改 1(本次帧) const data = await fetch('/api'); // 异步等待 this.loading = false; // 改 2(下一帧) this.data = data; // 改 3(同改 2 帧) } // loading=true 单独一次重渲染,loading=false + data 同帧一次,共 2 次

机制await让函数返回,调用栈断开。await 后的代码在新微任务帧执行,与 await 前不同帧。

4.2 多个 await 的多次跨帧

async loadData() { this.loading = true; // 改 1(帧 A) const user = await fetch('/user'); // 异步 this.user = user; // 改 2(帧 B) const posts = await fetch('/posts'); // 异步 this.posts = posts; // 改 3(帧 C) } // 3 次重渲染(loading、user、posts 各一次)

优化:用 batchUpdate 合并后两个:

async loadData() { this.loading = true; const user = await fetch('/user'); const posts = await fetch('/posts'); batchUpdate(() => { this.user = user; // 改 2 this.posts = posts; // 改 3 this.loading = false; // 改 4 }); } // 2 次重渲染(loading=true、batchUpdate 内合并一次)

4.3 Promise.all 并发再批量

async loadData() { this.loading = true; const [user, posts] = await Promise.all([ fetch('/user'), fetch('/posts') ]); // 并发,只一次 await batchUpdate(() => { this.user = user; this.posts = posts; this.loading = false; }); } // 2 次重渲染(最优)

关键经验Promise.all 并发 + batchUpdate 批量 = 异步最少重渲染——并发减少 await 次数,批量减少重渲染次数。

五、批量与深观察的协同

5.1 @Observed 实例改属性也批量

// Cat 是 @Observed class(第 43 篇) endGame() { this.gameState = GameState.GAME_OVER; // 改 @State // 改 @Observed 实例属性 this.cats.forEach((cat) => { cat.falling = false; // 改每个猫的 falling }); this.score = this.gameEngine.getScore(); // 改 @State } // gameState + 所有 cat.falling + score 都同帧批量,1 次重渲染

机制@Observed实例改属性也走「标记脏 + 延迟刷新」机制,与@State共用批量队列。

5.2 深观察与被动批量混用

// 同帧改 @State 和 @Observed 属性,全部合并 endGame() { this.gameState = GameState.GAME_OVER; // @State this.cats[0].y = 7; // @Observed 属性 this.cats[0].falling = false; // @Observed 属性 this.score = 99; // @State } // 1 次重渲染,所有脏组件(GameOverOverlay、CatItem、HUD)一起刷

关键经验@State 浅观察和 @Observed 深观察共用批量队列——同帧内都合并,不论观察层级。

六、完整代码:endGame 批量更新

// 来源:entry/src/main/ets/pages/Index.ets(V1 被动批量版) @Entry @Component struct Index { @State @Watch('onGameStateChange') gameState: GameState = GameState.IDLE; @State score: number = 0; @State cats: Cat[] = []; @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; private gameEngine: GameEngine = new GameEngine(); private gameLoopTimer: number = -1; private spawnTimer: number = -1; private timeTimer: number = -1; // 结束游戏:被动批量,5 个 state 连续改,1 次重渲染(本篇重点) endGame() { this.gameState = GameState.GAME_OVER; // 改 1 this.maxCombo = this.gameEngine.getMaxCombo(); // 改 2 this.mergeCount = this.gameEngine.getMergeCount(); // 改 3 this.highestLevel = this.gameEngine.getHighestLevel(); // 改 4 if (this.score > this.highScore) { this.highScore = this.score; // 改 5(条件) } this.clearTimers(); // 不改 state,不影响批量 // 所有改动在 endGame 调用栈内,ArkUI 下一 VSync 一次 build() // GameOverOverlay + HUD + 棋盘(如果有残留)一起重渲染 } // gameState 变化副作用(第 39 篇) onGameStateChange(newVal: GameState): void { switch (newVal) { case GameState.GAME_OVER: this.playSfx('over'); this.vibrate(); break; } } startGame() { this.clearTimers(); this.gameEngine.reset(); this.gameState = GameState.PLAYING; // 改 1 this.score = 0; // 改 2 this.cats = []; // 改 3 this.gameTime = 0; // 改 4 this.combo = { count: 0, multiplier: 1, lastMergeTime: 0 }; // 改 5 this.nextCatLevel = this.gameEngine.getNextCatLevel(); // 改 6 // 6 个 state 连续改,1 次重渲染 this.gameLoopTimer = setInterval(() => { if (this.gameState !== GameState.PLAYING) return; this.cats = this.gameEngine.updateCats(); // 改 1 this.score = this.gameEngine.getScore(); // 改 2 this.combo = this.gameEngine.getCombo(); // 改 3 if (this.gameEngine.isGameOver()) { this.endGame(); } }, 100); // 主循环每 100ms 改 3-4 个 state,每帧 1 次重渲染 /* spawnTimer、timeTimer 筥略 */ } handleColumnClick(column: number) { if (this.gameState !== GameState.PLAYING) return; if (this.gameEngine.dropCat(column)) { this.cats = this.gameEngine.getAllCats(); // 改 1 this.nextCatLevel = this.gameEngine.getNextCatLevel(); // 改 2 } // 2 个 state 同帧,1 次重渲染 } /* pauseGame / resumeGame / clearTimers / formatTime / aboutToDisappear 筥略 */ build() { Stack() { if (this.gameState === GameState.IDLE) { this.MainMenuView() } else { this.GameView() } if (this.gameState === GameState.PAUSED) { PauseOverlay({ gameState: this.$gameState, score: this.score }) } if (this.gameState === GameState.GAME_OVER) { this.GameOverOverlay() } } .width('100%').height('100%') } /* GameView / MainMenuView / GameOverOverlay / StatItem 等略 */ }

七、踩坑提示

7.1 setTimeout 分次改破坏批量

// ❌ 错误:setTimeout 分次,跨帧多次重渲染 endGame() { this.gameState = GameState.GAME_OVER; setTimeout(() => { this.maxCombo = 5; }, 0); setTimeout(() => { this.mergeCount = 10; }, 0); } // ✅ 正确:同调用栈连续改 endGame() { this.gameState = GameState.GAME_OVER; this.maxCombo = 5; this.mergeCount = 10; }

7.2 await 后忘 batchUpdate

// ❌ 错误:await 后连续改,跨帧多次重渲染 async endGame() { this.gameState = GameState.GAME_OVER; const stats = await this.fetchStats(); this.maxCombo = stats.maxCombo; // 跨帧 this.mergeCount = stats.mergeCount; // 跨帧 } // ✅ 正确:await 后用 batchUpdate async endGame() { this.gameState = GameState.GAME_OVER; const stats = await this.fetchStats(); batchUpdate(() => { this.maxCombo = stats.maxCombo; this.mergeCount = stats.mergeCount; }); }

7.3 以为 batchUpdate 是 V1

// ❌ 错误:V1 没有 batchUpdate,编译报错 import { batchUpdate } from '@kit.ArkUI'; // V1 项目可能无此导出 // ✅ 正确:V1 用被动批量(同调用栈),V2 才有 batchUpdate // V1:把异步后的改动用 Promise.all 合并,减少 await 次数 async endGame() { this.gameState = GameState.GAME_OVER; const stats = await this.fetchStats(); // 只一次 await // stats 拿到后连续改,被动批量 this.maxCombo = stats.maxCombo; this.mergeCount = stats.mergeCount; }

7.4 @Watch 触发时机误判

endGame() { this.gameState = GameState.GAME_OVER; // 触发 onGameStateChange this.maxCombo = 5; // 改 state } // onGameStateChange 在 endGame 调用栈内同步触发(@Watch 在赋值后立即) // 但 onGameStateChange 里改其他 state 也并入本次批量

关键经验@Watch 回调在「赋值后同步触发」,仍在同一调用栈——回调里改 state 也并入批量。

八、调试技巧

  1. console.info打重渲染次数:在 build() 首行 log,统计调用次数,对比批量效果。
  2. DevEco Profiler 看 Render:统计帧的 build() 调用数,被动批量应只 1 次。
  3. 跨帧排查:检查是否有 setTimeout/await 断调用栈;检查异步后是否 batchUpdate。
  4. @Watch 触发排查:在 onGameStateChange 首行 log,追是否同帧触发。

九、性能与最佳实践

  1. 同步连续改用被动批量——同调用栈自动合并,不用包 batchUpdate。
  2. 异步后连续改用 batchUpdate(V2)——强制合并跨帧改动。
  3. 避免 setTimeout(0) 分次改——跨帧多次重渲染,浪费。
  4. Promise.all 并发减少 await——一次 await 拿所有数据,后续被动批量。
  5. @State 浅观察和 @Observed 深观察共用批量队列——同帧都合并。
  6. @Watch 回调同帧触发——回调里改 state 也并入本次批量。

十、阶段二进度小结(31-45)

本篇是阶段二「状态管理 + 交互 + 动画」第 15 篇,进度小结:

主题核心要点
31@State响应式状态,改变触发重渲染
32多 @State同帧批量更新,一次重渲染
33setInterval 主循环100ms 物理周期,暂停短路
34计时器1000ms 秒级,formatTime 格式化
35spawnTimer2000ms 自动生成,随机列选择
36clearTimers-1 哨兵,三时机清理,aboutToDisappear 兜底
37onClick 列投放闭包捕获 col,handleColumnClick 三步
38箭头函数 this回调统一箭头保留 this,普通函数打断链
39@Watch状态变化副作用集中,防递归守卫
40@Prop父子单向只读,浅拷贝,显示型子组件
41@Link父子双向,$val 传引用,编辑型子组件
42@Provide/@Consume跨层隐式共享,避免 prop drilling
43@Observed+@ObjectLink类实例深观察,改内部属性触发
44数组项替换ForEach 密钥 diff + 深观察协同
45(本篇)批量更新被动批量(同帧)+ batchUpdate(异步后)

接下来第 46-50 篇会进入 V1 收尾与 V2 迁移:嵌套陷阱、@Reusable 列表复用、LazyForEach 大列表、V1 局限总结,然后 V2 迁移:@Local、@Param、@Event、@ObservedV2+@Trace、@Monitor。

总结

本篇我们从批量状态更新切入,掌握了被动批量(同调用栈自动合并)主动批量(batchUpdate 异步后强制合并)跨帧陷阱(setTimeout/await 断调用栈)批量与深观察协同四大要点,并给出了 endGame 5 个 state 同帧批量的完整代码。核心要点:同步连续改被动批量;异步后用 batchUpdate;避免 setTimeout 分次;Promise.all 减少 await

下一篇我们将拆解嵌套陷阱——@State 嵌套对象/数组的更新坑。

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


相关资源:

  • 「猫猫大作战」项目源码:本仓库entry/src/main/ets/pages/Index.ets
  • ArkUI 状态管理批量更新官方指南
  • ArkUI 状态管理概述
  • ArkUI 渲染管线与性能最佳实践
  • 开源鸿蒙跨平台社区
  • HarmonyOS 开发者官方文档首页
  • 系列索引:本仓库articles/INDEX.md