【知律|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的本地源码复核;示例改造代码用于说明工程方案,不代表当前版本已经实现学习天数、唯一题目覆盖或薄弱分类分析。
更多推荐




所有评论(0)