【中国方言题库|08】HarmonyOS ArkTS 练习结果实战:汇总正确率与地区薄弱项
摘要:结果页不是把几个数字摆进卡片,而是一次答题会话的“只读结算边界”。中国方言题库的
ExamResultPage接收PracticePage传来的题库、分数、题量、正确数、用时和逐题记录,计算正确率与等级,生成答题状态网格,并把考试快照写入本地历史。本文面向 HarmonyOS 5.0 及以上版本,基于真实 ArkTS 源码复核参数契约、状态恢复、零题保护、结果持久化、错题解析和再次考试链路。同时会明确一个容易被标题掩盖的事实:当前页面没有按地区统计薄弱项;若要增加该能力,必须先把questionId关联回题目与题库地区,不能从现有三个汇总数字中猜测。

一、先划清源码边界:已有结算,没有地区聚合
本文复核的核心文件是:
entry/src/main/ets/pages/ExamResultPage.ets
entry/src/main/ets/pages/PracticePage.ets
entry/src/main/ets/common/constants/ThemeConstants.ets
librarya/src/main/ets/models/Dialect.ets
librarya/src/main/ets/utils/UserDataManager.ets
libraryb/src/main/ets/utils/QuestionUtils.ets
真实页面已经实现以下能力:
- 从路由参数恢复考试快照;
- 展示百分制分数、正确率、用时和等级印章;
- 按题号生成正确、错误、未答三态网格;
- 把本次结果追加到
examHistory; - 跳转到错题解析;
- 使用
replaceUrl发起再次考试; - 根据系统底部避让区增加安全间距。
但源码中没有“地区薄弱项”列表,也没有在结果页读取 Region、Question.bankId 或题型分布。页面中显示的 ${236}/${1258} 排名也是固定展示值,不是真实联网排名。工程分析必须把“已经上线的能力”和“可设计的增强方案”分开。本文后半部分讨论地区薄弱项时,会明确标注为基于现有数据模型的可复核扩展,不把设计稿写成既有功能。
这条边界也决定了全文的核心原则:
结果页只消费快照,不重新推导考试事实。
唯一校验标记:结果页只消费快照,不重新推导考试事实。
二、为什么结果页要接收完整快照
PracticePage 在考试正常结束或倒计时结束时,会把本次会话数据放入路由参数:
router.replaceUrl({
url: 'pages/ExamResultPage',
params: {
bankId: this.bankId,
score: score,
total: this.questions.length,
correct: correctCount,
durationSec: this.timerSec,
records: JSON.stringify(this.records)
}
})
这组参数不是可以随意删减的 UI 文案,而是一份结算快照。score 决定分数与等级,total 决定正确率分母和答题网格长度,correct 决定正确率,durationSec 决定用时,records 决定每道题的状态与错题解析内容,bankId 则决定历史归属和再次考试的题库。
如果结果页只接收 score,它无法区分“8/10”和“16/20”;如果只接收 records,超时未答题不会出现在记录数组中,页面也无法知道应补多少个“未答”格子。因此,total 和 records.length 不能互相替代。
三、用接口固定路由契约
源码用 ExamResultParams 描述参数形状:
interface ExamResultParams {
bankId: string
score: number
total: number
correct: number
durationSec: number
records: string
}
在 HarmonyOS 5.0+ 的 Stage 模型应用中,页面间通过 router 传值很常见。接口的价值不是让运行时自动验证,而是让 ArkTS 编译期知道页面期待什么。调用方和消费方仍要共同维护契约,尤其要注意以下约束:
| 字段 | 语义 | 合法性要求 |
|---|---|---|
bankId |
本次考试所属题库 | 应能关联真实题库 |
score |
百分制结果 | 建议限制在 0 到 100 |
total |
试卷题量 | 不应为负数 |
correct |
正确题数 | 应满足 0 <= correct <= total |
durationSec |
实际用时 | 不应为负数 |
records |
逐题记录 JSON | 解析后应为 AnswerRecord[] |
当前源码直接赋值,没有额外归一化。这在调用链完全受控时可以工作,但如果未来从通知、卡片、跨设备接续或外部 Want 打开结果页,就应增加输入校验。
四、页面状态如何从路由参数恢复
aboutToAppear() 是结算初始化的关键:
aboutToAppear(): void {
const params = router.getParams() as ExamResultParams | undefined
if (params) {
this.bankId = params.bankId
this.score = params.score
this.total = params.total
this.correct = params.correct
this.durationSec = params.durationSec
try {
this.records = JSON.parse(params.records) as AnswerRecord[]
} catch (_) {}
}
this.rankInfo = getRankByScore(this.score)
this.examHistory = UserDataManager.addExamHistory(
this.examHistory, this.bankId, this.score, this.total, this.correct, this.durationSec)
}
这段代码完成三件事:
- 把路由快照转为 ArkUI
@State; - 根据分数选择等级文案、颜色和印章资源;
- 把考试结果追加到
@StorageLink('examHistory')。
状态字段全部给了确定初值:
@State score: number = 0
@State total: number = 0
@State correct: number = 0
@State durationSec: number = 0
@State bankId: string = ''
@State records: AnswerRecord[] = []
因此,即使参数缺失,页面也不会因为未初始化字段直接崩溃。但“能显示”不等于“数据有效”:参数缺失时仍会写入一个空题库、零分、零题的历史记录,这是当前实现值得修正的地方。
五、JSON 解析失败时为什么不能只吞异常
源码对 records 做了 try/catch:
try {
this.records = JSON.parse(params.records) as AnswerRecord[]
} catch (_) {}
这样可以避免损坏的 JSON 让页面退出,具备基本稳定性。不过空 catch 会隐藏数据问题。用户看到的结果可能是:
- 分数卡仍显示传入分数;
- 正确率仍来自
correct / total; - 答题网格因为
records=[]而全部显示“未答”; - “错题解析”没有可供关联的错误记录。
同一页面出现互相矛盾的指标,比显式错误态更难排查。更稳妥的方案是增加解析状态:
@State recordsValid: boolean = true
private parseRecords(raw: string): AnswerRecord[] {
try {
const parsed = JSON.parse(raw) as AnswerRecord[]
return Array.isArray(parsed) ? parsed : []
} catch (_) {
this.recordsValid = false
return []
}
}
当 recordsValid 为 false 时,可以保留分数卡,同时把逐题网格替换为“逐题记录不可用”,并禁用错题解析按钮。这里的重点是保持页面事实一致。
六、正确率计算中的零题保护
页面显示正确率的表达式是:
Math.round(this.correct / Math.max(this.total, 1) * 100)
Math.max(this.total, 1) 避免了除零。若 total=0,正确率显示 0%,不会出现 NaN% 或 Infinity%。这是小代码,却是结果页稳定性的必要防线。
但它只解决算术安全,没有解决业务合法性。若收到 correct=8, total=0,页面仍会显示 800%。建议先归一化:
private normalizedTotal(): number {
return Math.max(0, this.total)
}
private normalizedCorrect(): number {
return Math.min(Math.max(0, this.correct), this.normalizedTotal())
}
private accuracy(): number {
const total = this.normalizedTotal()
if (total === 0) return 0
return Math.round(this.normalizedCorrect() / total * 100)
}
把计算移出 build() 也更符合 ArkUI 声明式页面的职责:UI 树负责表达布局,方法负责准备稳定数据。
七、分数、正确率和逐题记录要保持同源
当前调用方生成数据时,正常结束分支使用:
const correctCount = this.records.filter(r => r.correct).length
const score = Math.round(correctCount / this.questions.length * 100)
超时分支使用公共工具:
const correctCount = QuestionUtils.correctCount(this.records)
const score = QuestionUtils.calcScore(this.records, this.questions.length)
两条路径的公式一致:正确数来自 records,分母来自试卷题量。这个同源关系很重要。若以后修改评分规则,例如不同题型权重不同,不能只改一条路径,否则同一份答题记录会因“手动提交”和“超时提交”得到不同分数。
更好的做法是让两条路径都调用一个纯函数,返回统一结算对象:
interface ExamSnapshot {
total: number
correct: number
score: number
}
function settle(records: AnswerRecord[], total: number): ExamSnapshot {
const correct = QuestionUtils.correctCount(records)
return {
total,
correct,
score: QuestionUtils.calcScore(records, total)
}
}
结果页仍然只消费快照,评分规则则集中在练习会话的结算边界。

