灯光模拟HarmonyOS应用实战-63-答对一题为何显示合格-用RecordKind区分单题作答与整场考试
灯光模拟HarmonyOS应用实战-63-答对一题为何显示合格-用RecordKind区分单题作答与整场考试
在练习系统里,“答对一道题”和“通过一场考试”都可以得到肯定结果,但两者不是同一个业务事实。前者描述一次作答,后者描述有开始、结束、总题数和合格规则的一段会话。如果把它们都压成一个 passed: boolean,写入时很省事,读取时却只能猜这条记录到底在说什么。
只读审阅 The_kemusan 当前源码可以确认:理论题每次作答都会调用 addSimpleRecord();科三灯光模拟与科三实操结束也调用同一个方法;它们最终都写成 PracticeRecord。历史卡片没有记录种类字段,只根据 record.passed 显示“合格”或“不合格”。所以,一次正确的理论题作答会形成 passed=true、score=1、total=1 的记录,并被当前卡片文案解释成“合格”。这是一条静态调用链结论,本次没有打开页面观察实际呈现。
本文只解决“记录种类和结论文案”这一件事,不重新设计历史筛选、耗时统计或整场成绩算法。核心做法是引入 RecordKind,让生产者在写入时声明这是单题作答还是整场考试,读取方不再依赖 mode、1/1 或字段是否为空来猜语义。

