【古诗词闯关|12】HarmonyOS ArkTS 单页组件拆分实战:降低题库、闯关与结果弹层耦合
证据边界 :本文基于
D:\huawei\one10当前可见普通 ArkTS 源码与同一 CSDN 草稿进行静态复核。当前事实、历史证据与建议实现分开陈述;本轮没有运行构建或设备验证。
ArkUI 页面能写到几百行,并不意味着它一定需要"拆成越多文件越好"。真正的问题是:当一次需求改动同时触碰布局、答题状态、持久化和路由时,页面是否还拥有清楚的职责边界;当结果弹层需要在另一个入口复用时,是否必须把整页状态一并复制;当一个按钮样式变化时,是否会意外改坏答题流程。
本文基于"古诗词闯关"真实 HarmonyOS ArkTS 源码展开。项目入口 Index.ets 使用 Navigation + NavPathStack,路由集中在 RouteMap.ets;视图层也已经提取了 OptionButton、PoetryTopBar、HeartBar、CountdownTimer、StarRating 等组件。因此,本文不会把现有工程描述成一个未经拆分的巨型页面,而是继续审计剩余的高耦合边界。
源码统计显示:
| 文件 | 行数 | 当前同时承担的职责 |
|---|---|---|
PoetryHomeView.ets |
395 | 生命周期、设置修改、静态诗句、首页分区、导航 |
QuizView.ets |
338 | 路由参数、会话状态、答题渲染、结果反馈、持久化、结算跳转 |
LevelCompleteView.ets |
235 | 结果解析、诗人解锁、结算渲染、下一关导航 |
PoetsCollectionView.ets |
180 | 列表状态、卡片网格、选中状态、详情遮罩、关闭交互 |
这些数字本身不是坏味道的判定标准。值得拆分的是"变化原因不同却被迫一起修改"的代码,而不是单纯行数多。下面以首页、答题页、结算页和诗人详情弹层为四个样本,给出不破坏现有路由与服务层的渐进拆分方案。

