【口算王|02】HarmonyOS ArkTS 答题提交实战:防止重复提交并推进下一题
答题页最容易被低估的不是布局,而是状态变化。用户一次点击会同时影响选中项、答案解析、答题记录、错题本、自动下一题、进度统计和页面路由。只要其中一个动作重复执行,就可能出现同一道题记录两次、学习进度翻倍、最后一题重复结算,或者自动推进与手动点击互相抢状态。
本文基于口算王项目 本地口算王工程(脱敏路径) 的真实源码,复核 PracticePage.ets、UserDataManager.ets、QuestionUtils.ets 和 MathModels.ets。当前实现已经用 showAnalysis 阻止同一道题重复选项提交,并让错题记录按 questionId 去重;但整场练习完成仍缺少独立的一次性结算锁,自动下一题的延迟任务也没有保存句柄。文章会明确区分现有保护和仍需补强的竞态,不把改进方案写成已经实现。
**证据边界:**本文"当前实现"来自本轮对 PracticePage、UserDataManager、QuestionUtils 与 MathModels 的静态复核;任务令牌、显式状态机、可取消调度器、sessionId 和幂等提交均为建议设计。本轮没有执行构建、模拟器或真机快速点击测试,也没有把建议代码合入工程。

一、一次答题不是一次赋值
用户点击选项后,真正的业务链路是:
text
读取当前题
拒绝非法模式和重复提交
保存选择
判断正误
写入 AnswerRecord
更新错题集合
展示解析
可选延迟推进
最后一题结算进度
考试模式跳转结果页
如果只在按钮上做视觉禁用,却没有在方法入口做状态守卫,快速连点、辅助功能事件或异步回调仍可能重复进入业务逻辑。口算王把第一道守卫放在 selectOption() 内部,这是正确方向。
二、当前页面有哪些关键状态
PracticePage 与答题提交直接相关的状态包括:
typescript
@State questions: Question[] = []
@State currentIdx: number = 0
@State selectedKey: string = ''
@State showAnalysis: boolean = false
@State records: AnswerRecord[] = []
@State mode: string = 'chapter'
@State timerSec: number = 0
@StorageLink('autoNextQuestion')
autoNextQuestion: boolean = false
它们分别回答:
- 当前练习有哪些题?
- 当前位于哪一道?
- 用户选了哪个答案?
- 这道题是否已经提交并进入解析态?
- 已形成哪些答题记录?
- 当前是章节、随机、考试、错题还是单题模式?
- 是否开启自动推进?
showAnalysis 在当前实现中同时承担"展示解析"和"本题已提交"两个语义。这能减少字段,但也会让显示状态与业务锁耦合。
三、提交入口先拒绝两种情况
真实代码开头是:
typescript
private selectOption(key: string): void {
if (this.mode === 'wrongAnalysis') return
if (this.showAnalysis) return
// ...
}
第一条防止"错题解析模式"被当成可作答模式。第二条防止用户在解析已经展示后再次选择其他答案。由于 showAnalysis = true 是同步赋值,连续点击同一选项或快速点击不同选项时,第一次进入后,后续调用会在方法入口返回。
这比单纯给选项设置灰色背景可靠,因为守卫位于业务方法内部。
四、为什么当前同题不会重复写 AnswerRecord
通过守卫后,代码立即执行:
typescript
this.selectedKey = key
this.showAnalysis = true
const q = this.currentQ()
随后才构造记录:
typescript
const correct = key === q.answer
this.records.push({
questionId: q.id,
selected: key,
correct
})
关键顺序是"先锁定解析态,再写记录"。ArkTS 这一段没有 await,所以不会在设置 showAnalysis 与 push() 之间让出执行权。对于同一页面实例中的同步连续点击,第二次调用会看到 showAnalysis === true。

