灯光模拟HarmonyOS应用实战-91-考试已经合格,再点灯光为何会改成演示提示:用ExamResultSeal冻结终局视图
科三灯光考试结束后,页面已经显示“考试合格!”或“考试未合格”,历史写入也已经发起。此时用户如果顺手再点一次近光灯、远光灯或雾灯,结果应该保持不变,还是立即切回自由演示?The_kemusan当前选择了一个隐式答案:结束态也进入演示分支,继续改变灯光状态,并把结果说明覆盖成“当前为灯光演示,不计入考试”。
这不代表历史记录一定被改写。当前赛后点击分支没有再次调用addSimpleRecord(),已发起的结束记录与页面提示属于两条不同链路。真正需要治理的是终局视图的所有权:一份已经形成的考试结论不应继续由通用演示提示字段覆盖;如果产品允许赛后试灯,也应进入独立演示会话,而不是让同一组字段同时扮演成绩卡和演示台。
本文只做源码审阅与建议设计。本次没有在模拟器或真机上完成一场考试后连续点击灯光,因此不会把可见闪烁、历史变化或特定交互时序写成已复现事实。文中的ExamResultSeal、CompletionSnapshot和ControlRouter尚未接入当前工程。
一、结束函数已经写出一组明确的终局字段
普通灯光路径在Index.ets:1522-1532进入finishLightExam()。函数锁住动作、结束考试、停止倒计时,并根据结果写入标题和说明:
private finishLightExam(passed: boolean, reason: string, wrongQuestionId: string, correctOption: string, selectedOption: string): void { this.actionLocked = true; this.examActive = false; this.examFinished = true; this.stopTimer(); this.currentPrompt = passed ? '考试合格!' : '考试未合格'; this.message = passed ? reason : `${reason}${correctOption.length > 0 ? ',正确选项:' + correctOption : ''}`; this.isSuccessMessage = passed; this.addSimpleRecord(SUBJECT_THREE, this.getLightExamMode(), passed, reason, wrongQuestionId, correctOption, selectedOption, this.correctCount, this.totalQuestions, Math.max(1, Math.floor((Date.now() - this.startAt) / 1000))); }finishPracticalExam()在1534-1544采用同样结构,只把标题改为“实操考试合格!”或“实操考试未合格”。从静态赋值顺序可以确认,结束时页面拥有一组一致的终局状态:examActive=false、examFinished=true、currentPrompt为结论、message为原因、isSuccessMessage为结果语义。
问题不是没有结束标志,而是后续操作没有把这个标志视为不可覆盖边界。
二、赛后点击与赛前演示共用了同一个分支
普通控制按钮最终进入handleLightAction()。Index.ets:1426-1432在“考试未开始”或“考试已结束”时执行同一段代码:
private handleLightAction(action: string): void { if (!this.examActive || this.examFinished) { this.applyActionToState(action); this.message = '当前为灯光演示,不计入考试'; this.isSuccessMessage = true; return; } // 进行中考试的判题分支 }实操按钮也一样:
private handlePracticalAction(action: string): void { if (!this.examActive || this.examFinished) { this.applyPracticalActionToState(action); this.message = '当前为实操按钮演示,不计入考试'; this.isSuccessMessage = true; return; } // 进行中实操的判题分支 }由于结束函数已经把examActive设为false,赛后点击必然满足第一项条件;examFinished的判断在这里甚至不是必需条件。控制器先改变lightState,再覆盖message和颜色语义。currentPrompt仍可能保持“考试合格!”,于是同一屏幕可能同时携带“终局标题”和“演示说明”两种来源。
三、页面说明会变,不等于持久化结果也会变
把风险边界说清楚,才能避免修错位置。当前结束函数只在完成时调用一次addSimpleRecord();赛后演示分支没有再次保存记录,也没有修改correctCount或totalQuestions。因此静态代码支持以下结论:
| 对象 | 结束时发生的动作 | 赛后点击当前会做什么 | 本次能否确认 |
|---|---|---|---|
currentPrompt | 写入合格/未合格标题 | 当前分支不改 | 源码可确认 |
message | 写入结束原因 | 改成演示提示 | 源码可确认 |
isSuccessMessage | 按结论赋值 | 强制改为true | 源码可确认 |
lightState | 保留结束时灯态 | 按按钮继续变化 | 源码可确认 |
PracticeRecord | 发起一次异步写入 | 没有新增写入调用 | 源码可确认 |
| 用户实际看到的帧序列 | 依赖渲染与操作时机 | 本次未操作设备 | 尚不能确认 |
这篇文章处理终局展示与赛后交互,不重复讨论写入失败、并发覆盖或历史迁移。即使记录最终可靠落盘,结果页被演示提示覆盖仍会让用户难以判断“刚才到底是否通过”。
四、先选产品策略:锁定、显式进入演示,或离开结果页
终局不是只能“全部禁用”。产品可以在三个策略中选择一个,但选择必须成为可见动作:
| 策略 | 结束后控制按钮 | 结果卡 | 适合场景 |
|---|---|---|---|
| 完全锁定 | 禁用并说明“重新开始后可操作” | 一直保持 | 强考试感、避免误触 |
| 显式进入演示 | 点击“进入灯光演示”后启用 | 保存为可返回的终局 | 兼顾复盘与自由试灯 |
| 离开结果页 | 控制按钮只在独立演示页出现 | 结果页不承载灯光控制 | 页面职责最清楚 |
当前单页架构改动最小的是第二种:结束后保持成绩卡和控制区的禁用态,提供一个明确按钮创建DemoSession;进入演示后,页面不再声称自己仍是本场结果页。若产品不需要赛后演示,第一种更简单,不必为“以后可能用”提前建复杂路由。
五、CompletionSnapshot把终局从散字段变成一个对象
当前结果分散在currentPrompt、message、isSuccessMessage、correctCount、totalQuestions和灯态中,任一通用方法都可能覆盖其中一部分。建议在结束瞬间构造不可变语义快照:
interface CompletionSnapshot { sessionId: string; mode: string; passed: boolean; headline: string; reason: string; correctCount: number; plannedCount: number; finalLampState: string; finishedAt: number; } function createCompletionSnapshot(sessionId: string, mode: string, passed: boolean, reason: string, correctCount: number, plannedCount: number, finalLampState: string, finishedAt: number): CompletionSnapshot { return { sessionId, mode, passed, headline: passed ? '考试合格!' : '考试未合格', reason, correctCount, plannedCount, finalLampState, finishedAt }; }函数一次性接收结束事件已经拥有的值,不在结果页重新推导。finalLampState表示结束瞬间的事实快照,后续演示灯态即使变化,也不能回写它。finishedAt应由可替换时钟提供,便于固定测试输入;示例直接传参,避免在纯函数里隐藏Date.now()。
六、ExamResultSeal拒绝终局后的通用字段写入
“冻结”不是把对象序列化后锁进文件,而是让所有后续事件通过同一状态迁移入口。可以先把页面阶段写成枚举:
enum ScreenPhase { PREVIEW = 'PREVIEW', EXAM = 'EXAM', RESULT = 'RESULT', DEMO = 'DEMO' } enum ControlChannel { JUDGE_EXAM = 'JUDGE_EXAM', APPLY_DEMO = 'APPLY_DEMO', REJECT_ON_RESULT = 'REJECT_ON_RESULT' } function routeControl(phase: ScreenPhase): ControlChannel { if (phase === ScreenPhase.EXAM) { return ControlChannel.JUDGE_EXAM; } if (phase === ScreenPhase.DEMO || phase === ScreenPhase.PREVIEW) { return ControlChannel.APPLY_DEMO; } return ControlChannel.REJECT_ON_RESULT; }RESULT不再落入!examActive的宽泛演示分支。控制点击先得到通道,再由对应所有者处理。结果页可以显示“考试已结束,选择重新考试或进入演示”,但不能调用applyActionToState()后再补一条提示,因为状态改变已经发生。
七、演示使用独立LampPreview,不覆盖CompletionSnapshot
如果用户明确进入演示,应创建只负责灯态的对象。它不持有分数、原因或passed:
interface DemoSession { id: string; lampState: string; message: string; } function applyDemoAction(session: DemoSession, action: string): DemoSession { return { id: session.id, lampState: reduceLampState(session.lampState, action), message: '当前为灯光演示,不计入考试' }; }这里的reduceLampState()代表从当前applyActionToState()提取出的纯状态映射;交替闪烁的定时副作用仍由LampPreview适配层拥有。DemoSession的消息可以变化,CompletionSnapshot始终保持结束时值。
页面可以在两个状态之间显式切换:
private handleControlIntent(action: string): void { const channel = routeControl(this.screenPhase); if (channel === ControlChannel.JUDGE_EXAM) { this.judgeExamAction(action); return; } if (channel === ControlChannel.APPLY_DEMO) { this.demoSession = applyDemoAction(this.demoSession, action); return; } this.resultNotice = '考试已经结束,请先选择重新考试或进入演示'; }最后一个分支允许给出短暂辅助提示,但提示应放在resultNotice,不能覆盖快照的reason。如果不希望结果页出现任何动态提示,可以直接禁用按钮并让无障碍说明表达原因。
八、历史写入、结果展示和赛后演示要分别确认成功
当前addSimpleRecord()异步调用store.addRecord(...).then(...),没有返回给结束函数一份明确的持久化回执。因此即使页面封住了终局,也只能说明“结果快照已形成”,不能同时声称“历史已经耐久保存”。建议把证据分成三层:
CompletionSnapshot创建成功:本场结论、分数、原因和结束灯态已冻结。SaveReceipt成功:仓储回读确认对应sessionId已保存。DemoSession启动:用户明确选择进入演示,后续灯态只属于演示。
三个步骤不能合并成一个isSuccessMessage=true。如果持久化失败,结果卡仍可显示本场结论,同时单独提示“历史未保存,可重试”;它不应把考试结论改成失败,也不能假装演示操作能补写历史。
建议保存接口返回清楚的结果:
interface SaveReceipt { sessionId: string; saved: boolean; issueCode: string; } interface CompletionRepository { save(snapshot: CompletionSnapshot): Promise<SaveReceipt>; }issueCode使用有限白名单,不携带 Preferences 原文或用户答题内容。页面根据saved显示历史状态,而终局标题始终读取snapshot.passed。
九、用正反用例证明“点击发生了,结果没有变”
只有一张结束页截图无法证明后续操作不会覆盖。实现后应保存快照,执行赛后点击,再比较所有终局字段。纯逻辑用例如下:
describe('ExamResultSeal', () => { it('rejects_control_on_result_phase', 0, () => { const channel = routeControl(ScreenPhase.RESULT); expect(channel).assertEqual(ControlChannel.REJECT_ON_RESULT); }); it('keeps_completion_when_demo_changes', 0, () => { const completion = createCompletionSnapshot( 'exam-91', '科三灯光模拟', true, '全部题目操作正确', 8, 8, ACTION_CLOSE, 1000); const demoBefore: DemoSession = { id: 'demo-91', lampState: ACTION_CLOSE, message: '' }; const demoAfter = applyDemoAction(demoBefore, ACTION_HIGH); expect(completion.reason).assertEqual('全部题目操作正确'); expect(completion.finalLampState).assertEqual(ACTION_CLOSE); expect(demoAfter.lampState).assertEqual(ACTION_HIGH); }); });还需要覆盖以下交互:
- 合格后连续点击全部普通灯光按钮,结果标题、原因和分数不变。
- 未合格后点击控制按钮,失败原因和正确选项不被覆盖。
- 实操模式与普通灯光模式采用同一终局门禁。
- 明确进入演示后,只改变
DemoSession.lampState。 - 从演示返回结果时,仍能读到原
CompletionSnapshot。 - 点击“重新考试”创建新
sessionId,不会改写旧快照。 - 交替灯动画在结果阶段不能被误启动。
- 历史保存失败与考试结论分别呈现。
- 进程重建后若不支持恢复结果,应明确回首页,不能拼接半份旧状态。
十、排障表与当前事实边界
| 现象 | 优先查看 | 常见原因 | 处理方向 |
|---|---|---|---|
| 合格标题仍在,说明变成演示 | message所有者 | 结果与演示共用字段 | 结果读快照,演示用独立消息 |
| 赛后点灯改变结束灯态 | lightState所有者 | 终局事实与实时预览共用变量 | 保存finalLampState,另建demoState |
| 失败结果突然显示成功色 | isSuccessMessage | 演示分支强制写true | 结果色从snapshot.passed派生 |
| 结果页误启动交替动画 | 控制路由 | RESULT 落入演示通道 | 在副作用前拒绝,不在之后补提示 |
| 演示后无法回看成绩 | 页面阶段 | 进入演示时删除了快照 | 由 ResultStore 保留,页面只切换阶段 |
| 成绩可见但历史没有 | 保存回执 | 页面结果与持久化混为成功 | 单独展示SaveReceipt |
| 重开后旧结果被改写 | 会话身份 | 复用同一对象或 sessionId | 新建会话,旧快照只读 |
当前源码可以确认:两个结束函数会写入终局标题、原因和结果语义;两个控制处理函数会在examActive=false或examFinished=true时继续应用灯光动作,并把message覆盖为演示提示;赛后演示分支没有再次调用历史写入。由此能确定字段所有权存在冲突,但不能把未执行的设备画面或存储结果写成已发生。
ExamResultSeal的核心不是增加一个finished布尔值,因为当前已经有examFinished。真正的改动是让结果、保存与演示分别拥有自己的对象和通道,所有赛后点击先经过阶段决策,终局字段不再被通用演示逻辑修改。本文没有修改项目源码,没有执行构建,没有生成 HAP,也没有在模拟器或真机完成赛后点击验证;实现后仍需按上述矩阵确认渲染、动画、存储与重开行为。