灯光模拟HarmonyOS应用实战-40-理论题历史为何每条都走1秒默认值:为每题建立开始时刻与可解释耗时

历史卡片显示“1s”,读者自然会把它理解为这道题真的只用了1秒。当前源码却没有为理论题记录开始时刻:answerTheoryQuestion() 写历史时不传 durationSeconds,addSimpleRecord() 遇到缺省值便存入1。于是理论题的1秒是构造记录时的兜底常量,而不是从题目呈现到首次有效答案的测量结果。
本文只讨论单题计时口径和记录可解释性。第39篇关注整场20题会话的结束与汇总;这里不新增整场倒计时,也不修改错题统计。目标是让历史中的数字能够回答“从哪一刻算起、到哪一刻停止、旧数据如何区分”,并且让这些规则可以在不等待真实时间的测试里验证。
一、先沿调用链找到1秒的真正来源
PracticeRecord 在 entry/src/main/ets/models/DrivingLightModels.ets:33-46 将 durationSeconds 定义为必填数字。历史卡片在 entry/src/main/ets/pages/Index.ets:1002-1009 直接渲染 record.durationSeconds + 's',页面不会说明它来自测量还是兜底。
写入链更关键:
answerTheoryQuestion()位于Index.ets:1266-1279,首次选择答案后调用addSimpleRecord();- 这次调用传到
selectedOption就结束,没有传最后的durationSeconds; addSimpleRecord()位于Index.ets:1614-1629,将未提供的耗时写成1;- 理论练习开始与下一题函数位于
Index.ets:1116-1124、1282-1290,两处都没有题目开始时间字段。
与当前行为等价的逻辑可以缩成下面几行:
function resolveDurationSeconds(durationSeconds?: number): number {
return durationSeconds === undefined ? 1 : durationSeconds;
}
const theoryDuration = resolveDurationSeconds();
这段逻辑保证持久化模型总有数字,但它把“未测量”改写成了“测得1秒”。数据类型完整并不等于语义真实。
二、先写清楚单题耗时的业务口径
计时方案最容易因“开始”含义模糊而分叉。理论题的可操作口径建议定义为:
题目及可选项已经成为当前交互题目时开始;用户第一次提交有效选项时停止。
这里有四个细节:
- 题组准备和随机排序不计入单题思考时间;
- 页面还未能作答时不能提前开始;
- 同一题重复点击不重新计算;
- 离开页面、跳过或重开时,不伪造一次已作答耗时。
若产品希望把后台停留排除在外,需要再引入暂停区间;当前源码没有这项规则,本文先采用单调经过时间连续计算。它足以修复固定1秒的语义问题,也更容易验证。
对极快点击,结果可以为0毫秒,但用于秒级展示时通常向上取整并至少显示1秒。这个“最少显示1秒”必须建立在真实差值之上,与完全没有时间戳却直接写1不同。
三、把墙上时间藏进Clock接口
直接在每个方法中调用 Date.now() 会让测试依赖实际等待。建议把读取时间收口成一个小接口:
export interface Clock {
nowMs(): number;
}
export class SystemClock implements Clock {
nowMs(): number {
return Date.now();
}
}
export class FixedClock implements Clock {
private currentMs: number = 0;
setNowMs(value: number): void {
this.currentMs = value;
}
nowMs(): number {
return this.currentMs;
}
}
生产页面注入 SystemClock,测试注入 FixedClock。接口只有一个方法,没有加入时区、格式化或定时任务等无关能力。单题耗时只需要计算经过时间,毫秒时间戳足够。
若项目不希望使用构造注入,也可以在独立计时对象中传入 Clock。重点是业务函数不要散落读取系统时间,否则边界用例会变成慢且不稳定的等待用例。
四、QuestionTiming只负责一次开始与一次冻结
每道当前题需要一个很小的计时状态:属于哪道题、何时呈现、是否已经冻结、最终耗时多少。建议模型如下:
export type DurationSource = 'MEASURED' | 'LEGACY_DEFAULT' | 'UNKNOWN';
export interface QuestionTimingSnapshot {
questionId: string;
presentedAtMs: number;
answeredAtMs: number;
elapsedMs: number;
source: DurationSource;
}
export class QuestionTiming {
private snapshot: QuestionTimingSnapshot = {
questionId: '',
presentedAtMs: 0,
answeredAtMs: 0,
elapsedMs: 0,
source: 'UNKNOWN'
};
present(questionId: string, nowMs: number): void {
this.snapshot = {
questionId,
presentedAtMs: nowMs,
answeredAtMs: 0,
elapsedMs: 0,
source: 'UNKNOWN'
};
}
freeze(questionId: string, nowMs: number): QuestionTimingSnapshot {
if (this.snapshot.questionId !== questionId ||
this.snapshot.presentedAtMs <= 0 ||
this.snapshot.answeredAtMs > 0) {
return this.snapshot;
}
const elapsedMs = Math.max(0, nowMs - this.snapshot.presentedAtMs);
this.snapshot = {
questionId,
presentedAtMs: this.snapshot.presentedAtMs,
answeredAtMs: nowMs,
elapsedMs,
source: 'MEASURED'
};
return this.snapshot;
}
current(): QuestionTimingSnapshot {
return this.snapshot;
}
}
present() 每次切入新的可作答题时重建快照;freeze() 只接受同一题ID,并且只冻结一次。即使用户快速连点选项,第二次调用也不会改变第一次答案时刻。若页面索引变化但异步事件携带旧题ID,题目不匹配分支也不会把旧答案耗时写给新题。
示例用 presentedAtMs <= 0 表示未开始,所以真实实现中的测试时间不要从0开始,或改用显式布尔字段。选择哪种方式并不影响核心规则:无开始证据时不应创建 MEASURED 结果。
五、开始时机必须与题目身份一起更新
当前 startTheoryPractice() 创建题组后把索引设为0;nextTheoryQuestion() 推进后清空答案状态。接入时应在“当前题确定且可操作”这一事务中同时调用 present(),而不是在页面每次重组时调用。
下面展示一个普通方法方向:
private presentTheoryQuestion(index: number): void {
if (index < 0 || index >= this.activeQuestions.length) {
return;
}
this.activeQuestionIndex = index;
this.selectedAnswer = '';
this.answerMessage = '请选择答案';
this.answerCorrect = true;
const question = this.activeQuestions[index];
this.questionTiming.present(question.id, this.clock.nowMs());
}
private startTheoryPractice(subject: string, mode: string, category: string): void {
this.activeSubject = subject;
this.activeMode = mode;
this.activeQuestions = this.selectTestQuestions(subject, category);
if (this.activeQuestions.length > 0) {
this.presentTheoryQuestion(0);
}
}
代码解释:presentTheoryQuestion() 让索引、答题状态与开始时刻共同切换,减少“页面显示q2但计时仍属于q1”的窗口。它应由开始、下一题和明确的答题卡跳转调用。ArkUI 的 build()、@Builder 或文本 getter 可能执行多次,不适合放置启动计时的副作用。
若图片、长文本等资源会导致选项晚于题干出现,需要产品定义“可操作”到哪一层。当前题卡中的选项来自内存对象,同步构建即可;因此建议以切换当前题的方法作为开始点,并在真机联调时核对页面可见与可点击时机。