一、本轮源码复核:已经拆出的组件与仍在页面里的职责
当前事实:组件目录不是空白起点
当前 poetry/components 下可见 CountdownTimer、DynastyRow、HeartBar、OptionButton、PoetryBackground、ScrollCard、StarRating 与 TopBar 八个组件。答题页已经组合生命值、倒计时和选项按钮,结算页已经组合星级组件。因此本文讨论的不是"把所有 UI 从零拆开",而是判断哪些页面职责已经形成稳定合同,哪些仍只是 @Builder 级别的局部整理。
OptionButton 是一个清楚的现状样本:输入为 label、text、OptionState,输出为 onTap;只有 DEFAULT 状态会触发回调。它不读取 QuizSession,不写 AppStorage,也不决定下一题路由。这个边界使父页面能统一决定正确、错误、选中和禁用状态。
CountdownTimer 也拥有独立生命周期:外部传入 totalSec、startKey、paused 和 onTimeout,组件内部维护 remaining 与 timerId,在出现时启动、startKey 改变时重启、消失时清理 interval。静态源码能证明清理代码存在,但本轮没有运行离页测试,不能把它写成"真机绝不残留计时器"。
当前事实:QuizView 仍是流程协调中心
QuizView 当前约 346 行。页面在 aboutToAppear() 中查找关卡并创建 QuizSession,持有 currentIndex、lives、timerKey、pickedIndex、revealAnswer、phase、paused、favorited 与 exiting 等状态。handlePick() 提交答案并同步展示状态,handleNext() 推进会话并重置题目状态,finalizeAndExit() 负责结算、AppStorage 乐观统计、quizResult 传递、页面跳转、后台 commit 与成就评估。
这说明组件拆分的核心瓶颈不是 build 树太长,而是页面同时承担展示状态、领域编排、存储副作用和导航策略。继续把 questionCard() 复制到一个新文件,却让新组件直接拿整个 QuizSession,只会移动代码位置,不会降低耦合。
当前事实:领域规则已经部分下沉
QuizSession 位于 libraryb 的服务层,管理题目索引、生命、正确数、开始时间、提交、推进和结算。它的 commit() 继续调用 PoetryStorageService 写关卡进度与用户统计。也就是说,页面不是规则唯一来源,但页面仍决定调用时机、乐观更新、跳转与异步错误降级。文章中的 Coordinator、Presenter 和不可变 ViewState 都是建议边界,不是当前类名。
二、建议实现:先建立状态快照,再提升组件层级
建议先定义面向渲染的快照,而不是把可变 Session 直接交给子组件。快照只包含当前帧需要的信息,例如题号、总题数、生命、题干、出处、选项视觉状态、反馈文案、是否暂停和主按钮文案。它不暴露 submit、advance、commit,也不允许展示组件自行导航。
interface QuizViewState {
current: number;
total: number;
lives: number;
stem: string;
source: string;
options: OptionViewState[];
phase: QuizPhase;
paused: boolean;
primaryAction: string;
}
这是建议代码,不代表仓库已实现。快照的价值是把"领域对象现在是什么"转换为"界面这一帧画什么"。Presenter 组件只消费快照并回传业务意图:answerSelected(index)、timeout()、nextRequested()、favoriteRequested()。页面协调器收到意图后,再调用 Session、存储或路由。
三、竞争与幂等:拆组件后仍需守住流程门禁
当前页面用 revealAnswer 防止一次题目重复选择,用 exiting 防止结算状态继续渲染题目;生命归零时还会延迟 1200 毫秒结算。拆分后要保留这些门禁,并验证"超时与点击几乎同时发生""连续点击下一题""延迟结算期间离页""后台 commit 失败"四类竞争。组件化不能替代流程幂等。
建议把 finalize 流程集中成单入口:首次进入时锁定退出状态,计算一次结果,生成一次路由参数,再触发持久化与成就评估。乐观统计与持久化统计必须有明确一致性策略,否则 UI 先加一次、后台重试又加一次的风险无法仅靠拆组件消除。此处是风险分析,不是已复现的数据重复问题。
四、结算页边界:展示结果不等于决定解锁
LevelCompleteView 当前约 243 行,负责解析 quizResult、查找关卡、调用 groupCleared、写诗人解锁,并用延迟回调跳转解锁页;同时它还决定下一关、重试与返回路线。StarRating 已经是展示组件,但整个页面仍混合展示、解锁政策与路由编排。
建议让结果区域消费 CompleteViewState,只渲染成功/失败、星级、答对数、用时、正确率和奖励;页面或导航协调器处理 next、retry、back 与 unlock。解锁判断若要下沉,应放在有完整关卡与进度数据的服务中,而不是让展示组件读取全局存储。
五、验收矩阵:用行为等价证明拆分没有改规则
第一组是状态渲染:答题中、答对、答错、生命归零、最后一题、结算中、加载失败都应有唯一画面。第二组是意图回流:默认选项可点,揭示答案后全部选项不再提交;暂停期间倒计时不减少;startKey 变化后从 totalSec 重启。
第三组是流程竞争:点击和超时只接受一个结果,重复点击下一题只推进一次,结算只生成一次参数,离页后不再收到计时回调。第四组是副作用:commit 失败时页面行为符合既定降级,成就评估失败不阻塞结果页,返回与重试不会留下错误栈。
第五组是布局:长题干、三行选项、不同字体缩放、手机横屏、平板和 2in1 窗口下,题目、选项、反馈与底部操作保持可达。当前源码为 OptionButton 设置 maxLines(3) 与 ellipsis,但这不等于所有文本都已通过设备验收。
本轮只完成静态源码复核与文章级验证,没有运行 DevEco 构建、测试、模拟器或真机,因此不报告编译成功、设备兼容、性能改善或缺陷复现结果。
本文将解决:
- 如何识别
@Builder继续留在页面,还是提取为独立@Component; - 如何让页面只拥有路由参数、页面状态和流程编排;
- 如何让展示组件只接收强类型输入并通过回调上报操作;
- 如何把答题业务继续留在
QuizSession,避免 UI 组件复制规则; - 如何拆出结算面板和诗人详情弹层,同时保持安全区、关闭和返回行为一致;
- 如何为拆分建立编译、状态、导航与多设备验证清单。
唯一核验标记:
AGC18-HMOS-16-12-PAGE-COMPONENT-BOUNDARIES
六、先确认当前工程已经有哪些正确边界
入口页只有顶层装配职责:
Navigation(this.pathStack) {
PoetryShell();
}
.hideTitleBar(true)
.navDestination(appRouter)
.mode(NavigationMode.Stack)
.width('100%')
.height('100%');
具体页面由 RouteMap.ets 统一映射:
@Builder
export function appRouter(name: string, param: Object) {
NavDestination() {
if (name === RouteNames.POETRY_QUIZ) {
QuizView({ param: param as string });
} else if (name === RouteNames.POETRY_COMPLETE) {
LevelCompleteView({
param: param !== null && param !== undefined
? param as string
: ''
});
}
}
.hideTitleBar(true);
}
这个路由边界应该保留。组件拆分不需要让每个子组件持有 NavPathStack,也不需要让展示组件认识 RouteNames。导航仍由路由页处理,子组件只上报"继续""重试""关闭"等业务意图。
二、@Builder 与独立组件不是二选一
当前 PoetryHomeView 使用多个局部 @Builder:
@Builder topBar() {}
@Builder titleBlock() {}
@Builder actionCard(...) {}
@Builder dailyVerse() {}
@Builder bottomGrid() {}
@Builder bottomCell(...) {}
局部 Builder 适合三个条件同时成立的片段:
- 只在当前页面使用;
- 直接依赖页面状态;
- 没有独立生命周期和复杂交互。
例如 titleDivider() 和 subDot() 只是两段装饰,留在页面更清楚。相反,actionCard() 同时接收标题、副标题、资源名、两种颜色和回调,已经形成稳定输入协议;dailyVerse() 又包含独立数据轮换。这两块更适合提取成可测试的组件。
七、用"变化原因"而不是"代码长度"决定拆分
可以给每段代码问四个问题:
| 问题 | 若答案为"是" |
|---|---|
| 是否拥有独立业务输入? | 建立强类型 Props |
| 是否产生用户操作? | 通过回调上报意图 |
| 是否有自己的临时状态? | 考虑独立组件 |
| 是否会在多个页面或状态复用? | 优先提取 |
反过来,如果一段代码只有三行、只被当前页面调用、拆出去还要传入十几个参数,就没有必要制造文件边界。

