【OpenHarmony/HarmonyOS】1900 行 ArkUI 页面如何治理:职责识别、渐进拆分与回归保护
【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。比如重开同时影响 isGameOver、isPaused、isGameInitialized、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 是很好的拆分候选清单
文件中已经存在:HelpOverlay、AvatarSelectionOverlay、UserProfileDialog、ProfileInfoView、ProfileHistoryView、HomeBuilder、DifficultyBuilder、GameBuilder、OverlayMenu、ResultOverlay 等 Builder。
Builder 可以减少 build() 的视觉长度,但它仍属于父组件,能直接访问父状态和私有方法,因此不等于建立了组件边界。
判断某个 Builder 是否适合提取为组件,可以看四个指标:
- 是否有清楚的输入和回调输出;
- 是否拥有独立动画或短生命周期状态;
- 是否可以在 Preview/测试中单独渲染;
- 是否不需要直接触碰 GameEngine 或全局 Manager。
以资料历史为例,它只需要标题与数据,已经接近纯展示组件:
@Builder
ProfileHistoryView(
title: ResourceStr,
data: Array<BattleRecord>
) {
// 标题、表头、ForEach 列表
}
它可以优先提取为 ProfileHistoryList。相反,GameBuilder 同时包含 Canvas、摇杆、开火按钮、HUD、暂停与结算,应该先在内部划分 GameSurface 和 GameHud,不宜直接整体搬到另一个同样巨大的文件。
六、目标结构:页面外壳、展示组件与会话控制器
可以将目标分成三层:
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);
因为没有再次传入 gameDifficulty 和 pendingMultiplayerConfig,startGame 会使用默认难度 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 存在重复实现
当前 GameBuilder 在 isGameOver 时渲染独立组件:
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:只提取纯展示组件
优先提取 StatBox、ProfileHistoryList、DifficultyCard 等。输入用 @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、暂停、结算、折叠形态 | 布局不偏移、不遮挡 |
| 数据 | 结算保存、资料编辑 | 只执行一次、失败可恢复 |
关键用例应包括:
- nightmare 难度重开后仍保持 nightmare;
- 多人配置重开时不丢失;
onGameEnd连续触发两次只结算一次;- 离开页面后 HUD interval 不再执行;
- 页面再次进入只注册一个 fold listener;
- Canvas 未获得有效尺寸时不初始化引擎;
- 结算弹窗关闭后再启动 loop,避免 UI 与引擎同时抢占;
- 资料历史为空时显示空态,mock 数据明确可替换;
- 图片选择失败保留旧头像;
- 组件拆分后触摸区域与折叠屏布局保持一致。
十五、如何控制每一步的风险?
每个重构批次遵循以下约束:
- 一次只移动一个职责,不顺手调整游戏数值;
- 先复制接口与测试,再移动实现,最后删旧路径;
- 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 页面重构 状态管理 组件化 技术债治理

更多推荐



所有评论(0)