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

摘要:答题页真正困难的部分不是画四个选项,而是保证"选中、判定、解析、错题、自动下一题、最终统计"按确定顺序发生。"中国方言题库"的 PracticePage 同时支持章节、随机、考试、错题练习和错题解析,使用 ArkUI @State 驱动单题界面,再通过 @StorageLinkUserDataManager 同步收藏、错题、笔记和学习进度。本文面向 HarmonyOS 5.0 及以上版本,基于真实源码逐段复核状态推进、计时、完成提交与生命周期清理,并指出混合错题统计和空题回退的边界。

一、把一次答题看成小型事务

用户点击选项后,页面需要完成:

text 复制代码
读取当前题
-> 锁定选择
-> 判断正误
-> 生成 AnswerRecord
-> 增删错题记录
-> 展示解析
-> 等待手动或自动推进

任何一步顺序错乱,都会出现重复计数、解析对应错题、自动跳题过早或错题无法移除。

本文的唯一标记是:一题只能生成一条可追踪的答题记录 。这个约束比"按钮只能点一次"更准确,因为最终统计依赖 records,不是依赖界面颜色。

二、运行时状态分为页面态与持久态

当前页面态包括:

ts 复制代码
@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 接入:

ts 复制代码
@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = []
@StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []
@StorageLink('noteRecords') noteRecords: NoteRecord[] = []
@StorageLink('bankProgress') progressList: BankProgress[] = []
@StorageLink('chapterProgress') chapterProgressList: ChapterProgress[] = []

页面更新数组后,UserDataManager 同时调用持久化。UI 状态与数据写入没有散落到选项卡片的渲染代码里。

三、路由参数决定练习模式

参数模型为:

ts 复制代码
interface PracticeParams {
  bankId: string
  chapterId?: string
  mode: string
  records?: string
}

源码注释列出:

text 复制代码
chapter
random
exam
wrong
wrongAnalysis

当前 mode 是普通字符串,运行可用,但编译器无法阻止 ramdom 这类拼写错误。演进时可收紧为联合类型:

ts 复制代码
type PracticeMode =
  | 'chapter'
  | 'random'
  | 'exam'
  | 'wrong'
  | 'wrongAnalysis'

这是类型改进建议,不能误写成当前已经采用。

四、加载题目时先区分特殊模式

aboutToAppear() 的核心分支是:

ts 复制代码
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 需要使用结果页带回的 recordswrong 需要遍历全部错题记录;章节模式才按 chapterId 过滤;剩余模式读取整个题库。

如果先判断 chapterId,携带历史参数的错题解析可能被错误地加载为章节练习。

五、随机与考试在加载后再加工

基础题集确定后:

ts 复制代码
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 小时构造确定性种子。同一题库在同一小时内题目顺序保持稳定,下一小时整体轮换。

这比每次进入考试都完全随机更适合中途返回和问题复现,但它不是防作弊系统,也没有服务端统一试卷。准确描述应是"客户端按小时确定性选题"。

六、考试选题不会修改原数组

选题方法先复制:

ts 复制代码
const arr: Question[] = pool.slice()

再执行带种子的 Fisher-Yates 洗牌:

ts 复制代码
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() 开头是:

ts 复制代码
if (this.mode === 'wrongAnalysis') return
if (this.showAnalysis) return

第一条让只读解析模式不能重新答题;第二条保证答案已揭示后,重复点击其他选项不会再追加记录。

随后一次性设置:

ts 复制代码
this.selectedKey = key
this.showAnalysis = true

因为 ArkUI 事件在同一调用中顺序执行,第二次点击到来时 showAnalysis 已为 true。这就是当前"一题一记录"的主要保护。

八、答题记录只保存统计所需字段

模型为:

ts 复制代码
export interface AnswerRecord {
  questionId: string
  selected: string
  correct: boolean
}

提交选项时:

ts 复制代码
const correct = key === q.answer
this.records.push({
  questionId: q.id,
  selected: key,
  correct
})

记录不复制题干、选项和解析,减少会话数据体积;结果页或错题解析通过 questionId 回查题库。

