羽球搭子 HarmonyOS 实战(17):比分撤销与边界校验
一、撤销不是把较大的一方减一分
比分从 8:7 变成 8:8 后发现误触,正确撤销结果应该回到 8:7;仅比较当前大小会把 A 队减成 7:8。要恢复最近一次动作,必须记录“哪支队伍获得了这一分”,而不是根据最终比分猜测。多场比赛同时计分时,历史还要按比赛标识隔离,否则在第二场点击撤销可能改动第一场。
一个轻量方案是Map<matchId, Team[]>:每次有效+1将队伍压栈,撤销时弹出栈顶并把对应比分减一。空栈、比赛不存在、比赛已结束都直接拒绝。ArkUI 的状态更新仍使用不可变数组替换,具体状态管理原则可参考ArkUI 状态管理概述。
二、先定义撤销的语义边界
用户通常把“撤销”理解为回退最近一次按钮加分,而不是恢复任意历史快照。直接录入 15:12、重置为 0:0、从云端覆盖比分都会建立新的基线,旧的逐分历史已经无法解释,应立即清空。比赛结束后也不继续撤销;若产品需要修改完赛比分,应走独立的“编辑结果”流程并记录审计事件。
| 操作 | 是否写入动作栈 | 是否清空动作栈 | 是否允许撤销 |
|---|---|---|---|
| 有效 +1 | 是,记录 A 或 B | 否 | 是 |
| 在 0 分执行 -1 | 否 | 否 | 不改变状态 |
| 直接录入比分 | 否 | 是 | 从新基线重新记录 |
| 重置比分 | 否 | 是 | 重置前历史作废 |
| 比赛结束 | 否 | 是 | 否 |
| 云端快照覆盖 | 否 | 是 | 以服务端快照为基线 |
这种定义简单且可向用户解释。如果要支持多步重做,还需要双栈和更多动作类型,但普通球场快记没有必要一开始就引入完整命令系统。
三、只记录真正生效的加分
动作栈必须在比分确认变化后写入。比赛已结束、标识无效或变化量为零时不能记录;减分也不能伪装成可撤销的加分。记录函数同时接收旧比分和新比分,只有目标队伍确实增加才压栈。
private recordHistory( matchId: string, team: Team, delta: number, before: Score, after: Score ): void { if (delta <= 0) return const changed = team === 'A' ? after.a > before.a : after.b > before.b if (!changed) return const stack = this.scoreHistory.get(matchId) ?? [] stack.push(team) this.scoreHistory.set(matchId, stack) }如果未来支持“一次加 2 分”,动作记录最好升级成{ team, delta, before },否则一次撤销只能减一。当前所有快捷按钮都以单分为单位,队伍栈已经足够,但要把这个前提写进模型约束。
四、撤销按栈顶动作回退
撤销先检查栈是否存在,再检查比赛仍处于playing。顺序很重要:不能先pop再发现比赛已结束,否则历史被无声丢弃。比分回退使用非负收敛,完成替换后才弹栈;如果 UI 更新过程中抛错,历史仍保留,可再次尝试。
undoLastScore(matchId: string): UndoResult { const stack = this.scoreHistory.get(matchId) if (stack === undefined || stack.length === 0) { return { ok: false, message: '暂无可撤销的得分' } } const index = this.matches.findIndex((item: ScoreMatch) => item.id === matchId) if (index < 0 || this.matches[index].status !== 'playing') { return { ok: false, message: '当前比赛不可修改' } } const team = stack[stack.length - 1] const current = this.matches[index] const a = team === 'A' ? Math.max(0, current.playerA.score - 1) : current.playerA.score const b = team === 'B' ? Math.max(0, current.playerB.score - 1) : current.playerB.score this.replaceScore(index, a, b) stack.pop() return { ok: true, message: `已撤销 ${team} 队上一分` } }返回结构化结果比在领域函数里直接弹 Toast 更容易测试。页面根据ok决定反馈样式,领域层只描述发生了什么。
五、比分边界必须在所有入口一致
除加减按钮外,快捷比分、文本输入、语音指令和云端事件都可能改变比分。每个入口各写一套校验会逐渐分叉。可以把整数解析、范围收敛和状态检查封装成共同函数,任何来源先得到ScoreValidation,通过后再更新。
function validateScore(rawA: number, rawB: number, maxScore: number): ScoreValidation { if (!Number.isFinite(rawA) || !Number.isFinite(rawB)) { return { valid: false, reason: '比分必须是数字' } } if (!Number.isInteger(rawA) || !Number.isInteger(rawB)) { return { valid: false, reason: '比分必须是整数' } } if (rawA < 0 || rawB < 0) { return { valid: false, reason: '比分不能为负数' } } if (rawA > maxScore || rawB > maxScore) { return { valid: false, reason: `比分不能超过 ${maxScore}` } } return { valid: true, scoreA: rawA, scoreB: rawB } }| 边界输入 | 处理 | 原因 |
|---|---|---|
| 8.5:7 | 拒绝 | 逐分计分只接受整数 |
| NaN:10 | 拒绝 | 文本解析失败 |
| 51:20(普通制) | 拒绝或明确收敛 | 超出产品保护上限 |
| 22:20(普通 21 分) | 进入结束态 | 满足到点且领先 2 分 |
| 21:20(抢 21) | 进入结束态 | 到点且不平分 |
六、重置与直接录入要切断旧历史
重置比分后若保留[A, B, A],下一次撤销会试图从 0:0 减分;直接录入 18:16 后保留旧栈,则撤销得到的“上一分”未必对应 18:16 的真实形成过程。两种操作都应先确认比赛可修改,再替换比分并删除该场历史。
resetScore(matchId: string): boolean { const match = this.findPlayingMatch(matchId) if (match === undefined) return false if (match.playerA.score === 0 && match.playerB.score === 0) return false this.updateMatch(matchId, this.copyWithScore(match, 0, 0)) this.scoreHistory.delete(matchId) return true } applyDirectScore(matchId: string, a: number, b: number): boolean { const checked = validateScore(a, b, this.maxScoreOf(matchId)) if (!checked.valid) return false this.updateScoreFromBaseline(matchId, checked.scoreA!, checked.scoreB!) this.scoreHistory.delete(matchId) return true }若需要撤销“重置”本身,应把重置建模成完整快照命令,而不是继续混用队伍栈。两种撤销语义不要同时隐藏在同一个按钮里。
七、自动结束后的纠错要走独立流程
普通 21 分制在 22:20 自动结束,系统会清空动作栈并保存结果。如果用户此时发现最后一分误触,直接撤销会让云端和统计页不知道比赛已从完成退回进行中。更安全的交互是进入“修正结果”弹窗,展示原比分和新比分,提交后产生score.updated事件,并重新计算胜方、结束时间和统计。
interface ScoreCorrection { matchId: string previousScoreA: number previousScoreB: number scoreA: number scoreB: number reason: string } function createCorrection(match: MatchItem, a: number, b: number): ScoreCorrection { return { matchId: match.id, previousScoreA: match.scoreA, previousScoreB: match.scoreB, scoreA: a, scoreB: b, reason: 'manual_correction' } }这条路径比“让结束态重新可点击”多一步,但能保留审计信息,也便于云端按版本处理冲突。
八、用动作序列而不是单点比分验收
撤销测试应输入完整动作序列。只检查某个最终比分无法证明栈顺序正确,也无法覆盖多场隔离和基线切换。
1. A、B、B 依次得分,确认比分 1:2,连续撤销得到 1:1、1:0、0:0。 2. 在空栈继续撤销,确认仅提示且比分不变化。 3. 同时创建两场比赛,在两场分别加分,确认撤销只影响目标场次。 4. 形成 6:4 后直接录入 15:12,再撤销,确认提示空栈而不是回到 14:12。 5. 重置后再次加分,确认新动作栈从空开始。 6. 比赛结束后点击撤销,确认被拒绝;通过结果修正流程更新时产生独立记录。
九、总结
比分撤销的可靠性来自清晰语义:它回退最近一次真实加分,不猜测较大比分,不跨比赛共享历史,不越过直接录入和重置形成的新基线,也不修改已经结束的比赛。按场次隔离的动作栈配合统一边界校验,足以覆盖球场快记的大多数纠错场景;需要修改完赛结果时,再使用带审计信息的独立流程。