八、等级印章是可复核的纯映射
页面通过 getRankByScore() 生成 RankInfo:
export function getRankByScore(score: number): RankInfo {
if (score >= 90) return { label: '优秀', stamp: $r('app.media.img_exam_stamp_excellent'), color: Colors.ERROR }
if (score >= 75) return { label: '良好', stamp: $r('app.media.img_exam_stamp_good'), color: Colors.ACCENT }
if (score >= 60) return { label: '及格', stamp: $r('app.media.img_exam_stamp_pass'), color: Colors.SUCCESS }
return { label: '继续努力', stamp: $r('app.media.img_exam_stamp_fail'), color: Colors.TEXT_HINT }
}
边界值是 90、75、60。测试时不能只测中间值,应至少覆盖:
59 -> 继续努力
60 -> 及格
74 -> 及格
75 -> 良好
89 -> 良好
90 -> 优秀
该函数没有副作用,适合做单元测试。视觉层只消费 label、stamp 和 color,不会重复写判断分支。
九、答题网格如何表达正确、错误和未答
AnswerRecord 的真实结构很小:
export interface AnswerRecord {
questionId: string
selected: string
correct: boolean
}
结果页没有直接遍历 records,而是以 total 为上限生成网格:
private makeAnswerGrid(): GridItem[] {
const result: GridItem[] = []
for (let i = 0; i < this.total; i++) {
let state: string = 'empty'
if (i < this.records.length) {
state = this.records[i].correct ? 'correct' : 'wrong'
}
result.push({ index: i + 1, state })
}
return result
}
这个设计正确表达了超时场景:已经产生记录的题目显示正确或错误,尚未提交的题目显示未答。若直接遍历 records,后面的未答题会从页面消失,用户无法分辨“试卷只有 12 题”还是“20 题只答了 12 题”。
十、网格默认依赖答题顺序
makeAnswerGrid() 默认 records[i] 就是第 i+1 道题。这个假设来自 PracticePage 每次提交后按顺序 push:
this.records.push({
questionId: q.id,
selected: key,
correct
})
当前页面没有答题卡跳题功能,因此顺序成立。若未来允许先答第十题、再回到第一题,单纯依赖数组索引就会失真。届时记录模型应增加 questionIndex,或者结果页拿到试卷题目 ID 顺序,再按 questionId 建立映射:
const recordMap = new Map<string, AnswerRecord>()
for (const record of records) {
recordMap.set(record.questionId, record)
}
这不是当前代码已经实现的能力,而是从现有顺序约束推导出的演进边界。
十一、状态颜色集中处理,避免 UI 分支扩散
源码把颜色映射放在方法中:
private stateColor(state: string): string {
if (state === 'correct') return Colors.SUCCESS
if (state === 'wrong') return Colors.ERROR
return Colors.DIVIDER
}
build() 中只需要:
.backgroundColor(this.stateColor(item.state))
这种写法比在每个修饰器中嵌套三元表达式更容易审查。不过 state 目前是普通 string,类型过宽。ArkTS 中可以收窄为联合类型:
type AnswerState = 'correct' | 'wrong' | 'empty'
interface GridItem {
index: number
state: AnswerState
}
这样新增状态时,编译器更容易帮助发现遗漏。
十二、用时格式化复用 QuestionUtils
结果页没有自行拼接分钟和秒,而是调用:
private formatDuration(sec: number): string {
return QuestionUtils.formatTime(sec)
}
公共实现为:
static formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = sec % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
因此 65 秒显示为 01:05。练习页倒计时和结果页用时使用同一格式化函数,避免同一应用出现 1:5、01:05、1分5秒 三套不一致格式。
输入仍应在调用前保证非负整数。若 durationSec=-1,当前函数会生成异常格式;若来自可信的内部计时器问题不大,若未来允许导入历史则应校验。
十三、考试历史为何使用 @StorageLink
页面声明:
@StorageLink('examHistory') examHistory: ExamHistory[] = []
@StorageLink 让组件字段与 AppStorage 中同名数据保持双向联系。结果页调用 addExamHistory() 后,将返回的新数组重新赋给 examHistory:
this.examHistory = UserDataManager.addExamHistory(
this.examHistory,
this.bankId,
this.score,
this.total,
this.correct,
this.durationSec
)
ExamHistory 保存的是真实结算字段:
export interface ExamHistory {
bankId: string
score: number
total: number
correct: number
durationSec: number
finishedAt: string
}
它没有保存逐题 records,因此历史列表能展示分数和用时,却不能从历史项恢复逐题解析。这是明确的数据边界。
十四、本地持久化采用“新记录在前”
UserDataManager.addExamHistory() 的实现是:
static addExamHistory(
records: ExamHistory[],
bankId: string,
score: number,
total: number,
correct: number,
durationSec: number
): ExamHistory[] {
const result: ExamHistory[] = [{
bankId,
score,
total,
correct,
durationSec,
finishedAt: nowStr()
}, ...records]
UserDataManager.persist(UserDataManager.K_EXAM, result)
return result
}
新记录放在数组头部,考试列表天然按最近优先展示。persist() 使用 Preferences 同步写入并 flushSync(),适合体量较小的本地历史。随着记录持续增长,JSON 数组会不断变大;如果产品要保留数千次考试、支持组合筛选或统计趋势,应该评估关系型存储,而不是无限扩张单个 Preferences 字符串。
十五、aboutToAppear 可能导致重复写历史
当前代码每次进入 aboutToAppear() 都追加历史。首次打开结果页符合预期,但如果页面因为生命周期变化再次触发,或者某种导航回退使页面重新出现,就可能写入重复记录。
可以为本次考试引入稳定 sessionId,并在历史记录中保存:
interface ExamHistory {
sessionId: string
bankId: string
score: number
total: number
correct: number
durationSec: number
finishedAt: string
}
写入前按 sessionId 去重。另一种做法是在 PracticePage 完成结算时持久化,结果页只展示;这样更符合“结果页只消费快照”的原则。当前源码尚未实现去重,文章不能把重复保护当作现成功能。
十六、错题解析链路为什么传递 records
点击“错题解析”时,页面跳转到练习页:
router.pushUrl({
url: 'pages/PracticePage',
params: {
bankId: this.bankId,
mode: 'wrongAnalysis',
records: JSON.stringify(this.records)
}
})
PracticePage 会解析记录、筛出 correct=false 的项,再按 questionId 从当前题库查回问题:
this.records = examRecords.filter(
(record: AnswerRecord) => !record.correct
)
const all = getQuestions(params.bankId)
for (const record of this.records) {
const found = all.find(q => q.id === record.questionId)
if (found) {
wrongQs.push(found)
}
}
因此,错题解析依赖三者一致:
bankId指向原考试题库;record.questionId仍能在该题库中找到;- 题库版本没有删除或改写这些 ID。
这也是为什么题目 ID 应是稳定业务键,而不是列表下标。
十七、“再考一次”使用 replaceUrl 的导航语义
再次考试的代码是:
router.replaceUrl({
url: 'pages/PracticePage',
params: {
bankId: this.bankId,
mode: 'exam'
}
})
使用 replaceUrl 而不是 pushUrl,意味着当前结果页被新考试页替换。用户完成第二次考试后,不会通过系统返回键回到上一张旧结果页。这符合重复考试的任务流:结果是阶段终点,再考一次开始新会话。
错题解析则用 pushUrl,因为用户看完解析后仍可能返回当前结果。这两个路由选择体现的是不同返回栈语义,不是随意替换。
十八、底部安全区适配真实系统导航区域
页面通过:
@StorageLink('navigationIndicatorHeightPx')
navigationIndicatorHeightPx: number = 0
计算底部间距:
private bottomSafePadding(): number {
return Math.max(
Sizes.BOTTOM_NAV_MIN_PADDING,
this.getUIContext().px2vp(this.navigationIndicatorHeightPx)
)
}
按钮区最终使用:
.padding({
left: Sizes.PADDING_LARGE,
right: Sizes.PADDING_LARGE,
top: 10,
bottom: 16 + this.bottomSafePadding()
})
这让底部按钮在不同导航模式下保持可点击,不会压进手势指示区。px2vp() 也避免把系统返回的像素直接当 vp 使用。对 HarmonyOS 多设备和小窗适配来说,这类边界比单纯加固定 16vp 更可靠。
十九、页面滚动与底部操作区分离
结果主体放在 Scroll 中,并使用 .layoutWeight(1);底部按钮则位于滚动区域之外。这样在小屏、横屏或字体变大时:
- 分数卡、指标和答题网格仍可滚动访问;
- “错题解析”和“再考一次”始终保持在底部操作区;
- 系统避让区只影响操作区,不会重复叠加到全部内容。
答题网格使用 Flex({ wrap: FlexWrap.Wrap }),每个题号格固定 36 x 36,通过换行适应宽度。这种结构比固定列数的手写网格更适合不同窗口宽度。