五、为什么不能只靠按钮颜色判断禁用
选项的背景和边框会依据 showAnalysis、正确答案和用户选择变化,但 .onClick() 始终存在:
typescript
.onClick(() => {
this.selectOption(opt.key)
})
也就是说,UI 没有通过 .enabled(false) 移除交互,真正防重依赖 selectOption() 守卫。这种设计仍然有效,但测试不能只观察颜色;必须检查 records.length 在重复点击后是否只增加 1。
建议把视觉状态与业务状态同时验证:
text
第一次点击 -> 展示解析,records +1
第二次点击 -> records 不变,selectedKey 不变
点击其他选项 -> records 不变,正确答案展示不变
六、错题写入为什么具备集合幂等性
答错后调用:
typescript
this.wrongRecords =
UserDataManager.addWrong(
this.wrongRecords,
q.id,
q.bankId
)
addWrong() 不是直接追加,而是先移除同题旧记录:
typescript
const filtered =
records.filter(r => r.questionId !== questionId)
const result: WrongRecord[] = [
{ questionId, bankId, wrongAt: nowStr() },
...filtered
]
因此同一个 questionId 在错题集合中最多保留一条。即使未来某个入口意外重复调用,集合也不会无限堆积同一道题,只会刷新错误时间并移动到列表前面。
这是一种数据层幂等保护,与页面层 showAnalysis 守卫形成两道防线。
七、错题模式答对后的删除同样幂等
当 mode === 'wrong' 且答对时:
typescript
this.wrongRecords =
UserDataManager.removeWrong(
this.wrongRecords,
q.id
)
实现只是过滤:
typescript
const result =
records.filter(r => r.questionId !== questionId)
目标不存在时结果不变,重复删除不会报错。这样从错题练习中答对同一道题,无论删除被触发一次还是两次,最终集合状态一致。
八、自动下一题的 800ms 是什么语义
提交成功后,如果用户开启自动推进:
typescript
if (this.autoNextQuestion) {
setTimeout(() => {
if (this.showAnalysis) {
this.goNext()
}
}, 800)
}
800ms 给用户短暂查看正误与解析,然后调用 goNext()。回调再次检查 showAnalysis,可以避免用户已经手动进入下一道后重复推进,因为手动 goNext() 会把新题的 showAnalysis 重置为 false。
这个判断解决了最常见的"自动推进与手动下一题同时发生"场景,但它不是完整的任务取消机制。
九、手动下一题为何通常不会连跳
底部按钮执行:
typescript
if (this.showAnalysis ||
this.mode === 'wrongAnalysis') {
this.goNext()
}
普通模式下,第一下点击进入 goNext():
typescript
this.currentIdx++
this.selectedKey = ''
this.showAnalysis = false
第二下快速点击时,按钮回调仍可能被触发,但条件发现 showAnalysis === false,不会再次调用 goNext()。因此普通中间题的快速连点通常只推进一题。
十、答题卡跳转让延迟回调变得更复杂
答题卡允许用户直接修改 currentIdx,并根据已有记录恢复:
typescript
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
}
假设用户答完当前题后开启了 800ms 自动推进,在定时器触发前打开答题卡并跳到另一道已答题,showAnalysis 仍为 true。旧定时器无法识别页面已切换,会从新的 currentIdx 再推进一次。
这说明用一个布尔值不足以证明"回调仍属于原题"。延迟任务至少要捕获提交时的 questionId 或序号,并在执行前比较。
十一、最后一题存在怎样的重复结算风险
最后一题调用 goNext() 时会:
text
统计 correctCount
updateProgress()
可选 updateChapterProgress()
考试模式 replaceUrl()
普通模式 router.back()
updateProgress() 使用累加:
typescript
finished: old.finished + addFinished
correct: old.correct + addCorrect
它不是基于 sessionId 的幂等写入。如果最后一题结算路径重复执行,同一场练习的完成数和正确数会再次累加。
当前 goNext() 没有 isFinalizing 或 sessionFinalized 守卫,自动下一题的定时器也没有保存 ID 并在页面消失时取消。因此不能声称最后一题已经具备严格的一次性结算保证。
十二、页面消失时只清理了哪些资源
aboutToDisappear() 会:
typescript
if (this.timerId !== -1) {
clearInterval(this.timerId)
}
还会停止并关闭 TTS 引擎。但是自动下一题使用的 setTimeout() 没有句柄字段,因此页面离开时无法显式 clearTimeout()。
回调触发后是否还能造成状态或路由副作用,要依赖组件生命周期和运行时行为。工程上更稳妥的做法是保存延迟任务 ID,并在推进、跳题、结算和页面消失时统一取消。
十三、考试倒计时和手动交卷可能竞争
页面每秒更新 timerSec:
typescript
if (this.mode === 'exam' &&
this.examRemainSec() <= 0) {
this.autoSubmitExam()
}
倒计时归零时,autoSubmitExam() 会停止 interval、更新进度并 replaceUrl() 到结果页。与此同时,用户可能正点击最后一题的"交卷"按钮,后者也会进入 goNext() 的考试结算分支。
两个分支都能更新进度和路由,但没有共享的 finalized 守卫。这是典型的"两个入口提交同一事务"。停止 interval 只能防止下一次时钟触发,不能让已经进入的另一个调用自动退出。
十四、提交状态应该用枚举表达
建议把答题状态从多个布尔值升级为明确状态机:
typescript
enum QuestionPhase {
Answering,
Reviewing,
Advancing
}
enum SessionPhase {
Active,
Finalizing,
Finalized
}
规则变得清晰:
text
只有 Answering 可以提交选项
提交成功后进入 Reviewing
推进时进入 Advancing
到达下一题后回到 Answering
只有 Active 可以开始整场结算
Finalizing 和 Finalized 拒绝所有结算入口
这是基于源码风险提出的改进模型,不是当前项目已经存在的枚举。
十五、用题目令牌约束延迟推进
自动推进任务可以捕获当前题 ID:
typescript
const submittedQuestionId = q.id
this.autoNextTimerId = setTimeout(() => {
const current = this.currentQ()
if (this.sessionPhase !== SessionPhase.Active) return
if (this.questionPhase !== QuestionPhase.Reviewing) return
if (!current || current.id !== submittedQuestionId) return
this.goNext()
}, 800)
如果用户打开答题卡跳走、页面路由离开、考试已经结算或当前题发生变化,旧回调都会失效。这比仅检查 showAnalysis 更接近"任务属于哪个状态"的真实问题。
十六、整场结算应集中到一个出口
当前 autoSubmitExam() 和 goNext() 都包含更新进度、计算分数和路由逻辑。重复实现会增加竞态和字段不一致风险。
可以收敛为:
typescript
private finalizeSession(reason: FinishReason): void {
if (this.sessionPhase !== SessionPhase.Active) return
this.sessionPhase = SessionPhase.Finalizing
this.cancelAutoNext()
this.stopExamTimer()
const summary = this.buildSummary(reason)
this.persistSummaryOnce(summary)
this.sessionPhase = SessionPhase.Finalized
this.openResult(summary)
}
手动完成、自动下一题到末题、倒计时结束和交卷按钮都只调用这一方法。一次性守卫位于最靠近持久化的位置。

