【知律|07】HarmonyOS ArkTS 学习统计实战:汇总学习天数、正确率和薄弱分类
学习统计页最重要的不是卡片数量,而是每个数字的口径。累计答了 500 次,究竟代表完成 500 道不同题目,还是同一套题重复练了几遍?“学习 7 天”是连续 7 天,还是历史上出现过 7 个日期?某个分类正确率低,是因为做了 50 题只对 20 题,还是只做 1 题恰好答错?如果模型没有回答这些问题,统计越丰富,误导性反而越强。
本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码,复核LearningStatsPage.ets、StatService.ets、UserDataManager.ets、PracticePage.ets与通用进度条。当前版本真实展示累计答题、累计正确率、考试记录条数、学习题库数、收藏数、错题数、近 5 次均分和各题库进度;但没有学习天数模型,也没有薄弱分类计算。本文先还原现有口径,再给出事件记录、按日聚合、加权正确率和薄弱项判定方案。
一、先列出页面当前真正展示的指标
LearningStatsPage的概览区显示:
totalAnswered:累计答题次数;accuracyPercent:累计正确率;examCount:考试历史数组长度;studiedBankCount:进度数组长度。
数据摘要区显示收藏题目数、错题数和近 5 次考试均分。下方按六个题库展示累计答题数、题库总量、题库正确率与“未开始 / 学习中 / 已完成”标签。
源码没有学习天数、连续学习天数、最近 7 日趋势,也没有“薄弱分类”卡片。因此本文标题中的这两项属于基于现有数据层的改造目标,不是对当前版本的虚构描述。
二、所有页面数据都来自本地 AppStorage
页面通过四个@StorageLink读取:
@StorageLink('bankProgress') progressList: BankProgress[] = [] @StorageLink('examHistory') examHistory: ExamHistory[] = [] @StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = [] @StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []这些数组由UserDataManager.init()从 Preferences 读取并写入AppStorage。没有服务器、账号同步或平台分析接口,因此页面展示的是当前设备上的本地学习记录,不是全网数据,也不是账号跨设备统计。
三、StatService 是当前统计口径的唯一入口
页面没有自己累加数据,而是调用:
private stats() { return StatService.summarize( this.progressList, this.examHistory, this.favRecords, this.wrongRecords ) }这比在多个卡片中各写一套计算更可靠。HomePage和MinePage也复用StatService.summarize(),说明项目已经具备“统计逻辑下沉到服务层”的基础。
四、累计答题数不是唯一题目完成数
StatService.summarize()对每个BankProgress.finished求和:
let totalAnswered = 0 for (const progress of progressList) { totalAnswered += progress.finished }PracticePage每完成一轮练习,就把本轮records.length加到旧值:
finished: old.finished + addFinished所以finished的准确含义是累计提交的答案记录数。重复做同一道题会再次累加。它适合回答“累计练习了多少题次”,不适合回答“覆盖了多少道不同题目”。
五、正确率是按题次加权的累计正确率
totalCorrect同样来自各题库correct累加,正确率为:
accuracyPercent: totalAnswered > 0 ? Math.round(totalCorrect / totalAnswered * 100) : 0这是按所有答题记录加权的总正确率。题量大的题库对总值影响更大,口径合理且可解释。它不是各题库正确率的简单平均。
六、为什么不能平均各题库百分比
假设民法做 100 题、正确 80 题,劳动法做 2 题、正确 0 题。直接平均两个正确率得到 40%,但真实累计正确率是80 / 102,约 78%。
正确实现必须先累加分子和分母:
function weightedAccuracy(items: BankProgress[]): number { let answered = 0 let correct = 0 for (const item of items) { answered += item.finished correct += item.correct } return answered > 0 ? Math.round(correct / answered * 100) : 0 }当前StatService正是这一口径。
七、题库进度文本可能超过题库总量
页面直接显示:
Text(`${StatService.bankFinished(progressList, bank.id)}/${bank.totalCount}`)由于finished是累计题次,用户重复练习后可能出现320/250。通用ProgressBar会把比例夹紧到 0 到 1:
Math.max(0, Math.min(1, this.ratio))所以视觉条不会超过 100%,但文本与“完成度”语义仍会冲突。这是当前模型中最需要澄清的边界。
八、把练习量与覆盖进度拆开
建议同时保留两个指标:
interface BankLearningSummary { bankId: string answerAttempts: number uniqueAnsweredCount: number totalQuestionCount: number correctAttempts: number }answerAttempts表示练习题次,允许超过题库总量;uniqueAnsweredCount按questionId去重,用于进度条。这样“练习 320 题次”和“覆盖 210/250 道题”可以同时成立。
九、现有 BankProgress 无法计算唯一覆盖
当前模型只有:
interface BankProgress { bankId: string finished: number correct: number lastChapterId: string updatedAt: string }它没有保存题目 ID 集合,也没有逐题事件。历史数据只能继续按累计题次解释,无法事后还原唯一题目数。升级时需要新增事件或题目状态表,不能凭空从finished推算覆盖率。
十、学习天数需要时间维度
BankProgress.updatedAt只保留每个题库最后一次更新时间,最多得到若干“最近日期”,无法恢复所有学习日期。WrongRecord.wrongAt只记录错题最后一次出错时间,收藏与考试历史也只覆盖部分行为。
因此当前数据不能准确计算总学习天数。真正的学习天数需要保存每次有效学习事件的日期。
十一、最小学习事件模型
type LearningAction = 'answer' | 'exam' | 'review' interface LearningEvent { id: string action: LearningAction bankId: string questionId?: string categoryType?: string correct?: boolean occurredAt: string }每个事件有稳定 ID、动作类型与时间。答题事件包含题目和正确性;考试事件可以关联会话 ID;复习事件只在产品明确需要时记录,不能因为打开页面就伪造学习行为。
十二、事件 ID 用于防止重复统计
页面生命周期、自动交卷与手动交卷可能让同一场行为重复触发。事件写入应按稳定 ID 去重:
function appendEvent( events: LearningEvent[], event: LearningEvent ): LearningEvent[] { if (events.some((item: LearningEvent) => item.id === event.id)) { return events } return [event, ...events] }事件 ID 由练习会话和题目提交序号生成,而不是单纯用当前时间。时间相同不代表重复,时间不同也不代表一定是两次行为。
十三、按本地日期计算活跃天数
function localDateKey(iso: string): string { const date = new Date(iso) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } function activeDayCount(events: LearningEvent[]): number { const dates = new Set<string>() for (const event of events) { dates.add(localDateKey(event.occurredAt)) } return dates.size }如果产品把“学习天数”定义为有至少一次有效答题的自然日,就只过滤action === 'answer'或已完成考试。定义必须写入测试与文案。
十四、总学习天数与连续天数不是同一指标
总学习天数是去重日期数量;连续学习天数要从今天或最近活跃日向前逐日判断。用户三个月内零散学习 20 天,总学习天数是 20,但最长连续可能只有 3。
页面要明确写“累计学习天数”“当前连续天数”或“最长连续天数”,不能只写一个模糊的“学习 20 天”。
十五、时区变化需要产品规则
设备跨时区时,同一 UTC 时间可能落到不同本地日期。对于完全本地应用,可以按事件发生时保存的本地日期键统计;如果未来跨设备同步,则应同时保存 UTC 时间、时区偏移和本地日期。
当前项目没有云同步,不需要引入服务器时间,但仍应避免把无时区的YYYY-MM-DD HH:mm当成跨设备可靠时间。
十六、考试次数等于历史数组长度
examCount当前直接取:
examCount: examHistory.length而ExamResultPage会在aboutToAppear()保存历史,历史模型没有sessionId。若结果页重复出现或异常直达产生重复记录,考试次数也会被放大。
统计服务只能汇总它收到的数据,无法替上游识别重复。应先给考试历史增加会话 ID,再谈可信考试次数。
十七、近 5 次均分依赖历史顺序
recentAvgScore()使用:
const recent = examHistory.slice(0, Math.min(count, examHistory.length))addExamHistory()会把新记录放在数组头部,所以当前顺序契约成立。若未来从 RDB 查询或迁移旧数据,应按完成时间显式倒序,不能依赖输入数组恰好有序。
十八、异常分数要先校验
历史模型允许任意 number。统计前应限制:
function validScore(value: number): boolean { return Number.isFinite(value) && value >= 0 && value <= 100 }更可靠的做法是保存正确数和总题数,由服务重新计算分数。无效记录进入诊断或迁移流程,不应静默参与均值。
十九、薄弱分类需要最小样本
做 1 题答错得到 0%,不能立即断言该分类是长期薄弱项。可以定义:
interface WeakAreaPolicy { minAnswered: number maxAccuracyPercent: number }例如至少完成 10 题且正确率低于 60% 才标记为“需要加强”。具体阈值是产品策略,不能冒充教育学标准。
二十、按题型识别薄弱项
要识别“案例分析”“法律纠错”等薄弱题型,事件必须保存categoryType:
interface CategoryStat { categoryType: string answered: number correct: number accuracyPercent: number }当前BankProgress只有bankId,只能计算题库维度,无法直接计算题型维度。不能把题库正确率重新命名为薄弱题型。
二十一、按法律领域识别薄弱项
领域薄弱项可以从bankId映射到Bank.regionId后汇总。若一个领域未来有多个题库,先聚合正确数与答题数,再计算加权正确率。
领域和题型是两个正交维度,页面可以分别展示“薄弱法律领域”和“薄弱学习题型”,不要把二者混在一个排序列表中。
二十二、薄弱项排序要稳定
function findWeakAreas( stats: CategoryStat[], policy: WeakAreaPolicy ): CategoryStat[] { return stats .filter((item: CategoryStat) => item.answered >= policy.minAnswered && item.accuracyPercent <= policy.maxAccuracyPercent ) .sort((a, b) => a.accuracyPercent - b.accuracyPercent || b.answered - a.answered || a.categoryType.localeCompare(b.categoryType) ) }先按正确率升序,再按样本量降序,最后按稳定 ID,保证结果可复现。UI 不展示内部评分。
二十三、收藏数与错题数的现有语义
收藏使用toggleFavorite()按questionId去重;错题使用addWrong()先过滤同题旧记录再插入,因此两个数组长度表示当前不同题目的收藏数与错题数,不是累计操作次数。
这与finished的累计题次口径不同。统计页应在文案中明确“当前收藏”“当前错题”,避免用户把它们理解成历史累计。
二十四、错题被答对移除后统计会下降
removeWrong()会从数组删除题目。因此“错题数”是当前待复习集合大小,可以下降。这是合理的学习状态指标。
如果产品还想展示“历史答错题次”,需要从答题事件另外汇总,不能用当前错题数组代替。
二十五、studiedBankCount 的当前口径
studiedBankCount等于progressList.length。只要某题库有进度记录,就算已学习。通常updateProgress()在完成一轮后创建记录,因此这个口径可用。
但导入旧数据或异常记录时,可能存在finished === 0的条目。更严格的实现是:
const studiedBankCount = progressList.filter( (item: BankProgress) => item.finished > 0 ).length还应排除找不到对应题库的孤儿记录。
二十六、页面反复调用 stats 会重复计算
一个构建周期中,模板多次调用this.stats()。当前数组很小,成本不高,但逻辑上会反复遍历。
可以在数据变化时生成一次视图状态:
@State viewState: LearningStatsViewState = emptyStats() private refreshStats(): void { this.viewState = this.statService.buildViewState( this.progressList, this.examHistory, this.favRecords, this.wrongRecords ) }如果使用响应式计算,要确保依赖数组替换后能触发刷新,不直接原地修改对象。
二十七、持久化失败当前被静默吞掉
UserDataManager.persist()捕获异常后没有反馈:
try { prefs.putSync(key, JSON.stringify(value)) prefs.flushSync() } catch (_) {}内存中的AppStorage可能已经更新,但磁盘写入失败,重启后数据回退。统计页会在当前会话看起来正常。
更可靠的服务应返回保存结果或记录可诊断错误,在设置页提供明确提示。不能把保存失败伪装成成功。
二十八、历史数据需要版本与迁移
从累计BankProgress升级到事件模型后,旧数据无法还原每个题目和日期。迁移时应保留旧累计值:
interface LearningDataEnvelope { schemaVersion: number legacyProgress: BankProgress[] events: LearningEvent[] }UI 可以把旧值标注为“历史累计”,新事件用于趋势、活跃天数与薄弱项。不要把迁移日当成所有旧学习行为的发生日。
二十九、多设备布局已有真实分支
页面在currentBp === 'lg'且宽度至少 700 时使用网格:
private useGridLayout(): boolean { return this.currentBp === 'lg' && this.pageWidth >= 700 }大屏概览指标排成一行,题库进度使用三列;其他场景使用两行概览和单列题库列表。它同时依赖断点和实际宽度,避免只看设备类型。
三十、三列卡片要验证文字和进度
每列宽 32%,六个题库会换成两行。需要验证长题库名、320/250这类超额文本、正确率标签与“学习中”不会互相挤压。
在 700 到 800 宽度附近,三列可能偏紧。可以根据最小卡片宽度选择两列或三列,而不是只用固定 32%。
三十一、空数据不能只显示一排 0
当前没有学习记录时,所有卡片会显示 0,每个题库标为“未开始”。功能上正确,但缺少引导。
可以在概览下增加空状态:“完成第一道题后,这里会生成本地学习统计”,并提供进入题库的操作。空状态不要虚构示例趋势或预填正确率。
三十二、统计页面不应该直接修改数据
统计页只读@StorageLink,没有清空或修正逻辑,这个职责边界正确。数据清理应放在设置页或专用服务中,并经过确认。
统计服务保持纯计算后,更容易验证“页面只展示,不改变学习记录”。
三十三、最小可交付的统计视图模型
interface LearningStatsViewState { totalAttempts: number uniqueAnswered: number activeDays: number accuracyPercent: number examCount: number recentAvgScore: number favoriteCount: number currentWrongCount: number weakDomains: WeakAreaItem[] weakQuestionTypes: WeakAreaItem[] banks: BankLearningSummary[] }每个字段都应有注释说明来源、去重规则和时间范围。视图层只使用这一个对象,不再到处调用静态函数。
三十四、测试要覆盖统计不变量
服务层至少验证:
- 无答题时正确率为 0,不出现除零;
- 总正确数不大于总答题数;
- 重复题目增加题次但不增加唯一覆盖;
- 同一天多次学习只增加 1 个活跃日;
- 跨日期事件增加活跃天数;
- 重复事件 ID 不重复计数;
- 近 5 次均分按完成时间排序;
- 无效分数不参与均值;
- 薄弱项满足最小样本后才出现;
- 领域和题型分别聚合;
- 当前错题移除后数量下降;
- 历史迁移不会伪造日期。
三十五、发布前口径检查
逐项确认:
- “累计答题”明确是题次还是不同题目;
- 进度条使用唯一覆盖,不使用可无限增长的累计题次;
- 学习天数来自有效事件的去重日期;
- 连续天数与累计天数分开;
- 正确率先汇总分子分母再计算;
- 考试历史按会话去重;
- 近 N 次按明确时间排序;
- 薄弱项有最小样本和阈值;
- 收藏数、当前错题数与历史操作次数不混用;
- Preferences 保存失败有可诊断路径;
- 多设备、长文本、空状态和深浅色完成检查;
- 页面数字只代表本机本地数据,不冒充平台数据。
三十六、结语
知律当前统计链路已经有清晰基础:答题页更新本地进度,UserDataManager持久化到 Preferences,StatService统一汇总,统计页通过响应式存储展示,并在大屏切换概览和题库网格。现有累计正确率、收藏数、错题数和近 5 次均分都能从源码找到明确来源。
需要补上的不是更多彩色卡片,而是时间与唯一性。只有记录稳定的学习事件,才能准确区分累计题次、唯一覆盖、活跃天数和连续天数;只有按最小样本与稳定维度聚合,才能把“薄弱分类”变成可解释建议。每个数字都能追溯到原始记录,学习统计才真正值得用户相信。
---
本文部分内容由 AI 辅助整理。所有现状判断均基于
D:\huawei\one19-11中com.jiaweikang.one19的本地源码复核;示例改造代码用于说明工程方案,不代表当前版本已经实现学习天数、唯一题目覆盖或薄弱分类分析。
