实操题面要求“开启左转向灯并保持3秒以上”,用户刚点下左转向灯,页面却已经停止倒计时并准备进入下一题。这里能从当前源码确认的不是某台设备发生了错误,而是一条更直接的静态事实:题库文案包含持续时间要求,判题函数只比较一次 action,没有保存灯光进入时刻,也没有等待持续条件完成。

转向灯持续状态门禁封面

本文把“在5秒内做出动作”和“动作状态连续保持3秒”拆成两条时间契约。前者决定用户何时必须开始操作,后者决定这次操作何时真正完成。示例均为建议设计,没有写入 The_kemusan 工程。

一、源码中的3秒要求没有进入判题数据

QuestionBank.ets 第117、118、120、122、128、129行的六条实操命令都出现“保持3秒以上”。然而 PracticalCommand 只有 idtextactioncategory,持续时间仍藏在中文文案里。

当前页面的核心判断可以压缩为下面这段等价摘录:

private handlePracticalAction(action: string): void {
  if (this.actionLocked || !this.currentPracticalCommand) {
    return
  }
  this.applyPracticalActionToState(action)
  if (action === this.currentPracticalCommand.action) {
    this.markPracticalCorrect('操作正确')
  } else {
    this.finishPracticalExam(false, '操作错误', '', '', action)
  }
}

Index.ets 第1445—1461行只验证本次 action 是否相等。匹配后,markPracticalCorrect() 在第1506—1519行立即锁题、停止5秒计时、增加正确数,再安排下一题。代码中没有“状态已保持多久”的输入,所以它无法从一次点击推导出连续三秒。

这也说明修复点不能放在显示文案里。把“3秒”改成粗体、增加倒计时圆环,都不会改变结算条件。

二、5秒响应期限和3秒持续条件是两种时钟

把两个数字放在同一个倒计时里,最容易造成规则含混。可以先用一张决策表确定语义:

时间规则起点终点解决的问题
响应期限题目开始可操作用户首次提交有效动作是否及时开始操作
持续条件目标灯态真正生效同一灯态连续存在至最短时长是否保持足够时间
场次耗时实操考试开始场次通过、失败或取消历史记录花了多久

当前页面的 TIMER_SECONDS = 5 只实现了第一行的一部分,而且命中 action 后就停止。要加入三秒保持,产品还必须先回答:5秒是“必须开始动作”的期限,还是“必须连三秒一起完成”的总期限?两种规则会给出不同的最晚启动时刻,文章和代码都不能替产品悄悄作决定。

本文后续采用更易解释的示例:用户必须在5秒内开启目标灯态;一旦按时进入目标态,系统再验证连续保持3秒。若项目采用总期限策略,只需让两个截止时间共享同一绝对终点。

三、把持续条件写进命令契约

不要从 text 中用正则提取“3秒”。文案可能改写为“至少三秒”或进入多语言资源,解析文字会让业务规则随翻译变化。更稳的方式是扩展命令模型:

export interface PracticalCommand {
  id: string
  text: string
  action: string
  category: string
  responseDeadlineMs: number
  minStableMs: number
}

const START_LEFT: PracticalCommand = {
  id: 'p_start_left',
  text: '起步:开启左转向灯并保持3秒以上',
  action: ACTION_LEFT_SIGNAL,
  category: '起步',
  responseDeadlineMs: 5000,
  minStableMs: 3000
}

responseDeadlineMsminStableMs 分开后,普通的一次性命令可以把 minStableMs 设为0,转向灯命令才设为3000。页面只展示 text,判题器只消费结构化字段,两者不会相互猜测。

如果未来某条指令要求“近光与示廓同时保持”,命令还需要状态谓词,而不能继续只用 action 相等。这部分由第38篇的多灯组状态模型负责,本篇只处理持续时间。

四、DwellGate只接受事件,不直接操作ArkUI

持续状态门禁适合写成普通类。它接收时钟值和灯态事件,输出等待、通过或失败,不引用页面对象,也不自行显示提示。

export enum DwellPhase {
  WAITING_ACTION,
  HOLDING,
  PASSED,
  FAILED,
  CANCELED
}

export interface DwellState {
  phase: DwellPhase
  commandId: string
  enteredAtMs: number
  minStableMs: number
}