十七、为何进度更新需要 sessionId
仅靠内存中的 sessionPhase 可以防止当前页面实例重复结算,但无法覆盖应用异常退出后重试、结果页返回、跨页面重复回调等情况。
更强的幂等模型是给每场练习生成 sessionId:
typescript
interface PracticeSessionSummary {
sessionId: string
bankId: string
chapterId: string
total: number
correct: number
durationSec: number
finishedAt: string
}
持久化层先检查该 sessionId 是否已结算,再决定是否累加进度。当前 BankProgress 只有累计值,没有 session 明细,所以这一能力尚未实现。
十八、records.push 的可变更新有什么影响
records 是 @State 数组,当前代码使用:
typescript
this.records.push(record)
业务上它确实增加了记录,但对于声明式 UI,使用不可变赋值更容易让状态变化明确:
typescript
this.records = [...this.records, record]
更重要的是,追加前可以按题目 ID 防御性去重:
typescript
if (this.records.some(
item => item.questionId === q.id
)) return
页面守卫已经能挡住普通重复点击,数据级去重则能覆盖答题卡回跳、状态恢复或未来异步改造带来的重复入口。
十九、错题更新与进度更新的幂等性不同
口算王的两个数据动作容易被误认为一样:
| 动作 | 实现 | 重复调用结果 |
|---|---|---|
addWrong |
先过滤同 ID 再插入 | 集合不重复 |
removeWrong |
按 ID 过滤 | 状态不变 |
updateProgress |
在旧值上累加 | 会重复增长 |
updateChapterProgress |
在旧值上累加 | 会重复增长 |
因此防重重点不是错题集合,而是整场完成后的累加式统计。幂等性必须按副作用分别判断,不能因为一个方法安全就推导整个提交链安全。
二十、测试矩阵要覆盖交互顺序
最小回归矩阵包括:
text
同一选项连续点击两次
不同选项快速各点一次
答题后立即手动下一题
答题后等待自动下一题
答题后在 800ms 内打开答题卡跳到未答题
答题后在 800ms 内跳到已答题
最后一题答完后立即点完成
最后一题开启自动推进并手动完成
考试倒计时归零时点击交卷
页面退出后等待旧延迟任务时间到达
错误题重复加入错题本
错题模式答对后重复删除
断言不仅看页面:
text
每个 questionId 最多一条 AnswerRecord
currentIdx 最多推进一次
错题集合无重复 questionId
finished 和 correct 只累加一次
结果页只打开一次
离开页面后没有旧任务继续导航
二十一、当前实现可以确认什么
从真实源码可以确认:
- 错题解析模式拒绝答题。
showAnalysis同步阻止同题重复选择。- 一次有效选择只追加一条
AnswerRecord。 - 错题添加和删除按
questionId具备集合幂等性。 - 自动下一题延迟 800ms,并再次检查
showAnalysis。 - 手动推进后会重置选择和解析态,普通快速连点不会连续跳题。
- 页面消失会停止考试 interval 和 TTS。
不能确认:
- 自动下一题延迟任务会在页面离开时被取消。
- 答题卡跳转后旧延迟任务一定不会推进新位置。
- 最后一题结算已有独立 finalized 锁。
- 倒计时提交与手动交卷不会同时更新进度。
- 进度持久化按 sessionId 去重。
- 上述竞态已经通过自动化或真机测试。
二十二、最小改造顺序
按风险和改动量排序:
text
第一步:保存 autoNextTimerId,并统一 cancelAutoNext()
第二步:延迟回调捕获并比较 questionId
第三步:增加 SessionPhase,一次性锁住结算
第四步:合并 autoSubmitExam 与末题完成出口
第五步:AnswerRecord 按 questionId 防御性去重
第六步:为持久化结算引入 sessionId
第七步:补齐快速点击、答题卡和倒计时竞态测试
答题提交的可靠性不取决于按钮动画,而取决于每个副作用是否有唯一入口、明确状态和幂等边界。口算王已经解决了同题同步重复选择和错题集合去重,下一步应把保护范围从"这一道题"扩展到"这一整场练习"。只有自动推进、手动推进、倒计时和结果路由共享同一个一次性结算出口,学习进度才不会因为一次快速点击变成两次完成。
二十三、先定义一次提交的身份
防重复不能只回答"按钮现在是否可点",还要回答"这次回调属于哪道题、哪一场练习、哪一轮页面状态"。当前同步点击由 showAnalysis 挡住,但 800ms 后触发的任务只看到执行时的布尔值,无法知道创建任务时的题目是否仍是当前题。
| 身份字段 | 解决的问题 | 失配时动作 |
|---|---|---|
| sessionId | 区分两场练习 | 拒绝旧场次副作用 |
| questionId | 确认任务属于原题 | 取消推进 |
| questionRevision | 识别同题状态已重置 | 丢弃旧回调 |
| sessionPhase | 确认整场仍可提交 | 拒绝记录与结算 |
这些字段不是为了让模型复杂,而是让每个异步动作携带可比较的因果关系。没有身份的延迟回调,即使时间只有 800ms,也可能在答题卡切换、路由离开或考试归零后变成过期任务。
二十四、把 timeout 变成可取消的单一资源
当前 interval 有 timerId 字段,自动推进 timeout 却没有句柄。建议为延迟推进建立与页面生命周期一致的管理方法,并在创建新任务前先取消旧任务。
arkts
private autoNextTimerId: number = -1
private cancelAutoNext(): void {
if (this.autoNextTimerId === -1) return
clearTimeout(this.autoNextTimerId)
this.autoNextTimerId = -1
}
private scheduleAutoNext(questionId: string): void {
this.cancelAutoNext()
this.autoNextTimerId = setTimeout(() => {
this.autoNextTimerId = -1
const current = this.currentQ()
if (!current || current.id !== questionId) return
if (this.questionPhase !== QuestionPhase.Reviewing) return
if (this.sessionPhase !== SessionPhase.Active) return
this.goNext()
}, 800)
}
aboutToDisappear()、答题卡跳题、手动下一题和整场结算都调用 cancelAutoNext()。这样"旧任务不会继续导航"由明确代码保证,不再依赖组件销毁后的运行时细节。
二十五、选项提交先做纯校验,再执行副作用
当前 selectOption() 的同步顺序已经能挡住常见连点。进一步收口时,可以把"能否提交"和"提交后写哪些数据"分开,使状态转换、答题记录与错题更新更容易分别验证。
arkts
interface SubmitDecision {
accepted: boolean
reason: string
}
private canSubmit(questionId: string): SubmitDecision {
if (this.mode === 'wrongAnalysis') {
return { accepted: false, reason: 'readonly' }
}
if (this.sessionPhase !== SessionPhase.Active) {
return { accepted: false, reason: 'session_closed' }
}
if (this.questionPhase !== QuestionPhase.Answering) {
return { accepted: false, reason: 'question_closed' }
}
if (this.records.some(item => item.questionId === questionId)) {
return { accepted: false, reason: 'duplicate' }
}
return { accepted: true, reason: '' }
}
方法入口先取得当前题并校验,再把阶段同步切到 Reviewing,随后构造不可变 records、更新错题集合、展示解析,最后才安排自动推进。即使未来加入异步存储,业务锁也应在第一个 await 之前完成。
二十六、状态机要约束按钮,也要约束业务方法
视觉上禁用按钮能减少误触,却不能替代方法守卫。更完整的对应关系如下:
| QuestionPhase | 选项 | 下一题 | 答题卡 |
|---|---|---|---|
| Answering | 可提交一次 | 禁用 | 可切换 |
| Reviewing | 不可再提交 | 可推进 | 切换前取消旧任务 |
| Advancing | 禁用 | 禁用 | 暂时禁用 |
UI 根据阶段设置颜色、可用态和无障碍提示,方法内部再次检查阶段。这样鼠标、触控、键盘、辅助功能和延迟回调都会经过同一业务规则,测试也不必用"按钮变灰"来间接推断记录没有重复。
二十七、把整场结算写成可重入但只生效一次
最后一题、倒计时归零和显式交卷都属于同一事务的入口。入口可以被调用多次,但只有第一次从 Active 切换到 Finalizing 的调用能够产生持久化与路由副作用。
arkts
private finalizeSession(reason: FinishReason): void {
if (this.sessionPhase !== SessionPhase.Active) return
this.sessionPhase = SessionPhase.Finalizing
this.cancelAutoNext()
this.stopExamTimer()
const records = this.records.slice()
const summary = this.buildSummary(this.sessionId, records, reason)
const saved = this.sessionService.saveOnce(summary)
if (!saved.success) {
this.sessionPhase = SessionPhase.Active
this.showSaveError(saved.message)
return
}
this.sessionPhase = SessionPhase.Finalized
this.openResult(summary)
}
建议代码把写入结果显式带回页面,是因为当前 UserDataManager.persist() 会捕获异常而不通知调用方。若保存失败仍直接跳转,用户看到完成页却可能没有真实进度;恢复到 Active 时还要保持题目和答案,允许安全重试。
二十八、累计进度之前先记录结算凭证
updateProgress() 与 updateChapterProgress() 都在旧值上增加 finished 和 correct。它们适合处理一次明确提交,却不具备重复调用后的同结果特性。只把 sessionPhase 放在页面内,仍挡不住进程重建后的重试。
arkts
interface FinalizedSession {
sessionId: string
bankId: string
chapterId: string
questionIds: string[]
answeredCount: number
correctCount: number
finishedAt: string
}
interface SaveOnceResult {
success: boolean
inserted: boolean
message: string
}
持久化层先按 sessionId 查询:不存在才写入结算凭证并更新累计值,已存在则返回 inserted=false,页面可以继续打开同一结果而不重复计数。若使用 Preferences,需要设计有界的已结算 ID 集合;数据量和查询需求增长后,应迁移到带唯一键的结构化存储。
二十九、测试要控制调度器,而不是等待 800ms
真实等待会让竞态用例变慢且偶发。可注入一个最小 Scheduler,在测试中保存任务并手动触发,再精确排列"提交、跳题、到时、离页"的顺序。
| 场景 | 事件顺序 | 关键断言 |
|---|---|---|
| 同题连点 | submit A、submit B | records 只增加一条 |
| 跳到已答题 | submit、sheetJump、runTimeout | currentIdx 不被旧任务改变 |
| 手动抢先 | submit、manualNext、runTimeout | 只推进一题 |
| 末题竞态 | timeout、manualFinish | 累计值和路由各执行一次 |
| 页面离开 | submit、disappear、runTimeout | 没有记录、推进或导航副作用 |
| 保存失败 | finalize、persistFail | 保留答案并提供重试 |
真机仍需覆盖快速触控、键盘确认、系统返回、前后台切换和最后一秒交卷。本轮没有执行这些操作,因此文章给出的是测试设计,不是运行通过结论。
三十、按最小风险分三步落地
第一步只增加 autoNextTimerId、取消方法和 questionId 校验,不改变数据结构,就能封住最直接的旧回调问题。第二步引入 QuestionPhase 与 SessionPhase,并把所有完成入口收敛到 finalizeSession()。第三步才增加 sessionId、保存结果反馈和持久化幂等凭证。
每一步都应保持现有模式行为:错题解析仍只读,错题练习答对仍移除记录,普通练习完成仍返回,考试仍进入结果页。分层改造比一次重写页面更容易定位回归,也能让现有 showAnalysis 在过渡期继续承担显示职责。
每完成一步都要复跑同一组基线:普通题首次提交、重复点击、手动推进、自动推进、答题卡切换、末题完成和考试超时。只有新增守卫生效且原有导航、错题更新与解析展示保持一致,才进入下一步;如果出现回归,应先缩小到当前阶段的状态转换,不要同时改动 UI、存储结构和路由参数。
三十一、总结:同步守卫保护一题,幂等事务保护整场
口算王当前已经用同步 showAnalysis 守卫解决了常见的同题重复点击,并让错题添加与删除按 questionId 保持集合一致。真正需要继续补强的,是无句柄自动推进、答题卡切换后的过期任务,以及末题与倒计时共享累加式进度时的一次性结算。
可靠方案的核心是给任务和事务都赋予身份:questionId 约束延迟推进,sessionId 约束持久化,QuestionPhase 管理单题状态,SessionPhase 管理整场结束。再配合统一取消、显式保存结果与可控调度测试,页面才能在快速点击和复杂生命周期中仍然只记录一次、只推进一步、只完成一场。
**AI 辅助声明:**本文在人工核对真实工程源码、答题守卫、800ms 自动推进、答题卡跳转和进度写入路径后,使用 AI 辅助整理结构、润色表达并生成示意图;当前实现、建议扩展与未执行验证均已分别说明。
CSDN-SERIES:ALL-163186345