一、当前一条 PracticeRecord 承担了两种结论
entry/src/main/ets/models/DrivingLightModels.ets 中的 PracticeRecord 只有一组共享字段。passed、score 和 total 可以同时容纳单题与整场数据,却没有字段说明这三个值应按哪种业务口径解释。
export interface PracticeRecord {
id: string;
subject: string;
mode: string;
passed: boolean;
score: number;
total: number;
reason: string;
wrongQuestionId: string;
correctOption: string;
selectedOption: string;
createdAt: number;
durationSeconds: number;
mastered?: boolean;
}
这段代码本身没有类型错误。问题在于,字段合法并不等于业务含义唯一。passed=true 对单题可理解为“本次答案正确”,对整场可理解为“本场达到合格条件”;total=1 也不能证明记录一定来自单题,因为一场只有一项的练习仍可能得到相同数值。
当前记录中还有 mode,因此不能说数据完全无法区分。调用方传入的模式字符串提供了一些线索,但它是面向展示的自由文本,不是受约束的种类协议。只要文案调整、国际化或新入口复用旧模式名,依赖字符串反推的代码就会变得脆弱。
| 记录来源 | passed 的含义 | score/total 的含义 | 适合的卡片结论 |
|---|---|---|---|
| 单题作答 | 本次答案是否正确 | 本题得分/1 | 回答正确、回答错误 |
| 整场考试 | 会话是否合格 | 已得分/有效总数 | 合格、不合格 |
| 旧记录且来源不明 | 无法无损确定 | 只能展示原值 | 历史记录、来源待确认 |
二、调用链证明单题和整场写进同一入口
理论题在 answerTheoryQuestion() 中得到答案后立即调用 addSimpleRecord()。调用没有传 score、total 和 durationSeconds,因此工厂方法会把正确作答补成 1/1,错误作答补成 0/1。
private answerTheoryQuestion(answer: string): void {
const question = this.getCurrentQuestion();
const correctAnswer = getQuestionAnswer(question);
const correct = answer === correctAnswer;
this.selectedAnswer = answer;
this.answerCorrect = correct;
this.answerMessage = correct ? '回答正确' : '回答错误';
this.addSimpleRecord(
question.subject,
this.activeMode,
correct,
correct ? '答对' : '答错',
correct ? '' : question.id,
correct ? '' : this.getOptionText(question, correctAnswer),
correct ? '' : this.getOptionText(question, answer)
);
}
整场科三模拟结束时,finishLightExam() 同样调用 addSimpleRecord(),只是额外传入 correctCount、totalQuestions 和持续时间。科三实操的 finishPracticalExam() 也走相同路径。于是,种类差异存在于调用现场,却在进入通用方法后丢失。
this.addSimpleRecord(
SUBJECT_THREE,
this.getLightExamMode(),
passed,
reason,
wrongQuestionId,
correctOption,
selectedOption,
this.correctCount,
this.totalQuestions,
Math.max(1, Math.floor((Date.now() - this.startAt) / 1000))
);
真正的问题链是:生产者知道种类,通用方法没有接收种类,持久化对象无法表达种类,历史卡只剩一个布尔值,最后把所有肯定结果统一翻译成“合格”。修复应从写入契约开始,而不是在卡片里追加更多字符串条件。
三、RecordKind 要成为持久化字段
最小改造可以先定义三个种类:单题作答、整场考试、来源不明的旧记录。UNKNOWN_LEGACY 很重要,它承认旧数据可能没有足够信息,而不是用一次武断迁移制造新的确定性。
export enum RecordKind {
QUESTION_ATTEMPT = 'question_attempt',
EXAM_SESSION = 'exam_session',
UNKNOWN_LEGACY = 'unknown_legacy'
}
export interface PracticeRecordBase {
id: string;
kind: RecordKind;
subject: string;
mode: string;
createdAt: number;
durationSeconds: number;
}
枚举值一旦落盘就是数据协议,不能为了代码观感随意改名。发布前应明确未知值如何处理、旧版本读到新值怎么办、统计是否包含 UNKNOWN_LEGACY。如果目标 ArkTS 版本对枚举序列化或联合类型有额外约束,应以项目实际 SDK 文档和编译结果为准。
kind 不应由卡片根据 total===1 临时计算。那样每个消费者仍会复制一套猜测。正确位置是记录生产者:理论题作答函数明确写 QUESTION_ATTEMPT,考试结束函数明确写 EXAM_SESSION,仓储只负责保存和读取这个决定。
四、用可辨识结构约束专属字段
只有一个 kind 已能修正文案,但进一步把专属字段分开,能防止“整场记录带 selectedOption”或“单题记录带整场总时长”这类组合悄悄出现。下面是方案示例,不是当前工程代码。
export interface QuestionAttemptRecord extends PracticeRecordBase {
kind: RecordKind.QUESTION_ATTEMPT;
correct: boolean;
questionId: string;
selectedOption: string;
correctOption: string;
reason: string;
}
export interface ExamSessionRecord extends PracticeRecordBase {
kind: RecordKind.EXAM_SESSION;
passed: boolean;
score: number;
total: number;
reason: string;
wrongQuestionId: string;
}
export interface UnknownLegacyRecord extends PracticeRecordBase {
kind: RecordKind.UNKNOWN_LEGACY;
legacyScore: number | undefined;
legacyTotal: number | undefined;
}
export type TypedPracticeRecord =
QuestionAttemptRecord | ExamSessionRecord | UnknownLegacyRecord;
联合结构让编译器在分支内收窄字段:QUESTION_ATTEMPT 使用 correct,EXAM_SESSION 使用 passed。这样可以避免继续让一个布尔值承担两个名词。若项目所用 ArkTS 规则不接受上述联合写法,可保留单一接口,但仍应让 kind 成为必填字段,并由工厂函数校验每种组合。
工厂函数比直接对象字面量更适合守住不变量。例如单题必须有稳定的 questionId;整场必须满足 0 <= score <= total;时间戳与时长必须是有限非负数。失败时应返回带原因的结果,不能悄悄写入半条记录。
五、让两个生产者各自构造明确种类
写入流程应先由调用现场选种类,再由对应工厂构造记录,最后交给仓储。这样 PracticeStore 无需知道页面事件,也不需要解析 mode。

