耳畔三国 HarmonyOS 设计篇(27):MainFrame 听读、地图与人物模块拆分规划
一、拆分对象是真实巨型页面,拆分结果仍是规划
当前MainFrame同时持有听读播放、TTS、媒体会话、后台任务、地图缩放、人物与事件选择、收藏笔记、主题和多端布局状态,文件已超过五千行。大量 Builder 与业务函数共用同一组页面字段,修改一个区域时很容易触碰另一个区域的生命周期。
这能证明“需要拆”,不能证明“已经拆完”。本文规划把听读、地图和人物三个领域移出主框架,同时让MainFrame退回到导航、主题和页面组合层。迁移期间必须保持现有行为可回滚,尤其不能因为整理代码破坏后台朗读、地图手势或本地数据。
| 当前集中职责 | 目标所有者 | MainFrame 保留 |
|---|---|---|
| TTS、进度、媒体会话、后台任务 | AudioFeature | 选中入口与页面装配 |
| 年份、阵营、缩放、全屏地图 | MapFeature | 导航到地图区域 |
| 人物选择、标签、关联跳转 | PeopleFeature | 跨模块路由协调 |
| 收藏、笔记、主题 | Shared / AppShell | 主题与顶层生命周期 |
二、模块边界按状态所有权划分
拆文件不是把 Builder 剪切到三个目录。每个模块必须拥有自己的状态、命令和持久化端口。听读模块拥有播放意图和媒体资源;地图模块拥有视口变换;人物模块拥有选择与内容投影。跨模块只传稳定 id 和事件,不共享对方内部对象。
export interface FeatureModule<State, Command, Event> { snapshot(): State dispatch(command: Command): Promise<void> subscribe(listener: (state: State) => void): () => void onEvent(listener: (event: Event) => void): () => void activate(): Promise<void> deactivate(): Promise<void> } export interface FeatureRoute { feature: 'audio' | 'map' | 'people' targetId?: string source: 'home' | 'search' | 'related' | 'favorite' }激活和停用是显式生命周期。切走人物页只解除 UI 订阅;切走听读页时是否继续播放由播放策略决定,不能随着组件销毁被动停止。
三、听读模块先隔离意图,再搬迁平台资源
听读是风险最高的模块,因为它同时连接 TTS、AVSession、后台任务、定时器和应用生命周期。第一阶段不移动平台对象,只提取AudioState、AudioCommand和 reducer,让现有方法通过命令更新状态。状态稳定后,再把媒体会话与 TTS 放入 runtime adapter。
export interface AudioState { selectedId?: string phase: 'idle' | 'preparing' | 'playing' | 'paused' | 'failed' progressSeconds: number durationSeconds: number loopMode: 'list' | 'single' | 'once' backgroundActive: boolean errorCode?: string } export type AudioCommand = | { type: 'select'; audioId: string } | { type: 'play' } | { type: 'pause' } | { type: 'seek'; seconds: number } | { type: 'next' } | { type: 'lifecycle'; state: 'foreground' | 'background' } export interface AudioRuntime { prepare(text: string): Promise<number> play(fromSeconds: number): Promise<void> pause(): Promise<void> release(): Promise<void> }| 迁移阶段 | 允许变化 | 必须保持 |
|---|---|---|
| 提取状态与命令 | 调用入口变为 dispatch | 播放、暂停、进度表现 |
| 提取 runtime adapter | 平台对象移出页面 | AVSession 与后台行为 |
| 提取页面组件 | Builder 独立 | 手机和平板布局 |
四、地图模块把视口变换与业务选择分开
地图的年份、阵营属于业务选择,缩放与偏移属于视口状态。两者混在一起时,切换年份可能意外沿用不适合新地图的偏移。地图模块应让业务模型决定可见区域,让视口控制器负责夹取比例、手势增量和复位。
export interface MapSelection { year: string factionId: string } export interface MapViewport { scale: number offsetX: number offsetY: number fullscreen: boolean } export interface MapController { selectYear(year: string): void selectFaction(factionId: string): void applyPinch(scaleDelta: number, centerX: number, centerY: number): void applyPan(deltaX: number, deltaY: number): void resetViewport(): void snapshot(): { selection: MapSelection; viewport: MapViewport } }年份变化后,如果地图资源尺寸或可见阵营集合变化,视口回到安全默认值;仅打开全屏时保留当前中心点。手势算法通过纯函数测试,不依赖完整页面渲染。
五、人物模块通过 id 建立跨域链接
人物详情会跳到相关人物、事件或阵营。模块间不能互相 import 页面状态,而是发布领域事件,由 AppShell 解析为路由。人物模块只知道personId,事件模块只知道eventId,共享目录服务负责名称到 id 的解析和不存在时的降级。
export type PeopleEvent = | { type: 'open_person'; personId: string } | { type: 'open_event'; eventId: string } | { type: 'open_faction'; factionId: string } | { type: 'play_person_audio'; personId: string } export interface PeopleState { selectedPersonId: string selectedTab: '生平' | '关系' | '评价' textMode: '原文' | '译文' relatedPersonIds: string[] } export interface HistoricalDirectory { personById(id: string): Person | undefined eventById(id: string): HistoryEvent | undefined factionById(id: string): Faction | undefined }找不到关联对象时保持当前页面并显示行内提示,不能跳到默认人物制造错觉。播放人物内容通过事件交给听读模块,人物模块不持有 TTS 实例。
六、AppShell 只做装配和跨模块协调
目标MainFrame只保留当前主标签、主题、多端布局、模块装配和事件路由。它不再计算听读进度、不再夹取地图坐标,也不直接读写人物收藏。共享的 Preferences 端口由依赖注入传给各 Repository;模块之间通过事件总线或显式协调器通信。
| 交互 | 事件来源 | 协调结果 |
|---|---|---|
| 人物页点击朗读 | PeopleEvent | AudioFeature 选择对应内容 |
| 地图点击阵营 | MapEvent | PeopleFeature 打开阵营人物 |
| 收藏中打开人物 | FavoriteEvent | AppShell 切页并传 personId |
| 应用进入后台 | 生命周期 | AudioFeature 决定继续或暂停 |
这种结构允许手机底部导航和平板侧栏复用同一模块状态,只替换页面组合,不复制业务逻辑。
七、迁移失败必须可以按模块回退
一次性改写五千行风险过高。每个模块都通过 feature flag 控制新旧实现,状态序列化格式暂时保持兼容。新模块启动失败时回到旧 Builder;持久化迁移失败时只读旧数据,不覆盖;听读 runtime 初始化失败时退回文本阅读;地图资源缺失时退回列表视图。
迁移期间最危险的是双写。首阶段只让旧实现写、新实现读影子状态;比对稳定后切为新实现单写。任何时刻都不能让两个播放器同时占用媒体会话,也不能让新旧地图控制器同时处理手势。
八、实施顺序和验收门槛
顺序建议为:先建立共享类型与模块合同;再提取人物模块,因为平台依赖最少;随后提取地图纯状态和手势;最后处理听读状态、runtime 与后台生命周期;三个模块稳定后再缩减 AppShell,并删除旧路径。
验收必须比较拆分前后的行为:冷启动与主题切换一致;人物、事件、阵营互跳不丢目标;地图缩放、全屏和年份切换可预测;朗读播放、暂停、拖动、后台继续和回前台恢复一致;收藏笔记不丢失;手机与平板入口一致;模块停用后订阅、定时器和平台资源都释放。证据计划包括新旧路径录屏、状态快照对比、媒体会话日志、内存和资源释放检查。没有这些结果前,文章保持设计定位。
参考:HarmonyOS ArkTS MVVM 开发指导。
九、总结
巨型页面的拆分单位不是视觉区块,而是状态所有权和生命周期。听读、地图、人物各自拥有状态与平台端口,AppShell 只负责装配和路由,才能真正降低修改半径。以可回滚的阶段迁移替代一次性重写,也能让后台播放和本地数据这些高风险能力在结构调整中保持可验证。