ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【中国方言题库|07】HarmonyOS ArkTS 方言练习实战:推进题目、提交答案并同步统计

【中国方言题库|07】HarmonyOS ArkTS 方言练习实战:推进题目、提交答案并同步统计

【中国方言题库|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).length
this.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而保留上一题的解析开关,新题会带着旧题的交互状态出现。如果旧计时器未取消,用户手动进入下一题后还可能被延迟回调再推进一次。

完成路径需要分开普通练习与考试。普通练习更新题库进度,有章节标识时再更新章节进度;考试则汇总分数、正确数、用时与逐题记录,然后使用替换路由进入结果页。验收时应确认返回键不会再回到已完成的考试会话,且页面离开后计时和语音资源均得到释放。

统计写回必须和答题记录同源

进度统计不能从界面文案反推,也不能同时维护一组脱离答题记录的手工计数器。更稳的方法是以本轮记录数量作为已完成数,以其中正确标识的条目数作为正确数,然后在完成节点一次性写回。这样可以追溯每个汇总数字的来源,也能在发生差异时回到逐题记录排查。

普通练习的题库进度和章节进度是两个粒度。题库进度只需题库标识,章节进度还必须有有效章节标识。如果章节信息缺失却仍写入,就会产生无法关联回页面的孤立数据。如果只写章节而不更新题库汇总,首页、题库卡和章节页又可能展示冲突的完成数。

错题状态同样应以最后一次有效提交为准。答错时添加错题,后续答对时移除,可以避免用户已掌握的题目长期滞留。但这个规则依赖稳定的题目标识,也依赖提交门闩真正拦截重复事件。回归时要检查同一题目不会出现多条错题记录,也不会在答对后因延迟回调被重新加回。

按模式组织回归矩阵

随机练习要验证题目加载后的顺序处理和完成进度;章节练习要增加章节进度的写回检查;考试模式要覆盖手动交卷、最后一题提交、倒计时归零和进入结果页四条路径;错题练习要检查跨题库记录的载入与归属边界。每种模式都要覆盖空数据、单题、多题和中途返回。

小窗、横屏和平板上还要检查题干、长选项、解析卡片与底部操作区。底部按钮不得进入系统手势区,解析展开后仍要能滚动到下一题或完成按钮,输入法、状态栏和导航栏变化时不能遮挡答题内容。真机验证时还应同步观察连续点击、计时回调与语音资源释放,这些都不能只靠静态源码得出通过结论。

故障场景也需要单独验收。题库为空时不应访问不存在的当前题,记录解析失败时不应让整个页面白屏,持久化写入异常时应保留当前答题结果并给出可理解的状态。考试倒计时与手动交卷同时发生时,结束逻辑必须具备幂等性,只能生成一份结果快照和一次路由跳转。

日志不必记录用户每个选项的完整内容,但应保留会话模式、题目标识、提交顺序、状态转换和写回结果等调试信息。这样能在不收集多余内容的前提下排查重复提交、跳题、记录数不一致和重复导航,同时为后续自动化回归提供可比对的事件序列。

最后还要把界面数字与持久化结果逐项对照:已答数、正确数、错题数和结果页快照应来自同一轮记录。页面重建、应用切后台、横竖屏切换和快速返回都不应导致计数被二次累加。只有这些边界一起通过,才能说明答题事务从交互到统计真正闭环。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进