灯光模拟HarmonyOS应用实战-23-延迟进入下一题如何用会话令牌拦截过期回调
答对后停留半秒,让用户看清“操作正确”,再进入下一题,这个交互很自然。风险也藏在这半秒里:用户可能点“首页”、点“复位”,甚至立即开始一场新考试;旧回调随后醒来,仍然把索引加一、展示下一条指令,或者给新会话启动一个倒计时。
这篇不把尚未存在的保护机制说成项目现状。我们先审计 The_kemusan 当前源码,确认两处 setTimeout 没有保存句柄、没有会话令牌;再给出一套最小加固方案,同时用“取消句柄”和“令牌校验”封住退出、重置、重开三条竞态路径。

先做现状审计:当前延迟回调没有归属
科三模拟答对后,源码中的 markLightCorrect() 会锁定操作、停止五秒倒计时、增加正确数,然后延迟进入下一题:
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() 结构相同,只是读取 practicalCommands 并调用 presentPracticalCommand()。两处都直接调用全局 setTimeout(),返回的定时器 ID 没有保存到字段。
这意味着当前类知道如何停止两类 setInterval:
timerId管理每秒递减的考试倒计时;altFlashTimerId管理远近光交替闪烁;
却不知道如何取消“进入下一题”的一次性延迟任务。当前源码中也不存在 sessionToken、examGeneration 或同类字段。下面讨论的令牌全部是建议加固,不是已实现能力。
竞态是怎样发生的:旧任务跨过页面边界
把用户操作按时间展开,问题会非常直观:
| 时间 | 当前会话 A 的动作 | 页面状态 |
|---|---|---|
| 0 ms | 用户答对第 2 题 | actionLocked = true,安排延迟回调 |
| 100 ms | 用户点击“首页” | 倒计时和闪光被停止,页面切到首页 |
| 550 ms | 会话 A 的旧回调执行 | currentIndex++,可能再次 presentLightCommand() |
| 551 ms | presentLightCommand() | 解锁动作并重新启动五秒倒计时 |
goHome() 的现有实现只停止两个 interval,再修改页面标识:
private goHome(): void {
this.stopTimer();
this.stopAltFlash();
this.currentPage = PAGE_HOME;
this.pageTitle = '驾考灯光综合助手';
}
旧回调并不检查 currentPage,所以“返回首页时已经停掉计时器”并不充分。它可以在返回之后调用 presentLightCommand(),后者又执行 startTimer()。从用户视角看首页没有考试界面,从状态机视角看旧考试却在后台继续推进。

图中的 600ms 只是展示竞态窗口的示意延迟,sessionToken、nextQuestion 也是概念名称。本项目实际延迟为 550ms 或 1000ms;下文建议字段名为 examSessionToken,推进逻辑分别调用灯光模拟或实操的题目呈现方法。图用于说明任务归属,迁移时应保留源码中的实际延迟与业务函数。
reset 的风险比 goHome 更隐蔽
resetSubjectThreeExam() 会清空考试字段,但它本身没有调用 stopTimer()、stopAltFlash(),也无法取消未保存的延迟回调:
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 = [];
}
从 openModule() 进入科三模块时,调用方会先停止两个 interval,再执行重置;但实操页面上的“复位”按钮会直接调用 resetSubjectThreeExam()。因此不能仅凭某一个入口的前置清理,认定重置函数自身是安全的。
更严重的场景是:A 会话答对后安排回调,用户复位并快速开始 B 会话。等 A 的回调执行时,examActive 已经因 B 会话重新变为 true。即便后来只补一个 if (!this.examActive) return,也拦不住这个旧任务,因为它看到的是新会话的活动状态。
这正是需要“会话身份”的原因:布尔值只能回答现在有没有考试,令牌才能回答这个回调属于哪一场考试。
最小模型:一个句柄负责取消,一个令牌负责验明身份
建议新增两个字段。以下代码是加固方案,不是当前源码:
private nextQuestionTimerId: number = -1;
private examSessionToken: number = 0;
private cancelPendingAdvance(): void {
if (this.nextQuestionTimerId >= 0) {
clearTimeout(this.nextQuestionTimerId);
this.nextQuestionTimerId = -1;
}
}
private invalidateExamSession(): void {
this.cancelPendingAdvance();
this.stopTimer();
this.stopAltFlash();
this.examSessionToken++;
}
两层保护承担不同职责:
- 保存 ID 并
clearTimeout(),取消不再需要的延迟任务。 - 增加
examSessionToken,显式校验任务的业务归属。若未来增加不能可靠取消的异步步骤,完成后也可以先检查令牌再修改状态。
这里不假设“计时器进入执行队列后就无法取消”这类未经当前平台验证的细节。对现有计时器,先完整接入取消路径;令牌则把旧任务不得修改新会话的业务约束写清楚。
令牌不需要是随机数,也不需要持久化。它只在当前页面实例内单调递增,用来标记“从哪次开始到哪次失效”的会话代次。

