答对后停留半秒,让用户看清“操作正确”,再进入下一题,这个交互很自然。风险也藏在这半秒里:用户可能点“首页”、点“复位”,甚至立即开始一场新考试;旧回调随后醒来,仍然把索引加一、展示下一条指令,或者给新会话启动一个倒计时。

这篇不把尚未存在的保护机制说成项目现状。我们先审计 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 管理远近光交替闪烁;

却不知道如何取消“进入下一题”的一次性延迟任务。当前源码中也不存在 sessionTokenexamGeneration 或同类字段。下面讨论的令牌全部是建议加固,不是已实现能力。

竞态是怎样发生的:旧任务跨过页面边界

把用户操作按时间展开,问题会非常直观:

时间当前会话 A 的动作页面状态
0 ms用户答对第 2 题actionLocked = true,安排延迟回调
100 ms用户点击“首页”倒计时和闪光被停止,页面切到首页
550 ms会话 A 的旧回调执行currentIndex++,可能再次 presentLightCommand()
551 mspresentLightCommand()解锁动作并重新启动五秒倒计时

goHome() 的现有实现只停止两个 interval,再修改页面标识:

private goHome(): void {
  this.stopTimer();
  this.stopAltFlash();
  this.currentPage = PAGE_HOME;
  this.pageTitle = '驾考灯光综合助手';
}

旧回调并不检查 currentPage,所以“返回首页时已经停掉计时器”并不充分。它可以在返回之后调用 presentLightCommand(),后者又执行 startTimer()。从用户视角看首页没有考试界面,从状态机视角看旧考试却在后台继续推进。

答对、退出、旧回调醒来与令牌拦截流程

图中的 600ms 只是展示竞态窗口的示意延迟,sessionTokennextQuestion 也是概念名称。本项目实际延迟为 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++;
}

两层保护承担不同职责:

  1. 保存 ID 并 clearTimeout(),取消不再需要的延迟任务。
  2. 增加 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可能挡住部分越界旧回调仍可能刚好命中新索引
句柄 + 令牌 + 页面/状态门禁同时覆盖取消与身份校验仍需确保所有退出入口都会失效

会话令牌的价值并不是“更保险”这种模糊描述,而是把回调的所属关系变成可比较数据。只要令牌不一致,旧任务就没有修改当前状态的权限。

分三步迁移,避免一次改乱状态机

第一步只增加 nextQuestionTimerIdexamSessionToken、取消和失效方法,让项目先编译。第二步把两处裸 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 答对,立即复位并开始会话 BA 回调推进 B 的题目A 令牌失效,B 从首题正常开始
T5答对最后一题后立即离开旧回调可能写入完成记录离开后不新增记录
T6正常连续答对全部题目可能因门禁过严卡住仍按 550/1000 ms 节奏完成并只写一条记录
T7答错或五秒超时直接结束考试不产生下一题延迟任务
T8页面销毁后等待两秒回调持有页面实例并继续改状态销毁入口使会话失效,无后续状态变化
T9连续快速点击正确按钮多次安排下一题actionLocked 与单句柄保证只安排一次

至少重复 T4 十次,因为它覆盖最关键的“旧会话失效后,新会话已经活跃”条件。若只测试返回首页,examActive 单一判断也可能假装通过,无法证明令牌真的有效。

证据边界与构建要求

本文确认的源码事实只有以下几点:项目当前保存了两个 interval 句柄;markLightCorrect()markPracticalCorrect() 使用未保存的 setTimeoutgoHome() 只停止 interval;resetSubjectThreeExam() 本身没有停止任何定时器;当前类不存在会话令牌。

本文给出的 nextQuestionTimerIdexamSessionTokeninvalidateExamSession() 和调度方法都是建议代码,必须在真实项目中修改后运行 ArkTS 编译与设备交互回归,不能仅因文章代码看起来完整就声称已经修复。特别要检查 ArkTS 工程实际使用的定时器返回类型、页面生命周期以及所有调用入口。

收尾:延迟任务也要有业务所有者

setTimeout 不只是“等 550 毫秒再执行”,它是一项未来会修改考试状态的任务。只要用户能在等待期间退出、复位或重开,这项任务就必须明确属于哪一场会话,并能在会话结束时被撤销。

最小可靠方案不复杂:保存一次性任务句柄,所有结束路径统一取消;每次开始或失效都递增令牌;回调执行前同时核对令牌、页面和考试状态。这样既保留答对后的视觉停留,又不会让上一场考试的回调越界控制下一场。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