【OpenHarmony/HarmonyOS】大型 ArkUI 页面状态管理:@State、@Prop、@Builder 与回调边界

ArkUI 的语法让界面非常接近“状态的函数”:变量变化,组件树自动更新。但在游戏类页面中,Canvas 引擎每帧变化、ArkUI 只需要低频 HUD,弹窗又要接收只读结果,摇杆还要把高频输入回传。把所有东西都标成 @State 不但不会更简单,反而会让 UI 高频重建、对象修改不生效、状态所有权混乱。本篇通过真实项目说明 @State@Prop@Builder@BuilderParam 和回调各自适合解决什么问题。🧱

一、先按“谁拥有数据”分类

状态管理的第一问题不是用哪个装饰器,而是数据权威属于谁。

数据权威拥有者ArkUI 角色推荐传递方式
坦克坐标、子弹、AIGameEngineCanvas 每帧直接绘制普通对象,不做 @State
当前波次、时间、局内晶石GameEngineHUD 低频显示副本回调或节流同步到 @State
是否暂停、是否结算页面决定组件树分支页面 @State
结算结果页面弹窗只读展示子组件 @Prop
摇杆拖动位置摇杆组件只影响自身外观子组件私有 @State
摇杆输入向量GameEngine子组件向父层发事件函数回调
设置行右侧控件调用方通用行负责布局@BuilderParam

如果一份数据有两个权威拥有者,迟早会不同步。项目让引擎保存实时世界,让页面保存 UI 阶段,这是合理起点。

把这条边界画成数据流会更直观:引擎中的高频世界状态不会直接变成大量 ArkUI 节点,而是由页面按 HUD 所需频率提取快照;子组件不反向修改父状态,只通过语义回调把用户意图送回页面。

flowchart LR
  A["GameEngine 高频世界状态"] -->|"节流提取 HUD 快照"| B["Index 页面 @State"]
  B -->|"@Prop 数据向下"| C["GameHud / GameOverDialog"]
  D["VirtualJoystick / 操作按钮"] -->|"回调事件向上"| B
  B -->|"调用输入、暂停、重开接口"| A

图中的两个方向承担不同语义:上半条链路传递事实状态,下半条链路传递用户意图。把二者混成双向可写对象,会让“谁最后修改了数据”变得无法追踪。

二、@State:组件自己拥有、变化后需要重建的值

虚拟摇杆是最直观例子:

@Component
export struct VirtualJoystick {
  @State private stickX: number = 0;
  @State private stickY: number = 0;
  @State private isDragging: boolean = false;
  @State private baseX: number = 0;
  @State private baseY: number = 0;
}

这些值由摇杆内部触摸事件修改,也只用于决定摇杆是否显示、底座位于哪里、杆帽偏移多少。父组件无需知道像素位置,所以状态留在子组件最合适。

if (this.isDragging) {
  Stack() {
    Circle({
      width: this.baseRadius * 2,
      height: this.baseRadius * 2
    });
    Circle({
      width: this.stickRadius * 2,
      height: this.stickRadius * 2
    }).position({
      x: this.baseRadius - this.stickRadius + this.stickX,
      y: this.baseRadius - this.stickRadius + this.stickY
    });
  }
}

触摸移动会频繁更新两个 @State,但影响范围仅在小组件内部。如果把这些字段提升到 1900 行 Index 页面,每次移动都可能让更大的组件树参与依赖分析。

三、不是所有变化都要成为 @State

GameEngine 包含坦克、子弹、粒子等高频数据,但页面只保存普通引用:

private gameEngine: GameEngine | null = null;
private context:
  CanvasRenderingContext2D = new CanvasRenderingContext2D(...);

引擎通过 Canvas 命令式绘制,而不是让每颗子弹成为 ArkUI 组件。这样 60~120 FPS 的坐标变化不会触发声明式组件重建。

页面只把用户真正看到的少量数据同步成状态:

@State currentWave: number = 1;
@State survivalTime: number = 0;
@State sessionCoins: number = 0;
@State teamAScore: number = 0;
@State teamBScore: number = 0;

当前通过 100ms 定时器读取引擎,大约以 10Hz 刷新 HUD。世界可以高帧率运行,数字文本不必同频更新。这是游戏引擎与声明式 UI 协作的关键策略。

四、@Prop:父组件拥有,子组件只读

结算弹窗接收父页面结果:

@Component
export struct GameOverDialog {
  @Prop result: 'win' | 'lose' | 'p2_win' = 'lose';
  @Prop stats: GameStats = new GameStats();
}

父页面构建时传入:

GameOverDialog({
  result: this.gameResult,
  stats: this.gameStats,
  onRestart: () => this.restartGame(),
  onExit: () => this.stopGame()
});

弹窗不能直接改变 gameResultgameStats 的权威值。它只显示,并通过回调告诉父页面“用户想重开”或“用户想退出”。这就是单向数据流:数据向下、事件向上。

