灯光模拟HarmonyOS应用实战-39-20题测试做到最后又回第1题:用TheorySession收口跳题、完成与汇总

“20题测试”首先是一份用户承诺:进入后会得到一组有限题目,完成最后一题后能够看到结果。本项目当前界面确实展示“20题测试”,选题函数也最多截取20题;但页面的“下一题”始终可点,推进函数又使用取模运算。只要题组非空,第20题之后就会回到第1题,流程本身没有“已完成”这一状态。
这不是根据设备现象作出的推断,而是对当前 ArkTS 控制流的只读审查。本文把问题限定在理论练习会话:怎样表示题目快照、作答与跳题、终点、汇总和重开。错题持久化、题目随机算法、整站统计口径仍由既有模块负责,不在这次建议中顺手改造。
一、标签写着20题,代码却只拥有一个循环游标
当前实现中,THEORY_TEST_COUNT 在 entry/src/main/ets/pages/Index.ets:63 定义为20。QuestionBankStartCard() 在 Index.ets:477-513 显示“20题测试”和“开始练习”。selectTestQuestions() 位于 Index.ets:1212-1229,它先复制并打乱候选题,再取 Math.min(20, shuffled.length) 个元素。由此能确认两件事:
- 一场练习最多20题,题库不足20题时实际题数更少;
- 这组题是开始练习时生成的内存数组,并非固定永远有20项。
问题出在结束语义。TheoryActionFooter() 的“下一题”直接调用 nextTheoryQuestion(),没有判断是否作答,也没有判断是否已到末题;Index.ets:1282-1290 又把索引更新为“当前索引加一后对题数取模”。下面是与现状等价的最小表达:
function nextIndex(current: number, total: number): number {
if (total === 0) {
return 0;
}
return (current + 1) % total;
}
这段代码能保证索引不越界,却无法表达“有限测试已经结束”。当 current 为19、total 为20时,返回值是0;对轮播图这是合理行为,对承诺有终点的测试则缺少了一个业务分支。
二、真实问题链不止是20跳回1
只把取模改成 Math.min(current + 1, total - 1) 仍然不够,因为当前字段组合没有形成完整会话。startTheoryPractice() 在 Index.ets:1116-1124 设置科目、模式、题组、当前索引和答题提示;answerTheoryQuestion() 在 Index.ets:1266-1279 只锁定当前题的首次答案并立刻追加一条历史;页面没有记录哪些题被跳过、这一场何时开始、是否结束、总共答了几题。
真实问题链可以按事件展开:
- 用户点击“开始练习”,页面创建题目数组和索引0;
- 用户可以不选答案直接点“下一题”,当前题没有明确的“跳过”记录;
- 每次答题单独写一条历史,但没有一份本场答题清单;
- 到末题后仍执行取模,索引回0,页面继续显示可作答流程;
- 已作答题的页面锁定只保存在当前
selectedAnswer,回到第1题后该字段被清空; - 页面无法据此生成“答对、答错、跳过、未看”的完整汇总。
因此,索引只是会话的一部分。有限测试需要把状态、题目快照和每题结果放在同一个所有者中。
三、先区分有限测试与循环练习
不是每一种刷题入口都必须终止。若产品确实需要反复练习,可以保留循环模式,但要让模式成为显式配置,而不是由取模运算暗中决定。建议定义两个运行模式:
export enum TheoryRunMode {
FINITE_TEST = 'FINITE_TEST',
LOOP_PRACTICE = 'LOOP_PRACTICE'
}
export enum TheorySessionStatus {
READY = 'READY',
IN_PROGRESS = 'IN_PROGRESS',
FINISHED = 'FINISHED',
CANCELED = 'CANCELED'
}
这两个枚举解决的是两类不同问题。TheoryRunMode 决定到达尾题后的导航策略;TheorySessionStatus 决定页面当前允许哪些事件。“20题测试”入口应创建 FINITE_TEST;若以后增加“循环刷题”,再明确使用 LOOP_PRACTICE。这样,产品文案与代码分支能一一对应。
对当前项目而言,建议先只接入有限测试,避免新增未经要求的入口。循环模式在示例中出现,是为了说明取模何时才有合法位置,并不代表应同时上线两套体验。
四、用TheorySession保存不可变题组与逐题结果
会话对象至少要回答四个问题:本场有哪些题、当前是哪一题、每题处于什么结果、整场是否已经结束。下面是一份保持简单的 ArkTS 模型:
export type AttemptState = 'UNSEEN' | 'SKIPPED' | 'ANSWERED';
export interface TheoryAttempt {
questionId: string;
state: AttemptState;
selectedAnswer: string;
correct: boolean;
}
export interface TheorySession {
id: string;
subject: string;
mode: TheoryRunMode;
status: TheorySessionStatus;
questionIds: string[];
attempts: TheoryAttempt[];
currentIndex: number;
startedAt: number;
finishedAt: number;
}
questionIds 是本场题目快照的顺序,开始后不再重新洗牌。attempts 与它等长并按索引对齐;这样即使题库对象以后增加说明字段,会话仍然只保存稳定ID与本场答案。finishedAt 在未结束时取0,避免用当前时间冒充完成时间。
创建会话时就建立完整的未访问结果,能消除“数组中没有这一项究竟是未看还是数据丢失”的歧义:
export function createTheorySession(
id: string,
subject: string,
mode: TheoryRunMode,
questionIds: string[],
now: number
): TheorySession {
const attempts: TheoryAttempt[] = [];
for (let index = 0; index < questionIds.length; index++) {
attempts.push({
questionId: questionIds[index],
state: 'UNSEEN',
selectedAnswer: '',
correct: false
});
}
return {
id,
subject,
mode,
status: questionIds.length === 0 ?
TheorySessionStatus.FINISHED : TheorySessionStatus.IN_PROGRESS,
questionIds,
attempts,
currentIndex: 0,
startedAt: now,
finishedAt: questionIds.length === 0 ? now : 0
};
}
空题组直接结束还是显示空态,可以由页面决定;关键是不能创建一个“进行中但永远没有当前题”的会话。示例让空题组进入 FINISHED,页面仍应优先显示“暂无题目”,而不是生成0/0的成绩卡。
五、把作答、跳题和推进拆成明确事件
当前“下一题”既承担导航又暗含跳过,但没有留下语义。更稳妥的做法是让页面先决定当前题如何结算,再让纯函数推进。首次作答仍需锁定,避免同一题被重复累计:
export function answerCurrent(
source: TheorySession,
answer: string,
correctAnswer: string
): TheorySession {
if (source.status !== TheorySessionStatus.IN_PROGRESS) {
return source;
}
const oldAttempt = source.attempts[source.currentIndex];
if (oldAttempt.state === 'ANSWERED') {
return source;
}
const attempts = source.attempts.slice();
attempts[source.currentIndex] = {
questionId: oldAttempt.questionId,
state: 'ANSWERED',
selectedAnswer: answer,
correct: answer === correctAnswer
};
return { ...source, attempts };
}
export function skipCurrent(source: TheorySession): TheorySession {
if (source.status !== TheorySessionStatus.IN_PROGRESS) {
return source;
}
const oldAttempt = source.attempts[source.currentIndex];
if (oldAttempt.state !== 'UNSEEN') {
return source;
}
const attempts = source.attempts.slice();
attempts[source.currentIndex] = {
questionId: oldAttempt.questionId,
state: 'SKIPPED',
selectedAnswer: '',
correct: false
};
return { ...source, attempts };
}
这里用新数组替换旧数组,便于 ArkUI 的状态更新观察到引用变化。skipCurrent() 只把 UNSEEN 变成 SKIPPED;已经作答的题不会被下一题按钮改写成跳过。若产品希望允许回看并补答,应另设“答题卡跳转”规则,而不是让同一个按钮兼顾所有行为。
页面按钮文案也应反映决策:未作答时显示“跳过本题”,作答后显示“下一题”,末题则分别显示“跳过并交卷”或“查看结果”。这样用户能预知点击后的结果。

