【OpenHarmony/HarmonyOS】1900 行 ArkUI 页面如何治理:职责识别、渐进拆分与回归保护

大页面通常不是某一天“写坏”的,而是一项项合理需求叠加出来的:主页要展示用户,游戏要挂 Canvas,HUD 要轮询引擎,结算要保存积分,折叠屏要监听形态,资料页又需要头像选择。最终,“迷宫坦克派对”的 Index.ets 达到 1903 行,包含 49 处 @State 和 14 个 @Builder。本文不假装已经完成重构,而是基于现有源码制作职责地图,给出一套能持续交付、可随时回退的渐进拆分方案。🏗️

一、1903 行不是根因,只是一个信号

单纯以文件行数判定质量并不严谨。一个 1900 行的静态配置可能比 300 行的混乱状态机更容易维护。Index.ets 真正的问题是变化原因太多:引擎接线、页面导航、资料编辑、折叠屏适配和战绩展示都会修改同一个组件。

当前文件的量化轮廓如下:

指标 当前源码事实 说明
文件行数 1903 页面、逻辑与样式集中
@State 出现次数 49 多组不同生命周期的响应式状态
@Builder 数量 14 已有局部拆分意识,但仍共享父状态
页面分支 home / difficulty / game 一个组件承担多个主视图
引擎实例 `GameEngine null`
结果 UI GameOverDialog + ResultOverlay 组件路径与旧 Builder 同时存在

这里的数字不是“必须立即重写”的结论,而是提示我们先回答三个问题:哪些状态属于同一个职责?哪些副作用需要成对释放?哪些行为在拆分前必须锁定?

二、先画职责地图,不急着移动代码 🔍

Index 当前承担的职责可以分成八组:

flowchart TD
  I["Index.ets"] --> A["应用外壳与 home/difficulty/game 切换"]
  I --> B["Canvas 与 GameEngine 生命周期"]
  I --> C["HUD、波次、计时、队伍比分"]
  I --> D["暂停、结算、重开、返回主页"]
  I --> E["用户资料、头像选择、编辑"]
  I --> F["排行榜入口、商城、设置、路由"]
  I --> G["折叠屏监听与横屏布局"]
  I --> H["帮助、加载、关卡过渡等 Overlay"]

这些职责的变化频率和资源生命周期完全不同。例如头像选择是低频系统交互,HUD 同步每 100ms 执行一次;折叠屏 listener 跟随页面,GameEngine loop 跟随游戏会话。把它们全部放进 aboutToAppear(),就很难判断离开页面时需要释放什么。

三、49 个 State 应按“所有者”重组

当前状态并非同一种类型:

状态组 代表字段 推荐所有者
外壳导航 currentPage、isLoading IndexShell
游戏会话 isGameRunning、isPaused、isGameOver、gameMode GameSessionController
HUD 快照 currentWave、survivalTime、sessionCoins、team scores GameHud 或 HUD model
用户资料 userName、userAvatar、profileTab、tempName ProfileOverlay
弹层 isHelpOpen、isLevelTransitionOpen 对应 Overlay/外壳
视觉动画 opacity、scale、rotationAngle 实际使用它的组件
设备形态 currentFoldStatus、screen size GameSurface/布局适配层

大页面中最危险的不是 State 数量本身,而是任意 Builder 都能读写任意 State。比如重开同时影响 isGameOverisPausedisGameInitialized、Canvas 挂载时机和引擎循环;资料保存又依赖 UserManager 与页面字段。状态所有权不清时,一次局部修改容易打破另一条路径。

四、布尔组合会产生“不合法但可表达”的状态

当前游戏状态由多个 boolean 协作:

@State isGameRunning: boolean = false;
@State isPaused: boolean = false;
@State isGameOver: boolean = false;
@State isGameInitialized: boolean = false;
@State isLoading: boolean = false;