代价是题目 ID 必须稳定。如果本地题库按数组索引生成 ID,插入题目后可能让历史记录指向另一题,这个风险属于内容版本管理,不应由答题页用冗余文本掩盖。

九、错题写入与答对移除发生在同一事件

错误答案:

ts 复制代码
this.wrongRecords = UserDataManager.addWrong(
  this.wrongRecords,
  q.id,
  q.bankId
)

错题练习中答对:

ts 复制代码
this.wrongRecords = UserDataManager.removeWrong(
  this.wrongRecords,
  q.id
)

addWrong() 会先按 questionId 过滤旧项,再把最新记录放到数组头部,因此重复答错不会无限生成重复错题:

ts 复制代码
const filtered =
  records.filter(r => r.questionId !== questionId)
const result = [
  { questionId, bankId, wrongAt: nowStr() },
  ...filtered
]

错题模式只有答对才移除;普通章节练习答对不会主动清除历史错题。这个行为是源码事实,产品若希望"任何模式答对都消除错题",需要明确改规则。

十、解析显示由 showAnalysis 驱动

选项颜色并不直接判断点击历史,而是统一读取:

ts 复制代码
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 为真时出现,展示正确答案、解析和可选例句。

十一、"下一题"按钮与答题状态绑定

按钮文案根据位置和模式变化:

ts 复制代码
this.currentIdx < this.questions.length - 1
  ? '下一题 >'
  : (this.mode === 'exam' ? '交卷' : '完成')

点击条件为:

ts 复制代码
if (this.showAnalysis ||
    this.mode === 'wrongAnalysis') {
  this.goNext()
}

所以普通练习必须先选择答案,才能前进;错题解析是只读浏览,可以直接翻页。按钮背景也根据同一条件使用主色或提示色,视觉与交互保持一致。

十二、推进下一题时要重置题级状态

尚未到末题时:

ts 复制代码
this.currentIdx++
this.selectedKey = ''
this.showAnalysis = false
this.activeAudioQuestionId = ''
this.audioStatusText = ''

这些字段必须一起重置。只清空选项而保留 showAnalysis,下一题会直接显示答案;只关闭解析而保留 selectedKey,新题可能继承旧选中态;不清理音频状态,播放提示会错挂到下一题。

错题解析模式不做普通重置,而是调用:

ts 复制代码
this.applyAnalysisState()

它从对应 AnswerRecord 恢复当时选项并保持解析可见。

十三、自动下一题有 800 毫秒阅读窗口

设置开启且非考试模式时:

ts 复制代码
setTimeout(() => {
  if (this.showAnalysis) {
    this.goNext()
  }
}, 800)

这给用户短暂查看颜色和解析的时间。考试模式被明确排除,避免选项点击后立刻跳题。

这里仍有一个可测试边界:用户在 800 毫秒内手动点击"下一题",定时回调到达时新题的 showAnalysis 已被重置为 false,所以不会再次推进。这个二次判断降低了双跳风险。

如果未来把状态重置改为异步,就应引入题目版本号或取消定时器,不能只依赖布尔值。

十四、完成普通练习后统一写总进度

末题执行:

ts 复制代码
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 后累加:

ts 复制代码
finished: old.finished + addFinished
correct: old.correct + addCorrect

并更新 lastChapterId 与时间。它记录的是累计答题次数,不是"去重后的已掌握题数"。同一道题重复练习会继续增加 finished,详情页展示时要把它理解为练习量。

十五、章节进度只在存在章节 ID 时写入

ts 复制代码
if (this.chapterId.length > 0) {
  this.chapterProgressList =
    UserDataManager.updateChapterProgress(
      this.chapterProgressList,
      this.bankId,
      this.chapterId,
      this.records.length,
      correctCount
    )
}

随机练习与考试没有章节 ID,因此不会伪造某个章节的完成度。章节模式则同时更新题库总进度与章节进度。

测试应验证两份统计的关系:

text 复制代码
章节练习:总进度增加,当前章节增加
随机练习:总进度增加,各章节不变
考试模式:总进度增加,各章节不变