六、推进函数必须在尾题收口
推进函数需要显式处理三种条件:会话不可操作、还有下一题、到达尾题。有限测试到尾题时进入 FINISHED;循环练习才回到0:
export function advanceSession(
source: TheorySession,
now: number
): TheorySession {
if (source.status !== TheorySessionStatus.IN_PROGRESS) {
return source;
}
const isLast = source.currentIndex >= source.questionIds.length - 1;
if (!isLast) {
return { ...source, currentIndex: source.currentIndex + 1 };
}
if (source.mode === TheoryRunMode.LOOP_PRACTICE) {
return { ...source, currentIndex: 0 };
}
return {
...source,
status: TheorySessionStatus.FINISHED,
finishedAt: now
};
}
这段函数没有定时器、存储或 UI 依赖,因此可以用固定输入验证每一条边界。有限测试结束后再次调用会返回原对象,不会重复写完成时间。真正集成时,页面应在状态首次从 IN_PROGRESS 变为 FINISHED 时执行一次汇总持久化,而不是每次重组 UI 都写记录。
如果要求“所有题必须作答才能交卷”,可以在末题分支检查是否存在 UNSEEN 或 SKIPPED,然后跳转到第一道未答题。当前界面允许直接点下一题,本文建议忠实记录跳过并允许完成,不额外引入强制作答规则。
七、单题历史与场次汇总必须分开
answerTheoryQuestion() 当前每答一题就调用 addSimpleRecord()。这类记录适合支撑错题与单题回顾,但不能代替场次结果,因为跳过题不会触发它,且20条记录之间没有共同的会话ID。
建议保留单题尝试,同时新增轻量汇总:
export interface TheorySessionSummary {
sessionId: string;
subject: string;
total: number;
answered: number;
correct: number;
wrong: number;
skipped: number;
startedAt: number;
finishedAt: number;
}
export function summarize(session: TheorySession): TheorySessionSummary {
let answered = 0;
let correct = 0;
let skipped = 0;
for (let index = 0; index < session.attempts.length; index++) {
const item = session.attempts[index];
if (item.state === 'ANSWERED') {
answered++;
if (item.correct) {
correct++;
}
} else if (item.state === 'SKIPPED') {
skipped++;
}
}
return {
sessionId: session.id,
subject: session.subject,
total: session.questionIds.length,
answered,
correct,
wrong: answered - correct,
skipped,
startedAt: session.startedAt,
finishedAt: session.finishedAt
};
}
这里的 total 取实际题组长度,不能硬编码20。若候选题只有12道,结果应显示12题。wrong 只表示已作答且错误,skipped 单独列出,避免把“用户没有选择”伪装成某个错误选项。汇总记录也不应重复塞入现有 PracticeRecord,除非先定义清楚两类记录的区分字段。

