学习统计页最重要的不是卡片数量,而是每个数字的口径。累计答了 500 次,究竟代表完成 500 道不同题目,还是同一套题重复练了几遍?“学习 7 天”是连续 7 天,还是历史上出现过 7 个日期?某个分类正确率低,是因为做了 50 题只对 20 题,还是只做 1 题恰好答错?如果模型没有回答这些问题,统计越丰富,误导性反而越强。

本文基于知律项目 D:\huawei\one19-11、包名 com.jiaweikang.one19 的真实源码,复核 LearningStatsPage.etsStatService.etsUserDataManager.etsPracticePage.ets 与通用进度条。当前版本真实展示累计答题、累计正确率、考试记录条数、学习题库数、收藏数、错题数、近 5 次均分和各题库进度;但没有学习天数模型,也没有薄弱分类计算。本文先还原现有口径,再给出事件记录、按日聚合、加权正确率和薄弱项判定方案。

学习统计可信口径封面

一、先列出页面当前真正展示的指标

LearningStatsPage 的概览区显示:

  1. totalAnswered:累计答题次数;
  2. accuracyPercent:累计正确率;
  3. examCount:考试历史数组长度;
  4. 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
  )
}

这比在多个卡片中各写一套计算更可靠。HomePageMinePage 也复用 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 表示练习题次,允许超过题库总量;uniqueAnsweredCountquestionId 去重,用于进度条。这样“练习 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-11com.jiaweikang.one19 的本地源码复核;示例改造代码用于说明工程方案,不代表当前版本已经实现学习天数、唯一题目覆盖或薄弱分类分析。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