TankShape 同样用 @Prop colorscaleSizefacing 接收外观配置。可复用视觉组件应该由调用者决定外观,内部不保存第二份颜色状态。

五、对象作为 @Prop 时要注意深层修改

stats 是一个类实例。父页面通过替换整个对象:

this.gameStats = new GameStats();
this.gameStats.score = source.score;

第一次赋新对象通常能触发依赖更新,但随后对对象内部字段逐个赋值是否被观察,取决于使用的状态管理版本和对象是否可观察。最稳妥的做法是先在局部变量中填完,再一次性赋值:

const snapshot = new GameStats();
snapshot.score = source.score;
snapshot.targetsDestroyed = source.targetsDestroyed;
snapshot.survivalTime = source.survivalTime;
this.gameStats = snapshot;

这样弹窗收到的是完整快照,不会经历“新对象已推送,但字段还没复制完”的中间状态。更复杂模型可以使用 @Observed@ObjectLink 或新版状态管理能力,但应与项目目标 API 和现有风格保持一致,不能混用后假设行为相同。

六、回调:子组件输出行为,而不是修改父状态

虚拟摇杆输出归一化向量:

public onMove: (vector: Vector2) => void = () => {};

private handleTouch(event: TouchEvent): void {
  // 计算 dx、dy 并限制最大距离
  const input = new Vector2(
    dx / this.maxDistance,
    dy / this.maxDistance
  );
  this.onMove(input);
}

父页面把事件交给引擎:

VirtualJoystick({
  isHiddenStyle: true,
  onMove: (vector: Vector2): void => {
    this.gameEngine?.setInputVector(
      vector.x,
      vector.y
    );
  }
});

摇杆不知道 GameEngine,GameEngine 也不知道 ArkUI 触摸组件,中间由页面装配。这种依赖方向让摇杆能单独预览,也能替换成键盘、手柄或传感器输入。

Touch Up 和 Cancel 时回调零向量非常重要,否则引擎会保留上一次方向,手指松开后坦克仍继续移动。

七、@Builder:复用组件树片段,不等于独立组件

Index 使用大量 @Builder 拆分同一页面中的视觉区块,如主页、难度、游戏、帮助遮罩、头像选择和资料弹窗。

@Builder
HelpOverlay(): void {
  Stack() {
    Rect()
      .fill('rgba(0,0,0,0.8)')
      .onClick(() => {
        this.isHelpOpen = false;
      });

    Column() {
      Text(this.helpTitle);
      // ...
    }
  }
}

Builder 可以直接访问宿主页面的所有字段,因此写起来方便。但它不是清晰的状态边界:帮助 Overlay 仍与 Index 的几十个字段同属一个组件,无法仅从参数看出依赖。

适合 Builder更适合独立 Component
只在一个页面使用的短布局片段在多个页面复用
强依赖宿主少量状态有独立状态和生命周期
不需要独立预览/测试需要单独测试、维护
参数很少、语义局部回调和输入契约明确

当 Builder 超过数百行或同时操作多组状态时,应该考虑提取组件,而不是继续用方法折叠代码。

八、@BuilderParam:把布局插槽交给调用者

设置页的 SettingItem 负责统一一行的标签、背景和间距,右侧内容由调用方提供:

@Component
struct SettingItem {
  @Prop label: ResourceStr = '';
  @BuilderParam contentBuilder: () => void;

  build(): void {
    Row() {
      Text(this.label);
      Blank();
      this.contentBuilder();
    }
  }
}

这样同一个容器可以放 Toggle、Slider、文本或按钮,而无需为每种控件写一个 SettingItem@BuilderParam 解决的是“可组合布局”,不是数据双向绑定。

调用者仍应拥有控件值,并在 onChange 中更新。容器只负责结构,这种模式类似具名插槽。

九、数组状态:修改元素还是替换引用

自定义大厅把玩家列表标记为 @State

@State teamAPlayers: Array<PlayerModel> = [
  new PlayerModel('我 (房主)', true)
];
@State teamBPlayers: Array<PlayerModel> = [];

发现设备后使用 push(),返回设置时直接赋新数组 []。不同状态管理版本对数组原地方法的观察能力可能有差异。为了让变更意图和不可变数据流更明确,可以统一替换:

this.teamBPlayers = [
  ...this.teamBPlayers,
  newPlayer
];

this.teamBPlayers = this.teamBPlayers.filter(
  item => item.deviceId !== leavingId
);

数组规模只有 1~6 时,复制成本可以忽略,换来的是稳定可预测的状态通知。

十、Map 状态与“强制刷新”问题 ⚠️

大厅槽位配置使用:

@State slotConfig: Map<string, string> = new Map();

点击后原地修改:

this.slotConfig.set(key, current);

// Force UI update hack if Map doesn't trigger
this.mapSizeValue = this.mapSizeValue;

mapSizeValue 赋相同值并不一定触发重建,且槽位状态与地图值没有语义关系。这种“借别的状态刷新”会让依赖难以理解。