十六、考试结束使用 replaceUrl

末题交卷后:

ts 复制代码
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(),回到题库详情查看更新后的进度。两种导航语义对应不同业务生命周期。

十七、考试倒计时到零自动提交

页面出现后启动每秒计时:

ts 复制代码
this.timerId = setInterval(() => {
  this.timerSec++
  if (this.mode === 'exam' &&
      this.examRemainSec() <= 0) {
    this.autoSubmitExam()
  }
}, 1000)

autoSubmitExam() 首先清理定时器并置为 -1,再计算已答记录和分数、更新总进度并替换到结果页。

未答题不会生成 AnswerRecord。分数工具接收"已答记录 + 试卷总题数",因此未答题应按总题数拉低分数。结果页收到的 records 只包含实际选择过的题,这一点需要在错题解析和答题卡展示中保持一致。

十八、页面离开必须释放计时与语音资源

ts 复制代码
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,所以本文不把它描述为可验证的方言真人读音。

十九、收藏和笔记不参与答题计数

收藏操作:

ts 复制代码
this.favRecords =
  UserDataManager.toggleFavorite(
    this.favRecords,
    q.id,
    q.bankId
  )

笔记保存:

ts 复制代码
this.noteRecords =
  UserDataManager.upsertNote(
    this.noteRecords,
    q.id,
    q.bankId,
    this.noteText
  )

它们和 records 分开。收藏、写笔记不会让题目变成"已答";选项选择也不会自动收藏。职责分离让统计口径清晰。

空笔记经过 trim() 后会删除旧记录,而不是保存空字符串。收藏则是按 questionId 切换,避免重复项。

二十、跨题库错题练习存在统计边界

wrong 模式遍历所有错题记录:

ts 复制代码
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

ts 复制代码
updateProgress(
  this.progressList,
  this.bankId,
  this.records.length,
  correctCount,
  this.chapterId
)

如果调用方为混合错题模式传入某个单一题库 ID,所有答题量可能被聚合到该题库。这是源码中真实存在的统计风险。

更准确的写法是按当前问题的 bankId 分组:

ts 复制代码
interface BankAnswerAggregate {
  bankId: string
  finished: number
  correct: number
}

完成时逐组调用 updateProgress()。在改造落地前,商店文案不宜宣称"跨地区错题统计已精确归因"。

二十一、空题回退可能扩大练习范围

章节查询为空时:

ts 复制代码
if (this.questions.length === 0 &&
    this.mode !== 'wrongAnalysis') {
  this.questions = getQuestions(params.bankId)
}

这能避免页面完全没有题,却会把"空章节"静默替换为"整库练习"。用户从某章进入后,实际题目范围可能变化。

建议用显式状态:

ts 复制代码
type LoadState =
  | 'ready'
  | 'emptyChapter'
  | 'emptyBank'
  | 'invalidParams'

空章节应回到详情并提示该章暂无内容,不能悄悄扩大范围。当前实现是回退整库,本文只陈述事实。

二十二、空题时底部工具栏仍需防御

正文使用:

ts 复制代码
if (this.currentQ() !== undefined) {
  // 渲染题目
}

收藏、笔记操作也会再次获取 currentQ() 并判空,因此不会直接访问不存在的题目。进度条在题量为零时使用比例 0

但模式标题除 wrongAnalysis 外会形成类似 1/0 的展示,下一题按钮状态也需要专门设计。完整空态应替换整块答题 UI,而不是只隐藏题目正文。

二十三、多设备与安全区域

顶部使用:

ts 复制代码
top: 8 + this.topSafePadding()

底部工具栏高度和内边距加入:

ts 复制代码
this.bottomSafePadding()

这让状态栏、导航条和手势区域不遮挡答题操作。题目内容放在 Scroll 中,底部预留 Blank().height(80),避免最后一段解析被固定工具栏盖住。

大字体、长方言词条和 2in1 窄窗口仍需真机或模拟器验证,尤其是四个选项换行、解析卡片高度和"下一题"按钮宽度。

