足球口袋教练 HarmonyOS 设计(21):训练大页面拆分的模块边界规划

发布时间:2026/7/23 15:31:14
足球口袋教练 HarmonyOS 设计(21):训练大页面拆分的模块边界规划 一、先把这篇文章的定位说清楚本文是一份尚待落地的模块化设计方案讨论对象是足球口袋教练中的训练大页面。文中的接口、类型与伪代码用于约束后续实现不代表这些拆分已经进入当前版本。这样定位的原因很直接当训练入口、动作详情、计时、计划和记录都由一个页面同时管理时任何一次小改动都会碰到共享状态、生命周期和持久化问题但在真实拆分完成、构建通过并获得运行证据之前不能把目标架构描述成既成事实。规划要解决的是“边界如何定”而不是简单追求文件数量。拆成五个页面文件却继续共享十几个可变字段仍然只是把复杂度搬家。真正有效的拆分需要回答四个问题领域模型由谁定义计时与记录由谁保存跨页面状态由谁持有页面退出后哪些任务必须继续。HarmonyOS 的状态管理与 MVVM 思路可以参阅华为开发者文档。本文只引用这一份平台资料重点放在训练业务自己的契约上。二、先冻结范围再讨论目录本轮规划包含训练目录、动作详情、训练计时、今日计划和训练记录五个视图。它们共享动作编号、训练时长和完成结果却有不同的生命周期。训练目录适合短生命周期查询计时任务可能跨越页面切换训练记录必须能够持久化统计视图只消费稳定结果不应直接控制计时器。暂不纳入本轮的内容包括云端账号同步、跨设备接续、语音播报和手表协同。这些能力都需要额外的平台配置与运行证据不能因为目录里预留了接口就宣称已经支持。当前规划只保证边界能够容纳未来适配不提前承诺具体能力。模块本轮职责明确不负责TrainingHome分类、筛选、进入动作详情计时器生命周期与记录写入TrainingDetail展示动作步骤并发起训练直接修改统计数据TrainingActive呈现计时状态与操作命令自行决定持久化格式TrainingPlan编排今日任务与完成顺序维护动作素材原始数据TrainingStats聚合已完成记录并展示趋势控制训练流程边界确定后目录只是边界的物理表达。页面依赖状态层状态层依赖服务接口服务层依赖模型与存储抽象反向依赖则通过命令和结果对象返回避免视图直接读写全局变量。三、领域模型先于页面组件第一步应当把训练动作、计划任务和训练记录定义成稳定模型。模型不引用 ArkUI 组件也不携带弹窗、颜色或路由对象。这样才能在单元测试、后台任务和不同设备页面中复用同一份业务语义。下面是设计阶段的类型草案。字段命名刻意区分“计划时长”“实际用时”和“完成时间”避免一个duration在不同页面表示不同含义。export type TrainingLevel beginner | intermediate | advanced export interface TrainingItem { id: string title: string categoryId: string level: TrainingLevel plannedSeconds: number stepIds: string[] enabled: boolean } export interface PlanTask { taskId: string trainingId: string order: number targetSets: number completedSets: number planDate: string } export interface TrainingRecord { recordId: string trainingId: string taskId?: string startedAt: number finishedAt: number elapsedSeconds: number completedSets: number finishReason: completed | cancelled | interrupted }模型还需要满足两个约束。其一持久化字段必须可序列化不把计时器句柄或 UI 上下文塞入记录其二业务标识不能用数组下标代替否则排序、筛选之后会把记录关联到错误动作。四、服务层以接口隔离变化训练数据的来源、计时方式和统计口径变化速度不同因此不适合放进一个万能服务。规划中拆成TrainingCatalogService、TrainingTimerService与TrainingReportService三组接口目录服务只负责查询计时服务维护会话报告服务保存记录并生成聚合结果。export interface TrainingCatalogService { list(categoryId?: string): PromiseTrainingItem[] getById(trainingId: string): PromiseTrainingItem | undefined } export interface TrainingTimerSnapshot { sessionId: string trainingId: string phase: idle | running | paused | finished startedAt?: number elapsedSeconds: number } export interface TrainingTimerService { start(trainingId: string): PromiseTrainingTimerSnapshot pause(sessionId: string): PromiseTrainingTimerSnapshot resume(sessionId: string): PromiseTrainingTimerSnapshot finish(sessionId: string): PromiseTrainingTimerSnapshot restore(): PromiseTrainingTimerSnapshot | undefined } export interface TrainingReportService { save(record: TrainingRecord): Promisevoid listByDate(from: string, to: string): PromiseTrainingRecord[] summarize(records: TrainingRecord[]): TrainingSummary }接口拆开以后本地静态训练目录可以先落地计时服务随后替换为可恢复实现统计页面也可以用固定记录验证聚合逻辑。任何一层尚未完成时都能通过明确的空结果或不可用状态降级而不是让页面猜测底层发生了什么。五、状态层只暴露视图真正需要的状态状态层的核心不是把所有字段集中到一个类而是把业务状态变成有限状态集合。规划中的TrainingViewModel负责把目录查询、计时快照和记录保存转换为页面可消费的状态同时提供唯一命令入口。export interface TrainingUiState { loading: boolean items: TrainingItem[] selected?: TrainingItem timer?: TrainingTimerSnapshot planTasks: PlanTask[] message?: string recoverable: boolean } export class TrainingViewModel { Trace state: TrainingUiState { loading: false, items: [], planTasks: [], recoverable: true } constructor( private catalog: TrainingCatalogService, private timer: TrainingTimerService, private report: TrainingReportService ) {} async load(categoryId?: string): Promisevoid { this.patch({ loading: true, message: undefined }) try { const items await this.catalog.list(categoryId) this.patch({ items, loading: false }) } catch (error) { this.patch({ loading: false, message: 训练内容暂时不可用 }) } } select(item: TrainingItem): void { this.patch({ selected: item, message: undefined }) } private patch(next: PartialTrainingUiState): void { this.state { ...this.state, ...next } } }页面不直接持有 Repository也不自行拼装错误文案。视图只观察loading、items、timer、message等稳定字段并通过load、select、start、pause、finish命令驱动变化。这样才能明确判断一次点击究竟触发了哪条业务路径。六、视图层的拆分与编排方式五个视图组件不应互相调用内部方法。顶层训练路由负责选择当前场景组件接收只读状态与回调。动作详情只发送“开始训练”意图活动训练页只发送计时命令完成后由状态层生成记录并决定去向。Component export struct TrainingScene { Param state: TrainingUiState Event onSelect: (item: TrainingItem) void Event onStart: (trainingId: string) void Event onPause: () void Event onFinish: () void build() { Column() { if (this.state.timer?.phase running) { TrainingActiveView({ snapshot: this.state.timer, onPause: this.onPause, onFinish: this.onFinish }) } else if (this.state.selected) { TrainingDetailView({ item: this.state.selected, onStart: this.onStart }) } else { TrainingHomeView({ items: this.state.items, loading: this.state.loading, onSelect: this.onSelect }) } } } }状态归属建议持有者离开页面后的处理筛选词、折叠项当前视图可以丢弃或按体验需要保存已选动作TrainingViewModel返回训练入口时可恢复计时会话TrainingTimerService页面离开后仍能查询快照今日计划PlanRepository重启后按日期恢复完成记录ReportRepository写入成功后才参与统计这套编排不会自动带来后台计时能力。后续若要支持切到后台仍准确计时应以开始时间和暂停区间计算时长并补充后台、锁屏、进程回收后的运行验证不能只依赖页面上的每秒回调。七、迁移顺序要让每一步都可回退拆分不适合一次性改完。建议先记录现有训练入口的可见行为再按“模型、纯服务、状态层、叶子视图、顶层编排”的顺序迁移。每一步都保留清晰的输入输出出现回归时可以定位到最近一层。1. 冻结动作列表、详情、开始、暂停、结束和记录查询的行为清单。 2. 提取领域类型并为旧数据增加显式转换函数。 3. 建立目录、计时、报告接口先用内存实现跑通契约测试。 4. 引入状态层让旧页面逐步改为消费统一状态。 5. 先拆无副作用的详情与统计视图再拆活动训练页。 6. 顶层页面最终只保留路由选择、组件装配和生命周期转发。 7. 替换正式持久化实现完成迁移、异常与恢复测试。迁移期间可以设置兼容适配器把旧页面已有数据转换成新模型。适配器是过渡边界不应长期承载新业务。等新旧行为对照通过后再删除旧字段和重复分支。八、失败、降级与验收证据设计方案只有覆盖失败路径才具备实施价值。目录读取失败时显示可重试空态素材缺失时仍展示文字步骤记录保存失败时保留待写入记录并提示用户路由恢复找不到动作时回到训练入口计时会话无法恢复时明确结束原会话不能静默生成一条完整记录。失败场景降级动作后续验收证据训练目录为空或解析失败空态加重试不渲染无效详情错误注入测试与页面截图计时中切到后台以时间戳重算不依赖 UI 回调次数前后台切换录屏与时长对照记录写入失败保留待写入对象统计不提前增加存储失败日志与重试结果路由参数失效返回训练入口并提示内容已变化无效参数自动化用例动作图片缺失使用占位图并保留文字步骤缺图构建包运行截图实施完成的验收条件也要事先约定页面文件不再保存计时器句柄计时服务能恢复运行、暂停和完成快照记录只有持久化成功后才进入统计五个视图能独立预览或测试拆分前后的核心操作行为一致构建通过并获得与能力标题直接对应的运行证据。在这些证据齐备前本文仍保持设计方案定位。后续实现文章应引用真实类型、构建结果和运行画面清楚区分“已完成”“正在迁移”和“待验证”。九、总结训练大页面拆分的关键不是把代码分散到更多文件而是建立单向、可验证的依赖模型定义业务语义服务封装变化状态层统一命令视图只负责呈现和交互。计时、计划与记录拥有不同生命周期必须分别确定状态归属和恢复策略。这份规划给出了后续开发可以逐项执行的边界、接口、迁移顺序、失败处理与验收条件。等模型、服务和组件真实落地并通过构建与运行验证后再把设计稿升级为实战复盘才能让标题、正文和工程事实始终一致。