灯光模拟HarmonyOS应用实战-92-科二做到一半切分类为何会立刻换一套题:用PracticeTransitionReceipt处理放弃与重建
灯光模拟HarmonyOS应用实战-92-科二做到一半切分类为何会立刻换一套题:用PracticeTransitionReceipt处理放弃与重建
科二灯光基础页顶部有“图标认知”“上车检查”“夜间场景”三个分类。用户做到了第十二题,想看一眼另一个分类,点下标签后会发生什么?The_kemusan 当前不会先询问,也不会保存本场题序:点击回调立即改写分类,重新随机选题,把索引归零,并清空当前选择。即使用户再次点击已经选中的分类,也会执行同一条重建链。

这里要区分“历史记录”和“进行中会话”。已经作答的单题会通过 addSimpleRecord() 发起保存,切分类并不会主动清空历史;但本场随机题组、当前位置、当前题是否正在复盘以及尚未作答的页面现场都只在内存字段中。本文处理的是分类意图怎样安全地关闭旧会话并创建新快照,不把它描述成所有已答数据必然丢失。
本次仅审阅当前源码,没有在设备上进行“答题中切分类”的手工操作。文中的 PracticeTransitionReceipt、转场协调器、确认界面和测试代码都是建议方案,尚未接入 The_kemusan。
系列前文已经说明过三类题如何筛选、不同入口怎样统一查询条件,以及随机题序如何重放;本文不再设计分类题池或随机算法,而是聚焦一个更窄的工程问题:旧会话已有进度时,分类点击能否在用户确认前保持无副作用,并在确认后只提交一次新会话。
一、三个分类标签直接绑定了重建练习的副作用
Index.ets:370-397 构建科二标签。每个 CategoryChip 的点击回调没有检查标签是否已经选中,也没有判断当前练习是否已有进度:
@Builder
SubjectTwoTabs() {
Row({ space: 8 }) {
this.CategoryChip('图标认知')
this.CategoryChip('上车检查')
this.CategoryChip('夜间场景')
}
}
@Builder
CategoryChip(label: string) {
Text(label)
.onClick(() => {
this.subject2Category = label;
this.startTheoryPractice(
SUBJECT_TWO, '科二灯光基础', label);
})
}
标签既是显示选择器,又直接承担“关闭当前练习并创建另一场”的命令。当前没有 if (this.subject2Category === label) 的幂等守卫,因此重复点击当前高亮标签同样调用 startTheoryPractice()。
二、startTheoryPractice会一次性替换题组和当前进度
Index.ets:1116-1124 的函数名像“开始练习”,而分类标签也直接调用它。该函数同步覆盖六项页面状态:
private startTheoryPractice(subject: string,
mode: string, category: string): void {
this.activeSubject = subject;
this.activeMode = mode;
this.activeQuestions =
this.selectTestQuestions(subject, category);
this.activeQuestionIndex = 0;
this.selectedAnswer = '';
this.answerMessage = '请选择答案';
this.answerCorrect = true;
}
这段控制流说明,分类点击后不是在原题组上做筛选,也不是暂存旧位置后预览新列表,而是立刻生成新 activeQuestions,把当前题号恢复为第一题,并清空答题反馈。赋值完成后,旧数组没有另一个会话对象持有,页面无法再恢复到原来的第十二题与原顺序。
如果当前题已经作答,它对应的单题记录可能已发起保存;如果当前题尚未作答,代码没有为“被分类切换跳过”生成记录。两种情况都不能简单称为“答案丢了”,更准确的说法是“进行中练习上下文被替换,且没有转场回执”。
三、重新调用选题函数意味着新样本和新顺序
selectTestQuestions() 在 Index.ets:1212-1229 先复制候选题,再直接用 Math.random() 执行 Fisher–Yates 形态的打乱,最后最多取 20 题:
private selectTestQuestions(subject: string,
category: string): QuestionItem[] {
const source = this.selectQuestions(subject, category);
const shuffled: QuestionItem[] = [];
for (let index = 0; index < source.length; index++) {
shuffled.push(source[index]);
}
for (let index = shuffled.length - 1; index > 0; index--) {
const target = Math.floor(Math.random() * (index + 1));
const current = shuffled[index];
shuffled[index] = shuffled[target];
shuffled[target] = current;
}
const result: QuestionItem[] = [];
const maxCount = Math.min(THEORY_TEST_COUNT, shuffled.length);
for (let index = 0; index < maxCount; index++) {
result.push(shuffled[index]);
}
return result;
}
当前题库生成常量为每个自动生成科目 1000 题,科二三个分类的候选量都大于 20。因此重新调用不只是把索引设为零,还可能取得另一组 20 题或另一种顺序。由于随机源没有会话种子,旧顺序也不能从分类名反推回来。
本文不重复改造随机算法本身。这里需要的最小约束是:只有转场决定允许重建时才调用一次选题,并把结果冻结成新会话快照;重复标签点击不应偷偷消费一轮新的随机数。
四、分类点击至少要区分四种意图
把所有点击都解释为“重开”会隐藏用户意图。更清楚的规则如下:
| 点击情境 | 建议结果 | 是否重抽题 | 是否需要确认 |
|---|---|---|---|
| 点击当前分类 | 保持当前会话 | 否 | 否 |
| 当前会话尚未开始,点击新分类 | 直接创建新会话 | 是 | 否 |
| 已有进度,点击新分类 | 展示转场决策 | 尚不执行 | 是 |
| 用户取消放弃 | 回到原会话与原位置 | 否 | 否 |
| 用户确认放弃 | 关闭旧会话,再创建新快照 | 是 | 已完成 |
| 快速连续点击多个分类 | 只处理最新有效意图 | 最多一次 | 取决于旧会话 |