开始新考试时先让旧会话失效
两种科三考试都应在初始化新状态之前调用失效函数。下面只展示关键改动,数组选择和提示文案仍沿用原有代码:
private startSubjectThreeExam(): void {
this.invalidateExamSession();
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]);
}
private startSubjectThreePracticalExam(): void {
this.invalidateExamSession();
this.lightState = LIGHT_CLOSED;
this.examActive = true;
this.examFinished = false;
this.actionLocked = false;
this.correctCount = 0;
this.currentIndex = 0;
this.startAt = Date.now();
this.practicalCommands = this.selectPracticalCommands();
this.totalQuestions = this.practicalCommands.length;
this.presentPracticalCommand(this.practicalCommands[0]);
}
invalidateExamSession() 递增令牌后,新安排的回调会捕获新值。即使 A 会话的旧回调晚于 B 会话启动才运行,它保存的令牌也与当前值不同。
这里没有引入通用任务调度器或复杂状态机。一个页面只有一种“进入下一题”的挂起任务,单个句柄和单个代次字段已经足够。
安排下一题时捕获令牌,并做四重门禁
建议把答对后的延迟逻辑移到专门方法。灯光模拟与实操分别保留业务明确的方法,避免用难懂的布尔参数把两个命令数组混在一起。
private scheduleNextLightCommand(delayMs: number): void {
this.cancelPendingAdvance();
const token = this.examSessionToken;
this.nextQuestionTimerId = setTimeout(() => {
if (token !== this.examSessionToken ||
this.currentPage !== PAGE_SUBJECT_THREE_EXAM ||
!this.examActive || this.examFinished) {
return;
}
this.nextQuestionTimerId = -1;
this.currentIndex++;
if (this.currentIndex < this.examCommands.length) {
this.presentLightCommand(this.examCommands[this.currentIndex]);
} else {
this.finishLightExam(true, '全部题目操作正确', '', '', '');
}
}, delayMs);
}
private scheduleNextPracticalCommand(delayMs: number): void {
this.cancelPendingAdvance();
const token = this.examSessionToken;
this.nextQuestionTimerId = setTimeout(() => {
if (token !== this.examSessionToken ||
this.currentPage !== PAGE_SUBJECT_THREE_FLOW ||
!this.examActive || this.examFinished) {
return;
}
this.nextQuestionTimerId = -1;
this.currentIndex++;
if (this.currentIndex < this.practicalCommands.length) {
this.presentPracticalCommand(this.practicalCommands[this.currentIndex]);
} else {
this.finishPracticalExam(true, '全部实操灯光项目操作正确', '', '', '');
}
}, delayMs);
}
回调先通过门禁,再清理当前句柄。这样即便一个不属于当前会话的任务进入这段逻辑,也不会先把新会话的 nextQuestionTimerId 覆盖成 -1。四个门禁回答四个不同问题:
token:是不是安排这个任务的那一场考试;currentPage:用户是否仍停留在对应科三页面;examActive:考试是否仍在进行;examFinished:是否已经有其他路径结束考试。
随后,两处答对逻辑只负责当前题结算并调用调度方法:
private markLightCorrect(text: string): void {
this.actionLocked = true;
this.stopTimer();
this.correctCount++;
this.message = text;
this.isSuccessMessage = true;
this.scheduleNextLightCommand(this.altFlashActive ? 1000 : 550);
}
private markPracticalCorrect(text: string): void {
this.actionLocked = true;
this.stopTimer();
this.correctCount++;
this.message = text;
this.isSuccessMessage = true;
this.scheduleNextPracticalCommand(this.altFlashActive ? 1000 : 550);
}
这保持了原有 550 ms / 1000 ms 的视觉停留时间,没有改变题目顺序、正确数或结束条件。
所有离开路径都必须收口到失效函数
只在“首页”按钮里清理仍不完整。会话至少会通过五种方式终止或替换:返回首页、复位、组件销毁、重新开始、考试正常或异常结束。它们都要让旧回调失去资格。
建议的关键接线如下。销毁阶段单独使用“只清句柄、不改 @State”的清理方法;这是因为组件即将销毁时没有必要再触发界面状态更新。
private invalidateExamSessionForDestroy(): void {
this.cancelPendingAdvance();
this.stopTimer();
if (this.altFlashTimerId >= 0) {
clearInterval(this.altFlashTimerId);
this.altFlashTimerId = -1;
}
this.examSessionToken++;
}
aboutToDisappear(): void {
this.invalidateExamSessionForDestroy();
}
private goHome(): void {
this.invalidateExamSession();
this.examActive = false;
this.actionLocked = true;
this.currentPage = PAGE_HOME;
this.pageTitle = '驾考灯光综合助手';
}
private resetSubjectThreeExam(): void {
this.invalidateExamSession();
this.lightState = LIGHT_CLOSED;
this.examActive = false;
this.examFinished = false;
this.actionLocked = false;
// 其余字段继续按当前源码复位
}
正常结束函数也应先取消任何残留的一次性任务并使令牌失效,再写结束状态和记录。为了确认停止闪光是否符合现有视觉需求,迁移时要分别验证“远近光交替答对后延迟一秒”和“直接答错结束”两条路径;不要只靠静态审查推断动画表现。
需要注意,resetSubjectThreeExam() 在建议代码中把 actionLocked 最终恢复为 false,因为复位后的页面仍允许演示按钮;goHome() 设为 true 则是在离开考试页面时阻止任何残余输入。这两个退出语义不同,不能为了复用把所有状态强行设成同一组值。
取消任务与校验会话各解决什么问题
| 单独方案 | 能解决什么 | 仍然漏掉什么 |
|---|---|---|
只保存 ID 并 clearTimeout | 取消不再需要的计时任务 | 不表达任务的业务归属;未来增加其他异步步骤时需要另外保护 |
只检查 examActive | 离开且未重开时可退出 | 用户快速重开后该值又是 true |
| 只检查页面 | 返回首页时可退出 | 同一页面内复位并重开仍会串会话 |
只检查 currentIndex | 可能挡住部分越界 | 旧回调仍可能刚好命中新索引 |
| 句柄 + 令牌 + 页面/状态门禁 | 同时覆盖取消与身份校验 | 仍需确保所有退出入口都会失效 |
会话令牌的价值并不是“更保险”这种模糊描述,而是把回调的所属关系变成可比较数据。只要令牌不一致,旧任务就没有修改当前状态的权限。
分三步迁移,避免一次改乱状态机
第一步只增加 nextQuestionTimerId、examSessionToken、取消和失效方法,让项目先编译。第二步把两处裸 setTimeout 替换为调度方法,保持原有延迟和题目推进逻辑。第三步逐个接入开始、复位、首页、销毁和结束路径,并为每条路径执行快速操作测试。
迁移时不要顺手重写题目随机算法、灯光状态映射或历史记录结构。这个修复只解决异步回调越界,改动越集中,越容易通过前后对照确认没有破坏正常考试。
如果团队使用日志,可临时记录令牌而不记录用户数据:
console.info(`schedule advance token=${token}`);
console.info(`invalidate session token=${this.examSessionToken}`);
console.info(`drop stale advance token=${token}, current=${this.examSessionToken}`);
验收时应看到旧回调被丢弃,而不是只看到“没有崩溃”。正式包中再移除这些高频诊断日志。
可执行验证矩阵:专门打时间窗口
| 编号 | 操作步骤 | 修复前可能现象 | 加固后的确定结果 |
|---|---|---|---|
| T1 | 答对普通灯光题,550 ms 内点首页 | 首页后台重新启动倒计时 | 旧回调被取消或令牌拦截 |
| T2 | 远近光交替答对,1000 ms 内点首页 | 一秒后状态继续推进 | 首页保持静止,无新 timer |
| T3 | 答对后立即点实操“复位” | 索引从 0 被旧回调改成 1 | 复位状态持续稳定 |
| T4 | 会话 A 答对,立即复位并开始会话 B | A 回调推进 B 的题目 | A 令牌失效,B 从首题正常开始 |
| T5 | 答对最后一题后立即离开 | 旧回调可能写入完成记录 | 离开后不新增记录 |
| T6 | 正常连续答对全部题目 | 可能因门禁过严卡住 | 仍按 550/1000 ms 节奏完成并只写一条记录 |
| T7 | 答错或五秒超时 | 直接结束考试 | 不产生下一题延迟任务 |
| T8 | 页面销毁后等待两秒 | 回调持有页面实例并继续改状态 | 销毁入口使会话失效,无后续状态变化 |
| T9 | 连续快速点击正确按钮 | 多次安排下一题 | actionLocked 与单句柄保证只安排一次 |
至少重复 T4 十次,因为它覆盖最关键的“旧会话失效后,新会话已经活跃”条件。若只测试返回首页,examActive 单一判断也可能假装通过,无法证明令牌真的有效。
证据边界与构建要求
本文确认的源码事实只有以下几点:项目当前保存了两个 interval 句柄;markLightCorrect() 与 markPracticalCorrect() 使用未保存的 setTimeout;goHome() 只停止 interval;resetSubjectThreeExam() 本身没有停止任何定时器;当前类不存在会话令牌。
本文给出的 nextQuestionTimerId、examSessionToken、invalidateExamSession() 和调度方法都是建议代码,必须在真实项目中修改后运行 ArkTS 编译与设备交互回归,不能仅因文章代码看起来完整就声称已经修复。特别要检查 ArkTS 工程实际使用的定时器返回类型、页面生命周期以及所有调用入口。
收尾:延迟任务也要有业务所有者
setTimeout 不只是“等 550 毫秒再执行”,它是一项未来会修改考试状态的任务。只要用户能在等待期间退出、复位或重开,这项任务就必须明确属于哪一场会话,并能在会话结束时被撤销。
最小可靠方案不复杂:保存一次性任务句柄,所有结束路径统一取消;每次开始或失效都递增令牌;回调执行前同时核对令牌、页面和考试状态。这样既保留答对后的视觉停留,又不会让上一场考试的回调越界控制下一场。
更多推荐

所有评论(0)