理论上它们能组合出 32 种状态,但真正合法的只有少数。例如:

  • loading 时是否允许 isGameRunning=true
  • gameOver 后能否同时 paused?
  • initialized=false、running=true 是 Canvas 等待态还是错误态?
  • restart 的短暂 timeout 中 UI 应显示什么?

演进时可建立显式会话阶段:

type GameSessionPhase =
  | 'idle'
  | 'mounting'
  | 'running'
  | 'paused'
  | 'settling'
  | 'finished'
  | 'failed';

interface GameSessionState {
  phase: GameSessionPhase;
  mode: GameModeType;
  difficulty: 'easy' | 'normal' | 'nightmare';
  result?: 'win' | 'lose';
}

这是建议结构,不是当前源码已经完成的改造。迁移时也不必一次删除全部 boolean,可先让 Controller 成为唯一写入口,再由 phase 派生旧字段,最后逐步移除旧状态。

五、现有 Builder 是很好的拆分候选清单

文件中已经存在:HelpOverlayAvatarSelectionOverlayUserProfileDialogProfileInfoViewProfileHistoryViewHomeBuilderDifficultyBuilderGameBuilderOverlayMenuResultOverlay 等 Builder。

Builder 可以减少 build() 的视觉长度,但它仍属于父组件,能直接访问父状态和私有方法,因此不等于建立了组件边界。

判断某个 Builder 是否适合提取为组件,可以看四个指标:

  1. 是否有清楚的输入和回调输出;
  2. 是否拥有独立动画或短生命周期状态;
  3. 是否可以在 Preview/测试中单独渲染;
  4. 是否不需要直接触碰 GameEngine 或全局 Manager。

以资料历史为例,它只需要标题与数据,已经接近纯展示组件:

@Builder
ProfileHistoryView(
  title: ResourceStr,
  data: Array<BattleRecord>
) {
  // 标题、表头、ForEach 列表
}

它可以优先提取为 ProfileHistoryList。相反,GameBuilder 同时包含 Canvas、摇杆、开火按钮、HUD、暂停与结算,应该先在内部划分 GameSurfaceGameHud,不宜直接整体搬到另一个同样巨大的文件。

六、目标结构:页面外壳、展示组件与会话控制器

可以将目标分成三层:

pages/
  Index.ets                 # IndexShell,只负责编排主视图

features/home/
  HomePanel.ets
  ProfileOverlay.ets
  ProfileHistoryList.ets

features/game/
  DifficultyPanel.ets
  GameSurface.ets           # Canvas、尺寸和输入
  GameHud.ets               # 波次、计时、比分、晶石
  PauseOverlay.ets
  GameSessionController.ts  # 引擎与会话生命周期
  GameSessionState.ts

依赖方向应该保持单向:

flowchart LR
  A["IndexShell"] --> B["HomePanel"]
  A --> C["DifficultyPanel"]
  A --> D["GameSurface"]
  D --> E["GameHud"]
  A --> F["ProfileOverlay"]
  A --> G["GameSessionController"]
  G --> H["GameEngine"]
  H --> G
  G --> A

图中的双向事件不应通过组件互相调用实现:Controller 调用 Engine;Engine 的回调进入 Controller;Controller 再发布小型状态快照给 UI。展示组件只接收状态和语义回调,不直接保存积分或操纵路由。

七、Controller 应该接管哪些逻辑?

当前 startGame() 同时做 UI loading、页面切换、状态复位、屏幕尺寸判断、延迟挂载和引擎初始化:

startGame(mode: GameModeType,
          difficulty = 'normal',
          multiplayerConfig = '{}'): void {
  this.isLoading = true;

  setTimeout(() => {
    this.currentPage = 'game';
    this.isGameRunning = true;
    this.isPaused = false;
    this.isGameOver = false;
    this.gameMode = mode;
    this.gameDifficulty = difficulty;
    this.pendingMultiplayerConfig = multiplayerConfig;
    // ...等待 Canvas 尺寸后 initGame
  }, 50);
}