六、首次有效答案冻结耗时并显式传入记录
答案校验通过入口门禁后,先冻结计时,再构造历史记录。毫秒转秒应集中在一个函数中:
export function toDisplaySeconds(elapsedMs: number): number {
if (elapsedMs <= 0) {
return 1;
}
return Math.max(1, Math.ceil(elapsedMs / 1000));
}
private answerTheoryQuestion(answer: string): void {
if (this.selectedAnswer.length > 0 || this.activeQuestions.length === 0) {
return;
}
const question = this.getCurrentQuestion();
const timing = this.questionTiming.freeze(question.id, this.clock.nowMs());
if (timing.source !== 'MEASURED') {
this.handleTimingUnavailable(question.id);
return;
}
const correctAnswer = getQuestionAnswer(question);
const correct = answer === correctAnswer;
const durationSeconds = toDisplaySeconds(timing.elapsedMs);
this.addSimpleRecord(
question.subject, this.activeMode, correct,
correct ? '答对:' + question.title : '答错:' + question.title,
correct ? '' : question.id,
correct ? '' : this.getOptionText(question, correctAnswer),
correct ? '' : this.getOptionText(question, answer),
undefined, undefined, durationSeconds
);
}
代码解释:只有 source === 'MEASURED' 才把秒数写成测量值。Math.ceil 让1001毫秒显示2秒;如果业务更希望四舍五入,可替换换算函数,但应在全站保持一致。示例中的 handleTimingUnavailable() 表示需要显式降级,不能悄悄继续使用1。
更清晰的长期方案是改造记录构造器,让理论题调用命名参数对象,避免连续三个 undefined。为了保持本文主题集中,示例暂时沿用现有函数形状,并在结构设计中给出下一步方向。
七、存储模型要区分测量值、旧默认值与未知值
只修正新记录后,历史中仍会存在旧的1秒数据。由于旧模型没有来源字段,无法从数值本身判断某条1秒到底是真实快速作答,还是旧代码的缺省值。迁移时不能把所有 durationSeconds === 1 自动认定为错误,也不能宣称它们都是准确测量。
建议新记录增加来源和可选毫秒值:
export interface DurationInfo {
source: DurationSource;
elapsedMs?: number;
displaySeconds?: number;
}
export interface TheoryAttemptRecord {
id: string;
questionId: string;
passed: boolean;
createdAt: number;
duration: DurationInfo;
}
代码解释:新写入记录使用 MEASURED,并同时保存 elapsedMs 与展示秒数;旧记录迁移成 LEGACY_DEFAULT,保留原有 displaySeconds,历史卡片可以显示“旧记录,耗时口径未知”。如果某次运行没有开始证据,则使用 UNKNOWN 且不填数值,页面显示“未记录”。
若暂时不能修改存储结构,也应至少在新记录的版本字段中标记计时功能上线点。没有任何标记时,历史数据无法可靠追溯来源,这正是用默认数填补语义缺口的长期成本。

