拓冰建站拓冰建站
首页 / 资讯中心 / 正文

【中国方言题库|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 到 100total试卷题量不应为负数correct正确题数应满足0 correct totaldurationSec实际用时不应为负数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) }这段代码完成三件事把路由快照转为 ArkUIState根据分数选择等级文案、颜色和印章资源把考试结果追加到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/catchtry { 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)避免了除零。若total0正确率显示0%不会出现NaN%或Infinity%。这是小代码却是结果页稳定性的必要防线。但它只解决算术安全没有解决业务合法性。若收到correct8, total0页面仍会显示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()生成RankInfoexport 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]就是第i1道题。这个假设来自PracticePage每次提交后按顺序pushthis.records.push({ questionId: q.id, selected: key, correct })当前页面没有答题卡跳题功能因此顺序成立。若未来允许先答第十题、再回到第一题单纯依赖数组索引就会失真。届时记录模型应增加questionIndex或者结果页拿到试卷题目 ID 顺序再按questionId建立映射const recordMap new Mapstring, 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()后将返回的新数组重新赋给examHistorythis.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会解析记录、筛出correctfalse的项再按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 Mapstring, 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 时开销很小。若未来综合卷达到数百题并加入地区、题型、章节多维聚合就不应在 ArkUIbuild()重建全部统计。可以在参数解析完成后生成一次ResultViewModelinterface ResultViewModel { accuracy: number durationText: string rank: RankInfo grid: GridItem[] weakGroups: WeakGroup[] }状态只在输入快照变化时更新UI 层负责渲染。这样既降低重复计算也让统计逻辑更容易独立测试。二十七、面向多设备的结果信息层级在手机上当前布局按“分数卡 - 指标 - 答题网格 - 操作按钮”纵向展开符合单列阅读。到了平板或 2in1可以把分数卡和答题网格放入左右分栏但不应改变数据语义紧凑宽度单列底部固定操作区 中等宽度分数与指标一列答题网格一列 展开宽度结果概览、逐题状态、薄弱项三段并列或主从布局不论使用何种布局都要保留分数和正确率的文本标签不能只靠颜色错误、正确、未答的图例键盘与鼠标可达的操作按钮系统返回能力深浅色下足够的文字与背景对比度。二十八、上线前的真实性检查结果页很容易出现“视觉像真的数据却不可复核”的问题。发布前应逐项检查分数、正确数、题量是否来自同一会话排名是否有真实数据源没有就删除或改为本地指标地区薄弱项是否真的存在跨地区样本正确率是否处理零题和非法范围历史记录是否防重复逐题解析是否能关联原题Preferences 中是否只保存必要的本地数据应用说明和隐私政策是否与“离线、本地统计”一致状态栏、底部导航区、横屏和小窗是否可用安装、启动、考试、结算、再考、返回和卸载流程是否稳定。尤其不要把静态排名、模拟数字或设计占位内容写进平台宣传文案。二十九、结语中国方言题库的ExamResultPage代码并不长但它连接了答题会话、等级映射、逐题状态、错题解析、考试历史和再次考试是一个典型的结算边界。真实实现最值得复用的设计有三点第一调用方传递完整快照结果页不重新猜测考试过程第二以total生成网格使超时未答也有明确位置第三通过StorageLink与UserDataManager把本次考试写入本地历史。同样重要的是承认当前边界页面没有地区薄弱项计算排名也是固定文本。若产品需要薄弱项应先建立可追溯的题目、题库、地区映射再设计聚合与样本阈值若没有真实排名源就使用本地可复核指标。技术文章的可信度来自把已经存在、可以改进和尚未实现三者讲清楚。---AI 辅助声明本文部分内容由 AI 辅助整理所有功能描述、代码片段和工程结论均依据项目真实源码人工复核未将建议方案表述为已实现能力。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门