GameSessionController 适合接管:

  • 当前 mode、difficulty、multiplayerConfig;
  • 引擎实例创建和 initGame/startGameLoop/stopGameLoop
  • 开始、暂停、继续、结算、重开、销毁等状态转换;
  • Engine 回调转成不可变 UI snapshot;
  • 一局结算的幂等保护;
  • 会话定时器的登记与释放。

但 Controller 不应直接生成 ArkUI 节点,也不应弹 Toast。页面把系统错误映射为用户反馈,Manager 负责存储和系统能力,Controller 只编排游戏会话。

八、定时器和监听器是拆分中的高风险区 ⚠️

aboutToAppear() 中创建了一个每 100ms 同步 HUD 的 interval,但返回 ID 没有保存:

setInterval(() => {
  if (this.isGameRunning && !this.isPaused && this.gameEngine) {
    this.currentWave = this.gameEngine.currentWave;
    this.survivalTime = Math.floor(
      (Date.now() - this.gameEngine.gameStats.startTime) / 1000
    );
    this.sessionCoins = this.gameEngine.gameStats.coinsCollected;
  }
}, 100);

aboutToDisappear() 当前只调用 stopGameLoop(),没有清理这个 interval。页面多次出现时,可能累计同步任务。

折叠屏监听同样在 aboutToAppear() 注册匿名回调:

display.on('foldStatusChange', (status) => {
  this.currentFoldStatus = status;
});

没有保留 callback,也没有看到对应 display.off。此外,波次 Banner、加载过渡、重开都使用多个 setTimeout。这些是重构前必须先登记的副作用,而不是搬文件后再处理。

建议建立资源袋:

class SessionResources {
  private timerIds: number[] = [];

  addTimer(id: number): void {
    this.timerIds.push(id);
  }

  clear(): void {
    this.timerIds.forEach((id) => clearTimeout(id));
    this.timerIds = [];
  }
}

interval 与 timeout 最好分别建模;listener 还需要保存完全相同的回调引用以便注销。页面离开、返回主页和开始新局都应调用幂等的 dispose()

九、路由参数与会话配置不能在拆分时丢失

onPageShow() 读取路由参数,但多人启动消费逻辑目前被注释为离线版本;pendingMultiplayerConfig 则保留在页面中。另一方面,restartGame() 调用:

setTimeout(() => {
  this.startGame(this.gameMode);
}, 10);

因为没有再次传入 gameDifficultypendingMultiplayerConfigstartGame 会使用默认难度 normal 和空多人配置。这是现有行为风险,重构测试必须把它锁定并决定是否修正。

更稳妥的会话参数应被保存成一个值对象:

interface GameLaunchConfig {
  mode: GameModeType;
  difficulty: 'easy' | 'normal' | 'nightmare';
  multiplayerConfig: string;
}

class GameSessionController {
  private launchConfig?: GameLaunchConfig;

  restart(): void {
    if (!this.launchConfig) return;
    this.start(this.launchConfig);
  }
}

这样重开天然复用完整配置。真正修改行为前,需要先补测试并确认产品期望;本文只给出设计,不修改现有源码。

十、结果 UI 存在重复实现

当前 GameBuilderisGameOver 时渲染独立组件:

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

文件后部又保留了完整的 ResultOverlay() Builder,包含胜负标题、击毁数、晶石、得分、时长和按钮;当前文件中没有看到它被调用。

重复 UI 的风险包括:修复统计字段时只改一份、多语言资源不一致、动画状态继续占据父组件、维护者误以为两套都在运行。治理时应先确认实机只走 GameOverDialog,再删除或迁出未使用 Builder。删除前可用截图测试保存当前生效路径的视觉基线。

十一、资料区也是独立 Feature,不只是一个弹窗

Index 内的资料职责包括:

  • 读取与展示用户名、头像;
  • 调用系统图片选择器;
  • 随机资料;
  • 暂存与保存昵称;
  • PVE/PVP Tab;
  • 总局数、胜率、最高分与目标数;
  • 一组页面本地的模拟历史记录。

