【中国方言题库|07】HarmonyOS ArkTS 方言练习实战:推进题目、提交答案并同步统计
证据边界:本轮静态复核
PracticePage.ets、UserDataManager.ets、MockBanks.ets与答题记录模型,未执行构建、模拟器、真机或发布验证。跨题库分组统计、显式空态和更严格的模式联合类属于建议实现,不代表当前已上线。
摘要:答题页真正困难的部分不是画四个选项,而是保证“选中、判定、解析、错题、自动下一题、最终统计”按确定顺序发生。“中国方言题库”的
PracticePage同时支持章节、随机、考试、错题练习和错题解析,使用 ArkUI@State驱动单题界面,再通过@StorageLink与UserDataManager同步收藏、错题、笔记和学习进度。本文面向 HarmonyOS 5.0 及以上版本,基于真实源码逐段复核状态推进、计时、完成提交与生命周期清理,并指出混合错题统计和空题回退的边界。
一题答题事务执行流
选项提交需要先拦截重复点击,再判定正误、写入最小答题记录、同步错题状态并展示解析,最后由用户或计时器推进下一题。
练习页状态与数据边界
页面短状态控制当前题、选项和解析,答题记录支撑本轮统计,进度与错题则通过服务写入持久状态,考试模式最后把快照传给结果页。
一、把一次答题看成小型事务
用户点击选项后,页面需要完成:
读取当前题 -> 锁定选择 -> 判断正误 -> 生成 AnswerRecord -> 增删错题记录 -> 展示解析 -> 等待手动或自动推进任何一步顺序错乱,都会出现重复计数、解析对应错题、自动跳题过早或错题无法移除。
本文的唯一标记是:一题只能生成一条可追踪的答题记录。这个约束比“按钮只能点一次”更准确,因为最终统计依赖records,不是依赖界面颜色。
二、运行时状态分为页面态与持久态
当前页面态包括:
@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 chapterId: string = '' @State timerSec: number = 0这些状态只描述当前练习会话。跨页面共享的数据通过@StorageLink接入:
@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = [] @StorageLink('wrongRecords') wrongRecords: WrongRecord[] = [] @StorageLink('noteRecords') noteRecords: NoteRecord[] = [] @StorageLink('bankProgress') progressList: BankProgress[] = [] @StorageLink('chapterProgress') chapterProgressList: ChapterProgress[] = []页面更新数组后,UserDataManager同时调用持久化。UI 状态与数据写入没有散落到选项卡片的渲染代码里。
三、路由参数决定练习模式
参数模型为:
interface PracticeParams { bankId: string chapterId?: string mode: string records?: string }源码注释列出:
chapter random exam wrong wrongAnalysis当前mode是普通字符串,运行可用,但编译器无法阻止ramdom这类拼写错误。演进时可收紧为联合类型:
type PracticeMode = | 'chapter' | 'random' | 'exam' | 'wrong' | 'wrongAnalysis'这是类型改进建议,不能误写成当前已经采用。
四、加载题目时先区分特殊模式
aboutToAppear()的核心分支是:
if (this.mode === 'wrongAnalysis') { this.loadWrongAnalysis(params) } else if (this.mode === 'wrong') { // 从错题记录跨题库回查 } else if (params.chapterId) { this.questions = getQuestionsByChapter(params.bankId, params.chapterId) } else { this.questions = getQuestions(params.bankId) }顺序不能随意调整。wrongAnalysis需要使用结果页带回的records;wrong需要遍历全部错题记录;章节模式才按chapterId过滤;剩余模式读取整个题库。
如果先判断chapterId,携带历史参数的错题解析可能被错误地加载为章节练习。
五、随机与考试在加载后再加工
基础题集确定后:
if (this.mode === 'exam') { this.questions = this.pickHourlyExamQuestions(this.questions, 20) } else if (this.mode === 'random') { this.questions = QuestionUtils.shuffle(this.questions) }随机练习直接洗牌。考试模式最多选择 20 题,并使用题库 ID 与当前 UTC 小时构造确定性种子。同一题库在同一小时内题目顺序保持稳定,下一小时整体轮换。
这比每次进入考试都完全随机更适合中途返回和问题复现,但它不是防作弊系统,也没有服务端统一试卷。准确描述应是“客户端按小时确定性选题”。
六、考试选题不会修改原数组
选题方法先复制:
const arr: Question[] = pool.slice()再执行带种子的 Fisher-Yates 洗牌:
for (let i = arr.length - 1; i > 0; i--) { seed = (seed * 1664525 + 1013904223) >>> 0 const j = seed % (i + 1) const tmp = arr[i] arr[i] = arr[j] arr[j] = tmp }如果直接在缓存题目数组上洗牌,随机练习会改变后续章节列表、搜索或考试顺序。slice()保持共享题库缓存不被页面会话污染。
七、选项点击先用两个门闩防重入
selectOption()开头是:
if (this.mode === 'wrongAnalysis') return if (this.showAnalysis) return第一条让只读解析模式不能重新答题;第二条保证答案已揭示后,重复点击其他选项不会再追加记录。
随后一次性设置:
this.selectedKey = key this.showAnalysis = true因为 ArkUI 事件在同一调用中顺序执行,第二次点击到来时showAnalysis已为true。这就是当前“一题一记录”的主要保护。
八、答题记录只保存统计所需字段
模型为:
export interface AnswerRecord { questionId: string selected: string correct: boolean }提交选项时:
const correct = key === q.answer this.records.push({ questionId: q.id, selected: key, correct })记录不复制题干、选项和解析,减少会话数据体积;结果页或错题解析通过questionId回查题库。
代价是题目 ID 必须稳定。如果本地题库按数组索引生成 ID,插入题目后可能让历史记录指向另一题,这个风险属于内容版本管理,不应由答题页用冗余文本掩盖。
九、错题写入与答对移除发生在同一事件
错误答案:
this.wrongRecords = UserDataManager.addWrong( this.wrongRecords, q.id, q.bankId )错题练习中答对:
this.wrongRecords = UserDataManager.removeWrong( this.wrongRecords, q.id )addWrong()会先按questionId过滤旧项,再把最新记录放到数组头部,因此重复答错不会无限生成重复错题:
const filtered = records.filter(r => r.questionId !== questionId) const result = [ { questionId, bankId, wrongAt: nowStr() }, ...filtered ]错题模式只有答对才移除;普通章节练习答对不会主动清除历史错题。这个行为是源码事实,产品若希望“任何模式答对都消除错题”,需要明确改规则。
十、解析显示由showAnalysis驱动
选项颜色并不直接判断点击历史,而是统一读取:
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 }答案揭示后,正确项始终显示正确色;用户错选项显示错误色;其他项回到普通色。边框使用同样的判断,避免背景和边框表达互相冲突。
解析卡片也只在showAnalysis为真时出现,展示正确答案、解析和可选例句。
十一、“下一题”按钮与答题状态绑定
按钮文案根据位置和模式变化:
this.currentIdx < this.questions.length - 1 ? '下一题 >' : (this.mode === 'exam' ? '交卷' : '完成')点击条件为:
if (this.showAnalysis || this.mode === 'wrongAnalysis') { this.goNext() }所以普通练习必须先选择答案,才能前进;错题解析是只读浏览,可以直接翻页。按钮背景也根据同一条件使用主色或提示色,视觉与交互保持一致。
十二、推进下一题时要重置题级状态
尚未到末题时:
this.currentIdx++ this.selectedKey = '' this.showAnalysis = false this.activeAudioQuestionId = '' this.audioStatusText = ''这些字段必须一起重置。只清空选项而保留showAnalysis,下一题会直接显示答案;只关闭解析而保留selectedKey,新题可能继承旧选中态;不清理音频状态,播放提示会错挂到下一题。
错题解析模式不做普通重置,而是调用:
this.applyAnalysisState()它从对应AnswerRecord恢复当时选项并保持解析可见。
十三、自动下一题有 800 毫秒阅读窗口
设置开启且非考试模式时:
setTimeout(() => { if (this.showAnalysis) { this.goNext() } }, 800)这给用户短暂查看颜色和解析的时间。考试模式被明确排除,避免选项点击后立刻跳题。
这里仍有一个可测试边界:用户在 800 毫秒内手动点击“下一题”,定时回调到达时新题的showAnalysis已被重置为false,所以不会再次推进。这个二次判断降低了双跳风险。
如果未来把状态重置改为异步,就应引入题目版本号或取消定时器,不能只依赖布尔值。
十四、完成普通练习后统一写总进度
末题执行:
const correctCount = this.records.filter(r => r.correct).lengththis.progressList = UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, this.chapterId )updateProgress()找到相同bankId后累加:
finished: old.finished + addFinished correct: old.correct + addCorrect并更新lastChapterId与时间。它记录的是累计答题次数,不是“去重后的已掌握题数”。同一道题重复练习会继续增加finished,详情页展示时要把它理解为练习量。
十五、章节进度只在存在章节 ID 时写入
if (this.chapterId.length > 0) { this.chapterProgressList = UserDataManager.updateChapterProgress( this.chapterProgressList, this.bankId, this.chapterId, this.records.length, correctCount ) }随机练习与考试没有章节 ID,因此不会伪造某个章节的完成度。章节模式则同时更新题库总进度与章节进度。
测试应验证两份统计的关系:
章节练习:总进度增加,当前章节增加 随机练习:总进度增加,各章节不变 考试模式:总进度增加,各章节不变十六、考试结束使用replaceUrl
末题交卷后:
router.replaceUrl({ url: 'pages/ExamResultPage', params: { bankId: this.bankId, score, total: this.questions.length, correct: correctCount, durationSec: this.timerSec, records: JSON.stringify(this.records) } })使用replaceUrl后,结果页返回不会再回到已经提交的考试会话,避免重复提交。
普通练习完成则router.back(),回到题库详情查看更新后的进度。两种导航语义对应不同业务生命周期。
十七、考试倒计时到零自动提交
页面出现后启动每秒计时:
this.timerId = setInterval(() => { this.timerSec++ if (this.mode === 'exam' && this.examRemainSec() <= 0) { this.autoSubmitExam() } }, 1000)autoSubmitExam()首先清理定时器并置为-1,再计算已答记录和分数、更新总进度并替换到结果页。
未答题不会生成AnswerRecord。分数工具接收“已答记录 + 试卷总题数”,因此未答题应按总题数拉低分数。结果页收到的records只包含实际选择过的题,这一点需要在错题解析和答题卡展示中保持一致。
十八、页面离开必须释放计时与语音资源
aboutToDisappear(): void { if (this.timerId !== -1) { clearInterval(this.timerId) } if (this.ttsEngine) { try { this.ttsEngine.stop() this.ttsEngine.shutdown() } catch (_) { } this.ttsEngine = undefined } }如果不清理定时器,离开页面后timerSec仍会增长;如果不停止 TTS,返回详情后语音可能继续播放。生命周期清理与答题正确性同样重要。
当前语音引擎使用zh-CN,历史audio题又在题库构建时被重分类为vocab,所以本文不把它描述为可验证的方言真人读音。
十九、收藏和笔记不参与答题计数
收藏操作:
this.favRecords = UserDataManager.toggleFavorite( this.favRecords, q.id, q.bankId )笔记保存:
this.noteRecords = UserDataManager.upsertNote( this.noteRecords, q.id, q.bankId, this.noteText )它们和records分开。收藏、写笔记不会让题目变成“已答”;选项选择也不会自动收藏。职责分离让统计口径清晰。
空笔记经过trim()后会删除旧记录,而不是保存空字符串。收藏则是按questionId切换,避免重复项。
二十、跨题库错题练习存在统计边界
wrong模式遍历所有错题记录:
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) }所以一轮错题练习可能同时包含多个bankId。但会话完成时总进度使用的是页面参数this.bankId:
updateProgress( this.progressList, this.bankId, this.records.length, correctCount, this.chapterId )如果调用方为混合错题模式传入某个单一题库 ID,所有答题量可能被聚合到该题库。这是源码中真实存在的统计风险。
更准确的写法是按当前问题的bankId分组:
interface BankAnswerAggregate { bankId: string finished: number correct: number }完成时逐组调用updateProgress()。在改造落地前,商店文案不宜宣称“跨地区错题统计已精确归因”。
二十一、空题回退可能扩大练习范围
章节查询为空时:
if (this.questions.length === 0 && this.mode !== 'wrongAnalysis') { this.questions = getQuestions(params.bankId) }这能避免页面完全没有题,却会把“空章节”静默替换为“整库练习”。用户从某章进入后,实际题目范围可能变化。
建议用显式状态:
type LoadState = | 'ready' | 'emptyChapter' | 'emptyBank' | 'invalidParams'空章节应回到详情并提示该章暂无内容,不能悄悄扩大范围。当前实现是回退整库,本文只陈述事实。
二十二、空题时底部工具栏仍需防御
正文使用:
if (this.currentQ() !== undefined) { // 渲染题目 }收藏、笔记操作也会再次获取currentQ()并判空,因此不会直接访问不存在的题目。进度条在题量为零时使用比例0。
但模式标题除wrongAnalysis外会形成类似1/0的展示,下一题按钮状态也需要专门设计。完整空态应替换整块答题 UI,而不是只隐藏题目正文。
二十三、多设备与安全区域
顶部使用:
top: 8 + this.topSafePadding()底部工具栏高度和内边距加入:
this.bottomSafePadding()这让状态栏、导航条和手势区域不遮挡答题操作。题目内容放在Scroll中,底部预留Blank().height(80),避免最后一段解析被固定工具栏盖住。
大字体、长方言词条和 2in1 窄窗口仍需真机或模拟器验证,尤其是四个选项换行、解析卡片高度和“下一题”按钮宽度。
二十四、测试矩阵
单题事务
首次点击只追加一条 AnswerRecord 解析显示后重复点击不追加记录 正确项与错选项颜色正确 下一题重置 selectedKey 与 showAnalysis 最后一题文案切换为完成或交卷模式
chapter 只加载章节题 random 洗牌且不修改缓存原数组 exam 最多 20 题并显示倒计时 wrong 答对后移除错题 wrongAnalysis 只读且恢复历史选择数据
普通答错写入错题 收藏切换不增加答题数 空笔记删除旧笔记 章节练习更新两级进度 随机与考试不更新章节进度 跨题库错题统计按真实 bankId 审核生命周期
离开页面停止计时器 离开页面停止并关闭 TTS 考试超时只提交一次 结果页替换当前考试页面 系统返回不会重复写完成进度二十五、可宣传与不可宣传的边界
可以准确描述:
- 支持章节、随机、考试、错题练习和错题解析模式。
- 选项提交后显示正确答案与解析。
- 错题会写入本地记录。
- 错题模式答对可移除错题。
- 普通完成后同步题库与章节统计。
- 考试支持倒计时和超时提交。
- 支持本地收藏与笔记。
- 页面离开时清理计时与语音资源。
当前不能直接描述:
- 所有方言听音题均有标准真人音频。
- 跨题库错题进度是否按真实
bankId正确归属;当前源码仍存在统计边界,不得表述为已精确分组。 - 空章节一定显示独立空态。
- 考试题目由服务端统一下发。
- 题目 ID 在内容更新后绝对稳定。
- 学习进度等于去重后的掌握题数。
二十六、总结
PracticePage已经形成一条可复核的本地答题链路:路由参数选择题集,@State管理当前题和解析,选项事件生成唯一AnswerRecord,错误答案同步错题,goNext()在末题统一写进度,考试模式再进入结果页。自动下一题、倒计时、收藏、笔记和生命周期清理都围绕同一会话状态工作。
这套实现最值得保留的是顺序明确:先判定,后记录,再反馈,最后推进。需要继续修正的边界也很具体:混合错题应按真实bankId分组统计,空章节不应静默扩展为整库,空题页面需要独立状态,题目 ID 需要内容版本稳定性。
面向 HarmonyOS 5.0+ 继续演进时,可以把练习页理解为一个有限状态机,而不是一组按钮回调。只要“一题一记录、完成一次写回、资源离开即释放”三条约束始终成立,题型和设备形态扩展时就不容易破坏统计可信度。
连续操作下的一致性验收
单次答题通过不代表事务边界稳定。测试时应连续快速点击同一选项、先点错再点对、解析展示期间立即点下一题、最后一题重复提交,并核对记录数量是否与已完成题数一致。任何一次点击都不应产生两条答题记录,也不应把同一道错题反复追加到错题集。
题级状态重置要同时检查已选项、是否已答、解析显示和自动推进计时器。如果只更换currentIdx而保留上一题的解析开关,新题会带着旧题的交互状态出现。如果旧计时器未取消,用户手动进入下一题后还可能被延迟回调再推进一次。
完成路径需要分开普通练习与考试。普通练习更新题库进度,有章节标识时再更新章节进度;考试则汇总分数、正确数、用时与逐题记录,然后使用替换路由进入结果页。验收时应确认返回键不会再回到已完成的考试会话,且页面离开后计时和语音资源均得到释放。
统计写回必须和答题记录同源
进度统计不能从界面文案反推,也不能同时维护一组脱离答题记录的手工计数器。更稳的方法是以本轮记录数量作为已完成数,以其中正确标识的条目数作为正确数,然后在完成节点一次性写回。这样可以追溯每个汇总数字的来源,也能在发生差异时回到逐题记录排查。
普通练习的题库进度和章节进度是两个粒度。题库进度只需题库标识,章节进度还必须有有效章节标识。如果章节信息缺失却仍写入,就会产生无法关联回页面的孤立数据。如果只写章节而不更新题库汇总,首页、题库卡和章节页又可能展示冲突的完成数。
错题状态同样应以最后一次有效提交为准。答错时添加错题,后续答对时移除,可以避免用户已掌握的题目长期滞留。但这个规则依赖稳定的题目标识,也依赖提交门闩真正拦截重复事件。回归时要检查同一题目不会出现多条错题记录,也不会在答对后因延迟回调被重新加回。
按模式组织回归矩阵
随机练习要验证题目加载后的顺序处理和完成进度;章节练习要增加章节进度的写回检查;考试模式要覆盖手动交卷、最后一题提交、倒计时归零和进入结果页四条路径;错题练习要检查跨题库记录的载入与归属边界。每种模式都要覆盖空数据、单题、多题和中途返回。
小窗、横屏和平板上还要检查题干、长选项、解析卡片与底部操作区。底部按钮不得进入系统手势区,解析展开后仍要能滚动到下一题或完成按钮,输入法、状态栏和导航栏变化时不能遮挡答题内容。真机验证时还应同步观察连续点击、计时回调与语音资源释放,这些都不能只靠静态源码得出通过结论。
故障场景也需要单独验收。题库为空时不应访问不存在的当前题,记录解析失败时不应让整个页面白屏,持久化写入异常时应保留当前答题结果并给出可理解的状态。考试倒计时与手动交卷同时发生时,结束逻辑必须具备幂等性,只能生成一份结果快照和一次路由跳转。
日志不必记录用户每个选项的完整内容,但应保留会话模式、题目标识、提交顺序、状态转换和写回结果等调试信息。这样能在不收集多余内容的前提下排查重复提交、跳题、记录数不一致和重复导航,同时为后续自动化回归提供可比对的事件序列。
最后还要把界面数字与持久化结果逐项对照:已答数、正确数、错题数和结果页快照应来自同一轮记录。页面重建、应用切后台、横竖屏切换和快速返回都不应导致计数被二次累加。只有这些边界一起通过,才能说明答题事务从交互到统计真正闭环。