封面

灯光考试里最难复现的状态问题,通常出现在“操作正确”后的半秒钟:页面已经返回首页,旧回调却推进了下一题;用户刚重开考试,上一轮回调又把新一轮题号加一;远近光交替看似停止,倒计时却在别的页面重新启动。

这类问题不能靠一句 lightState = LIGHT_CLOSED 解决。The_kemusan 同时维护倒计时、远近光闪烁、考试锁、当前命令、命令序列、得分和提示文案;其中两个循环任务保存了句柄,答对后的延迟推进却直接调用 setTimeout(),没有保存句柄。源码已经做了多处清理,但还存在一个真实的延迟回调竞态。

本文分别讨论“重置灯光”“重开考试”“返回首页”三个动作,明确当前代码做了什么、没有做什么,以及怎样用可取消句柄和轮次令牌把异步边界补完整。

先列状态账本:什么该清,什么必须保留

Index.ets:137-174 中与科三考试直接相关的状态,可以分成四组:

类别 字段 离开或重开时的处理
页面展示 lightStatecurrentPromptmessageisSuccessMessagetimeLeft 恢复初始值
考试控制 examActiveexamFinishedactionLockedcurrentIndextotalQuestionscorrectCount 结束旧轮次并清零
当前上下文 currentLightCommandcurrentPracticalCommandexamCommandspracticalCommandsstartAt 命令与数组失效;新开时重建开始时间
异步任务 timerIdaltFlashTimerId、远近光步数,以及未保存的推进 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() 先停止旧闪烁,再保存 altFlashTimerIdstopAltFlash() 不只清 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() 会覆盖 currentLightCommandcurrentPromptactionLockedtimeLeft,并启动新倒计时。因此在同步路径上,重开后题号、得分、开始时间、题目序列和提示都来自新一轮。

不过,停止两个 interval 并不能取消所有异步工作。如果上一轮已经答对并安排了延迟推进,那段回调仍可能在新一轮启动后运行。重开流程必须同时处理推进 timeout,而当前源码没有对应句柄。

返回首页当前只停循环,不等于完整重置

首页按钮调用的是 goHome()

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

它能保证调用当下两个 interval 停止,并切换页面;它没有把 examActiveactionLocked、当前命令或命令数组清空。再次通过 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_FLOWpracticalCommands,不要让两个考试模式共享错误的守卫条件。

让重置成为一个有顺序的事务

重置不是随便给字段赋初值,而应保持固定顺序:先让异步任务失效,再清当前命令和序列,最后恢复 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、提示 灯光关闭,提示为“灯光已复位”,不新增记录
考试内复位 倒计时中点“重置灯光” examActivetimeLeft 考试仍继续,倒计时未被当成重开
正常重开 完成或失败后再次开始 题号、得分、开始时间、命令数组 新轮次从第 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,同时保留不属于本轮考试的持久历史。做到这一点,灯光关闭、题号归零和页面返回才不只是视觉上的复位,而是真正结束了上一轮状态机。

Logo

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

更多推荐