二十、当前“排名”是静态文本,不能当真实平台数据
指标区包含:
this.MetricItem(`${236}/${1258}`, '排名')
源码没有网络请求、排行榜服务、用户账号或本地排名算法。因此 236/1258 是固定展示值,不是实时排名,也不能在技术文章中描述成“超过多少用户”。
如果应用定位为纯本地题库,建议把该指标改成可由本地数据复核的内容,例如:
- 本次正确题数;
- 本次未答题数;
- 历史最高分;
- 相比上次提升分值。
这比伪造在线排名更符合隐私声明和离线产品边界。
二十一、如何基于真实记录增加地区薄弱项
当前 AnswerRecord 只有 questionId、selected、correct。要统计地区薄弱项,不能直接按结果数组分组,因为记录中没有 regionId。但题目本身含有 bankId,题库模型又能关联地区,因此可以在结算时执行一条可复核链路:
AnswerRecord.questionId
-> 试卷题目 Question
-> Question.bankId
-> DialectBank.regionId
-> Region
若一场考试只属于一个 bankId,地区维度只有一个,所谓“地区薄弱项”没有比较意义。更合理的场景是综合卷:题目来自四川话、粤语、东北话等多个题库,再按真实 Question.bankId 聚合。
因此扩展前要先回答:产品是否真的有跨地区综合卷?当前 PracticePage 的考试模式从单个 bankId 取题,答案是否定的。此时结果页更适合展示“题型薄弱项”或“章节薄弱项”,而不是伪造多个地区。
二十二、一个可审计的薄弱项聚合模型
如果未来增加综合卷,可以扩展结算记录,而不是在结果页反复扫描全量题库:
interface AnswerSnapshot {
questionId: string
bankId: string
regionId: string
type: string
selected: string
correct: boolean
}
interface WeakGroup {
key: string
total: number
wrong: number
accuracy: number
}
聚合函数保持纯粹:
function aggregateByRegion(records: AnswerSnapshot[]): WeakGroup[] {
const groups = new Map<string, WeakGroup>()
for (const item of records) {
const group = groups.get(item.regionId) ?? {
key: item.regionId,
total: 0,
wrong: 0,
accuracy: 0
}
group.total += 1
if (!item.correct) group.wrong += 1
group.accuracy = Math.round(
(group.total - group.wrong) / group.total * 100
)
groups.set(item.regionId, group)
}
return Array.from(groups.values())
.sort((a, b) => a.accuracy - b.accuracy)
}
这段是基于现有模型的增强示例,不是当前项目源码。它之所以可审计,是因为每个分组字段都来自答题当时的快照,不依赖后来可能变化的题库元数据。
二十三、薄弱项不能只看最低正确率
假设两个地区结果如下:
四川:1 题,0 正确,正确率 0%
粤语:20 题,12 正确,正确率 60%
只按正确率排序会把四川列为最薄弱,但样本只有一题,置信度很低。工程上至少要同时展示:
- 答题总数;
- 错题数;
- 正确率;
- 最小样本阈值。
例如只有 total >= 5 才进入“薄弱项”排序,其余显示为“样本不足”。这不是复杂统计模型,却能避免 UI 给用户过度确定的结论。
二十四、结果快照要考虑题库版本
目前历史记录只保存 bankId,逐题记录只保存 questionId。如果题库更新后删除问题或复用 ID,旧结果的错题解析会失去依据。面向长期维护,可增加:
interface ExamSnapshotMeta {
sessionId: string
bankId: string
bankVersion: number
startedAt: string
finishedAt: string
}
对离线应用而言,不一定需要保存完整题干,但至少应保证题目 ID 稳定,并记录题库版本。若版本不兼容,历史页可以继续展示分数,却不再提供逐题解析入口。
二十五、测试清单:先测数据一致性,再测漂亮卡片
结果页的最小测试矩阵应包括:
| 场景 | 期望 |
|---|---|
| 20 题答对 15 题 | 正确率 75%,等级“良好” |
| 0 题 | 正确率 0%,不出现 NaN |
| 超时只答 8/20 | 后 12 格显示未答 |
records 非法 JSON |
页面不崩溃,并显示数据异常状态 |
| 59/60/74/75/89/90 分 | 等级边界正确 |
| 点击错题解析 | 只加载本场错误题 |
| 点击再考一次 | 替换结果页并进入同一题库考试 |
| 手势导航模式 | 底部按钮不被遮挡 |
| 小窗与横屏 | 网格换行,内容可滚动 |
| 页面重复出现 | 不重复写入同一场历史 |
当前源码已经覆盖其中部分表现,但非法 JSON 提示、历史幂等和参数归一化仍需增强。测试报告必须区分“已有通过项”和“建议项”。
二十六、性能上避免在 build 中做重聚合
当前 makeAnswerGrid() 每次调用都会创建一个新数组,题量最多约 20 时开销很小。若未来综合卷达到数百题,并加入地区、题型、章节多维聚合,就不应在 ArkUI build() 重建全部统计。
可以在参数解析完成后生成一次 ResultViewModel:
interface ResultViewModel {
accuracy: number
durationText: string
rank: RankInfo
grid: GridItem[]
weakGroups: WeakGroup[]
}
状态只在输入快照变化时更新,UI 层负责渲染。这样既降低重复计算,也让统计逻辑更容易独立测试。
二十七、面向多设备的结果信息层级
在手机上,当前布局按“分数卡 -> 指标 -> 答题网格 -> 操作按钮”纵向展开,符合单列阅读。到了平板或 2in1,可以把分数卡和答题网格放入左右分栏,但不应改变数据语义:
紧凑宽度:单列,底部固定操作区
中等宽度:分数与指标一列,答题网格一列
展开宽度:结果概览、逐题状态、薄弱项三段并列或主从布局
不论使用何种布局,都要保留:
- 分数和正确率的文本标签,不能只靠颜色;
- 错误、正确、未答的图例;
- 键盘与鼠标可达的操作按钮;
- 系统返回能力;
- 深浅色下足够的文字与背景对比度。
二十八、上线前的真实性检查
结果页很容易出现“视觉像真的,数据却不可复核”的问题。发布前应逐项检查:
- 分数、正确数、题量是否来自同一会话;
- 排名是否有真实数据源,没有就删除或改为本地指标;
- 地区薄弱项是否真的存在跨地区样本;
- 正确率是否处理零题和非法范围;
- 历史记录是否防重复;
- 逐题解析是否能关联原题;
- Preferences 中是否只保存必要的本地数据;
- 应用说明和隐私政策是否与“离线、本地统计”一致;
- 状态栏、底部导航区、横屏和小窗是否可用;
- 安装、启动、考试、结算、再考、返回和卸载流程是否稳定。
尤其不要把静态排名、模拟数字或设计占位内容写进平台宣传文案。
二十九、结语
中国方言题库的 ExamResultPage 代码并不长,但它连接了答题会话、等级映射、逐题状态、错题解析、考试历史和再次考试,是一个典型的结算边界。真实实现最值得复用的设计有三点:
第一,调用方传递完整快照,结果页不重新猜测考试过程;第二,以 total 生成网格,使超时未答也有明确位置;第三,通过 @StorageLink 与 UserDataManager 把本次考试写入本地历史。
同样重要的是承认当前边界:页面没有地区薄弱项计算,排名也是固定文本。若产品需要薄弱项,应先建立可追溯的题目、题库、地区映射,再设计聚合与样本阈值;若没有真实排名源,就使用本地可复核指标。技术文章的可信度,来自把已经存在、可以改进和尚未实现三者讲清楚。
---
AI 辅助声明:本文部分内容由 AI 辅助整理,所有功能描述、代码片段和工程结论均依据项目真实源码人工复核;未将建议方案表述为已实现能力。
更多推荐



所有评论(0)