【口算王|04】HarmonyOS ArkTS 错题反馈实战:显示计算步骤并记录错误类型
证据边界:本文的“当前实现”来自本轮对本地口算王工程中
ExamResultPage.ets、PracticePage.ets、MathModels.ets、MockBanks.ets与UserDataManager.ets的静态复核。可以确认答题记录、错题本、结果页复盘路由和静态解析链路;结构化计算步骤、错误类型、题目级耗时和诊断置信度均属于建议扩展。本轮未执行构建、安装、真机复盘、持久化故障或多设备视觉测试。
口算应用的“错题反馈”很容易被做成一句红色提示:答案错了,正确答案是 B。这个反馈虽然即时,却没有回答真正影响下一次练习的问题:用户错在符号识别、运算顺序、进位退位,还是只点错了选项?如果结果页只保存一个correct=false,复盘页就只能重复展示题目,无法形成有针对性的训练。
本文基于口算王项目本地口算王工程(脱敏路径)的真实源码,复核ExamResultPage.ets、PracticePage.ets、MathModels.ets、MockBanks.ets与UserDataManager.ets。文中的当前实现均以本轮静态源码复核为依据,工程身份信息已脱敏。
先说明事实边界:当前版本已经能在挑战结束后筛出错题、恢复用户选项、标出正确答案、展示题目自带解析,并把错题加入本地错题本;但AnswerRecord只有题目 ID、所选选项和正误三个字段,没有错误类型、计算步骤、作答耗时或诊断置信度。下面先把现有链路讲清楚,再给出兼容旧数据的增强方案。
一、错题反馈不是一个弹窗,而是一条数据链路
一次挑战从答题到复盘,至少跨过五个边界:
PracticePage接收选项并生成答题记录。- 错题通过
UserDataManager.addWrong()写入本地集合。 - 交卷时把
records序列化成路由参数。 ExamResultPage反序列化记录并筛选复盘入口。- 再次进入
PracticePage的wrongAnalysis模式,恢复错选项并展示解析。
真正需要守住的是“题目、用户答案、正确答案、解析内容”之间的一致性。如果路由参数损坏、题库版本变化,或者记录顺序与题目顺序不一致,页面即使没有崩溃,也可能把甲题的答案显示到乙题上。因此,错题反馈首先是数据契约问题,其次才是颜色与卡片样式问题。
二、现有 AnswerRecord 能回答什么
librarya/src/main/ets/models/MathModels.ets中的真实定义非常克制:
export interface AnswerRecord { questionId: string selected: string correct: boolean }这三个字段可以回答:
- 哪道题被作答;
- 用户选择了哪个选项;
- 这次选择是否正确。
它们无法回答:
- 用户用了多长时间;
- 用户中间写过哪些计算步骤;
- 错误属于哪种类型;
- 诊断来自规则还是用户自报;
- 题目内容是否在记录之后发生变化。
因此,当前项目中的“错题解析”准确说是错题回放:根据questionId找回题目,恢复selected,再显示题目静态解析。文章后续给出的错误分类属于增强设计,不是对现有源码能力的虚构。
三、真实答题入口如何产生记录
PracticePage.selectOption()在首次选择后立即把页面切到解析态,并写入记录:
private selectOption(key: string): void { if (this.mode === 'wrongAnalysis') return if (this.showAnalysis) return this.selectedKey = key this.showAnalysis = true const q = this.currentQ() if (q) { const correct = key === q.answer this.records.push({ questionId: q.id, selected: key, correct }) if (!correct) { this.wrongRecords = UserDataManager.addWrong(this.wrongRecords, q.id, q.bankId) } else if (this.mode === 'wrong') { this.wrongRecords = UserDataManager.removeWrong(this.wrongRecords, q.id) } } }这里有两个值得保留的细节。第一,showAnalysis是同步守卫,重复点击不会再次追加记录。第二,错题本与挑战记录分开保存:records服务于本次会话,wrongRecords服务于跨会话复习。
不过,records.push()直接修改数组,在复杂状态管理场景中不如不可变更新清晰。若后续增加诊断字段,可改为:
const record: AnswerRecordV2 = { schemaVersion: 2, questionId: q.id, selected: key, correct, answeredAt: Date.now(), elapsedMs: this.currentQuestionElapsedMs(), errorType: correct ? 'none' : this.inferErrorType(q, key) } this.records = [...this.records, record]新对象一次性构造完成,便于日志、持久化、测试和未来迁移,也避免多个分支分别补字段。
四、错题本的去重语义
项目当前的本地错题写入不是简单追加。UserDataManager.addWrong()会先过滤同题旧记录,再把最新记录放到数组头部:
static addWrong( records: WrongRecord[], questionId: string, bankId: string ): WrongRecord[] { const filtered = records.filter(r => r.questionId !== questionId) const result: WrongRecord[] = [ { questionId, bankId, wrongAt: nowStr() }, ...filtered ] UserDataManager.persist(UserDataManager.K_WRONG, result) return result }这意味着错题本当前采用“每题一条、最近错误时间优先”的集合语义,而不是完整错误历史。它适合回答“现在有哪些题需要复习”,不适合回答“同一题连续错了几次、错误类型是否发生变化”。
如果产品需要学习诊断,不应直接把WrongRecord膨胀成无限增长的历史数组。更稳妥的做法是保留两个模型:
| 模型 | 用途 | 建议主键 |
|---|---|---|
WrongRecord | 当前待复习错题 | questionId |
AttemptRecord | 每次作答事件 | sessionId + questionId + attemptNo |
一个负责快速展示,一个负责统计分析,避免“列表去重”和“历史留痕”互相冲突。
五、结果页如何把错题送入解析模式
挑战结束后,ExamResultPage从路由参数恢复答题记录。源码对 JSON 解析做了异常兜底:
try { this.records = JSON.parse(params.records) as AnswerRecord[] } catch (_) {}点击“错题解析”时,结果页把整组记录再次序列化,跳转到答题页:
router.pushUrl({ url: 'pages/PracticePage', params: { bankId: this.bankId, mode: 'wrongAnalysis', records: JSON.stringify(this.records) } })这一实现能跑通主流程,但as AnswerRecord[]只是编译期断言,不会验证运行时数据。字符串即使解析成功,也可能缺少questionId,或把correct写成字符串。面向真实设备做稳定性收口时,应把路由输入视为不可信边界。
可以增加一个窄化函数:
function isAnswerRecord(value: object): boolean { const data = value as Record<string, Object> return typeof data.questionId === 'string' && typeof data.selected === 'string' && typeof data.correct === 'boolean' } function parseAnswerRecords(raw: string): AnswerRecord[] { try { const parsed: Object = JSON.parse(raw) as Object if (!Array.isArray(parsed)) return [] return parsed.filter((item: Object) => isAnswerRecord(item)) .map((item: Object) => item as AnswerRecord) } catch (_) { return [] } }实际项目需要按 ArkTS 编译器允许的对象类型进一步调整,但设计原则不变:解析、校验、降级放在路由边界,页面只消费已验证模型。
六、wrongAnalysis 为什么要按 questionId 对齐
loadWrongAnalysis()先过滤错误记录,再遍历记录并从题库查找题目:
this.records = examRecords.filter( (record: AnswerRecord) => !record.correct ) const all = getQuestions(params.bankId) const wrongQs: Question[] = [] for (const record of this.records) { const found = all.find(q => q.id === record.questionId) if (found) { wrongQs.push(found) } } this.questions = wrongQs随后applyAnalysisState()用相同索引读取记录与题目:
private applyAnalysisState(): void { const record = this.records[this.currentIdx] this.selectedKey = record ? record.selected : '' this.showAnalysis = this.questions.length > 0 }这里存在一个真实边界:如果某条记录对应的题目已经从题库删除,wrongQs会少一项,但records仍保留原长度,两个数组可能错位。更稳妥的结构不是维护平行数组,而是构造绑定对象:
interface WrongAnalysisItem { question: Question record: AnswerRecord } private buildWrongItems( records: AnswerRecord[], allQuestions: Question[] ): WrongAnalysisItem[] { const result: WrongAnalysisItem[] = [] for (const record of records) { if (record.correct) continue const question = allQuestions.find( (item: Question) => item.id === record.questionId ) if (question) { result.push({ question, record }) } } return result }页面只遍历WrongAnalysisItem[],题目与作答天然成对,题库升级造成的缺失项也不会污染后续索引。
七、当前“计算步骤”实际上是什么
题目模型含有analysis和可选的example:
export interface Question { id: string bankId: string chapterId: string type: string stem: string options: Option[] answer: string analysis: string example?: string }MockBanks.ets生成的解析是按题型准备的一句策略,例如:
- 加法:“从个位算起,注意进位”;
- 混合运算:“先算乘除,再算加减”;
- 应用题:“先找数量关系”;
- 限时题:“看清符号,按顺序计算”。
页面显示的是:
Text(this.currentQ()!.analysis) if (this.currentQ()!.example) { Text(`例句:${this.currentQ()!.example!}`) }所以当前能力是“规则提示 + 完整算式示例”,不是用户实际计算轨迹,也不是逐步推导。若文章或商店文案声称“记录每一步计算”,就会超过源码事实。
八、把静态解析升级为结构化步骤
要让解析真正支持步骤展示,可以在题目生成阶段产生结构化数据,而不是临时切割中文字符串:
export interface CalculationStep { order: number expression: string result: string reason: string } export interface QuestionExplanation { strategy: string steps: CalculationStep[] checkpoint: string }例如8 × 6 + 5可以生成:
const explanation: QuestionExplanation = { strategy: '混合运算先乘除、后加减', steps: [ { order: 1, expression: '8 × 6', result: '48', reason: '乘法优先' }, { order: 2, expression: '48 + 5', result: '53', reason: '再完成加法' } ], checkpoint: '先圈出乘号,再开始计算' }结构化步骤的价值不只是 UI 好看。它允许无障碍朗读逐步播报、允许平板端用左右栏展示、允许测试直接断言步骤顺序,也方便未来根据错误类型突出某一步。
九、错误类型必须可解释
错误分类不能只凭“选错了哪个选项”就下结论。当前干扰项由correctOption()根据偏移量生成,选项并没有携带“为什么错”的语义。因此,直接把 B 解释为“进位错误”是不可靠的。
可采用两层分类:
export type ErrorType = 'none' | 'operator_misread' | 'order_of_operations' | 'carry_or_borrow' | 'fact_recall' | 'word_relation' | 'careless' | 'unknown' export interface ErrorDiagnosis { type: ErrorType source: 'rule' | 'self_report' confidence: number message: string }规则层只在证据充分时分类。例如混合运算题中,用户答案恰好等于从左到右错误计算的结果,可以判断为order_of_operations;其他情况保持unknown。用户也可以在复盘时选择“看错符号”“不会算”“点错了”,以self_report保存。
错误分类的原则是:宁可显示“暂未判断”,也不要给孩子一个没有依据的标签。
十、兼容旧记录的 AnswerRecordV2
线上或已上架应用升级模型时,不能假设本地只有新数据。建议显式设置版本:
export interface AnswerRecordV2 { schemaVersion: 2 sessionId: string questionId: string selected: string correct: boolean answeredAt: number elapsedMs: number errorType: ErrorType } function migrateAnswerRecord(old: AnswerRecord): AnswerRecordV2 { return { schemaVersion: 2, sessionId: '', questionId: old.questionId, selected: old.selected, correct: old.correct, answeredAt: 0, elapsedMs: 0, errorType: old.correct ? 'none' : 'unknown' } }旧数据缺失的信息用明确的未知值表示,不能根据当前时间或题型随意补造。迁移后仍能显示原有答案解析,新功能只对新作答提供更丰富诊断。
十一、反馈卡片如何组织信息层级
错题反馈页不应一次把所有信息堆在屏幕上。推荐由浅入深:
- 第一层显示题干、用户选择、正确答案;
- 第二层显示结构化计算步骤;
- 第三层显示错误类型与下一次检查点;
- 最后提供“再做一道同类题”或“加入错题训练”。
对于当前源码,第一层与部分第二层已经具备:选项颜色区分正确和错误,解析卡显示正确答案、analysis与example。错误类型标签和逐步推导则需要新增模型和渲染组件。
在 ArkUI 中,步骤列表应放入可滚动区域,并为长文本设置layoutWeight、maxLines或自然换行。底部操作区继续使用项目现有的安全区计算,避免被手势导航区域遮挡。
十二、结果页还存在两个可复核风险
1. 历史记录可能重复写入
ExamResultPage.aboutToAppear()每次出现都会执行addExamHistory()。如果页面因生命周期或路由行为再次出现,而历史方法没有会话幂等键,就可能重复保存同一场挑战。增强模型中的sessionId可以同时解决答题记录关联与结果历史去重。
2. 排名是固定展示值
结果页当前显示${236}/${1258},源码没有联网排名或本地排名计算。它是静态界面内容,不应在文章中描述为真实平台排名。错题反馈设计也不应依赖这个数字。
这两个点与错误分类一样,都要坚持“显示内容必须有数据来源”。
十三、建议的服务边界
当诊断规则增多时,不要把所有逻辑塞回PracticePage。可以按职责拆分:
export class WrongFeedbackService { static buildItem( question: Question, record: AnswerRecordV2 ): WrongFeedbackItem { return { question, record, explanation: ExplanationFactory.build(question), diagnosis: ErrorDiagnosisService.diagnose(question, record) } } }页面负责:
- 接收已验证的复盘项;
- 渲染选项、步骤和诊断;
- 响应上一题、下一题与再次练习。
服务负责:
- 生成计算步骤;
- 根据题型和答案推断错误;
- 输出稳定的展示模型。
仓储或UserDataManager负责:
- 保存待复习错题;
- 保存作答事件;
- 执行模型迁移与清理策略。
这样可以在不启动完整 ArkUI 页面的情况下测试分类规则。
十四、验证用例不能只测“答错会变红”
至少覆盖以下场景:
| 场景 | 预期 |
|---|---|
| 全部答对 | 错题解析列表为空,并有明确空态 |
| 一道错题 | 恢复用户选项,正确答案与题目匹配 |
| 未答题 | 结果网格显示未答,不伪造错误类型 |
| 路由 JSON 损坏 | 安全降级为空记录,不崩溃 |
| 记录缺少题目 | 跳过缺失项,后续题目不发生索引错位 |
| 旧版记录 | 迁移为unknown,原解析仍可查看 |
| 混合运算典型错误 | 证据充分时标记运算顺序错误 |
| 无法判断的干扰项 | 显示未知,不强行归因 |
| 重复进入结果页 | 同一sessionId不重复写历史 |
| 深浅色与大字体 | 正误颜色之外仍有文字和图标区分 |
尤其要注意无障碍:不能只靠红绿颜色表达正误。选项旁应同时显示“你的答案”“正确答案”或对应图标,保证色觉差异用户也能理解。
十五、排障时优先看数据,而不是先改 UI
| 现象 | 常见原因 | 检查方式 |
|---|---|---|
| 错题数量正确但题目错位 | records与questions平行数组失配 | 打印 questionId 配对结果 |
| 用户答案没有恢复 | 记录被过滤或 selected 为空 | 检查路由解析与applyAnalysisState() |
| 正确答案颜色不对 | option key 与 answer key 不一致 | 核对A/B/C/D映射 |
| 解析内容太泛 | 题库只有静态analysis | 增加结构化 explanation |
| 错误类型总是 unknown | 干扰项没有错误语义 | 在题目生成时携带 distractor reason |
| 错题本重复 | 主键和去重规则不明确 | 核对questionId去重 |
| 历史记录重复 | 结果页多次出现且无 sessionId | 为挑战建立幂等会话键 |
十六、发布前的工程检查清单
AnswerRecord的现有字段与文章描述一致;wrongAnalysis只展示错误记录;- 题目与记录通过
questionId绑定; - JSON 解析失败有空态或提示;
- 题库缺题不会造成后续索引错位;
analysis和example被描述为静态解析,不冒充真实书写轨迹;- 错误类型有证据来源和未知兜底;
- 旧记录迁移不伪造时间、步骤或错误原因;
- 错题本集合与作答历史分开建模;
- 红绿状态同时配有文字含义;
- 结果页历史写入具备会话幂等性;
- 平板、小窗、深浅色与底部安全区均完成验证。
十七、wrongAnalysis 与 wrong 是两条不同的数据路径
源码里同时存在wrongAnalysis和wrong两种模式,它们不能混为一个“错题功能”。wrongAnalysis接收刚结束挑战的完整records,因此能够恢复本次错选项;wrong则遍历持久化的WrongRecord,按题目 ID 重新组题。后者只知道哪道题待复习,不保存当时选了什么。
| 模式 | 数据来源 | 能够回答 | 当前限制 |
|---|---|---|---|
wrongAnalysis | 结果页路由中的 AnswerRecord[] | 本次错题、用户选择、正确答案 | 依赖路由数据和当前题库仍能匹配 |
wrong | Preferences 中的 WrongRecord[] | 当前有哪些题需要重练 | 不能恢复上次错误选项与错误原因 |
另外,selectOption()在wrongAnalysis模式会直接返回,这使复盘页保持只读;在wrong模式重新答对时,则会调用removeWrong()将该题移出错题本。设计新字段时应保留这两个不同目的,避免复盘操作意外改写待复习集合。
十八、题目与作答绑定后还要报告缺失项
WrongAnalysisItem能消除平行数组错位,但简单跳过找不到的题目仍可能让用户误以为本次只有更少的错题。组装服务应同时返回可展示项与缺失记录,页面才能区分“没有错题”和“有错题但题库版本已经无法恢复”。
interface WrongAnalysisResult { items: WrongAnalysisItem[] missingQuestionIds: string[] } function buildWrongAnalysis( records: AnswerRecord[], questions: Question[] ): WrongAnalysisResult { const items: WrongAnalysisItem[] = [] const missingQuestionIds: string[] = [] for (const record of records) { if (record.correct) continue const question = questions.find((item: Question) => item.id === record.questionId) if (question) { items.push({ question, record }) } else { missingQuestionIds.push(record.questionId) } } return { items, missingQuestionIds } }展示层不需要泄露内部 ID,可以提示“本次有 1 道错题因题库更新无法恢复”。如果缺失比例很高,应提供返回结果页或重新挑战的出口,而不是进入一个看似正常、内容却不完整的解析列表。
十九、错误类型应建立证据等级
当前correctOption()只是围绕答案做数值偏移,干扰项没有携带“进位错误”“运算顺序错误”等语义。要记录错误类型,最重要的不是增加枚举,而是说明分类证据来自哪里。建议把诊断分为确定规则、候选规则、用户自报和未知四级。
| 证据 | 可记录结果 | 页面表达 |
|---|---|---|
| 错误答案精确命中某条可复算规则 | 规则类型 + 规则版本 | “可能忽略了乘除优先” |
| 多个规则都能得到同一错误答案 | 候选类型集合 | 请用户选择更符合的原因 |
| 用户主动选择“点错了” | self_report | 明确标注来自用户反馈 |
| 没有可验证证据 | unknown | “暂未判断错误原因” |
文案最好使用“可能”“根据本次答案推测”,而不是给出能力诊断。对于儿童学习场景,错误类型应描述可改变的操作步骤,不使用“粗心”“能力差”等人格化标签;即使保留careless内部枚举,也应让界面呈现为“请再次核对符号和选项”。
二十、计算步骤必须由运算模型生成
题目当前的analysis是一句题型策略,example是完整算式和最终答案。它们适合做即时提示,却无法告诉页面哪一步应高亮、哪一步与错误选项相关。结构化步骤应来自生成题目时使用的同一运算树,而不是发布时对中文解析做字符串切割。
interface ArithmeticNode { operator: '+' | '-' | '×' | '÷' left: number | ArithmeticNode right: number | ArithmeticNode } interface CalculationTrace { questionId: string steps: CalculationStep[] finalAnswer: number ruleVersion: number }以混合运算为例,先遍历运算树得到合法顺序,再输出每步表达式、结果和依据。测试可以用最终答案反向核对最后一步,也能验证所有中间结果都来自前一步。题库升级时用ruleVersion标记规则,避免旧记录套用新解释后出现前后矛盾。
二十一、持久化失败不能被成功状态掩盖
UserDataManager.persist()当前在putSync()或flushSync()出错时直接捕获异常,调用方仍会拿到已经更新的内存数组。于是页面可能立即显示“已加入错题本”,重启应用后记录却消失。这个风险来自静态代码路径,本轮没有制造磁盘故障进行复现。
export interface SaveResult<T> { success: boolean value: T message: string } static saveWrongRecords(records: WrongRecord[]): SaveResult<WrongRecord[]> { try { UserDataManager.persistWrong(records) return { success: true, value: records, message: '' } } catch (error) { return { success: false, value: records, message: '错题保存失败,请重试' } } }实际改造可以用返回结果或抛出可识别异常,但 UI 必须知道持久化是否成功。失败时保留本页答题记录,允许用户继续查看解析,同时不要用已保存图标误导。验收应覆盖 Preferences 未初始化、写入失败和重新启动回读三种情况。
二十二、记录升级要同时设计容量与清理
如果新增AttemptRecord保存每次答案、用时和错误类型,数据量会从“每题最多一条错题”变成“每次作答一条事件”。只加字段不设保留策略,长期使用后 Preferences 中的 JSON 会持续增大,启动时解析和整体重写成本也会上升。
可以按产品目的选择策略:保留最近若干会话用于结果回放;为长期统计保留按日聚合;对已经掌握的错题只移出待复习集合,不一定删除历史事件;清空学习数据时同时清理记录、聚合与版本元数据。若记录规模需要查询、筛选和迁移,结构化数据库比单个 Preferences 数组更合适。
旧WrongRecord和AnswerRecord没有错误原因,迁移时必须写成unknown。不能根据当前题型、当前解析或迁移当天时间补造历史事实,否则统计图看似完整,证据却是假的。
二十三、端到端验收要覆盖两个入口和三类状态
错题反馈至少包含“本次结果复盘”和“错题本重练”两个入口,还要分别覆盖正常、部分恢复和空状态。最小烟测可以从一场包含正确题、错题和未答题的挑战开始,记录 questionId、selected 与预期题干,然后逐条核对结果页和复盘页。
- 完成挑战并进入结果页,确认错题数量来自
correct=false的记录; - 打开错题解析,核对题目、用户选择、正确答案、analysis 与 example;
- 删除或替换一条本地题目后再次恢复,确认缺失项不会导致后续索引错位;
- 传入损坏 JSON、空数组和字段类型错误,确认页面进入可理解的降级状态;
- 从错题本进入 wrong 模式,答对后确认该题被移出待复习集合;
- 重启应用,核对错题本与页面提示是否和持久化结果一致;
- 在深浅色、大字体、小窗与底部手势区检查文字、图标和操作可达性。
本轮只完成静态源码复核,未执行上述构建和真机步骤。因此文章把这些内容保留为验收方法,不写成已通过结论。
二十四、总结
口算王当前的错题链路已经具备一个可工作的闭环:答题页记录正误,错误题目进入本地错题本,结果页把答题记录传给wrongAnalysis,复盘页恢复用户选项并展示正确答案、题型解析和算式示例。
但它还不是完整的学习诊断系统。真实源码没有错误类型字段,也没有用户逐步计算轨迹。要进一步实现“显示计算步骤并记录错误类型”,应先把题目与记录绑定成单一复盘模型,再引入结构化步骤、可解释错误分类、版本迁移和sessionId幂等键。这样得到的反馈不仅告诉用户“错了”,还能够诚实地说明“系统知道什么、为什么这样判断,以及下一次应该检查哪一步”。
CSDN-SERIES:ALL-163187036