export function enterTarget(
  state: DwellState,
  commandId: string,
  nowMs: number,
  minStableMs: number
): DwellState {
  if (state.phase !== DwellPhase.WAITING_ACTION) {
    return state
  }
  return {
    phase: minStableMs === 0 ? DwellPhase.PASSED : DwellPhase.HOLDING,
    commandId,
    enteredAtMs: nowMs,
    minStableMs
  }
}

export function evaluateDwell(state: DwellState, nowMs: number): DwellState {
  if (state.phase !== DwellPhase.HOLDING) {
    return state
  }
  if (nowMs - state.enteredAtMs < state.minStableMs) {
    return state
  }
  return { ...state, phase: DwellPhase.PASSED }
}

这段代码把最重要的不变量写清楚:只有 HOLDING 可以随时间转成 PASSED;一次性命令可以直接通过;已经完成或取消的状态不会再次加分。enteredAtMs 表示灯态生效时间,不是按钮按下时长,因此不要求用户长按屏幕。

5秒响应与3秒保持双时间轴

流程图中的两个终点必须在代码评审时被明确命名。看到“5秒”和“3秒”并排还不够,工程师需要知道哪个时钟允许暂停、哪个时钟在切后台后继续,以及迟到回调如何确认仍属于当前题。

五、用绝对时间和会话令牌拒绝迟到回调

真实调度器可能晚于3000毫秒执行。判断条件应该读取当前时间与进入时间的差值,而不是每次回调累加100毫秒。为了让重开考试后旧任务不能通过新题,还要把门禁绑定到会话和题目:

interface Clock {
  nowMs(): number
}

interface DwellTicket {
  sessionId: number
  questionId: string
  dueAtMs: number
}

class DwellScheduler {
  private ticket: DwellTicket | null = null

  schedule(ticket: DwellTicket): void {
    this.ticket = ticket
  }

  cancel(sessionId: number): void {
    if (this.ticket && this.ticket.sessionId === sessionId) {
      this.ticket = null
    }
  }

  isCurrent(sessionId: number, questionId: string): boolean {
    return this.ticket !== null &&
      this.ticket.sessionId === sessionId &&
      this.ticket.questionId === questionId
  }
}

调度器负责“稍后唤醒”,Clock 负责“现在到底几点”,会话令牌负责“这次唤醒还算不算数”。即使回调已经进入事件队列,isCurrent() 仍能在提交结果前挡住旧题。

实际接入时可以保存 timeout 句柄以便主动取消,再保留令牌作为第二道门禁。只有句柄没有身份校验,仍然无法覆盖取消与回调同时发生的边界。

六、页面接入顺序决定用户看到的是等待还是通过

正确 action 到来时,页面不应马上调用原来的 markPracticalCorrect()。推荐顺序是先更新灯态,再启动持续门禁,最后根据阶段更新提示:

private submitPracticalAction(action: string): void {
  const command = this.currentPracticalCommand
  if (!command || this.actionLocked || !this.examActive) {
    return
  }

  this.applyPracticalActionToState(action)
  if (action !== command.action) {
    this.finishPracticalExam(false, '操作错误', command.id, command.action, action)
    return
  }

  const nowMs = this.clock.nowMs()
  this.dwellState = enterTarget(
    this.dwellState,
    command.id,
    nowMs,
    command.minStableMs
  )

  if (this.dwellState.phase === DwellPhase.PASSED) {
    this.commitPracticalPass()
    return
  }

  this.message = '动作正确,请保持当前灯光状态'
  this.scheduleDwellCompletion(command, nowMs)
}

此时 actionLocked 不应简单设为true。保持期间仍需要接收“关闭转向”或“切换到错误灯态”等事件,否则系统无法知道状态已经中断。可以单独增加 settlementLocked 防重复结算,而让灯态事件继续进入 reducer。

达到三秒后,回调再次读取当前灯态、当前 sessionId、当前 questionId 和绝对时间。四项都满足才调用一次 commitPracticalPass()

七、中途切换、重置和离开都要结束门禁

持续条件最危险的不是正常完成,而是旧任务在业务已经离开后继续结算。以下入口都应调用统一的 cancelDwell()

  1. 用户切换到错误灯态;
  2. 用户关闭目标转向灯;
  3. 5秒响应期限按产品规则结束;
  4. 用户点击重置;
  5. 返回首页或进入其他模块;
  6. 组件离开;
  7. 新一轮考试开始;
  8. 当前题已通过或失败。
