答题页面看起来只是"选一个答案,再点下一题",实际却是一段很容易重复结算的状态流程:选项只能提交一次,解析出现后不能改答案,错题要写入本地,错题练习答对后要移除,自动下一题不能越过用户手动操作,最后一题还要把题库与章节统计同步保存。进入模拟考试后,手动交卷和倒计时自动交卷又可能在同一秒发生。
本文基于知律项目 D:\huawei\one19-11、包名 com.jiaweikang.one19 的真实源码,复核 PracticePage.ets、QuestionUtils、UserDataManager.ets 和题库生成逻辑。当前实现已经用 showAnalysis 挡住同一题的重复选择,并通过 @StorageLink 回流收藏、错题、笔记和学习进度;但会话结束没有统一幂等锁,手动完成、重复点击与考试倒计时存在重复写入风险。文章会先还原现状,再给出可迁移的修正方案,不把建议写成已上线能力。

一、练习页同时支持五种模式
路由参数中的 mode 支持章节练习、随机练习、模拟考试、错题练习和错题解析:
ts
interface PracticeParams {
bankId: string
chapterId?: string
mode: string
records?: string
}
五种模式共用一套题目 UI,却不共享完全相同的写入规则。章节练习需要同步章节统计;错题练习答对后移除错题;错题解析只读历史答案;考试模式限制 20 题并在超时后交卷。把这些差异散落在多个点击回调里,会让结算边界越来越难验证。
二、题目加载顺序决定真实题集
aboutToAppear 根据模式建立 questions:
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)
}
如果章节筛选结果为空,非错题解析模式会回退到整个题库。这个回退避免空页面,却会改变用户预期:用户从某章节进入,最终可能练到全库题目。更稳的做法是显示章节空态并让用户主动切换,不要静默扩大范围。
三、随机练习与考试抽题不同
随机模式直接使用 QuestionUtils.shuffle;考试模式调用 pickHourlyExamQuestions,按题库 ID 和当前 UTC 小时构造种子,再执行 Fisher-Yates 洗牌,最多取 20 题。同一小时内题目顺序稳定,跨小时整体轮换。
这是一种可复核的确定性抽题,并非服务器下发试卷。文章或产品文案可以写"按小时轮换的本地模拟题",不能写成"全国统一题库"或"实时考试数据"。
四、单题提交的第一道防线是 showAnalysis
核心逻辑先检查只读模式和解析状态:
ts
private selectOption(key: string): void {
if (this.mode === 'wrongAnalysis') return
if (this.showAnalysis) return
this.selectedKey = key
this.showAnalysis = true
// 记录答案
}
第一次点击后立即把 showAnalysis 设为 true,后续选项点击直接返回。这能在同一页面生命周期内阻止同题重复提交,也让选项颜色切换到正确、错误和未选状态。
五、答案记录只保存会话事实
提交后向 records 写入:
ts
const correct = key === q.answer
this.records.push({
questionId: q.id,
selected: key,
correct
})
AnswerRecord 只包含题目 ID、选择键和对错,不复制题干或解析,保持了会话记录轻量。后续成绩计算通过 records 完成,错题解析再按 questionId 回查题库。前提是题目 ID 在内容升级后保持稳定,否则历史记录会找不到题目。
六、错题写入按 questionId 去重
答错时调用 addWrong,它先过滤同一题再把新记录放到数组开头:
ts
static addWrong(
records: WrongRecord[],
questionId: string,
bankId: string
): WrongRecord[] {
const filtered = records.filter(r => r.questionId !== questionId)
const result = [{ questionId, bankId, wrongAt: nowStr() }, ...filtered]
UserDataManager.persist(UserDataManager.K_WRONG, result)
return result
}
所以同一题多次答错不会让错题数量无限增加,但 wrongAt 会刷新为最近一次时间。这个数表示"当前去重错题数",不是累计答错次数。
七、错题练习答对会移除
当 mode === 'wrong' 且答案正确,页面调用 removeWrong。普通章节或随机练习答对不会主动清理旧错题,这意味着一题曾经答错后,只有进入错题模式并答对才会移除。
这是明确的产品规则,应在 UI 中说明。若期望任何模式答对都消除错题,需要修改条件并补回归测试,不能仅改变文案。
八、解析状态驱动选项颜色
optionBg 和 optionBorder 在提交前后使用不同规则:
ts
if (!this.showAnalysis) {
return this.selectedKey === key
? Colors.OPTION_SELECTED_BG
: 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
正确答案始终显示正确色,错误选择显示错误色,其余选项保持普通状态。颜色之外还应为屏幕朗读补充"正确答案""你的选择错误"等语义,避免只靠红绿区分结果。
九、自动下一题有二次状态检查
设置开启后,非考试模式延迟 800ms:
ts
if (this.autoNextQuestion && this.mode !== 'exam') {
setTimeout(() => {
if (this.showAnalysis) {
this.goNext()
}
}, 800)
}
用户若在 800ms 内手动点击下一题,goNext 会把新题的 showAnalysis 重置为 false,延迟回调随后不会再次前进。这一二次检查有效避免常见的"自动与手动各跳一次"问题。

十、答题卡可以回看已答题
点击答题卡题号后,页面按 questionId 查找历史记录:
ts
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
}
已答题恢复锁定与解析状态,未答题可继续选择。由于 records 以题目 ID 查询,即使题目顺序经过洗牌也能正确恢复;不要改成用数组下标绑定答案。
十一、错题解析模式是只读会话
loadWrongAnalysis 从路由中的 JSON 解析 AnswerRecord,只保留错误项,再回查问题。applyAnalysisState 自动展示用户当时的选择和解析,selectOption 在该模式直接返回。
路由 JSON 属于不可信输入,当前解析失败时回退空数组是合理降级;进一步可以校验每条记录的 questionId、selected 与 correct 类型,防止异常对象进入 UI。
十二、题库统计在整场结束时才写
普通练习不是每答一题就写 BankProgress,而是在最后一次 goNext 中按本次 records 汇总:
ts
const correctCount = this.records.filter(r => r.correct).length
this.progressList = UserDataManager.updateProgress(
this.progressList,
this.bankId,
this.records.length,
correctCount,
this.chapterId
)
这种批量结算减少 Preferences 写入次数,但用户中途返回会丢失本场题库进度;错题和收藏因为即时保存仍会保留。产品需要明确选择"完成后结算"还是"逐题结算",两者不能混用。
十三、章节统计只在 chapterId 非空时写入
ts
if (this.chapterId.length > 0) {
this.chapterProgressList =
UserDataManager.updateChapterProgress(
this.chapterProgressList,
this.bankId,
this.chapterId,
this.records.length,
correctCount
)
}
随机练习、考试和全库练习不会增加章节统计,即使题目本身带 chapterId。这是当前源码事实。若希望所有模式都按题目分摊章节进度,需要按 records 对题目分组,而不是把路由 chapterId 作为唯一依据。
十四、finished 是累计次数而非覆盖题数
updateProgress 将 old.finished + addFinished,没有按 questionId 去重。重复完成同一套题会继续增加 finished,详情页再用 finished / totalCount 并限制到 100%。
因此它表达累计答题量,不是唯一题目覆盖率。对外展示"完成度"时需要谨慎;要表达覆盖率,应另存已答 questionId 集合。
十五、考试超时走独立结算入口
计时器每秒增加 timerSec,剩余时间归零时调用 autoSubmitExam。该方法清除计时器、计算正确数和分数、更新题库进度,再用 router.replaceUrl 进入结果页。
它没有更新章节统计,这与考试通常跨章节相符;但也没有在当前方法中调用 addExamHistory。除非结果页另有真实写入,否则不能宣称"考试历史已同步保存"。
十六、手动交卷和自动交卷存在竞争窗口
最后一题手动点击"交卷"会走 goNext,其中先更新题库进度再导航;与此同时,计时器仍可能触发 autoSubmitExam。当前代码没有 isFinishing、submittedAt 或 sessionId 防重。
在页面销毁前的短窗口内,两条路径可能都执行 updateProgress,造成同一场考试重复累计。普通练习最后一题快速重复点击,也可能在路由离开前多次进入完成分支。
十七、用会话级幂等锁统一完成
最小修正是新增一个不参与 UI 的完成锁:
ts
private isFinishing: boolean = false
private beginFinish(): boolean {
if (this.isFinishing) return false
this.isFinishing = true
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
return true
}
所有完成路径第一行调用 beginFinish(),返回 false 时立即退出。锁必须在任何持久化和路由之前设置,才能覆盖同步重复点击与计时器回调。
十八、结算逻辑应收敛到一个方法
ts
private finishSession(reason: 'manual' | 'timeout'): void {
if (!this.beginFinish()) return
const uniqueRecords = this.uniqueAnswerRecords(this.records)
const correctCount = uniqueRecords.filter(r => r.correct).length
this.progressList = UserDataManager.updateProgress(
this.progressList,
this.bankId,
uniqueRecords.length,
correctCount,
this.chapterId
)
this.navigateAfterFinish(uniqueRecords, correctCount, reason)
}
手动完成和超时完成只传不同 reason,不各自复制统计与路由。这样测试可以直接验证"一次会话只结算一次"。
十九、结算前再按 questionId 去重
虽然 showAnalysis 已经防止常规重复提交,结算层仍可以做最后一道防线:
ts
private uniqueAnswerRecords(records: AnswerRecord[]): AnswerRecord[] {
const seen: Set<string> = new Set<string>()
const result: AnswerRecord[] = []
for (const record of records) {
if (seen.has(record.questionId)) continue
seen.add(record.questionId)
result.push(record)
}
return result
}
它防止未来改动、异常路由数据或状态恢复把重复题目带入结算。去重规则要固定:通常保留第一次正式提交,不能在结算时偷偷选择最后一次或正确答案。
二十、持久化结果需要反馈
UserDataManager.persist 当前捕获异常后静默返回,页面无法知道写入是否成功。内存里的 StorageLink 先变化,重启后却可能回退。
更可靠的接口应返回 boolean 或结果对象;结算失败时不要立即导航,可以提示"学习进度未保存",允许重试或明确继续离开。尤其是考试结果,不能只依赖界面瞬时显示。
二十一、考试历史需要明确写入点
公共管理器已有 addExamHistory,但当前 PracticePage 的手动和超时完成代码都未调用它。建议在统一 finishSession 成功保存题库进度后写一次考试历史:
ts
if (this.mode === 'exam') {
this.examHistory = UserDataManager.addExamHistory(
this.examHistory,
this.bankId,
score,
uniqueRecords.length,
correctCount,
this.timerSec
)
}
这需要页面增加对应 StorageLink,并确认结果页不会再次写入。一个实体只能有一个明确所有者。
二十二、中途返回的产品语义要写清
当前用户直接返回时,计时器和 TTS 会释放,但本场 records 不会同步到题库或章节进度。错题、收藏与笔记已经即时持久化,形成"部分数据保留、汇总进度不保留"的行为。
可选方案有两个:坚持完成后结算并在返回前提示,或逐题写入并使用 sessionId 防重。不要在页面消失时无条件补写,因为正常完成导航也会触发消失,容易再次结算。
二十三、自动下一题要给解析阅读时间
800ms 足以看到颜色变化,却可能来不及阅读较长法律解析。设置项虽然允许用户关闭自动下一题,但更稳的设计是提供 2 至 5 秒可选延迟,或在解析卡片上显示倒计时与取消按钮。
法律学习强调理解,不应为了节奏把解析一闪而过。此处属于产品优化建议,不是现有源码能力。
二十四、空题状态与考试除零
页面为空时显示"暂无可做的题目"并提供返回。考试分数计算需要继续保证 questions.length > 0;当前加载逻辑通常回退全库,但防御层仍应处理空数组,避免 correctCount / 0。
统一结算方法可以在开头返回错误状态,禁止写入零题考试记录。
二十五、TTS 与答题状态应解耦
听音逻辑优先创建离线引擎,失败后尝试在线引擎,再失败则保留文字弹窗。它不会阻止选项提交,这是合理的能力降级。
在线 TTS 可能引入网络和隐私披露要求,发布材料必须与真实 SDK 行为一致。文章只确认源码存在在线回退,不推断服务一定可用,也不输出任何账号或鉴权信息。
二十六、多设备安全区已经进入页面
顶部和底部间距分别从 topAvoidAreaHeightPx 与 navigationIndicatorHeightPx 计算,底部工具栏高度包含避让值。答题卡浮层、笔记弹窗和听音弹窗也把底部安全距离加入 padding。
仍需在手机横屏、平板和 2in1 小窗口验证长题干、四个长选项与固定工具栏不会互相遮挡。答题卡高度为 86%,内部 Scroll 保证题号可达。