八、跳题、后台与系统时钟变化要有明确策略
计时器并非只有作答一条出口。以下策略应在接入前确定:
- 下一题未作答:丢弃当前
QuestionTiming或记录为跳过,不写一个虚构耗时; - 重新开始:清除旧题计时,创建新会话后再呈现首题;
- 页面返回:当前题未作答时终止本次快照;
- 进入后台:基础方案继续累计经过时间;若产品决定暂停,则记录后台区间并扣除;
- 系统时间回拨:
Math.max(0, delta)只能避免负数,严谨方案应使用平台提供的单调时钟能力。
最后一点属于需要结合目标 HarmonyOS SDK 核实的 API 设计。本文的 ArkTS 示例使用 Date.now() 以贴近现有项目,不能据此宣称已经抵抗用户手动改时钟。实现前应查阅项目目标 API 对单调时间源与生命周期回调的官方说明,再选择具体平台接口。
5秒科三作答期与这里的理论题耗时也应分开:前者是到期即失败的截止控制,后者是答题历史的经过时间度量。它们可以共享 Clock,但不应共享同一个倒计时状态。
九、固定时钟让边界用例无需等待
计时逻辑适合用确定性时钟验证。下面的用例同时覆盖向上取整与重复冻结:
describe('QuestionTiming', () => {
it('freezes the first valid answer time', () => {
const timing = new QuestionTiming();
timing.present('q-1', 10000);
const first = timing.freeze('q-1', 11201);
const second = timing.freeze('q-1', 19000);
expect(first.elapsedMs).assertEqual(1201);
expect(toDisplaySeconds(first.elapsedMs)).assertEqual(2);
expect(second.answeredAtMs).assertEqual(11201);
});
it('rejects a stale question event', () => {
const timing = new QuestionTiming();
timing.present('q-2', 20000);
const result = timing.freeze('q-1', 23000);
expect(result.source).assertEqual('UNKNOWN');
expect(result.elapsedMs).assertEqual(0);
});
});
代码解释:测试不调用定时器,也不等待1.2秒。第一条保证第二次冻结不能改写首次答案;第二条模拟旧题事件晚到,确认题目身份门禁有效。
完整验证矩阵如下:
| 场景 | 时间输入 | 预期记录 |
|---|---|---|
| 立即点击 | 1–999ms | MEASURED,展示1s,保存真实毫秒 |
| 整秒边界 | 1000ms | MEASURED,展示1s |
| 超过整秒 | 1001ms | MEASURED,展示2s |
| 重复点击 | 1200ms后再点 | answeredAt与elapsedMs保持首次值 |
| 旧题事件 | 当前q2却提交q1 | 不生成q2耗时,不写历史 |
| 未调用present | 直接answer | UNKNOWN降级,不使用固定1秒 |
| 跳过 | 呈现后点下一题 | 不创建已作答耗时 |
| 后台10秒 | 按基础策略继续 | 耗时包含后台10秒,并在需求中说明 |
| 旧历史 | 只有durationSeconds=1 | 标记LEGACY_DEFAULT,不反推真实用时 |
| 系统回拨 | now小于presentedAt | 不产生负数,并记录异常路径 |
十、排障与证据边界
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 新理论记录仍全是1s | 调用方仍未传 durationSeconds | 从答案入口追踪到记录构造器 |
| 切到下一题后耗时异常变长 | 没有在题目切换时重新present | 统一使用 presentTheoryQuestion() |
| 同一题连续点击耗时变化 | freeze缺少首次锁定 | answeredAt大于0时返回既有快照 |
| q1耗时写进q2 | 异步事件没有携带questionId | 同时校验会话ID与题目ID |
| 历史把旧1s显示为真实 | 没有来源字段 | 迁移为LEGACY_DEFAULT并提示口径 |
| 后台停留导致数值很大 | 产品未定义暂停规则 | 明确连续累计或暂停扣除策略 |
| 设备改时钟后出现0s | 使用墙上时间计算差值 | 核实并换用适配SDK的单调时间源 |
| 页面重组后计时重置 | 在Builder中调用present | 只在题目切换事务中启动 |
本文能从源码确认:PracticeRecord.durationSeconds 是必填数字;历史卡片直接展示它;理论题答题路径没有传入耗时;addSimpleRecord() 将缺省值写成1;科三考试结束路径则显式传入从 startAt 计算的整场秒数。由此可以确认理论题1秒来自默认分支,但没有依据判断既有某条1秒记录对应用户真实花费多少时间。
文中的 Clock、QuestionTiming、来源字段和接入方法属于建议代码,尚未集成到 The_kemusan。本次没有执行当前项目构建,没有生成新的 HAP,没有在模拟器或真机上验证页面呈现时刻、后台行为或平台时间源,也没有修改现有历史数据。实现后应分别完成纯函数用例、持久化兼容、页面交互和真机生命周期核对,再把新耗时用于对外展示。
更多推荐


所有评论(0)