其中 pveHistory 使用写死的日期与成绩,并不是云端或真实本地战绩。重构时若直接把它命名为 Repository 返回值,会把 mock 状态伪装成真实数据。建议显式注入:

interface BattleHistorySource {
  loadPveHistory(): Promise<Array<PveHistoryRow>>;
}

class MockBattleHistorySource implements BattleHistorySource {
  async loadPveHistory(): Promise<Array<PveHistoryRow>> {
    return SAMPLE_HISTORY;
  }
}

当真实存储接通时替换实现,UI 无需知道数据来自 mock、Preferences 或云端。开发构建还可以在页面角落以非发布方式标明 mock,避免测试截图被误解。

十二、渐进拆分的六个阶段

阶段 0:建立行为清单

记录主页、难度选择、四种模式、暂停、结算、重开、头像、折叠形态和返回栈的现有行为。对关键页面截屏,并保存状态转换表。

阶段 1:只提取纯展示组件

优先提取 StatBoxProfileHistoryListDifficultyCard 等。输入用 @Prop,操作通过回调返回,不调用 Manager 与 Engine。此阶段行为不变,风险最低。

阶段 2:提取 Overlay

移动帮助、头像选择、暂停、资料与关卡过渡。让每个 Overlay 持有自己的动画状态,父组件只保存是否打开与必要数据。

阶段 3:拆分 GameSurface 与 GameHud

GameSurface 负责 Canvas 尺寸、摇杆和开火输入;GameHud 只读 HUD snapshot。不要让 HUD 每个字段各自访问 GameEngine。

阶段 4:引入 GameSessionController

先把现有方法委托给 Controller,再逐个移动引擎生命周期。每搬一条状态转换就跑回归测试,不在同一个提交中同时更改玩法规则。

阶段 5:收拢副作用

所有 timer、DisplaySync、fold listener、Engine callback 通过 start/dispose 成对管理,确保多次调用 dispose 安全。

阶段 6:删除兼容层和死代码

确认 ResultOverlay、旧 boolean 和旧 Builder 没有调用点后再删除。最后把 Index 收敛成编排层,而不是追求某个机械的行数目标。

十三、组件接口要小,避免“把大页面搬成大参数包”

一个常见失败方式是把 49 个 State 全部作为属性传给子组件。文件变小了,耦合没有减少。

推荐给 GameHud 一个语义快照:

interface GameHudSnapshot {
  mode: GameModeType;
  wave: number;
  survivalSeconds: number;
  sessionCoins: number;
  teamAScore: number;
  teamBScore: number;
  isPaused: boolean;
}

@Component
struct GameHud {
  @Prop snapshot: GameHudSnapshot;
  onPause: () => void = () => {};
}

快照应在固定入口整体替换,避免普通对象内部字段变化无法触发预期 UI 更新。回调使用 onPause/onFire/onExit 等语义名称,而不是把整个 Controller 暴露给组件。

十四、回归保护:先做特征测试,再改结构 🧪

大页面重构最适合“特征测试”:先记录当前真实行为,即使其中有待改进之处;结构迁移阶段保证行为不意外变化;业务修复另开步骤。

测试层 必测行为 关注点
纯逻辑 session 状态转换 非法转换、重复结算
组件 DifficultyPanel、HUD、Overlay 输入/回调、空数据、多语言
引擎适配 start/pause/resume/restart/dispose 调用次数和参数保持
页面集成 home → difficulty → game 页面分支、返回行为
生命周期 appear/disappear 多次 无重复 timer/listener
视觉截图 HUD、暂停、结算、折叠形态 布局不偏移、不遮挡
数据 结算保存、资料编辑 只执行一次、失败可恢复