private cancelDwell(reason: string): void {
  this.dwellScheduler.cancel(this.sessionId)
  this.dwellState = {
    phase: DwellPhase.CANCELED,
    commandId: this.currentPracticalCommand ?
      this.currentPracticalCommand.id : '',
    enteredAtMs: 0,
    minStableMs: 0
  }
  this.dwellCancelReason = reason
}

取消原因可以保留在调试状态中,但不应把题目原文或用户操作序列无差别写入公开日志。页面是否把“提前关闭”判为立即失败,还是允许在总期限内重新开始保持,也需要显式产品规则。

持续状态门禁的模块与所有权

结构图把题库规则、灯态 reducer、时钟、调度器和页面结算分开。这样单元用例可以验证2999/3000毫秒,而设备层只需要验证真实事件和生命周期接线。

八、用可控时钟覆盖毫秒边界

单元用例不需要真实等待三秒。FakeClock 可以精确推进时间,并验证一次门禁只提交一次:

it('passes_only_after_three_seconds', 0, () => {
  const clock = new FakeClock(1000)
  let state = makeWaitingState()

  state = enterTarget(state, 'p_start_left', clock.nowMs(), 3000)
  clock.advance(2999)
  state = evaluateDwell(state, clock.nowMs())
  expect(state.phase).assertEqual(DwellPhase.HOLDING)

  clock.advance(1)
  state = evaluateDwell(state, clock.nowMs())
  expect(state.phase).assertEqual(DwellPhase.PASSED)

  state = evaluateDwell(state, clock.nowMs() + 5000)
  expect(state.phase).assertEqual(DwellPhase.PASSED)
})

这条用例验证的是业务时间,不验证 ArkUI 按钮。页面集成还需分别模拟切换动作、重置、返回和新会话,确认事件映射与门禁输出一致。

完整验证矩阵至少包含:

编号场景预期
D37-01普通无持续命令正确 action 立即通过
D37-02左转向保持2999ms仍在等待,不加分
D37-03左转向保持3000ms只通过一次
D37-042999ms时关闭转向不通过,旧任务失效
D37-05保持期间重复点相同动作不建立第二张门票
D37-06保持期间改为右转向按规则失败或重新开始,不能沿用旧起点
D37-07回首页后旧回调到达首页和新会话均不被修改
D37-08晚于响应期限才开启按明确的期限策略处理
D37-09切后台后回来使用时间戳重算,行为符合暂停或继续政策

九、排查时先看规则、身份和时间来源

现象优先核对常见原因处理方向
一点就进入下一题minStableMs 是否进入命令仍只比较 action命中后进入 HOLDING
等了三秒仍不通过目标灯态和题目ID回调绑定了旧题同时校验 sessionId 与 questionId
提前关闭仍判正确是否接收保持期间的灯态事件actionLocked 把中断事件挡掉分离输入锁和结算锁
重开后第一题自动通过旧门票是否取消timeout 跨会话存活句柄取消加令牌门禁
不同设备等待差异大是否累计回调次数调度延迟被当成业务时间用绝对时间差计算
用户只剩两秒可操作5秒与3秒的关系两个期限共用含混起点明确开始期限或完成期限

排查顺序应先确认命令结构中的时间规则,再确认当前灯态,随后核对会话身份,最后才看调度器是否按时唤醒。把所有问题归咎于 timeout 精度,容易错过真正的规则缺口。

十、结语:一次正确事件不等于持续条件完成

当前源码已经把实操按钮和模拟按钮分开,也能在5秒内判断 action 对错;它没有把六条命令中的“保持3秒以上”转成业务字段。可靠的补法是把持续时长放进命令契约,由灯态门禁记录进入时间,以绝对时钟判断驻留时间,并让重置、离开和新会话统一取消旧工作。

文中的 PracticalCommand 扩展、DwellGate、时钟接口和页面接入代码都属于建议实现,尚未集成到 The_kemusan。本轮没有修改项目源码,没有执行项目构建,没有生成新的 HAP,也没有进行模拟器或真机验证;真实驾考规则、前后台时间政策和设备计时表现仍需分别核对。

Logo

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

更多推荐