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

本文把“在5秒内做出动作”和“动作状态连续保持3秒”拆成两条时间契约。前者决定用户何时必须开始操作,后者决定这次操作何时真正完成。示例均为建议设计,没有写入 The_kemusan 工程。
一、源码中的3秒要求没有进入判题数据
QuestionBank.ets 第117、118、120、122、128、129行的六条实操命令都出现“保持3秒以上”。然而 PracticalCommand 只有 id、text、action 和 category,持续时间仍藏在中文文案里。
当前页面的核心判断可以压缩为下面这段等价摘录:
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
}
responseDeadlineMs 和 minStableMs 分开后,普通的一次性命令可以把 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秒”并排还不够,工程师需要知道哪个时钟允许暂停、哪个时钟在切后台后继续,以及迟到回调如何确认仍属于当前题。
五、用绝对时间和会话令牌拒绝迟到回调
真实调度器可能晚于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():
- 用户切换到错误灯态;
- 用户关闭目标转向灯;
- 5秒响应期限按产品规则结束;
- 用户点击重置;
- 返回首页或进入其他模块;
- 组件离开;
- 新一轮考试开始;
- 当前题已通过或失败。
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-04 | 2999ms时关闭转向 | 不通过,旧任务失效 |
| 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,也没有进行模拟器或真机验证;真实驾考规则、前后台时间政策和设备计时表现仍需分别核对。
更多推荐


所有评论(0)