灯光模拟HarmonyOS应用实战-16-重置灯光、重开考试和返回首页时要清掉哪些状态

灯光考试里最难复现的状态问题,通常出现在“操作正确”后的半秒钟:页面已经返回首页,旧回调却推进了下一题;用户刚重开考试,上一轮回调又把新一轮题号加一;远近光交替看似停止,倒计时却在别的页面重新启动。
这类问题不能靠一句 lightState = LIGHT_CLOSED 解决。The_kemusan 同时维护倒计时、远近光闪烁、考试锁、当前命令、命令序列、得分和提示文案;其中两个循环任务保存了句柄,答对后的延迟推进却直接调用 setTimeout(),没有保存句柄。源码已经做了多处清理,但还存在一个真实的延迟回调竞态。
本文分别讨论“重置灯光”“重开考试”“返回首页”三个动作,明确当前代码做了什么、没有做什么,以及怎样用可取消句柄和轮次令牌把异步边界补完整。
先列状态账本:什么该清,什么必须保留
Index.ets:137-174 中与科三考试直接相关的状态,可以分成四组:
| 类别 | 字段 | 离开或重开时的处理 |
|---|---|---|
| 页面展示 | lightState、currentPrompt、message、isSuccessMessage、timeLeft |
恢复初始值 |
| 考试控制 | examActive、examFinished、actionLocked、currentIndex、totalQuestions、correctCount |
结束旧轮次并清零 |
| 当前上下文 | currentLightCommand、currentPracticalCommand、examCommands、practicalCommands、startAt |
命令与数组失效;新开时重建开始时间 |
| 异步任务 | timerId、altFlashTimerId、远近光步数,以及未保存的推进 setTimeout |
全部取消或作废 |
records 不在清理名单里。它是练习历史的页面快照,重开考试或返回首页都不应删除;只有用户明确执行“清空记录”时才调用 PracticeStore.clearRecords()。同样,题库练习的 selectedAnswer 属于另一条业务链,不要在科三重置函数里顺手清理所有页面状态。