八、首页真正耦合的是设置、静态内容和导航
PoetryHomeView 的状态不多,但职责跨度较大:
@StorageLink('navStack')
navStack: NavPathStack = new NavPathStack();
@State verseIndex: number = new Date().getDate() % 7;
@StorageLink(StateKeys.APP_SETTINGS)
settings: AppSettings = new AppSettings();
同一文件既修改声音设置,又内置 7 条每日诗句,还直接 push 10 个左右的页面入口。布局变化时,开发者需要在业务状态和路由代码之间来回跳转。
渐进拆分可分为三步:
- 提取只负责显示和点击的首页入口卡;
- 把每日诗句数据变成强类型模型;
- 页面保留设置切换和路由编排。
九、首页行动卡只需要输入和操作回调
现有 actionCard() 已经非常接近独立组件。可以定义:
export interface PoetryActionCardModel {
title: string;
subtitle: string;
iconRes: string;
colorFrom: string;
colorTo: string;
}
@Component
export struct PoetryActionCard {
@Prop model: PoetryActionCardModel;
onTap: () => void = () => {};
build() {
Row() {
Column() {
Text(this.model.title);
Text(this.model.subtitle);
}
.layoutWeight(1);
Image($r('app.media.' + this.model.iconRes))
.width(48)
.height(48);
}
.width('100%')
.constraintSize({ minHeight: 86 })
.linearGradient({
angle: 135,
colors: [
[this.model.colorFrom, 0],
[this.model.colorTo, 1]
]
})
.onClick(() => this.onTap());
}
}
组件不知道"朝代页"或"作者页",因此它不会依赖 RouteNames。页面仍然决定点击后的目的地:
PoetryActionCard({
model: this.dynastyAction,
onTap: () => {
this.navStack.pushPath({
name: RouteNames.POETRY_DYNASTY
});
}
});
这条边界让视觉组件可复用,同时不把导航权交给下层。
十、每日诗句应先从平行数组变成同一模型
当前每日诗句由两个平行数组提供:
const verses: string[] = [
'海内存知己,天涯若比邻',
// ...
];
const authors: string[] = [
'------ 王勃《送杜少府之任蜀州》',
// ...
];
两个数组依赖相同下标,新增或删除一项时容易错位。拆组件前先把数据契约写稳:
export interface DailyVerse {
content: string;
source: string;
}
export const DAILY_VERSES: DailyVerse[] = [
{
content: '海内存知己,天涯若比邻',
source: '王勃《送杜少府之任蜀州》'
},
{
content: '春风得意马蹄疾,一日看尽长安花',
source: '孟郊《登科后》'
}
];
展示组件只接收当前项和换一条回调:
@Component
export struct DailyVerseCard {
@Prop verse: DailyVerse;
onNext: () => void = () => {};
build() {
Column() {
Text(this.verse.content);
Text('------ ' + this.verse.source);
Text('换一条')
.onClick(() => this.onNext());
}
}
}
数据结构修复与组件拆分同时完成,避免把平行数组的隐患原样搬到新文件。
十一、答题页的耦合比首页更深
QuizView 有 10 个页面状态:
@State level: PoetryLevel | null = null;
@State session: QuizSession | null = null;
@State currentIndex: number = 0;
@State lives: number = 5;
@State timerKey: number = 0;
@State pickedIndex: number = -1;
@State revealAnswer: number = -1;
@State phase: QuizPhase = QuizPhase.ANSWERING;
@State paused: boolean = false;
@State favorited: boolean = false;
@State exiting: boolean = false;
这些状态并不都应该移动到子组件。session、currentIndex、lives、phase 和 exiting 共同描述页面流程,仍应由路由页或专门的页面状态对象统一拥有。子组件只读取渲染所需的快照。
若把每个状态分别传给多个子组件,会得到大量参数和难以追踪的双向修改。正确方向是"页面集中拥有状态,组件接收最小只读输入"。
十二、现有领域层已经承担答题规则
答题提交并不是在 UI 中重新计算正确答案,而是委托给 QuizSession:
const r: QuizAnswerResult = this.session!.submit(idx);
this.revealAnswer = r.correctIndex;
this.lives = r.livesLeft;
this.phase = r.correct
? QuizPhase.CORRECT
: QuizPhase.WRONG;
这是值得保留的结构。组件拆分不应新建一个 QuestionCard,然后让它自行判断答案;否则领域规则会在页面、组件和服务之间复制。
更稳的依赖方向是:
QuizView
├─ 调用 QuizSession 完成领域规则
├─ 将结果映射为页面状态
└─ 把只读状态传给展示组件
十三、先定义答题展示快照
为了避免向组件传入整个 QuizSession,可以定义页面专用快照:
export interface QuizQuestionUiModel {
indexText: string;
stem: string;
sourceText: string;
options: QuizOptionUiModel[];
}
export interface QuizOptionUiModel {
label: string;
text: string;
state: OptionState;
}
页面负责从会话生成快照:
private questionUiModel(): QuizQuestionUiModel {
const question = this.session!.currentQuestion();
return {
indexText: (this.currentIndex + 1) +
' / ' + this.session!.total(),
stem: question.stem,
sourceText: this.level!.poetry.author +
' · 《' + this.level!.poetry.title + '》',
options: question.options.map(
(text: string, idx: number): QuizOptionUiModel => ({
label: LABELS[idx],
text: text,
state: this.optionStateOf(idx)
})
)
};
}
组件获得的是可渲染、可断言的简单模型,不依赖服务、存储或路由。
十四、题目卡与选项列表拆分到什么粒度
项目已经有独立 OptionButton,因此不需要再为 A、B、C、D 写四种组件。推荐结构:
@Component
export struct QuizQuestionSection {
@Prop model: QuizQuestionUiModel;
onPick: (index: number) => void =
(_index: number): void => {};
build() {
Column({ space: 12 }) {
QuizQuestionCard({
stem: this.model.stem,
sourceText: this.model.sourceText
});
ForEach(
this.model.options,
(option: QuizOptionUiModel, idx: number) => {
OptionButton({
label: option.label,
text: option.text,
state: option.state,
onTap: () => this.onPick(idx)
});
}
);
}
}
}
QuizQuestionSection 负责题目区的组合,OptionButton 继续负责单个选项外观。页面只处理 onPick(index),不会把 QuizSession 暴露给视图组件。
十五、反馈面板只发出"下一步"意图
当前 feedbackPanel() 同时显示结果、诗词全文,并调用页面的 handleNext()。提取后可定义:
export interface QuizFeedbackModel {
correct: boolean;
content: string;
sourceText: string;
nextLabel: string;
}
@Component
export struct QuizFeedbackPanel {
@Prop model: QuizFeedbackModel;
onNext: () => void = () => {};
build() {
Column() {
Text(this.model.correct ? '答对了' : '答错了');
Text(this.model.content);
Text(this.model.sourceText);
Text(this.model.nextLabel)
.onClick(() => this.onNext());
}
}
}
组件不会判断是不是最后一题,也不会直接进入结算页。nextLabel 和下一步动作都由页面根据会话状态决定。
十六、路由页缩回到"装配+流程"
完成拆分后,QuizView.build() 可以只表达页面结构:
build() {
Stack() {
PoetryBackground({ overlayOpacity: 0.25 });
Column() {
PoetryTopBar({
title: this.level === null
? '答题'
: this.level!.poetry.title
}) {
this.favoriteButton();
}
if (this.session === null) {
QuizLoadError();
} else if (this.exiting) {
QuizSettlingState();
} else {
QuizStatusBar({
current: this.currentIndex + 1,
total: this.session!.total(),
lives: this.lives,
timerKey: this.timerKey,
paused: this.paused,
onTimeout: () => this.handleTimeout()
});
QuizQuestionSection({
model: this.questionUiModel(),
onPick: (idx: number) =>
this.handlePick(idx)
});
if (this.isFeedbackVisible()) {
QuizFeedbackPanel({
model: this.feedbackUiModel(),
onNext: () => this.handleNext()
});
}
}
}
}
}
这里仍然能一眼看到加载、结算、答题和反馈四种页面状态,但具体像素和排版已经由下层组件负责。
十七、不要把计时器状态拆成第二套真源
CountdownTimer 已经是独立组件,接收:
CountdownTimer({
totalSec: QuizSession.QUESTION_SECONDS,
startKey: this.timerKey,
paused: this.paused,
onTimeout: () => this.handleTimeout()
});
拆分 QuizStatusBar 时,计时器仍应从页面获得 startKey 和 paused。不要在状态栏组件内部再保存一份"当前题号对应的暂停状态",否则页面答题状态和计时状态可能分叉。
一个状态只能有一个拥有者。子组件可以有动画进度等短生命周期状态,但业务暂停、题目切换和超时处理仍由页面统一协调。
十八、结算页同时处理了解锁、展示与导航
LevelCompleteView.aboutToAppear() 做了三件事:
this.data = JSON.parse(raw) as CompleteParam;
this.level = LevelService.findById(this.data!.levelId);
if (this.data!.passed && lv !== null) {
this.tryUnlockPoet();
}
随后 tryUnlockPoet() 查询关卡组、诗人列表并写入存储;页面底部的按钮又负责 popToName() 和延迟 push 下一关。展示层与流程层紧密混在一起。
推荐拆成:
LevelCompleteView:解析参数、调用解锁流程、执行导航;LevelResultPanel:展示成功/失败、星级、统计和奖励;LevelResultActions:展示按钮,只上报继续、重试或返回;PoetUnlockCoordinator或现有服务方法:计算并持久化解锁结果。
十九、结算面板使用一个强类型模型
当前 CompleteParam 已经接近展示模型,但还缺少标题和作者。可以在页面中组合:
export interface LevelResultUiModel {
passed: boolean;
title: string;
sourceText: string;
stars: number;
correctText: string;
timeText: string;
accuracyText: string;
flowersText: string;
scrollsText: string;
}
然后让面板只渲染:
@Component
export struct LevelResultPanel {
@Prop model: LevelResultUiModel;
build() {
Column() {
Text(this.model.passed
? '闯关成功'
: '闯关失败');
Text(this.model.sourceText);
StarRating({
stars: this.model.stars,
maxStars: 3,
sizeVp: 36
});
ResultStatRow({
label: '答对题数',
value: this.model.correctText
});
}
}
}
展示组件不调用 LevelService,也不读取 AppStorage,因此可以用构造好的模型进行预览和边界测试。
二十、诗人解锁逻辑不属于结算布局
当前 tryUnlockPoet() 的核心逻辑是:
const groups: LevelGroup[] =
LevelService.groupsOfDynasty(this.level!.dynasty);
const cleared: boolean =
LevelService.groupCleared(groups[groupIdx]);
const poets: Poet[] =
PoetService.byDynasty(this.level!.dynasty);
const target: Poet =
poets[groupIdx % poets.length];
const isNew: boolean =
await PoetryStorageService.unlockPoet(target.id);
这段逻辑与"卡片圆角多少""奖励图标多大"没有关系。它应该成为服务层可复用方法,例如:
export class PoetUnlockService {
static async unlockForClearedGroup(
level: PoetryLevel
): Promise<number | null> {
const groups =
LevelService.groupsOfDynasty(level.dynasty);
const group = groups[level.groupIndex];
if (group === undefined ||
!LevelService.groupCleared(group)) {
return null;
}
const poets = PoetService.byDynasty(level.dynasty);
if (poets.length === 0) {
return null;
}
const target =
poets[level.groupIndex % poets.length];
const isNew =
await PoetryStorageService.unlockPoet(target.id);
return isNew ? target.id : null;
}
}
页面得到诗人 ID 后决定是否跳转解锁页,服务不依赖 NavPathStack。
二十一、详情弹层是最清晰的独立组件候选
PoetsCollectionView 使用 selected: Poet | null 控制弹层:
if (this.selected !== null) {
this.detailDialog();
}
detailDialog() 占用了页面近一半代码,包含遮罩、头像、锁定态、简介、安全区和关闭按钮。这一块具有稳定输入和明确退出操作,很适合提取:
@Component
export struct PoetDetailOverlay {
@Prop poet: Poet;
@Prop unlocked: boolean = false;
@Prop safeInsets: SafeInsets;
onClose: () => void = () => {};
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#73000000')
.onClick(() => this.onClose());
PoetDetailCard({
poet: this.poet,
unlocked: this.unlocked,
onClose: () => this.onClose()
});
}
.padding({
left: this.safeInsets.left + 28,
right: this.safeInsets.right + 28,
top: this.safeInsets.top + 16,
bottom: this.safeInsets.bottom + 16
});
}
}
列表页只保留选中状态:
if (this.selected !== null) {
PoetDetailOverlay({
poet: this.selected!,
unlocked: this.isUnlocked(this.selected!),
safeInsets: this.safeInsets(),
onClose: () => {
this.selected = null;
}
});
}
二十二、弹层关闭必须保持单向数据流
独立弹层不应直接修改父页面的 selected。它只调用 onClose(),由父页面完成状态修改。
这个方向带来三个好处:
- 弹层不需要知道父页面如何表示"未选中";
- 外部点击、关闭按钮和系统返回可以汇聚到同一回调;
- 后续把弹层换成
bindSheet或其他标准容器时,列表状态无需重写。
单向流可以概括为:
父页面 selected
↓ Props
详情弹层
↓ onClose
父页面 selected = null