“已有进度”不能只看 selectedAnswer。用户可能已经答过前十一题,但第十二题尚未选择,此时当前字段为空,整场仍然有进度。建议由会话保存 visitedCount、answeredCount 或逐题尝试状态,再用明确规则判断是否需要确认。
五、PracticeSessionSnapshot冻结题序与当前游标
进行中上下文应成为一个对象,而不是由页面上的分类、数组、索引和选择字段拼出来。最小模型可以这样定义:
enum PracticeStatus {
READY = 'READY',
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
ABANDONED = 'ABANDONED'
}
interface PracticeAttempt {
questionId: string;
selectedAnswer: string;
answered: boolean;
}
interface PracticeSessionSnapshot {
sessionId: string;
subject: string;
category: string;
status: PracticeStatus;
questionIds: string[];
attempts: PracticeAttempt[];
currentIndex: number;
revision: number;
}
questionIds 保存开始时已经确定的顺序;attempts 与题目 ID 对齐;currentIndex 只在数组范围内移动;revision 每次接受有效事件后递增。页面重新构建时从这个快照派生高亮分类、题号、选中项和提示,不再分别修改六个松散字段。
分类并不属于全局设置,而属于会话创建参数。切换分类意味着请求另一份会话,旧对象必须先得到明确处置。
六、TransitionDecision先计算,确认前不修改页面
转场协调器接收目标分类和当前会话,只返回决定,不调用随机数、不清空数组、不改高亮标签:
enum TransitionAction {
KEEP_CURRENT = 'KEEP_CURRENT',
START_NEW = 'START_NEW',
REQUEST_ABANDON = 'REQUEST_ABANDON'
}
interface TransitionDecision {
action: TransitionAction;
targetCategory: string;
expectedRevision: number;
reason: string;
}
function decideCategoryTransition(current: PracticeSessionSnapshot,
targetCategory: string): TransitionDecision {
if (targetCategory === current.category) {
return {
action: TransitionAction.KEEP_CURRENT,
targetCategory,
expectedRevision: current.revision,
reason: 'same_category'
};
}
if (current.status === PracticeStatus.READY) {
return {
action: TransitionAction.START_NEW,
targetCategory,
expectedRevision: current.revision,
reason: 'session_not_started'
};
}
return {
action: TransitionAction.REQUEST_ABANDON,
targetCategory,
expectedRevision: current.revision,
reason: 'active_progress_exists'
};
}
如果当前分类相同,函数返回 KEEP_CURRENT,页面不变,随机源也不会被调用。已有进度则返回请求确认,直到用户给出决定之前,subject2Category、题组、游标和答案都保持原值。
七、确认放弃后,用一份Receipt原子提交新会话
用户确认只是授权转场,不代表新题组一定创建成功。选题可能返回空数组,未来也可能由异步仓储或远端题库提供。因此应先构造候选新会话,再一次性提交页面状态,并返回回执:
interface PracticeTransitionReceipt {
fromSessionId: string;
toSessionId: string;
targetCategory: string;
committed: boolean;
questionCount: number;
issueCode: string;
}
function buildTransitionReceipt(from: PracticeSessionSnapshot,
targetCategory: string, next: PracticeSessionSnapshot | null):
PracticeTransitionReceipt {
if (!next || next.questionIds.length === 0) {
return {
fromSessionId: from.sessionId,
toSessionId: '',
targetCategory,
committed: false,
questionCount: 0,
issueCode: 'empty_target_pool'
};
}
return {
fromSessionId: from.sessionId,
toSessionId: next.sessionId,
targetCategory,
committed: true,
questionCount: next.questionIds.length,
issueCode: ''
};
}
committed=false 时继续保留旧会话,不能先把标签改成目标分类再显示空题。成功回执记录旧、新会话 ID 和实际题数,但不复制题目正文或用户答案。历史层是否保存一条“主动放弃”摘要由产品规则决定,不能把取消确认自动算作答错。