八、页面生命周期要决定取消、恢复还是重开
会话引入后,退出页面不应再依赖一组散落字段自然消失。建议明确三条策略:
- 点击“重新开始”:把当前进行中会话标记为
CANCELED,再用新的题目快照创建新ID; - 从理论页返回首页:若本次不支持断点续答,释放内存并记录取消,不生成完成汇总;
- 应用切入后台:内存会话可以保持;若系统回收进程后仍要求恢复,才增加持久化快照。
不要在第一版同时实现跨进程恢复。当前问题只需让一次页面会话有明确终点。若后续保存快照,恢复时必须校验题目ID仍能从题库解析,并把无法解析的会话标为不可恢复,不能悄悄重新抽题后沿用旧成绩。
ArkUI 页面可以保留一个 @State session 或把会话字段拆成框架允许观察的状态。核心约束是:所有按钮都通过同一个 reducer 更新会话,视图只从会话派生“题号、按钮文案、结果卡”,不再单独修改 activeQuestionIndex 与 selectedAnswer。
九、验证矩阵要覆盖题数与行为的笛卡尔积
纯函数验证应先于页面联调。下面的用例能够直接拦住“20回1”和“跳题不入汇总”:
describe('TheorySession finite flow', () => {
it('finishes after the actual last question', () => {
let session = createTheorySession(
'run-1', 'subject1', TheoryRunMode.FINITE_TEST,
['q1', 'q2'], 1000
);
session = answerCurrent(session, 'A', 'A');
session = advanceSession(session, 1100);
session = skipCurrent(session);
session = advanceSession(session, 1200);
expect(session.status).assertEqual(TheorySessionStatus.FINISHED);
expect(session.currentIndex).assertEqual(1);
expect(summarize(session).skipped).assertEqual(1);
});
});
这条用例把题组压缩为两题,仍能覆盖开始、作答、推进、跳过和结束。固定时钟值让 finishedAt 可断言,不依赖真实等待。
页面层还需按以下矩阵核对:
| 维度 | 输入或操作 | 预期结果 |
|---|---|---|
| 题组长度 | 0题 | 显示空态,不出现可作答的“下一题” |
| 题组长度 | 1题 | 首题也是末题,结算后直接显示结果 |
| 题组长度 | 12题 | 进度与汇总均为12,不显示20作为实际总数 |
| 标准题组 | 20题 | 第20题结算后进入完成页,不回到1/20 |
| 未作答推进 | 中间题点击跳过 | 该题记为SKIPPED,后续题可继续 |
| 重复点击 | 完成按钮连续点击 | 只产生一份场次汇总 |
| 重开 | 第7题点击重新开始 | 旧会话取消,新会话ID与题目快照重新创建 |
| 回退 | 完成后系统返回 | 不恢复成进行中的第20题 |
| 循环配置 | LOOP_PRACTICE到尾题 | 只有该显式模式允许回到第1题 |
这里把“20题”作为上限与“本场实际总数”分开,能覆盖题库不足20题的真实分支。
十、排障与交付边界
集成时最容易出现的问题不是编译语法,而是旧字段和新会话同时写入,形成两个事实源。
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 结果页出现后又回题目页 | 是否仍调用旧的取模推进 | 所有导航统一经过 advanceSession() |
| 跳过数始终为0 | 下一题前是否执行 skipCurrent() | 未作答时显式结算为SKIPPED |
| 12道题显示20题满分 | 是否继续读取常量 | UI与汇总读取 questionIds.length |
| 同一场产生两份汇总 | 完成副作用是否在重组期间执行 | 只监听一次状态跃迁并按sessionId幂等 |
| 重开后旧答案混入新题组 | 是否复用 attempts 数组 | 创建新会话并生成全新的数组 |
| 完成后按钮仍可操作 | 按钮是否读取 session.status | FINISHED状态禁用作答与推进 |
| 返回第1题后答案消失 | 仍只依赖 selectedAnswer | 从 attempts[currentIndex] 派生选中态 |
本文能确认的源码事实是:界面存在“20题测试”入口;选题函数最多返回20题;下一题按钮没有作答或末题门禁;推进函数使用取模;当前代码没有场次完成与汇总模型。基于这些事实,可以提出有限会话改造,但不能宣称某款设备已经出现重复记录、卡顿或数据丢失。
文中的 TheorySession、reducer 与汇总代码均为建议示例,尚未集成到 The_kemusan。本次没有修改项目源码,没有执行当前项目构建,没有生成新的 HAP,也没有进行模拟器或真机验证。完成实现后,还应分别补做纯函数用例、页面交互核对、存储幂等验证和真机生命周期验证,再决定是否用于实际练习流程。
更多推荐


所有评论(0)