【句匠|06】HarmonyOS ArkTS 练习题状态实战:推进题目、提交答案并避免重复计分

发布时间:2026/9/6 23:57:35
【句匠|06】HarmonyOS ArkTS 练习题状态实战:推进题目、提交答案并避免重复计分 在学习类应用里练习题页面看起来只是“选 A/B/C/D再点下一题”。实际落到 HarmonyOS ArkTS 页面后它很容易变成一个状态交叉点题目来源可能是章节练习、随机练习、模拟考试、错题练习或考试后的错题解析用户点选后要立刻展示对错和解析题目推进时要重置当前题状态练习结束时还要把完成数、正确数、章节进度和考试结果写到正确的位置。如果这些状态没有边界最常见的问题不是页面显示错一点而是计分会被重复追加。用户连续点两次选项、从题号面板跳回已答题、考试倒计时触发自动提交、手动进入下一题这些动作都会靠近records和进度写入。本文基于句匠项目真实源码D:\huawei\one18-11\entry\src\main\ets\pages\PracticePage.ets复盘这个页面如何用selectedKey、showAnalysis、records、currentIdx把一次答题过程收束成可推演的状态链。本文只讨论源码中可以复核的部分PracticePage.ets的题目加载、选项提交、解析态锁定、题目推进、错题解析恢复、考试计时和本地进度写入。它不声称实现了服务端判题、云端同步计分、真实多人防刷或线上排行榜写入。正文唯一复核标记com.jiaweikang.one18。1. 把练习题页面拆成状态机而不是按钮集合练习题页面的问题通常出在“点击之后还能不能再点”。在句匠源码里页面没有把选项按钮当成单独组件孤立处理而是把答题过程拆成几个核心状态状态字段所在位置作用questionsState当前模式下实际参与练习的题目列表currentIdxState当前题下标用于题目推进和题号展示selectedKeyState当前题用户已选择的选项showAnalysisState是否进入解析态也是防止重复提交的关键门闩recordsState本轮练习产生的答题记录modeState区分章节、随机、考试、错题、错题解析源码中的字段定义如下State questions: Question[] [] State currentIdx: number 0 State selectedKey: string State showAnalysis: boolean false State records: AnswerRecord[] [] State mode: string chapter State bankId: string State timerSec: number 0 StorageLink(wrongRecords) wrongRecords: WrongRecord[] [] StorageLink(bankProgress) progressList: BankProgress[] [] StorageLink(chapterProgress) chapterProgressList: ChapterProgress[] [] StorageLink(examDurationSec) examDurationSec: number 1800 StorageLink(autoNextQuestion) autoNextQuestion: boolean false State chapterId: string 这里最重要的不是字段数量而是字段之间的职责没有混在一起。selectedKey只表示当前题的选择showAnalysis表示当前题是否已提交并展示解析records才是本轮练习最终用于计分和进度写入的记录集合。这样做的好处是页面可以在视觉反馈、下一题推进、进度写入之间共享同一份事实而不是让每个按钮自己维护一套临时状态。AnswerRecord的模型也很克制export interface AnswerRecord { questionId: string selected: string correct: boolean }这说明当前源码记录的是“某一题选择了什么、是否正确”没有记录提交时间、用时、重试次数或远端流水号。因此文章后面所有关于计分的讨论都限定在本地records数组和本地持久化进度范围内。2. 题目入口先统一到aboutToAppear练习题页面接收的入口参数来自router.getParams()。源码用PracticeParams明确了页面会读哪些参数interface PracticeParams { bankId: string chapterId?: string mode: string // chapter | random | exam | wrong | wrongAnalysis records?: string }aboutToAppear()里会根据mode和chapterId选择题目来源。章节练习走getQuestionsByChapter普通题库走getQuestions随机练习再交给QuestionUtils.shuffle考试模式会裁剪成一组固定数量题目错题练习从本地wrongRecords反查题库。aboutToAppear(): void { const params router.getParams() as PracticeParams | undefined if (params) { this.bankId params.bankId this.mode params.mode || chapter this.chapterId params.chapterId || if (this.mode wrongAnalysis) { this.loadWrongAnalysis(params) } else if (this.mode wrong) { const wrongQs: Question[] [] for (const wr of this.wrongRecords) { const all getQuestions(wr.bankId) const found all.find(q q.id wr.questionId) if (found) wrongQs.push(found) } this.questions QuestionUtils.shuffle(wrongQs) } else if (params.chapterId) { this.questions getQuestionsByChapter(params.bankId, params.chapterId) } else { this.questions getQuestions(params.bankId) } if (this.questions.length 0 this.mode ! wrongAnalysis) { this.questions getQuestions(params.bankId) } if (this.mode exam) { this.questions this.pickHourlyExamQuestions(this.questions, 20) } else if (this.mode random) { this.questions QuestionUtils.shuffle(this.questions) } } }这个入口设计的工程价值在于页面主体不用关心“题目是怎么来的”。页面只面对questions、currentIdx和mode。如果后续增加新的练习入口优先应该扩展入口装载逻辑而不是在每个选项按钮、底部按钮、题号面板里到处判断题源。3.showAnalysis是提交后的门闩真正影响重复计分的代码在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) } if (this.autoNextQuestion this.mode ! exam) { setTimeout(() { if (this.showAnalysis) { this.goNext() } }, 800) } } }这段代码的关键顺序是步骤动作防错意义1wrongAnalysis直接返回解析页只复盘不产生新答题记录2showAnalysis为真直接返回当前题已经提交不再向records追加3写入selectedKeyUI 能知道用户选了哪个选项4打开showAnalysis后续点击进入锁定态5records.push(...)本题只在第一次有效提交时计入本轮记录6更新错题记录错题练习和普通练习共享本地错题事实从源码看当前页面的防重复计分不是依靠防抖函数也不是依靠按钮禁用属性而是依靠提交后的页面状态showAnalysis。一旦第一次选择成功后续对同一题的选项点击会在if (this.showAnalysis) return处被截断。这个边界要说清楚它能防止“同一题解析态下再次点击选项导致records.push重复执行”它不等价于服务端幂等也不处理跨设备、跨进程、恶意脚本重放这类问题。对于当前离线题库练习页面这个本地状态门闩是合理的。4. 选项颜色从状态推导不单独保存提交答案后用户需要立刻看到正确项和错误项。源码没有额外保存“哪个按钮应该变红、哪个按钮应该变绿”而是通过optionBg和optionBorder从当前题答案、已选项和解析态推导。private optionBg(key: string): string { if (!this.showAnalysis) { return this.selectedKey key ? Colors.OPTION_SELECTED_BG : Colors.OPTION_BG } const q this.currentQ() if (!q) return Colors.OPTION_BG if (key q.answer) return Colors.OPTION_CORRECT_BG if (key this.selectedKey) return Colors.OPTION_WRONG_BG return Colors.OPTION_BG } private optionBorder(key: string): string { if (!this.showAnalysis) { return this.selectedKey key ? Colors.OPTION_SELECTED_BORDER : Colors.OPTION_BORDER } const q this.currentQ() if (!q) return Colors.OPTION_BORDER if (key q.answer) return Colors.OPTION_CORRECT_BORDER if (key this.selectedKey key ! q.answer) return Colors.OPTION_WRONG_BORDER return Colors.OPTION_BORDER }这类推导式写法适合 ArkUI 页面。原因很简单selectedKey和showAnalysis已经是页面事实再新增optionColorMap之类的状态只会制造同步问题。只要当前题变化时把selectedKey和showAnalysis重置颜色自然跟着变。实际排查时也更直接现象优先检查提交后正确答案没有变绿showAnalysis是否被置为true错误选项没有变红selectedKey是否等于当前按钮 key下一题保留上一题颜色goNext()是否重置selectedKey和showAnalysis解析页颜色异常applyAnalysisState()是否从records取到对应记录5.goNext负责推进和重置不让提交逻辑顺手做完所有事选项提交只负责本题记录题目推进交给goNext()。这样可以避免一个函数同时处理选项、判题、下一题、考试结果和进度持久化。private goNext(): void { if (this.currentIdx this.questions.length - 1) { this.currentIdx if (this.mode wrongAnalysis) { this.applyAnalysisState() } else { this.selectedKey this.showAnalysis false this.activeAudioQuestionId this.audioStatusText } } else { if (this.mode wrongAnalysis) { router.back() return } const correctCount this.records.filter(r r.correct).length this.progressList UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, this.chapterId) if (this.chapterId.length 0) { this.chapterProgressList UserDataManager.updateChapterProgress( this.chapterProgressList, this.bankId, this.chapterId, this.records.length, correctCount) } if (this.mode exam) { const score Math.round(correctCount / this.questions.length * 100) router.replaceUrl({ url: pages/ExamResultPage, params: { bankId: this.bankId, score: score, total: this.questions.length, correct: correctCount, durationSec: this.timerSec, records: JSON.stringify(this.records), } }) } else { router.back() } } }这里有两个明显边界第一未到最后一题时只做推进和重置。currentIdx后普通练习会清空selectedKey、关闭showAnalysis让下一题重新进入可答状态。错题解析模式则不会清空而是调用applyAnalysisState()恢复该错题在考试中的选择。第二到最后一题时才做汇总。源码用records.length作为完成数量用records.filter(r r.correct).length作为正确数量然后通过UserDataManager.updateProgress写入题库进度。章节练习还会额外调用updateChapterProgress。这个拆分对重复计分也有帮助。只要records没有被重复追加完成时的records.length和correctCount就是本轮练习的最终结果。进度写入不需要重新扫描 UI也不需要从按钮颜色反推结果。6. 题号面板按记录恢复状态避免回看时破坏答题事实练习题页面通常会有题号面板。用户点击某个题号时页面需要恢复该题是否已答、选了什么、是否展示解析。源码在SheetDot的点击事件里做了这件事Builder SheetDot(idx: number) { Stack() { Text(${idx 1}) .fontSize(Sizes.SMALL_FONT) .fontColor(this.dotTextColor(idx)) .textAlign(TextAlign.Center) } .width(36).height(36) .borderRadius(6) .backgroundColor(this.dotBg(idx)) .margin(4) .onClick(() { this.currentIdx idx this.showSheet false const record this.records.find(r r.questionId this.questions[idx].id) if (record) { this.selectedKey record.selected this.showAnalysis true } else { this.selectedKey this.showAnalysis false } this.activeAudioQuestionId }) }这段代码说明题号跳转不是“重新打开一道题”而是“根据已有答题记录恢复一道题的页面状态”。如果records里已经存在当前题记录页面会把selectedKey恢复为已选项并把showAnalysis设为true。这意味着用户回看已答题时仍处于解析态继续点击选项也会被selectOption的门闩拦下。题号点的颜色同样从records推导private dotBg(idx: number): string { if (idx this.currentIdx) return Colors.PRIMARY const record this.records.find(r r.questionId this.questions[idx].id) if (!record) return Colors.DIVIDER return record.correct ? Colors.SUCCESS : Colors.ERROR }这种设计让题号面板成为答题记录的可视化结果而不是第二套答题状态。工程上应尽量避免“题号面板里再存一个 answeredMap”因为那会带来两个问题答题记录和面板状态可能不同步最后计分时不知道该信哪个。7. 错题解析模式只读考试记录不产生新记录句匠里有一个特殊入口wrongAnalysis。它会接收考试结果页传来的records字符串筛出错误记录然后反查题库展示错题解析。private loadWrongAnalysis(params: PracticeParams): void { let examRecords: AnswerRecord[] [] if (params.records) { try { examRecords JSON.parse(params.records) as AnswerRecord[] } catch (_) { examRecords [] } } 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 } private applyAnalysisState(): void { const record this.records[this.currentIdx] this.selectedKey record ? record.selected : this.showAnalysis this.questions.length 0 this.activeAudioQuestionId }这和错题练习不是一回事。错题练习仍然允许重新答题答对后会从wrongRecords中移除错题解析只是复盘考试中的错误答案所以selectOption一开始就用if (this.mode wrongAnalysis) return禁止提交。从用户体验看错题解析页也应该展示用户当时选错了什么、正确答案是什么、解析是什么。源码通过applyAnalysisState()把selectedKey设置为考试记录里的选择并直接打开showAnalysis这就复用了选项颜色推导逻辑不需要单独写一套解析页按钮样式。8. 考试模式有倒计时自动提交但仍复用records考试模式多了计时器。页面出现时会启动setInterval每秒增加timerSec考试模式下剩余时间到 0 就调用autoSubmitExam()。this.timerId setInterval(() { this.timerSec if (this.mode exam this.examRemainSec() 0) { this.autoSubmitExam() } }, 1000) private examRemainSec(): number { return Math.max(0, this.examDurationSec - this.timerSec) }自动提交函数没有重新构造一套考试记录而是继续使用当前recordsprivate autoSubmitExam(): void { if (this.timerId ! -1) clearInterval(this.timerId) this.timerId -1 const correctCount QuestionUtils.correctCount(this.records) const score QuestionUtils.calcScore(this.records, this.questions.length) this.progressList UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, ) router.replaceUrl({ url: pages/ExamResultPage, params: { bankId: this.bankId, score: score, total: this.questions.length, correct: correctCount, durationSec: this.timerSec, records: JSON.stringify(this.records), } }) }这里有一个值得注意的真实边界records.length表示用户实际提交过的题目数不一定等于考试总题数。倒计时结束时未答题不会生成selected: 的空记录得分由QuestionUtils.calcScore(this.records, this.questions.length)按总题数作为分母计算。因此未答题天然按 0 分处理但不会出现在records数组里。如果产品后续需要在考试结果页明确显示“未答题”可以在提交前补齐空记录或者在结果页按questions和records做差集。但这不是当前源码已经实现的能力不能在材料里过度声称。9. 进度写入在UserDataManager中累加练习结束后页面调用UserDataManager.updateProgress。这个函数会查找对应题库进度如果已有记录就累加finished和correct如果没有就创建新记录。static updateProgress( records: BankProgress[], bankId: string, addFinished: number, addCorrect: number, chapterId: string ): BankProgress[] { const idx records.findIndex(r r.bankId bankId) let next: BankProgress[] if (idx 0) { const old records[idx] const updated: BankProgress { bankId, finished: old.finished addFinished, correct: old.correct addCorrect, lastChapterId: chapterId, updatedAt: nowStr(), } next [...records] next[idx] updated } else { next [{ bankId, finished: addFinished, correct: addCorrect, lastChapterId: chapterId, updatedAt: nowStr() }, ...records] } UserDataManager.persist(UserDataManager.K_PROGRESS, next) return next }这说明进度不是覆盖式写入而是增量累加。也正因为如此前面的防重复提交很重要如果同一题多次进入records这里的finished和correct会被放大。当前源码通过showAnalysis限制同一题在解析态下再次追加记录从而保护本轮练习的计数输入。不过还要保持边界意识。updateProgress自身没有判断AnswerRecord.questionId是否重复它只是信任调用方传进来的addFinished和addCorrect。因此重复计分的第一道防线在页面状态不在持久化工具里。如果后续加入“题号面板允许未提交题返回修改”“考试交卷前允许改答案”等能力就需要重新设计记录更新策略比如按questionId覆盖而不是单纯push。10. 自动下一题要看解析态是否还存在源码支持autoNextQuestion。提交后如果不是考试模式会延迟 800ms 自动进入下一题if (this.autoNextQuestion this.mode ! exam) { setTimeout(() { if (this.showAnalysis) { this.goNext() } }, 800) }这行if (this.showAnalysis)不只是形式检查。延迟期间用户可能点击题号、返回、关闭页面或发生其他状态变化。源码至少保证了自动推进只在当前仍处于解析态时执行。它没有实现复杂的任务取消令牌但对这个页面的轻量自动下一题已经足够。实际项目里如果自动下一题逻辑变重比如要发网络请求、写本地数据库、播放动画或联动语音朗读建议继续提高边界场景建议页面离开后仍触发回调在aboutToDisappear中清理定时器或保存任务标识用户快速跳题后自动推进错题回调里校验questionId是否仍然匹配考试模式需要自动进入下一题明确是否允许改答案再决定是否继续使用showAnalysis门闩自动推进影响朗读弹窗进入下一题前关闭音频状态当前源码在goNext()里已经重置activeAudioQuestionId和audioStatusText这能避免下一题还残留上一题朗读状态。11. 本地复核清单基于这份源码可以按下面的方式复核练习题状态链验证项操作预期结果普通提交进入章节练习点击一个选项selectedKey写入showAnalysistruerecords追加一条重复点击同一题解析出现后再点其他选项selectOption被showAnalysis拦截records不再追加下一题点击下一题currentIdxselectedKeyshowAnalysisfalse题号回看打开题号面板并点击已答题根据records恢复选项和解析态错题练习错题模式答对错题调用UserDataManager.removeWrong移除错题错题解析从考试记录进入wrongAnalysis页面展示错误记录不允许重新提交考试倒计时剩余时间归零清理计时器计算分数跳转考试结果页练习完成最后一题后继续调用updateProgress章节模式额外调用updateChapterProgress如果要在 DevEco Studio 或真机中观察可以在不打印敏感信息的前提下临时记录非隐私状态例如当前题号、records.length、showAnalysis。验证完成后应移除调试输出避免发布包保留无意义日志。12. 常见问题与处理建议问题可能原因处理方向同一题被计入两次提交后没有进入解析态或新增逻辑绕过了selectOption确认所有选项点击都走selectOption并在追加records前保持showAnalysis门闩返回已答题后还能改答案题号跳转没有从records恢复解析态检查SheetDot点击事件是否正确设置selectedKey和showAnalysis考试得分低于预期未答题不会生成记录但总题数仍作为分母检查records.length和questions.length确认产品是否需要补齐未答题记录错题解析页还能提交wrongAnalysis分支被绕过确认入口mode参数正确并保留selectOption的模式判断练习完成后进度暴涨records中存在重复questionId在页面层排查重复提交必要时改成按questionId覆盖记录下一题残留上一题颜色推进时没有清空当前题状态检查goNext()的普通模式分支是否重置selectedKey和showAnalysis13. 可以迁移的工程写法如果把这套写法迁移到其他 HarmonyOS 学习应用建议保留四个边界第一入口装载只负责把不同来源转成统一的questions。章节、随机、考试、错题都可以不同但页面主体最好只面对题目数组和模式。第二一次有效提交只在一个函数里追加答题记录。不要让选项组件、题号面板、底部按钮分别写records否则最终计分会很难追踪。第三提交后的锁定态要显式存在。句匠用的是showAnalysis其他项目也可以叫submitted或answerLocked。名字可以不同但职责必须清楚它既驱动解析展示也阻止重复写入。第四最终进度写入只消费答题记录不消费 UI 表现。颜色、按钮样式、弹窗都不应该成为计分依据。计分应来自AnswerRecord这样的结构化记录。总结句匠的PracticePage.ets没有把练习题页面做成复杂框架而是用几组直接的 ArkTS 状态完成了关键链路aboutToAppear统一装载题源selectOption完成提交并用showAnalysis锁住重复点击goNext推进题目并在结束时写入进度SheetDot通过records恢复已答题状态wrongAnalysis复用记录做只读解析考试模式则在倒计时结束时复用同一份records生成结果页参数。这套实现最值得复用的不是某个 UI 样式而是状态边界当前选择、解析锁定、答题记录、最终进度各有归属。对学习类 HarmonyOS 应用来说只要这几条边界清楚题目推进、回看、自动下一题、考试提交和错题解析就能在同一个页面里保持可维护。