二十三、组件不要直接读取全局存储
页面拆分后,最容易出现的捷径是让每个组件都写:
@StorageLink(StateKeys.APP_SETTINGS)
settings: AppSettings = new AppSettings();
这样虽然减少传参,却把全局状态耦合扩散到更多文件。更合适的原则是:
- 路由页和应用壳可以消费
AppStorage; - 可复用展示组件接收普通 Props;
- 需要写入的操作通过回调上报;
- 服务层负责持久化,不让组件直接调用 Preferences。
例如声音按钮只需要 enabled 和 onToggle,不需要整个 AppSettings。
二十四、回调只表达业务意图,不暴露实现细节
不推荐这样的组件接口:
onTap: (
stack: NavPathStack,
settings: AppSettings
) => void
它把父层对象泄漏给子组件。推荐:
onStartDynasty: () => void
onStartAuthor: () => void
onToggleSound: () => void
onClose: () => void
onRetry: () => void
组件只表达"发生了什么",页面决定"如何实现"。回调命名也应反映业务动作,而不是 onClick1、onConfirm2。
二十五、拆分后的目录不需要另建大框架
项目现有结构已经有 views/poetry/components,可以继续沿用:
entry/src/main/ets/views/poetry/
├── PoetryHomeView.ets
├── QuizView.ets
├── LevelCompleteView.ets
├── PoetsCollectionView.ets
└── components/
├── home/
│ ├── PoetryActionCard.ets
│ └── DailyVerseCard.ets
├── quiz/
│ ├── QuizQuestionSection.ets
│ ├── QuizQuestionCard.ets
│ └── QuizFeedbackPanel.ets
├── result/
│ ├── LevelResultPanel.ets
│ └── LevelResultActions.ets
└── poet/
└── PoetDetailOverlay.ets
只有出现多个同类组件后才建立子目录。若当前改动只提取一个弹层,可以先放在现有 components 下,避免一次性制造过深目录。
二十六、避免三种过度拆分
1. 每个 Text 都变成组件
只有样式复用,没有独立语义时,优先使用主题 token、@Styles 或局部 Builder。
2. 为一次点击建立 ViewModel
单个"关闭弹层"只需要回调。ViewModel 应解决状态组合、异步流程或可测试业务规则,而不是包装每个布尔值。
3. 子组件重新查询服务
若 QuizQuestionSection 再调用 LevelService.findById(),它就重新承担数据获取职责。Props 应携带完成展示所需的最小数据。
二十七、先拆纯展示,再拆状态流程
一次把 395 行首页和 338 行答题页全部重写,回归范围会很大。更安全的顺序是:
- 提取
PoetDetailOverlay,保持父状态不变; - 提取
PoetryActionCard和DailyVerseCard; - 提取
QuizQuestionSection与QuizFeedbackPanel; - 用 UI Model 收束组件参数;
- 最后再评估是否需要页面状态对象;
- 每一步都运行编译和关键路径冒烟。
每次只改变一个职责边界,出现问题时能快速定位。
二十八、组件契约要覆盖加载、错误和禁用态
组件提取不能只展示成功状态。答题页当前有:
if (this.session === null) {
Text('关卡加载失败');
} else if (this.exiting) {
Text('正在结算...');
}
若提取 QuizContent,其契约至少要区分:
export enum QuizPageState {
LOADING = 'loading',
READY = 'ready',
FEEDBACK = 'feedback',
SETTLING = 'settling',
FAILED = 'failed'
}
避免用 model === null 同时表示"正在加载"和"加载失败"。页面状态清楚后,组件才能提供正确空态、禁用态和重试入口。
二十九、导航仍是路由页的责任
结算页当前使用:
this.navStack.popToName(
RouteNames.POETRY_LEVEL
);
setTimeout(() => {
this.navStack.pushPath({
name: RouteNames.POETRY_QUIZ,
param: nextLevelId
});
}, 150);
拆出 LevelResultActions 后,这段代码仍留在 LevelCompleteView。按钮组件只调用 onNext()。这样可以统一处理:
- 返回关卡列表;
- 延迟进入下一关;
- 同一关重试;
- 路由栈不存在目标页时的兜底;
- 防止重复点击触发两次导航。
组件不应掌握路由栈结构。
三十、拆分后的测试分成四层
| 层级 | 重点 |
|---|---|
| UI Model | 输入映射是否完整,正确率是否处理除零 |
| 展示组件 | 长标题、空奖励、锁定诗人、正确/错误态 |
| 页面流程 | 选答案、超时、下一题、结算、重试 |
| 路由与持久化 | popToName、解锁写入、返回后刷新 |
纯展示组件可以使用构造模型进行 Preview;领域规则继续由 QuizSession 和服务测试覆盖;页面层重点验证回调是否调用正确流程。
三十一、多设备验证不能因拆组件而遗漏
组件拆分可能改变布局测量,因此要在手机、平板和 2in1 上复核:
- 首页行动卡长副标题不会挤压图标;
- 题目区与反馈区在小窗中可滚动;
- 结算面板放大字体后按钮仍可达;
- 详情弹层使用父页面传入的四向安全区;
- 横屏下遮罩覆盖整个窗口;
- 鼠标点击遮罩和关闭按钮都只触发一次
onClose; - 键盘焦点不会进入已隐藏的弹层;
- 系统返回优先关闭弹层,再离开列表页。
拆文件成功不等于页面体验不变,布局和交互回归必须单独执行。
三十二、常见故障与定位顺序
1. 提取后组件不刷新
先检查传入值是否真正由 @State、@Prop 或 @StorageLink 驱动,再检查是否把可变对象原地修改。父页面应生成新模型或更新可观察状态。
2. 点击选项后状态颜色不变
确认 OptionState 由页面重新映射并传入,而不是让 QuizQuestionSection 缓存第一次的 options。
3. 弹层关闭后又立刻出现
检查 onClose 是否只修改父页面 selected,以及子组件是否在生命周期中自行恢复选中对象。
4. 下一关按钮重复跳转
页面应在流程开始时设置 exiting 或导航锁,展示组件只上报一次意图。不要在按钮和父页面同时执行 push。
5. 解锁诗人但结果页没有跳转
检查服务是否返回新解锁 ID,页面是否仍在可见生命周期,以及延迟回调执行前路由是否已经离开。
6. Preview 无法构造组件
检查组件 Props 是否依赖 NavPathStack、AppStorage 或服务单例。纯展示组件应能用普通模型独立构造。
三十三、上线前可复核清单
-
Index.ets继续只承担导航壳与全局浮层装配; -
RouteMap.ets仍是页面路由唯一映射入口; - 首页组件不直接 push 路由;
- 答题展示组件不调用
QuizSession.submit(); - 选项、反馈和结果组件只消费强类型 UI Model;
- 结算展示组件不读取
AppStorage; - 诗人解锁规则已从布局代码隔离;
- 详情弹层通过 Props 接收诗人和安全区;
- 关闭、继续、重试均通过单向回调上报;
- 没有为简单装饰创建无意义小组件;
- 每一步拆分后通过
assembleHap或项目既有构建命令; - 选关、答题、超时、结算、解锁、重试和返回完成冒烟;
- 手机、小窗、平板、2in1 与放大字体布局均无回归;
- 发布包完成安装、启动、核心流程和卸载检查。
三十四、总结:页面负责流程,组件负责表达
"古诗词闯关"已经具备不错的拆分基础:Navigation 与路由映射集中管理页面,QuizSession 承担答题规则,OptionButton、CountdownTimer 和 StarRating 也已经独立。下一步不需要推倒重来,而是沿着现有方向收紧剩余边界。
首页可以把行动卡和每日诗句提取为输入明确的组件;答题页继续拥有会话和流程状态,把题目、选项与反馈转换成只读 UI Model;结算页把诗人解锁移到服务,把结果展示和按钮操作拆开;诗人列表则把稳定的详情遮罩提取成独立弹层。
拆分的最终目标不是文件数量,而是让每次变化只影响该负责的层:题库规则变化进入领域服务,页面流程变化留在路由页,视觉变化进入展示组件,持久化变化留在服务。做到这一点,组件才真正降低耦合,而不是把同一团逻辑分散到更多文件。
本轮没有执行 DevEco 构建、自动化测试、模拟器、真机、性能测量或发布验证,也没有虚构源码能力、构建结果、发布数据、用户数据或平台评价。AI 未读取或保存签名配置、证书、密码、Cookie、Token 或其他私密凭据。