关键用例应包括:

  1. nightmare 难度重开后仍保持 nightmare;
  2. 多人配置重开时不丢失;
  3. onGameEnd 连续触发两次只结算一次;
  4. 离开页面后 HUD interval 不再执行;
  5. 页面再次进入只注册一个 fold listener;
  6. Canvas 未获得有效尺寸时不初始化引擎;
  7. 结算弹窗关闭后再启动 loop,避免 UI 与引擎同时抢占;
  8. 资料历史为空时显示空态,mock 数据明确可替换;
  9. 图片选择失败保留旧头像;
  10. 组件拆分后触摸区域与折叠屏布局保持一致。

十五、如何控制每一步的风险?

每个重构批次遵循以下约束:

  • 一次只移动一个职责,不顺手调整游戏数值;
  • 先复制接口与测试,再移动实现,最后删旧路径;
  • UI 提取前记录截图,逻辑提取前记录调用序列;
  • 新旧实现可短期由开关选择,便于快速回退;
  • 每次拆分后做编译、单测、页面导航和一局实玩;
  • 不修改 Cloud/P2P 等尚未闭环能力的产品表述;
  • 对 timer/listener 使用资源计数,验证回到 0;
  • 变更结束后重新搜索旧 Builder、旧字段和重复组件。

衡量结果不只看 Index 减少了多少行,还看:状态是否有唯一所有者、副作用是否成对释放、组件能否独立预览、错误是否能被定位,以及新增一个玩法是否还要修改五个不相关区域。

十六、一次完整迁移后的职责契约

目标契约可以写成:

模块 负责 不负责
IndexShell 主视图编排、路由级 Overlay 引擎帧循环、资料持久化
HomePanel 主页展示与操作回调 直接 push 路由、保存用户
DifficultyPanel 难度选项和说明 启动 Engine
GameSurface Canvas、尺寸、输入事件 结算入账
GameHud 展示 HUD snapshot 轮询 Engine
ProfileOverlay 编辑暂存与展示 决定存储实现
GameSessionController 会话状态、Engine 编排、释放 构建 ArkUI 节点
Repository/Manager 数据与系统能力 页面动画

契约的价值是让团队能回答“新代码应该放在哪里”。如果模块不负责某件事,就通过接口交给正确所有者,而不是为了方便重新引用父页面。

十七、当前不应在文章中宣称什么?

为了让技术文章与源码一致,需要特别避免以下表述:

  • 不能说 Index.ets 已经拆成上述组件;当前仍是 1903 行单文件;
  • 不能说 HUD 定时器和折叠屏监听已完整释放;当前没有对应清理;
  • 不能说资料历史已接云端;当前数组是 mock;
  • 不能说多人路由参数已经被 Index 消费;相关代码被注释;
  • 不能说结算 UI 只有一套;仍有组件与旧 Builder 两份实现;
  • 不能说引入状态枚举后问题已解决;枚举与 Controller 都是本文建议。

准确区分现状与方案,会让重构文章更有价值:读者能看到真实债务、推导过程和可以验证的下一步,而不是只看到一张漂亮目录树。

十八、总结 ✨

Index.ets 的 1903 行来自真实功能增长:它既是页面外壳,也是 GameEngine 宿主、Canvas 表面、HUD 同步器、资料中心、折叠屏监听者与结算协调者。49 个 @State 和 14 个 Builder 并非单独的错误,但它们揭示了状态所有权与副作用边界已经模糊。未保存的 HUD interval、未注销的 fold listener、多处 timeout、mock 战绩、重开参数丢失风险和重复结果 UI,都是拆分前需要锁定的真实行为。

治理大型 ArkUI 页面不应从“新建十个文件”开始,而应从职责地图和特征测试开始:先提取纯展示组件,再拆 Overlay 和 GameSurface/HUD,随后用 GameSessionController 收拢引擎状态与资源释放,最后删除兼容层和死代码。每一步都保留可运行版本、截图基线和回归用例。最终目标不是把 1900 变成一个好看的数字,而是让每种变化只有一个明确落点,让下一次新增玩法不再扩大整个页面的风险半径。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS ArkUI 页面重构 状态管理 组件化 技术债治理

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