
灯光模拟HarmonyOS应用实战-63-答对一题为何显示合格-用RecordKind区分单题作答与整场考试在练习系统里“答对一道题”和“通过一场考试”都可以得到肯定结果但两者不是同一个业务事实。前者描述一次作答后者描述有开始、结束、总题数和合格规则的一段会话。如果把它们都压成一个passed: boolean写入时很省事读取时却只能猜这条记录到底在说什么。只读审阅The_kemusan当前源码可以确认理论题每次作答都会调用addSimpleRecord()科三灯光模拟与科三实操结束也调用同一个方法它们最终都写成PracticeRecord。历史卡片没有记录种类字段只根据record.passed显示“合格”或“不合格”。所以一次正确的理论题作答会形成passedtrue、score1、total1的记录并被当前卡片文案解释成“合格”。这是一条静态调用链结论本次没有打开页面观察实际呈现。本文只解决“记录种类和结论文案”这一件事不重新设计历史筛选、耗时统计或整场成绩算法。核心做法是引入RecordKind让生产者在写入时声明这是单题作答还是整场考试读取方不再依赖mode、1/1或字段是否为空来猜语义。一、当前一条 PracticeRecord 承担了两种结论entry/src/main/ets/models/DrivingLightModels.ets中的PracticeRecord只有一组共享字段。passed、score和total可以同时容纳单题与整场数据却没有字段说明这三个值应按哪种业务口径解释。exportinterfacePracticeRecord{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;}这段代码本身没有类型错误。问题在于字段合法并不等于业务含义唯一。passedtrue对单题可理解为“本次答案正确”对整场可理解为“本场达到合格条件”total1也不能证明记录一定来自单题因为一场只有一项的练习仍可能得到相同数值。当前记录中还有mode因此不能说数据完全无法区分。调用方传入的模式字符串提供了一些线索但它是面向展示的自由文本不是受约束的种类协议。只要文案调整、国际化或新入口复用旧模式名依赖字符串反推的代码就会变得脆弱。记录来源passed的含义score/total的含义适合的卡片结论单题作答本次答案是否正确本题得分/1回答正确、回答错误整场考试会话是否合格已得分/有效总数合格、不合格旧记录且来源不明无法无损确定只能展示原值历史记录、来源待确认二、调用链证明单题和整场写进同一入口理论题在answerTheoryQuestion()中得到答案后立即调用addSimpleRecord()。调用没有传score、total和durationSeconds因此工厂方法会把正确作答补成1/1错误作答补成0/1。privateanswerTheoryQuestion(answer:string):void{constquestionthis.getCurrentQuestion();constcorrectAnswergetQuestionAnswer(question);constcorrectanswercorrectAnswer;this.selectedAnsweranswer;this.answerCorrectcorrect;this.answerMessagecorrect?回答正确:回答错误;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很重要它承认旧数据可能没有足够信息而不是用一次武断迁移制造新的确定性。exportenumRecordKind{QUESTION_ATTEMPTquestion_attempt,EXAM_SESSIONexam_session,UNKNOWN_LEGACYunknown_legacy}exportinterfacePracticeRecordBase{id:string;kind:RecordKind;subject:string;mode:string;createdAt:number;durationSeconds:number;}枚举值一旦落盘就是数据协议不能为了代码观感随意改名。发布前应明确未知值如何处理、旧版本读到新值怎么办、统计是否包含UNKNOWN_LEGACY。如果目标 ArkTS 版本对枚举序列化或联合类型有额外约束应以项目实际 SDK 文档和编译结果为准。kind不应由卡片根据total1临时计算。那样每个消费者仍会复制一套猜测。正确位置是记录生产者理论题作答函数明确写QUESTION_ATTEMPT考试结束函数明确写EXAM_SESSION仓储只负责保存和读取这个决定。四、用可辨识结构约束专属字段只有一个kind已能修正文案但进一步把专属字段分开能防止“整场记录带 selectedOption”或“单题记录带整场总时长”这类组合悄悄出现。下面是方案示例不是当前工程代码。exportinterfaceQuestionAttemptRecordextendsPracticeRecordBase{kind:RecordKind.QUESTION_ATTEMPT;correct:boolean;questionId:string;selectedOption:string;correctOption:string;reason:string;}exportinterfaceExamSessionRecordextendsPracticeRecordBase{kind:RecordKind.EXAM_SESSION;passed:boolean;score:number;total:number;reason:string;wrongQuestionId:string;}exportinterfaceUnknownLegacyRecordextendsPracticeRecordBase{kind:RecordKind.UNKNOWN_LEGACY;legacyScore:number|undefined;legacyTotal:number|undefined;}exporttypeTypedPracticeRecordQuestionAttemptRecord|ExamSessionRecord|UnknownLegacyRecord;联合结构让编译器在分支内收窄字段QUESTION_ATTEMPT使用correctEXAM_SESSION使用passed。这样可以避免继续让一个布尔值承担两个名词。若项目所用 ArkTS 规则不接受上述联合写法可保留单一接口但仍应让kind成为必填字段并由工厂函数校验每种组合。工厂函数比直接对象字面量更适合守住不变量。例如单题必须有稳定的questionId整场必须满足0 score total时间戳与时长必须是有限非负数。失败时应返回带原因的结果不能悄悄写入半条记录。五、让两个生产者各自构造明确种类写入流程应先由调用现场选种类再由对应工厂构造记录最后交给仓储。这样PracticeStore无需知道页面事件也不需要解析mode。functioncreateQuestionAttempt(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:selectedcorrectAnswer,selectedOption:selected,correctOption:correctAnswer,reason:selectedcorrectAnswer?回答正确:回答错误,createdAt,durationSeconds:0};}这里把选项键写入示例真实项目也可以保存展示文本但必须明确升级时题库变化如何处理。durationSeconds: 0只是示意若业务需要单题作答耗时应从明确的题目呈现时刻计算不能继续让通用工厂默认为 1 秒并假装是实测值。整场工厂接收会话快照拒绝非法总数并在一个位置形成结果。functioncreateExamSession(subject:string,mode:string,passed:boolean,score:number,total:number,durationSeconds:number):ExamSessionRecord{if(total0||score0||scoretotal){thrownewError(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/total1/1全部判成单题整场练习可能只有一项提前失败的会话也可能只完成一项。仅凭mode文案同样不稳因为文案可以修改。建议采用“显式值优先、可信来源映射次之、其余未知”的策略。只有当前发布版本能够列出一组历史上稳定且互斥的模式名时才可在带迁移版本号的映射表中推断无法证明的记录保留为UNKNOWN_LEGACY。interfaceLegacyRecordCandidate{kind?:string;mode?:string;}interfaceKindDecision{kind:RecordKind;basis:string;}functiondecideLegacyKind(raw:LegacyRecordCandidate):KindDecision{if(raw.kindRecordKind.QUESTION_ATTEMPT){return{kind:RecordKind.QUESTION_ATTEMPT,basis:explicit};}if(raw.kindRecordKind.EXAM_SESSION){return{kind:RecordKind.EXAM_SESSION,basis:explicit};}constmappedlookupReviewedLegacyMode(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 ? 合格 : 不合格。改造后页面应先获得一个纯展示模型。单题使用“回答正确/回答错误”整场使用“合格/不合格”未知旧记录使用中性标题并保留原始分数避免把推断当事实。interfaceRecordHeadline{title:string;tone:string;metric:string;}functiontoHeadline(record:TypedPracticeRecord):RecordHeadline{if(record.kindRecordKind.QUESTION_ATTEMPT){return{title:record.correct?回答正确:回答错误,tone:record.correct?positive:negative,metric:record.correct?本题 1/1:本题 0/1};}if(record.kindRecordKind.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 篇会专门讨论字段损坏这里仅强调与种类相关的组合约束。functionvalidateKindFields(record:TypedPracticeRecord):string[]{constissues:string[][];if(record.kindRecordKind.QUESTION_ATTEMPT){if(record.questionId.length0)issues.push(missing_question_id);}elseif(record.kindRecordKind.EXAM_SESSION){if(record.total0)issues.push(invalid_total);if(record.score0||record.scorerecord.total){issues.push(invalid_score_range);}}else{issues.push(unknown_record_kind);}returnissues;}一个带kindQUESTION_ATTEMPT却没有questionId的对象不应被默认补成空字符串后继续当正常记录它至少要进入问题清单。一个整场记录如果scoretotal也不能只靠 UI 截断。运行时解码、领域约束与展示降级要分层才能判断问题来自原始数据、迁移还是页面。兼容发布建议先让新版本能读旧格式再让新写入带种类最后才考虑回写历史。若先改写入而读取端不识别新旧版本来回安装可能互相产生不可理解的记录。实际降级策略要结合应用发布渠道和数据保留策略决定。九、用来源矩阵验证文案和迁移边界单元层应覆盖工厂、迁移分类、展示模型三部分。集成层再用临时 Preferences 写入混合记录重建仓储后核对顺序和文案。不要只测两个成功样例未知旧记录和矛盾字段才是此次改造的关键。constcases:Array[RecordKind,boolean,string][[RecordKind.QUESTION_ATTEMPT,true,回答正确],[RecordKind.QUESTION_ATTEMPT,false,回答错误],[RecordKind.EXAM_SESSION,true,合格],[RecordKind.EXAM_SESSION,false,不合格]];for(constitemofcases){constrecordbuildRecordForCase(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/1或0/1HistoryCard只根据passed显示“合格/不合格”。本文没有实际打开历史页面因此没有把用户是否看到或如何理解文案写成已观察事实。RecordKind、可辨识记录结构、专用工厂、旧模式映射、未知旧记录分支和新的展示模型都只是改进方案尚未写入The_kemusan。本次没有运行项目构建没有生成新的 HAP没有安装模拟器包也没有在真机验证历史写入、进程重建或迁移结果。最终字段形态、ArkTS 写法、旧数据分布和回滚兼容仍需在目标 SDK、编译产物与设备数据上继续确认。