resetSubjectThreeExam 只重置同步上下文
当前源码的完整重置函数位于 Index.ets:1292-1308:
private resetSubjectThreeExam(): void {
this.lightState = LIGHT_CLOSED;
this.examActive = false;
this.examFinished = false;
this.actionLocked = false;
this.timeLeft = TIMER_SECONDS;
this.currentIndex = 0;
this.totalQuestions = 0;
this.correctCount = 0;
this.currentPrompt = '点击“开始考试”';
this.message = '可先点灯光按钮熟悉操作';
this.isSuccessMessage = true;
this.currentLightCommand = null;
this.currentPracticalCommand = null;
this.examCommands = [];
this.practicalCommands = [];
}
它把 UI、考试控制、当前命令和两套序列都恢复了,但函数本身没有调用 stopTimer() 或 stopAltFlash()。当前调用点 openModule() 会在它之前先停两个循环任务,所以通过现有入口进入科三页面时顺序是安全的:
openModule
-> stopTimer
-> stopAltFlash
-> currentPage = 科三页面
-> resetSubjectThreeExam
这也意味着 resetSubjectThreeExam() 不是一个可以随处独立调用的“完整事务”。如果以后新增“重置考试”按钮直接调用它,旧 interval 仍可能继续运行。更稳的做法是让重置函数自己拥有异步清理,调用方只表达业务意图。
两个 interval 为什么能被正确停止
倒计时先保存 timerId,每次启动前先停止旧任务;清理后把句柄恢复为 -1:
private startTimer(): void {
this.stopTimer();
this.timerId = setInterval(() => {
if (!this.examActive || this.actionLocked) {
return;
}
this.timeLeft = Math.max(0, this.timeLeft - 1);
if (this.timeLeft <= 0) {
this.stopTimer();
this.handleTimeout();
}
}, 1000);
}
private stopTimer(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
远近光交替采用相同的资源所有权:startAltFlash() 先停止旧闪烁,再保存 altFlashTimerId;stopAltFlash() 不只清 interval,还重置步数和两个展示状态。
private stopAltFlash(): void {
if (this.altFlashTimerId >= 0) {
clearInterval(this.altFlashTimerId);
this.altFlashTimerId = -1;
}
this.altFlashStep = 0;
this.altFlashActive = false;
this.altFlashHighVisible = false;
}
“保存句柄、重复启动前先停、停止后清哨兵值”是这里最值得迁移的模式。它让启动和停止幂等:即使调用两次 stopTimer(),第二次也不会误清别的任务。
重置灯光按钮不是重置考试按钮
页面底部的“重置灯光”只停止远近交替并关闭灯光:
.onClick(() => {
this.stopAltFlash();
this.lightState = LIGHT_CLOSED;
if (!this.examActive) {
this.message = '灯光已复位';
this.isSuccessMessage = true;
}
})
它不会停止倒计时,不会把 examActive 改为 false,也不会清题号、得分或当前命令。考试进行中点击它,当前题仍继续计时,只是灯光状态回到关闭;非考试状态下才把提示改成“灯光已复位”。
这个边界要在产品命名和验收中保持一致:
- “重置灯光”是一次设备状态操作,适合演示阶段。
- “重开考试”是新建考试轮次,必须清理全部临时上下文。
- “返回首页”是离开业务范围,至少要让所有异步任务立即失效。
如果把三者都绑定到同一个轻量函数,用户看到的页面可能相似,内部状态却完全不同。

重开考试会重建主要状态,但仍要防旧回调
startSubjectThreeExam() 与实操版入口都先停止倒计时和远近光,再开始新一轮:
private startSubjectThreeExam(): void {
this.stopTimer();
this.stopAltFlash();
this.lightState = LIGHT_CLOSED;
this.examActive = true;
this.examFinished = false;
this.actionLocked = false;
this.correctCount = 0;
this.currentIndex = 0;
this.startAt = Date.now();
this.examCommands = this.selectLightCommands();
this.totalQuestions = this.examCommands.length;
this.message = '请在 5 秒内完成对应灯光操作';
this.isSuccessMessage = true;
this.presentLightCommand(this.examCommands[0]);
}
presentLightCommand() 会覆盖 currentLightCommand、currentPrompt、actionLocked 与 timeLeft,并启动新倒计时。因此在同步路径上,重开后题号、得分、开始时间、题目序列和提示都来自新一轮。
不过,停止两个 interval 并不能取消所有异步工作。如果上一轮已经答对并安排了延迟推进,那段回调仍可能在新一轮启动后运行。重开流程必须同时处理推进 timeout,而当前源码没有对应句柄。
返回首页当前只停循环,不等于完整重置
首页按钮调用的是 goHome():
private goHome(): void {
this.stopTimer();
this.stopAltFlash();
this.currentPage = PAGE_HOME;
this.pageTitle = '驾考灯光综合助手';
}
它能保证调用当下两个 interval 停止,并切换页面;它没有把 examActive、actionLocked、当前命令或命令数组清空。再次通过 openModule() 进入科三时,会先停 interval 再调用 resetSubjectThreeExam(),所以正常“返回首页 → 再进入”最终会得到初始同步状态。
问题仍然在延迟回调:如果 goHome() 之后 timeout 才触发,它可能推进 currentIndex,调用 presentLightCommand(),并重新创建倒计时。此时用户已经在首页,goHome() 刚停掉的任务又被旧回调启动了。
组件的 aboutToDisappear() 也只调用 stopTimer() 与 stopAltFlash()。页面路由或组件销毁能覆盖 interval,但仍无法撤销没有句柄的 timeout。
真正的竞态在 markLightCorrect 的 setTimeout
答对后,源码锁住操作、停止当前倒计时,然后延迟 550ms 或 1000ms 推进下一题:
private markLightCorrect(text: string): void {
this.actionLocked = true;
this.stopTimer();
this.correctCount++;
this.message = text;
this.isSuccessMessage = true;
setTimeout(() => {
this.currentIndex++;
if (this.currentIndex < this.examCommands.length) {
this.presentLightCommand(this.examCommands[this.currentIndex]);
} else {
this.finishLightExam(true, '全部题目操作正确', '', '', '');
}
}, this.altFlashActive ? 1000 : 550);
}
实操路径 markPracticalCorrect() 采用同样方式。这里没有把 timeout ID 存到字段,也没有在回调里检查“仍在同一轮考试、仍在科三页面”。可复现窗口是:
T0 本题答对,安排 550ms 后推进
T1 立刻返回首页,goHome 停止现有 interval
T2 旧 timeout 到期,currentIndex 自增
T3 presentLightCommand 启动新的 interval
另一个窗口是 T1 立即重开考试:旧回调可能在新数组上推进,导致新一轮跳过第一题。静态源码已经能证明回调缺少取消与轮次守卫;本文没有把它写成已经在设备上观察到的故障。
修复思路一:为推进 timeout 保存句柄
最小改造是像倒计时一样保存推进任务。下面是迁移示例,当前源码尚未实现:
private advanceTimeoutId: number = -1;
private stopAdvanceTimeout(): void {
if (this.advanceTimeoutId >= 0) {
clearTimeout(this.advanceTimeoutId);
this.advanceTimeoutId = -1;
}
}
private stopExamAsyncTasks(): void {
this.stopTimer();
this.stopAltFlash();
this.stopAdvanceTimeout();
}
随后,goHome()、resetSubjectThreeExam()、两个开始考试函数和 aboutToDisappear() 都调用 stopExamAsyncTasks()。安排新推进前也先取消旧推进,确保一个页面实例最多只有一个待执行任务。
句柄解决“能取消”的问题,但还需要防止“取消与执行同时发生”或未来其他 Promise 回调穿透轮次,因此建议再加一个轮次令牌。
修复思路二:用 generation 让旧轮次自动失效
每次开始、重置或离开时递增 examGeneration。安排回调时捕获当前值,到期后先比较:
private examGeneration: number = 0;
private invalidateExamRound(): void {
this.examGeneration++;
this.stopExamAsyncTasks();
}
private scheduleNextLightCommand(): void {
this.stopAdvanceTimeout();
const generation = this.examGeneration;
this.advanceTimeoutId = setTimeout(() => {
this.advanceTimeoutId = -1;
if (generation !== this.examGeneration ||
!this.examActive ||
this.currentPage !== PAGE_SUBJECT_THREE_EXAM) {
return;
}
this.currentIndex++;
if (this.currentIndex < this.examCommands.length) {
this.presentLightCommand(this.examCommands[this.currentIndex]);
} else {
this.finishLightExam(true, '全部题目操作正确', '', '', '');
}
}, this.altFlashActive ? 1000 : 550);
}
句柄负责主动取消,generation 负责被动拒绝旧工作,两层一起使用更稳。实操路径可以复用相同策略,但页面常量和命令数组要匹配 PAGE_SUBJECT_THREE_FLOW 与 practicalCommands,不要让两个考试模式共享错误的守卫条件。
让重置成为一个有顺序的事务
重置不是随便给字段赋初值,而应保持固定顺序:先让异步任务失效,再清当前命令和序列,最后恢复 UI。否则旧回调可能在清理过程中重新写入状态。
private resetSubjectThreeExamSafely(): void {
this.invalidateExamRound();
this.examActive = false;
this.examFinished = false;
this.actionLocked = false;
this.currentLightCommand = null;
this.currentPracticalCommand = null;
this.examCommands = [];
this.practicalCommands = [];
this.currentIndex = 0;
this.totalQuestions = 0;
this.correctCount = 0;
this.timeLeft = TIMER_SECONDS;
this.lightState = LIGHT_CLOSED;
this.currentPrompt = '点击“开始考试”';
this.message = '可先点灯光按钮熟悉操作';
this.isSuccessMessage = true;
}
这段同样是建议实现,不代表工程文件已经修改。startAt 可以在新考试开始时覆盖;如果调试工具会展示上次开始时间,也可在重置时明确设为 0。重要的是给每个字段一个可解释的所有者,而不是为了“清得彻底”误删 records。
可复现验证矩阵
| 场景 | 操作时机 | 要观察的状态 | 预期 |
|---|---|---|---|
| 演示复位 | 未考试时点亮远光,再点“重置灯光” | lightState、提示 |
灯光关闭,提示为“灯光已复位”,不新增记录 |
| 考试内复位 | 倒计时中点“重置灯光” | examActive、timeLeft |
考试仍继续,倒计时未被当成重开 |
| 正常重开 | 完成或失败后再次开始 | 题号、得分、开始时间、命令数组 | 新轮次从第 0 题和 0 分开始 |
| 立即返回 | 答对后 550ms 内返回首页 | 首页状态、后台 timer | 修复前存在旧回调重启 timer 的风险;修复后不得推进 |
| 快速重开 | 答对后立刻开始新轮次 | 新轮次第一题 | 旧回调不得让 currentIndex 变为 1 |
| 远近交替中离开 | 闪烁尚未完成时返回 | altFlashTimerId、高低灯状态 |
interval 清除,闪烁状态恢复为 false |
| 组件离开 | 页面生命周期结束 | 三类异步任务 | timer、flash、advance 都应失效 |
| 历史保留 | 重开或回首页后打开历史 | records |
已完成记录仍在,不被考试重置误删 |
当前源码对两个 interval 的清理、入口重置和记录保留可以由静态调用链确认;本轮没有修改 The_kemusan 源码,也没有运行 HAP 构建、模拟器或真机操作。带“修复后”的项目必须在实际实现 generation 与 timeout 句柄后再执行回归,不能把本文示例当成已合入结果。
故障出现时按“还活着的任务”排查
| 现象 | 最可能的活跃对象 | 先看哪里 |
|---|---|---|
| 回首页后题号还变 | 未取消的推进 timeout | markLightCorrect() / markPracticalCorrect() |
| 回首页后倒计时重新出现 | 旧 timeout 调用了 present*Command() |
timeout 句柄与 generation 守卫 |
| 灯光仍在高低切换 | altFlashTimerId 未清 |
stopAltFlash() 是否覆盖离开入口 |
| 新考试跳过第一题 | 上一轮推进回调穿透 | 开始新轮次前是否作废旧 generation |
| 重置后仍按旧题判定 | 当前命令未置空 | currentLightCommand / currentPracticalCommand |
| 重开后历史消失 | 错把 records 纳入考试重置 |
只清临时考试状态,持久记录单独管理 |
排查这类问题时,不要先调颜色和文案。先列出 interval、timeout、Promise 以及当前命令对象,逐个回答“谁创建、谁持有句柄、谁负责取消、回调如何确认自己仍有效”,竞态通常就会显形。
小结:重置的本质是终止旧轮次的所有权
The_kemusan 已经为倒计时和远近光 interval 建立了明确句柄,也在进入模块、返回首页和组件离开时进行清理;同步状态则由 resetSubjectThreeExam() 恢复。当前缺口是答对后的 setTimeout() 没有句柄和轮次校验,因此“返回首页”与“重开考试”还不能证明所有旧工作都已终止。
一个可靠的重置流程应当先作废异步任务,再清考试上下文,最后恢复 UI,同时保留不属于本轮考试的持久历史。做到这一点,灯光关闭、题号归零和页面返回才不只是视觉上的复位,而是真正结束了上一轮状态机。
更多推荐



所有评论(0)