二十七、推荐的状态枚举
多个布尔值可以收敛为明确阶段:
ts
type PracticePhase =
| 'loading'
| 'answering'
| 'reviewing'
| 'finishing'
| 'finished'
| 'error'
answering 才接受选项,reviewing 才允许下一题,finishing 禁止所有点击,finished 只等待路由离开。相比 showAnalysis + timerId + mode 的隐式组合,状态枚举更容易测试非法转换。
二十八、故障排查表
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 同一题出现两条记录 | showAnalysis 设置时机 | 提交前立即锁定 |
| 自动跳过两题 | 延迟回调是否复查状态 | 保留二次检查 |
| 考试进度翻倍 | 手动与超时是否并发结算 | 增加 isFinishing |
| 章节进度不变 | 路由 chapterId 是否为空 | 明确模式统计规则 |
| 错题数量不增加 | addWrong 是否按 ID 去重 | 区分当前错题与累计错误 |
| 重启后统计回退 | Preferences flush 是否失败 | 返回保存结果 |
| 历史考试为空 | 是否调用 addExamHistory | 统一结算写一次 |
| 中途返回无完成度 | 当前采用完成后结算 | 提示或改逐题结算 |
二十九、回归测试清单
- 同一选项连续点击只产生一条 AnswerRecord;
- 解析出现后点击其他选项不改变答案;
- 自动下一题开启时,手动先点不会多跳一题;
- 答题卡回到已答题时恢复选择和解析;
- 普通模式答错添加一条去重错题;
- 错题模式答对后移除该题;
- 完成章节练习只结算一次题库和章节统计;
- 随机练习不误写固定章节;
- 手动交卷与倒计时同秒发生只结算一次;
- 连续点击"完成/交卷"只导航一次;
- 空题集不计算分数、不写进度;
- 中途返回行为与页面提示一致;
- Preferences 写入失败有可见反馈;
- TTS 不可用时文字弹窗仍可使用;
- 深浅色、横屏、小窗口和底部手势区无覆盖;
- 屏幕朗读能识别选中、正确和错误状态。
三十、源码事实与能力边界
可以确认:页面支持五种模式;单题通过 showAnalysis 防止重复选择;错题按 questionId 去重;自动下一题有延迟后的状态复查;题库和章节进度在正常完成时累计;考试超时会自动进入结果页;收藏、笔记和错题通过 Preferences 即时保存。
必须谨慎:finished 是累计答题次数,不是唯一覆盖数;章节统计只在路由 chapterId 存在时更新;中途退出不结算本场汇总;考试历史在当前页面没有写入;完成阶段缺少幂等锁,存在重复结算窗口。以上边界都来自源码,不以 UI 看起来正常为依据。
三十一、结语
普法练习页的可靠性不在于按钮多,而在于状态转换能否回答三个问题:这一题是否已经提交,这一场是否已经结算,这一份统计是否真的保存成功。知律当前的单题锁定、错题去重和自动下一题复查已经形成了清晰基础,但整场结束仍需统一幂等入口。
把选题、提交、解析、下一题和结算拆成稳定状态,再让题库统计、章节统计、错题与考试历史各有唯一写入所有者,HarmonyOS 5.0 及以上的 ArkUI 页面才能在快速点击、倒计时、多模式切换和生命周期变化下保持一致。
本文部分内容由 AI 辅助整理,所有源码事实、数据口径和能力边界均以知律项目实际代码复核为准;题目中的法律内容仅用于软件工程分析,不构成法律意见。