更明确的方法是替换 Map 引用:

const next = new Map(this.slotConfig);
next.set(key, current);
this.slotConfig = next;

或将固定六个槽位建模为数组对象,每次替换对应元素。选择哪种取决于目标 ArkUI 状态管理能力,但原则是“修改谁,就通知谁”,不要用无关字段制造刷新。

十一、重复状态会产生漂移

大厅同时保存:

@State mapSize:
  'small' | 'medium' | 'large' = 'medium';
@State mapSizeValue: number = 1;

点击 SizeOption 时同时修改两者。只要某条路径漏改一个,文本描述、按钮选中和最终传参就会不一致。

可以只保存 mapSize,索引由纯函数推导:

private mapSizeIndex(): number {
  if (this.mapSize === 'small') return 0;
  if (this.mapSize === 'large') return 2;
  return 1;
}

类似重复还出现在 currentPage 与多组布尔状态、永久金币与页面缓存金币。派生值尽量计算,不要再保存第二份。

十二、大页面中如何按领域分组状态

Index 当前有数十个 @State,包括:

  • 页面与游戏阶段;
  • 用户资料与头像选择;
  • 波次、时间和 PvP 分数;
  • 经济数据;
  • 帮助弹窗;
  • 折叠屏状态;
  • 难度动画;
  • 各类 Overlay。

字段过多的直接问题不是文件看起来长,而是任何 Builder 都能访问所有状态,所有权不可见。渐进拆分可以从稳定边界开始:

IndexShell
├── HomePanel(资料、模式入口、余额)
├── DifficultyPanel(难度选择与入场成本)
├── GameSurface(Canvas 与尺寸)
├── GameHud(波次、时间、分数)
├── PauseOverlay(命令回调)
└── GameOverDialog(结果快照)

父层只保存主阶段和 GameSession,子组件获得最小输入。不要第一步就引入全局 Store;先把局部所有权理清,往往已经能消除大部分复杂度。

十三、生命周期回调中的状态更新

页面在 aboutToAppear() 中注册折叠状态监听、初始化 Manager、绑定引擎回调并启动 HUD 定时器。异步回调会修改 @State,所以页面离开时必须解除:

display.on('foldStatusChange', callback);
setInterval(() => {
  this.sessionCoins = engine.gameStats.coinsCollected;
}, 100);

当前 aboutToDisappear() 只停止游戏循环,没有保存并清除 interval,也没有注销 foldStatusChange。这会让离开后的旧页面仍被回调持有,并继续修改状态。

状态管理和生命周期不可分割:谁注册回调,谁保存句柄并释放;谁给 Manager 的 onGameEnd 赋函数,谁在销毁时置空或换成会话令牌保护。

十四、性能:让 UI 更新频率匹配信息价值

不同数据需要不同频率:

数据合理更新方式
坦克位置、子弹Canvas 游戏循环直接绘制
摇杆像素偏移组件局部 Touch 事件
倒计时文本10Hz 或更低即可
晶石数字拾取事件触发,或 10Hz 同步
总金币结算/购买/页面恢复时刷新
排行榜进入页面时加载
头像和昵称用户确认后更新

把引擎整个对象标成响应式会让内部每次数值变化都可能污染 UI 更新;反过来,永久余额只在初始化读取一次又会在从商城返回后过期。更新频率必须由用户是否能感知和数据变化来源决定。

十五、测试状态边界 🧪

测试点断言
摇杆 Down/Move/Up私有状态变化,父层依次收到零/方向/零
父页面更新 gameResult弹窗标题随 @Prop 更新
弹窗点击重开只调用回调,不直接访问引擎
数组增加玩家TeamSlots 立即出现新成员
Map 切换槽位文本立即从等待→AI→关闭
修改无关状态不应成为槽位刷新的必要条件
引擎 120Hz 更新ArkUI HUD 不以 120Hz 重建
页面反复进入离开interval 和监听器数量不增长
统计快照填充子组件不看到半完成对象
多个 Overlay 状态互斥规则明确,不相互覆盖

十六、总结 ✨

ArkUI 状态管理的关键不是装饰器数量,而是边界。组件自己拥有、影响自身构建的数据用 @State;父层拥有、子层只读的数据用 @Prop;行为通过回调向上传递;布局插槽使用 @BuilderParam;短小局部视图用 @Builder,真正拥有状态和生命周期的模块应提取为独立组件。

项目已经做对了几项重要选择:Canvas 世界不进入响应式树,摇杆状态留在组件内部,结算弹窗保持只读,HUD 以较低频率复制引擎值。同时也存在 Map 原地修改后借无关字段刷新、重复保存地图值、超大页面状态过多、监听与定时器未完整清理等问题。通过最小权威源、不可变替换、明确事件方向和领域化组件拆分,才能让声明式 UI 在实时游戏中既灵活又可控。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS ArkUI 状态管理 声明式UI 组件化 Canvas

img

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