二十四、测试矩阵

单题事务

text 复制代码
首次点击只追加一条 AnswerRecord
解析显示后重复点击不追加记录
正确项与错选项颜色正确
下一题重置 selectedKey 与 showAnalysis
最后一题文案切换为完成或交卷

模式

text 复制代码
chapter 只加载章节题
random 洗牌且不修改缓存原数组
exam 最多 20 题并显示倒计时
wrong 答对后移除错题
wrongAnalysis 只读且恢复历史选择

数据

text 复制代码
普通答错写入错题
收藏切换不增加答题数
空笔记删除旧笔记
章节练习更新两级进度
随机与考试不更新章节进度
跨题库错题统计按真实 bankId 审核

生命周期

text 复制代码
离开页面停止计时器
离开页面停止并关闭 TTS
考试超时只提交一次
结果页替换当前考试页面
系统返回不会重复写完成进度

二十五、可宣传与不可宣传的边界

可以准确描述:

  • 支持章节、随机、考试、错题练习和错题解析模式。
  • 选项提交后显示正确答案与解析。
  • 错题会写入本地记录。
  • 错题模式答对可移除错题。
  • 普通完成后同步题库与章节统计。
  • 考试支持倒计时和超时提交。
  • 支持本地收藏与笔记。
  • 页面离开时清理计时与语音资源。

当前不能直接描述:

  • 所有方言听音题均有标准真人音频。
  • 跨题库错题进度已经精确分组。
  • 空章节一定显示独立空态。
  • 考试题目由服务端统一下发。
  • 题目 ID 在内容更新后绝对稳定。
  • 学习进度等于去重后的掌握题数。

二十六、总结

PracticePage 已经形成一条可复核的本地答题链路:路由参数选择题集,@State 管理当前题和解析,选项事件生成唯一 AnswerRecord,错误答案同步错题,goNext() 在末题统一写进度,考试模式再进入结果页。自动下一题、倒计时、收藏、笔记和生命周期清理都围绕同一会话状态工作。

这套实现最值得保留的是顺序明确:先判定,后记录,再反馈,最后推进。需要继续修正的边界也很具体:混合错题应按真实 bankId 分组统计,空章节不应静默扩展为整库,空题页面需要独立状态,题目 ID 需要内容版本稳定性。

面向 HarmonyOS 5.0+ 继续演进时,可以把练习页理解为一个有限状态机,而不是一组按钮回调。只要"一题一记录、完成一次写回、资源离开即释放"三条约束始终成立,题型和设备形态扩展时就不容易破坏统计可信度。


AI 辅助声明: 本文由 AI 辅助整理,现状结论基于"中国方言题库"当前 PracticePageUserDataManagerMockBanks 与模型源码复核;跨题库分组统计、显式空态和联合类型均作为改进建议呈现,未描述为已上线能力。

相关推荐
iFlyCai1 小时前
深入理解Flutter:StatefulWidget生命周期全解析(一)
flutter·dart·状态管理·statefulwidget
贾伟康2 小时前
【中国方言题库|05】HarmonyOS ArkTS 东北话分库实战:让地区内容与通用列表解耦
harmonyos·arkts·路由·arkui·数据架构
HwJack203 小时前
鸿蒙AbilityKit深度解析:应用模型的核心骨架
华为·harmonyos
●VON3 小时前
芯稿 MarkDeck 使用指南:用 Markdown 写出可编辑的 PPT
华为·编辑器·powerpoint·harmonyos·鸿蒙
特立独行的猫a3 小时前
仓颉版 Tauri:给鸿蒙铺一条 Web 开发者的路
前端·华为·harmonyos·tauri·仓颉
2501_9197490318 小时前
华为鸿蒙记录咖啡APP—小羊咖啡
华为·harmonyos·鸿蒙
柠落少女244019 小时前
React Native 全能商城应用实现与鸿蒙跨端适配深度解析
react native·华为·harmonyos
2501_9197490319 小时前
华为鸿蒙图片像素工具APP—小羊像素
华为·harmonyos·鸿蒙