function createQuestionAttempt(
question: QuestionItem,
selected: string,
correctAnswer: string,
createdAt: number
): QuestionAttemptRecord {
return {
id: createRecordId(createdAt),
kind: RecordKind.QUESTION_ATTEMPT,
subject: question.subject,
mode: '理论单题练习',
questionId: question.id,
correct: selected === correctAnswer,
selectedOption: selected,
correctOption: correctAnswer,
reason: selected === correctAnswer ? '回答正确' : '回答错误',
createdAt,
durationSeconds: 0
};
}
这里把选项键写入示例,真实项目也可以保存展示文本,但必须明确升级时题库变化如何处理。durationSeconds: 0 只是示意;若业务需要单题作答耗时,应从明确的题目呈现时刻计算,不能继续让通用工厂默认为 1 秒并假装是实测值。
整场工厂接收会话快照,拒绝非法总数,并在一个位置形成结果。
function createExamSession(
subject: string,
mode: string,
passed: boolean,
score: number,
total: number,
durationSeconds: number
): ExamSessionRecord {
if (total <= 0 || score < 0 || score > total) {
throw new Error('invalid exam score snapshot');
}
return {
id: createRecordId(Date.now()),
kind: RecordKind.EXAM_SESSION,
subject,
mode,
passed,
score,
total,
reason: passed ? '考试结束:合格' : '考试结束:未合格',
wrongQuestionId: '',
createdAt: Date.now(),
durationSeconds: Math.max(0, durationSeconds)
};
}
生产者的两个入口应该直接调用各自工厂,不再经过一个有十多个位置参数的 addSimpleRecord()。位置参数过多很容易把 correctOption 和 selectedOption 传反;对象参数或专用工厂能让审阅者看到每个值的名字。
六、旧记录迁移必须允许无法确定
旧 exam_history 中没有 kind。迁移时最危险的做法是把 score/total===1/1 全部判成单题:整场练习可能只有一项,提前失败的会话也可能只完成一项。仅凭 mode 文案同样不稳,因为文案可以修改。
建议采用“显式值优先、可信来源映射次之、其余未知”的策略。只有当前发布版本能够列出一组历史上稳定且互斥的模式名时,才可在带迁移版本号的映射表中推断;无法证明的记录保留为 UNKNOWN_LEGACY。
interface LegacyRecordCandidate {
kind?: string;
mode?: string;
}
interface KindDecision {
kind: RecordKind;
basis: string;
}
function decideLegacyKind(
raw: LegacyRecordCandidate
): KindDecision {
if (raw.kind === RecordKind.QUESTION_ATTEMPT) {
return { kind: RecordKind.QUESTION_ATTEMPT, basis: 'explicit' };
}
if (raw.kind === RecordKind.EXAM_SESSION) {
return { kind: RecordKind.EXAM_SESSION, basis: 'explicit' };
}
const mapped = lookupReviewedLegacyMode(raw.mode);
if (mapped !== undefined) {
return { kind: mapped, basis: 'reviewed_mode_map' };
}
return { kind: RecordKind.UNKNOWN_LEGACY, basis: 'ambiguous' };
}
迁移结果应保留 basis 或迁移日志,至少记录未知数量。不要在读取时每次重新猜并立即覆盖原文;应先生成迁移报告,确认映射后再写新格式。新版本可以采取“双读新写”:读取兼容旧记录,新产生的记录一律带 kind,等观察到旧记录比例后再决定是否做一次性升级。
| 旧数据线索 | 能否直接定种类 | 处理建议 |
|---|---|---|
已有合法 kind | 可以 | 按对应字段规则继续校验 |
| 模式名在经过审阅的固定映射表中 | 有条件可以 | 记录映射依据与迁移版本 |
只有 1/1 或 0/1 | 不可以 | 标为来源不明 |
wrongQuestionId 为空 | 不可以 | 正确单题和合格整场都可能为空 |
未知 kind 字符串 | 不可以 | 隔离或按未知旧记录只读展示 |
七、历史卡和统计都按 kind 选择语义

