灯光模拟HarmonyOS应用实战-63-答对一题为何显示合格-用RecordKind区分单题作答与整场考试

在练习系统里,“答对一道题”和“通过一场考试”都可以得到肯定结果,但两者不是同一个业务事实。前者描述一次作答,后者描述有开始、结束、总题数和合格规则的一段会话。如果把它们都压成一个 passed: boolean,写入时很省事,读取时却只能猜这条记录到底在说什么。

只读审阅 The_kemusan 当前源码可以确认:理论题每次作答都会调用 addSimpleRecord();科三灯光模拟与科三实操结束也调用同一个方法;它们最终都写成 PracticeRecord。历史卡片没有记录种类字段,只根据 record.passed 显示“合格”或“不合格”。所以,一次正确的理论题作答会形成 passed=true、score=1、total=1 的记录,并被当前卡片文案解释成“合格”。这是一条静态调用链结论,本次没有打开页面观察实际呈现。

本文只解决“记录种类和结论文案”这一件事,不重新设计历史筛选、耗时统计或整场成绩算法。核心做法是引入 RecordKind,让生产者在写入时声明这是单题作答还是整场考试,读取方不再依赖 mode1/1 或字段是否为空来猜语义。

RecordKind 区分单题与整场

一、当前一条 PracticeRecord 承担了两种结论

entry/src/main/ets/models/DrivingLightModels.ets 中的 PracticeRecord 只有一组共享字段。passedscoretotal 可以同时容纳单题与整场数据,却没有字段说明这三个值应按哪种业务口径解释。

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()。调用没有传 scoretotaldurationSeconds,因此工厂方法会把正确作答补成 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(),只是额外传入 correctCounttotalQuestions 和持续时间。科三实操的 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 使用 correctEXAM_SESSION 使用 passed。这样可以避免继续让一个布尔值承担两个名词。若项目所用 ArkTS 规则不接受上述联合写法,可保留单一接口,但仍应让 kind 成为必填字段,并由工厂函数校验每种组合。

工厂函数比直接对象字面量更适合守住不变量。例如单题必须有稳定的 questionId;整场必须满足 0 <= score <= total;时间戳与时长必须是有限非负数。失败时应返回带原因的结果,不能悄悄写入半条记录。

五、让两个生产者各自构造明确种类

写入流程应先由调用现场选种类,再由对应工厂构造记录,最后交给仓储。这样 PracticeStore 无需知道页面事件,也不需要解析 mode

RecordKind 写入流程

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()。位置参数过多很容易把 correctOptionselectedOption 传反;对象参数或专用工厂能让审阅者看到每个值的名字。

六、旧记录迁移必须允许无法确定

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/10/1不可以标为来源不明
wrongQuestionId 为空不可以正确单题和合格整场都可能为空
未知 kind 字符串不可以隔离或按未知旧记录只读展示

七、历史卡和统计都按 kind 选择语义

RecordKind 职责结构

当前 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/1UNKNOWN_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/10/1HistoryCard 只根据 passed 显示“合格/不合格”。本文没有实际打开历史页面,因此没有把用户是否看到或如何理解文案写成已观察事实。

RecordKind、可辨识记录结构、专用工厂、旧模式映射、未知旧记录分支和新的展示模型都只是改进方案,尚未写入 The_kemusan。本次没有运行项目构建,没有生成新的 HAP,没有安装模拟器包,也没有在真机验证历史写入、进程重建或迁移结果。最终字段形态、ArkTS 写法、旧数据分布和回滚兼容仍需在目标 SDK、编译产物与设备数据上继续确认。

Logo

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

更多推荐