八、页面点击只提交Intent,协调器拥有副作用顺序
Builder 不应直接调用 startTheoryPractice()。它只提交 CategoryIntent,由协调器完成“判断 → 确认 → 建候选 → 提交”的顺序:
private onCategoryIntent(targetCategory: string): void {
const decision = decideCategoryTransition(
this.practiceSession, targetCategory);
if (decision.action === TransitionAction.KEEP_CURRENT) {
return;
}
if (decision.action === TransitionAction.REQUEST_ABANDON) {
this.pendingTransition = decision;
this.showAbandonDialog = true;
return;
}
this.commitNewCategory(decision);
}
private commitNewCategory(decision: TransitionDecision): void {
if (this.practiceSession.revision !==
decision.expectedRevision) {
return;
}
const next = this.createPracticeSession(
decision.targetCategory);
const receipt = buildTransitionReceipt(
this.practiceSession, decision.targetCategory, next);
if (receipt.committed && next) {
this.practiceSession = next;
}
this.lastTransitionReceipt = receipt;
}
确认框打开期间用户仍可能答题或通过其他入口重开,expectedRevision 用来拒绝已经过期的决定。若修订号变化,旧确认不能关闭新会话。真正接入时还应给对话框事件加唯一 intent ID,避免双击“确认”创建两份新题组。
九、重复点击、快速切换和保存时序要一起验证
建议先把随机源替换为可计数的测试桩,才能断言同分类点击没有触发重抽:
class CountingSampler {
calls: number = 0;
sample(category: string): string[] {
this.calls++;
return [`${category}_q1`, `${category}_q2`];
}
}
describe('PracticeTransition', () => {
it('keeps_same_category_without_sampling', 0, () => {
const session = makeActiveSession('图标认知', 12);
const sampler = new CountingSampler();
const decision = decideCategoryTransition(
session, '图标认知');
expect(decision.action)
.assertEqual(TransitionAction.KEEP_CURRENT);
expect(sampler.calls).assertEqual(0);
expect(session.currentIndex).assertEqual(12);
});
});
实现后的验证清单包括:
- 重复点击当前分类,题目 ID 顺序、游标、答案和修订号都不变。
- 未开始会话切到新分类,只创建一次新题快照。
- 已有进度切分类,确认前高亮标签与当前题不变。
- 取消放弃后回到完全相同的题目和位置。
- 确认放弃后旧会话进入
ABANDONED,新会话从索引零开始。 - 目标分类为空题时不提交,旧会话仍可继续。
- 确认层打开后又答一题,旧修订号决定被拒绝。
- 快速点击三个分类只提交最后一项有效意图。
- 双击确认只生成一个
toSessionId。 - 已完成单题记录不会因分类切换被清空或重复保存。
十、排障表、落地顺序与事实边界
| 现象 | 优先查看 | 常见原因 | 处理方向 |
|---|---|---|---|
| 再点当前标签就回第1题 | 点击幂等性 | 标签直接调用开始函数 | 同分类返回 KEEP_CURRENT |
| 取消切换后题序变了 | 候选创建时机 | 确认前已经调用随机选题 | 用户确认后才采样 |
| 标签变了但页面为空 | 提交顺序 | 先写 category,后发现无题 | 候选完整后原子替换 |
| 旧确认关闭了新会话 | 修订号 | 对话框回调没有上下文 | 比较 sessionId 与 revision |
| 双击确认产生两组题 | 意图幂等 | 同一决定重复消费 | 为 intent 建唯一 ID 和完成态 |
| 已答记录重复出现 | 历史副作用 | 切分类时重放答案 | 历史只响应明确的作答事件 |
| 新分类仍显示旧答案色 | 页面字段双写 | 新会话与旧 selectedAnswer 并存 | UI 全部从会话快照派生 |
| 切换后无法回看旧场次 | 放弃策略不清 | 直接删除旧对象 | 明确保存摘要或仅关闭,不冒充完成 |
落地时可以保持改动很小:先给同分类点击增加无副作用决策;再引入最小会话快照;随后把确认后的选题与提交收进一个协调器;最后才决定是否持久化主动放弃摘要。不要同时改造整个题库、随机算法和历史协议,否则很难判断哪一项改变了用户体验。
当前源码可以确认:三个科二标签的点击回调立即写入 subject2Category 并调用 startTheoryPractice();该函数会重建随机题组、把索引归零并清空当前选择;选题函数每次直接使用 Math.random();现有代码没有同分类守卫、在途会话判断、放弃确认或转场回执。由此能确定当前控制流会替换内存会话,但不能宣称本轮已在设备上观察到特定题号、历史丢失或重复记录。
PracticeTransitionReceipt 让一次分类切换变得可解释:旧会话是谁、用户是否允许放弃、新题组是否创建成功、最终有没有提交。本文没有修改 The_kemusan 业务源码,没有执行 hvigorw assembleHap --no-daemon,没有生成新的 HAP,也没有进行模拟器或真机交互;真正接入后,还需用固定随机源、页面确认流程和设备快速点击场景完成验证。
更多推荐



所有评论(0)