当前 HistoryCard 顶部直接执行 record.passed ? '合格' : '不合格'。改造后,页面应先获得一个纯展示模型。单题使用“回答正确/回答错误”,整场使用“合格/不合格”,未知旧记录使用中性标题并保留原始分数,避免把推断当事实。
interface RecordHeadline {
title: string;
tone: string;
metric: string;
}
function toHeadline(record: TypedPracticeRecord): RecordHeadline {
if (record.kind === RecordKind.QUESTION_ATTEMPT) {
return {
title: record.correct ? '回答正确' : '回答错误',
tone: record.correct ? 'positive' : 'negative',
metric: record.correct ? '本题 1/1' : '本题 0/1'
};
}
if (record.kind === RecordKind.EXAM_SESSION) {
return {
title: record.passed ? '合格' : '不合格',
tone: record.passed ? 'positive' : 'negative',
metric: `${record.score}/${record.total}`
};
}
return { title: '历史记录', tone: 'neutral', metric: '来源待确认' };
}
统计也要显式选择集合,但本文不重新定义整套指标。最低要求是:单题正确率只遍历 QUESTION_ATTEMPT,整场合格率只遍历 EXAM_SESSION,未知旧记录不静默进入任何分母。页面如果只想展示“全部历史条数”,可以计入未知记录,但名称必须写成“记录数”,不能写成“考试次数”。
这个职责划分也让后续扩展更安全。将来新增“章节测验”或“模拟评估”时,可以新增受审阅的种类和展示分支,而不是继续让 mode 文案承担协议角色。
八、序列化与解码守住字段组合
落盘前应把联合记录转成稳定 DTO,读取后根据 kind 逐类解码。不能再用 JSON.parse(raw) as PracticeRecord[] 代替运行时校验。第 66 篇会专门讨论字段损坏;这里仅强调与种类相关的组合约束。
function validateKindFields(record: TypedPracticeRecord): string[] {
const issues: string[] = [];
if (record.kind === RecordKind.QUESTION_ATTEMPT) {
if (record.questionId.length === 0) issues.push('missing_question_id');
} else if (record.kind === RecordKind.EXAM_SESSION) {
if (record.total <= 0) issues.push('invalid_total');
if (record.score < 0 || record.score > record.total) {
issues.push('invalid_score_range');
}
} else {
issues.push('unknown_record_kind');
}
return issues;
}
一个带 kind=QUESTION_ATTEMPT 却没有 questionId 的对象,不应被默认补成空字符串后继续当正常记录;它至少要进入问题清单。一个整场记录如果 score>total,也不能只靠 UI 截断。运行时解码、领域约束与展示降级要分层,才能判断问题来自原始数据、迁移还是页面。
兼容发布建议先让新版本能读旧格式,再让新写入带种类,最后才考虑回写历史。若先改写入而读取端不识别,新旧版本来回安装可能互相产生不可理解的记录。实际降级策略要结合应用发布渠道和数据保留策略决定。
九、用来源矩阵验证文案和迁移边界
单元层应覆盖工厂、迁移分类、展示模型三部分。集成层再用临时 Preferences 写入混合记录,重建仓储后核对顺序和文案。不要只测两个成功样例;未知旧记录和矛盾字段才是此次改造的关键。
const cases: Array<[RecordKind, boolean, string]> = [
[RecordKind.QUESTION_ATTEMPT, true, '回答正确'],
[RecordKind.QUESTION_ATTEMPT, false, '回答错误'],
[RecordKind.EXAM_SESSION, true, '合格'],
[RecordKind.EXAM_SESSION, false, '不合格']
];
for (const item of cases) {
const record = buildRecordForCase(item[0], item[1]);
expect(toHeadline(record).title).toBe(item[2]);
}
expect(toHeadline(buildUnknownLegacy()).tone).toBe('neutral');
| 用例 | 输入 | 预期 | 重点核对 |
|---|---|---|---|
| R01 | 正确理论单题 | QUESTION_ATTEMPT | 标题为“回答正确” |
| R02 | 错误理论单题 | QUESTION_ATTEMPT | 保留题目与选项信息 |
| R03 | 合格科三整场 | EXAM_SESSION | 标题为“合格”,展示整场分数 |
| R04 | 不合格实操整场 | EXAM_SESSION | 标题为“不合格”,保留失败原因 |
| R05 | 旧记录仅有 1/1 | UNKNOWN_LEGACY | 不凭数值猜成单题 |
| R06 | 未知 kind | 问题记录 | 不进入正确率或合格率分母 |
| R07 | 混合新旧数组 | 可用记录与未知记录 | 顺序稳定,页面不中断 |
| R08 | 重启后再次读取 | 同一持久化值 | 种类和标题保持一致 |
这些用例描述的是实现后的验收目标,不是本文已经执行的结果。还应为模式映射表建立固定输入,防止后续改展示文案时意外改变旧记录分类。
十、验证清单、排障表与事实边界
实现后可逐项核对:
-
RecordKind是持久化字段,枚举值有稳定说明。 - 理论单题生产者只创建
QUESTION_ATTEMPT。 - 科三模拟与实操结束只创建
EXAM_SESSION。 - 单题使用
correct,整场使用passed,页面不再混用名词。 - 卡片标题由
kind和领域结果共同生成。 - 单题正确率与整场合格率使用不同集合。
- 旧记录只有在可信映射存在时才推断种类。
- 无法确定的旧记录以中性方式展示并排除出专属分母。
- 未知
kind与字段矛盾能够形成可追踪问题。 - 双读新写期间,新旧记录混合读取顺序稳定。
- 目标 ArkTS 类型写法、序列化行为经过项目编译核对。
- 模拟器和真机分别核对历史卡文案、颜色与重启后数据。
| 症状 | 优先核对 | 常见原因 | 修复方向 |
|---|---|---|---|
| 答对一题仍显示“合格” | 新记录的 kind 与卡片入口 | 页面仍直接读取 passed | 统一通过 toHeadline() 生成标题 |
所有旧 1/1 都成了单题 | 迁移分类函数 | 用分数猜种类 | 回退为 UNKNOWN_LEGACY,只保留可信映射 |
| 整场记录没有总分 | 工厂与调用参数 | 错走单题工厂 | 在生产者处固定工厂,不在仓储猜测 |
| 正确率突然包含考试 | 统计筛选条件 | 仍遍历全部历史 | 先按 kind 分组再计算 |
| 新版本读取后旧版异常 | 序列化兼容策略 | 先写新格式却没设计降级 | 评估双写或发布窗口,明确最低可读版本 |
| 未知记录导致页面空白 | 未知分支 | 分支没有安全回退 | 提供中性卡片并记录问题原因 |
| 模式改名后迁移结果变化 | 模式映射表 | 运行时按任意字符串包含判断 | 使用带版本、精确值、经过审阅的映射 |
当前源码能够确认的是:PracticeRecord 没有记录种类;理论单题和整场结束都调用 addSimpleRecord();未传成绩的单题会得到 1/1 或 0/1;HistoryCard 只根据 passed 显示“合格/不合格”。本文没有实际打开历史页面,因此没有把用户是否看到或如何理解文案写成已观察事实。
RecordKind、可辨识记录结构、专用工厂、旧模式映射、未知旧记录分支和新的展示模型都只是改进方案,尚未写入 The_kemusan。本次没有运行项目构建,没有生成新的 HAP,没有安装模拟器包,也没有在真机验证历史写入、进程重建或迁移结果。最终字段形态、ArkTS 写法、旧数据分布和回滚兼容仍需在目标 SDK、编译产物与设备数据上继续确认。
更多推荐



所有评论(0)