【口算王|09】HarmonyOS ArkTS 学习统计实战:计算连续训练和正确率趋势

证据边界:本文的“当前实现”来自本轮对本地口算王工程中 LearningStatsPage.ets、StatService.ets、UserDataManager.ets、PracticePage.ets、ExamResultPage.ets 与 EntryAbility.ets 的静态复核。能够确认的是四组 AppStorage 状态、Preferences 恢复、累计答题与正确率聚合、题库进度和近期挑战均分;连续训练、按日正确率历史与趋势图仍是建议扩展。本轮未执行构建、安装、真机运行、数据迁移或性能测量。

学习统计页面最容易出现一种“数字都对,但结论不成立”的问题:累计答题数确实来自真实记录,正确率公式也没有写错,页面却把这些累计值包装成“连续学习”“近期趋势”或“能力提升”。累计值只能说明到目前为止发生了多少次答题,无法回答用户连续训练了几天,也无法告诉用户正确率是在上升还是下降。

连续训练需要按自然日保存活动证据;正确率趋势需要多个时间窗口的答题分子和分母。若数据模型只保留每个题库的累计 finished、累计 correct 和最后更新时间,即使界面画出一条折线,也无法从累计终值还原每天的变化。

本文基于口算王项目 本地口算王工程(脱敏路径) 的真实源码,复核 LearningStatsPage.ets、StatService.ets、UserDataManager.ets、PracticePage.ets、ExamResultPage.ets 与 EntryAbility.ets。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前实现真实支持累计答题、累计正确率、挑战次数、训练题库数、收藏数、错题数、近 5 次挑战均分和各题库进度;连续训练天数与正确率趋势尚未实现,文中将其作为基于现有数据链路的增强方案,不把设计代码描述成现成功能。

学习统计当前能力与建议扩展封面

本文会完成四个工程判断:

  • 复核当前每个统计数字的真实来源和计算公式;
  • 说明累计正确率、近 5 次均分与正确率趋势的差异;
  • 设计能够计算连续训练天数和日正确率趋势的数据模型;
  • 给出 ArkTS 聚合算法、持久化边界、多设备布局与测试矩阵。

一、统计页读取的是四组 AppStorage 数据

LearningStatsPage.ets 没有自己读取文件,也没有制造演示数字。页面通过 @StorageLink 订阅四组应用级状态:

@StorageLink('bankProgress')
progressList: BankProgress[] = []

@StorageLink('examHistory')
examHistory: ExamHistory[] = []

@StorageLink('favoriteRecords')
favRecords: FavoriteRecord[] = []

@StorageLink('wrongRecords')
wrongRecords: WrongRecord[] = []

这四组状态分别承担不同统计口径:

状态页面用途是否包含时间序列
bankProgress累计答题、累计答对、题库进度只保留每个题库最后更新时间
examHistory挑战次数、近 5 次均分每次挑战都有完成时间
favoriteRecords当前收藏数量有收藏时间,但不是训练证据
wrongRecords当前错题数量有最近错误时间,但会去重

页面把计算交给 StatService:

private stats() {
  return StatService.summarize(
```arkts
this.progressList,
this.examHistory,
this.favRecords,
this.wrongRecords
```
  )
}

这是一个合理的职责边界。ArkUI 页面负责展示和响应式排列,统计公式集中在基础特性层。首页和“我的”页面也调用同一个 summarize(),从而避免同一个“正确率”在三个页面出现三种算法。

二、数据在应用启动时从 Preferences 恢复

EntryAbility 启动后调用 UserDataManager.init(this.context)。管理器使用 ArkData Preferences 打开名为 daily_math_drill 的本地存储,并把 JSON 字符串恢复到 AppStorage。

static init(
  context: common.UIAbilityContext | common.Context
): void {
  UserDataManager.prefs =
```arkts
preferences.getPreferencesSync(context, {
  name: UserDataManager.STORE_NAME
})
```

  const progStr = UserDataManager.prefs.getSync(
```arkts
UserDataManager.K_PROGRESS,
'[]'
```
  ) as string

  AppStorage.setOrCreate<BankProgress[]>(
```arkts
'bankProgress',
JSON.parse(progStr) as BankProgress[]
```
  )
}

因此统计页展示的是本机持久化数据,不是网络账户数据,也不是云端跨设备同步结果。用户卸载应用、清除应用数据或在另一台设备安装时,这些统计不会自动迁移。

当前初始化用一个大 try/catch 处理解析失败。任何一项 JSON 异常都可能进入兜底分支,把多组 AppStorage 设为空数组。更细致的做法是逐项解析并校验结构,使某个设置损坏时不影响全部学习记录:

function parseArray<T>(
  raw: string,
  fallback: T[]
): T[] {
  try {
```arkts
const value = JSON.parse(raw) as Object
return Array.isArray(value)
  ? value as T[]
  : fallback
```
  } catch (_) {
```arkts
return fallback
```
  }
}

统计可信度不仅取决于公式,也取决于数据恢复是否完整。页面显示 0 时,应能区分“用户没有训练”和“持久化记录解析失败”。

三、累计答题和累计正确率如何计算

StatService.summarize() 遍历每个 BankProgress,把 finished 与 correct 相加:

let totalAnswered = 0
let totalCorrect = 0

for (const p of progressList) {
  totalAnswered += p.finished
  totalCorrect += p.correct
}

const accuracyPercent = totalAnswered > 0
  ? Math.round(
```arkts
  totalCorrect / totalAnswered * 100
)
```
  : 0

这里使用的是加权总体正确率,不是“各题库正确率的平均值”。假设一年级答了 100 题、正确 90 题,二年级答了 10 题、正确 5 题:

总体正确率 = (90 + 5) / (100 + 10)
```arkts
       = 86.36%
       ≈ 86%
```

如果先计算 90% 和 50% 再取平均,会得到 70%,小样本题库被赋予了与大样本题库相同权重。当前实现按总答对数除以总答题数,口径更适合“全部训练正确率”。

零答题保护也很重要。没有答题时返回 0,避免 NaN 或除零结果进入 UI:

accuracyPercent:
  totalAnswered > 0
```arkts
? Math.round(
    totalCorrect / totalAnswered * 100
  )
: 0
```

但 0% 同时可能代表“尚未答题”和“答过但全部错误”。如果产品需要区分这两个状态,统计结果应增加 hasTrainingData,页面在无数据时显示“暂无记录”,不要让新用户误以为自己正确率为零。

学习统计当前实现链路

四、BankProgress 是累计快照,不是答题流水

当前进度模型如下:

export interface BankProgress {
  bankId: string
  finished: number
  correct: number
  lastChapterId: string
  updatedAt: string
}

一次练习结束后,UserDataManager.updateProgress() 会找到同题库记录,把本次答题数和答对数累加:

const updated: BankProgress = {
  bankId,
  finished: old.finished + addFinished,
  correct: old.correct + addCorrect,
  lastChapterId: chapterId,
  updatedAt: nowStr()
}

这种结构非常适合快速展示累计值。页面不需要扫描几千条答题流水,就能立即获得题库已答题数和正确率。

它的限制同样明确:旧的 updatedAt 会被覆盖。假设用户周一答 20 题、周二答 30 题,最终只留下累计 50 题和周二的更新时间。仅凭这条记录,无法还原周一、周二分别答了多少题,也无法证明训练连续发生在两个自然日。

因此当前 BankProgress 不能直接计算连续训练天数,也不能生成日正确率趋势。要保留历史,必须新增会追加的会话记录或按日汇总记录。

五、挑战次数和近 5 次均分来自 ExamHistory

挑战结果使用另一组数据:

export interface ExamHistory {
  bankId: string
  score: number
  total: number
  correct: number
  durationSec: number
  finishedAt: string
}

ExamResultPage 出现后调用 addExamHistory(),新记录放在数组头部:

const result: ExamHistory[] = [{
  bankId,
  score,
  total,
  correct,
  durationSec,
  finishedAt: nowStr()
}, ...records]

因此 recentAvgScore() 直接取数组前 N 项:

static recentAvgScore(
  examHistory: ExamHistory[],
  count: number = 5
): number {
  if (examHistory.length === 0) return 0

  const recent = examHistory.slice(
```arkts
0,
Math.min(count, examHistory.length)
```
  )
  const sum = recent.reduce(
```arkts
(value, exam) => value + exam.score,
0
```
  )
  return Math.round(sum / recent.length)
}

这里存在一个隐含契约:examHistory 必须始终按时间倒序。当前写入方法通过前插维持了顺序;如果以后导入备份、合并云端数据或执行迁移,最好在计算前按可解析时间排序,不要继续依赖数组位置。

还要区分“近 5 次均分”和“正确率趋势”。均分只有一个数字,它告诉用户最近五次挑战的平均表现;趋势需要至少两个有时间顺序的点,才能判断变化方向。当前统计页没有绘制趋势图,也没有比较前后窗口。

六、各题库进度和正确率使用独立分母

统计页逐个遍历 BANKS,对每个题库调用:

static bankFinished(
  progressList: BankProgress[],
  bankId: string
): number {
  const progress = progressList.find(
```arkts
item => item.bankId === bankId
```
  )
  return progress ? progress.finished : 0
}

static bankAccuracy(
  progressList: BankProgress[],
  bankId: string
): number {
  const progress = progressList.find(
```arkts
item => item.bankId === bankId
```
  )
  if (!progress || progress.finished === 0) {
```arkts
return 0
```
  }
  return Math.round(
```arkts
progress.correct /
progress.finished * 100
```
  )
}

卡片展示 finished / bank.totalCount、正确率和“未开始/学习中/已完成”标签。标签规则是:

private progressLabel(bank: Bank): string {
  const finished = StatService.bankFinished(
```arkts
this.progressList,
bank.id
```
  )
  if (finished === 0) return '未开始'
  if (finished >= bank.totalCount) return '已完成'
  return '学习中'
}

这里的 finished 是累计答题次数,并不一定是“去重后完成题目数”。用户重复训练同一批题时,累计次数可能超过题库题目总数,页面就会显示类似 120/100。标签会进入“已完成”,但分数文本仍可能超过总量。

如果业务想表达“累计练习次数”,就不应拿它除以题库唯一题目数;如果想表达“题库完成进度”,则需要保存已完成题目的唯一 ID 集合。两个指标不能共用一个字段名称。

七、studiedBankCount 的真实口径

summarize() 直接使用:

studiedBankCount: progressList.length

在当前写入逻辑中,updateProgress() 只有完成练习后才创建记录,所以数组长度通常等于“有训练记录的题库数”。但从数据健壮性看,更准确的算法应过滤 finished > 0,并去重 bankId:

const studiedBankIds = new Set<string>()

progressList.forEach((progress: BankProgress) => {
  if (progress.finished > 0) {
```arkts
studiedBankIds.add(progress.bankId)
```
  }
})

const studiedBankCount = studiedBankIds.size

这样即使迁移数据包含零进度项或重复项,统计也不会虚高。文章和发布说明应称其为“训练题库数”或“已学习题库数”,而不是“训练套数已完成”,因为有一题记录也会计入。

八、连续训练需要新增日维度证据

要计算连续天数,至少需要知道用户在哪些自然日完成过有效训练。一个可复用的数据模型是训练会话:

export interface TrainingSession {
  sessionId: string
  bankId: string
  answered: number
  correct: number
  startedAt: number
  finishedAt: number
  mode: 'practice' | 'exam' | 'wrong'
}

每次完成练习时追加一条记录。连续训练只关心有效会话的日期集合,不关心同一天训练几次:

function localDateKey(timestamp: number): string {
  const date = new Date(timestamp)
  const year = date.getFullYear()
  const month = String(
```arkts
date.getMonth() + 1
```
  ).padStart(2, '0')
  const day = String(
```arkts
date.getDate()
```
  ).padStart(2, '0')
  return `${year}-${month}-${day}`
}

这里使用本地日期,而不是直接截取 UTC 格式。中国时区凌晨训练时,toISOString().slice(0, 10) 可能落到前一天,导致连续天数计算错误。

随后把有效训练日去重:

function trainingDateKeys(
  sessions: TrainingSession[]
): string[] {
  const keys = sessions
```arkts
.filter((session: TrainingSession) =>
  session.answered > 0
)
.map((session: TrainingSession) =>
  localDateKey(session.finishedAt)
)
```

  return Array.from(new Set(keys)).sort()
}

是否把“只打开页面”“收藏一道题”“查看错题”算作训练,需要产品先定义。更稳的口径是至少提交一道答案,避免用户仅启动应用就增加连续天数。

九、连续天数算法必须明确“截至今天”还是“最近一段”

连续训练通常有两种口径:

  1. 当前连续:必须今天或昨天有训练,从最近有效日向前计算;
  2. 历史最长连续:扫描全部日期,找到最长相邻区间。

计算当前连续天数时,可以先建立日期集合,再从今天向前:

function previousLocalDay(date: Date): Date {
  return new Date(
```arkts
date.getFullYear(),
date.getMonth(),
date.getDate() - 1
```
  )
}

function currentStreak(
  sessions: TrainingSession[],
  now: Date = new Date()
): number {
  const keys = new Set(
```arkts
trainingDateKeys(sessions)
```
  )

  let cursor = new Date(
```arkts
now.getFullYear(),
now.getMonth(),
now.getDate()
```
  )

  if (!keys.has(localDateKey(cursor.getTime()))) {
```arkts
cursor = previousLocalDay(cursor)
```
  }

  let streak = 0
  while (keys.has(localDateKey(cursor.getTime()))) {
```arkts
streak += 1
cursor = previousLocalDay(cursor)
```
  }
  return streak
}

这段代码允许“今天尚未训练,但昨天之前连续”仍显示当前连续天数。若产品要求当天未训练立即归零,可以删除回退到昨天的分支。规则没有绝对对错,但必须写进验收用例。

跨月、跨年和闰日不要用毫秒数简单减 24 * 60 * 60 * 1000 后直接格式化。通过本地日历构造前一天,能降低夏令时地区的日期偏移风险。

十、正确率趋势需要保留分子和分母

趋势点不能只保存百分比。一天答 1 题全对是 100%,一天答 100 题对 90 题是 90%,如果直接平均百分比会得到 95%,却忽略样本量。

按日聚合时应保存:

export interface DailyAccuracyPoint {
  date: string
  answered: number
  correct: number
  accuracyPercent: number
}

从会话记录计算最近七天:

function buildDailyAccuracy(
  sessions: TrainingSession[]
): DailyAccuracyPoint[] {
  const map = new Map<string, {
```arkts
answered: number
correct: number
```
  }>()

  sessions.forEach((session: TrainingSession) => {
```arkts
if (session.answered <= 0) return
```

```arkts
const key = localDateKey(session.finishedAt)
const old = map.get(key) || {
  answered: 0,
  correct: 0
}
map.set(key, {
  answered: old.answered + session.answered,
  correct: old.correct + session.correct
})
```
  })

  return Array.from(map.entries())
```arkts
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, value]) => ({
  date,
  answered: value.answered,
  correct: value.correct,
  accuracyPercent:
    value.answered > 0
      ? Math.round(
          value.correct /
          value.answered * 100
        )
      : 0
}))
```
}

图表点由真实分子、分母计算。Tooltip 可以同时显示“86%,18/21”,用户能理解样本量;后续修改四舍五入规则时,也不必丢弃历史。

当前统计与建议扩展边界

十一、如何判断趋势,而不是凭肉眼描述

只有两个点时,最后一天比前一天高,并不代表稳定提升。可以比较两个时间窗口的加权正确率:

function weightedAccuracy(
  points: DailyAccuracyPoint[]
): number {
  const answered = points.reduce(
```arkts
(sum, point) => sum + point.answered,
0
```
  )
  const correct = points.reduce(
```arkts
(sum, point) => sum + point.correct,
0
```
  )

  return answered > 0
```arkts
? correct / answered * 100
: 0
```
}

function trendDelta(
  points: DailyAccuracyPoint[]
): number {
  if (points.length < 4) return 0

  const middle = Math.floor(points.length / 2)
  const previous = weightedAccuracy(
```arkts
points.slice(0, middle)
```
  )
  const recent = weightedAccuracy(
```arkts
points.slice(middle)
```
  )

  return Math.round(recent - previous)
}

产品还应设置最小样本量。例如最近窗口少于 20 道题时,只展示趋势线,不输出“提升明显”之类结论。统计结论必须与样本量绑定,不能因为一天答对一道题就宣称能力跃升。

当前源码没有上述趋势判断,也没有趋势结论文案。它只显示累计正确率和近 5 次挑战均分。这一边界在技术文章、应用介绍和上架材料中都应保持一致。

十二、持久化会话时要控制数据规模

Preferences 适合轻量设置和小规模记录。若长期保存每次训练会话,数组会不断增长,序列化和同步写入成本也会增加。可以采用“近期会话 + 日汇总”策略:

export interface DailyTrainingSummary {
  date: string
  answered: number
  correct: number
  sessionCount: number
  activeBankIds: string[]
}

每次训练结束只更新当天汇总:

function mergeDailySummary(
  records: DailyTrainingSummary[],
  session: TrainingSession
): DailyTrainingSummary[] {
  const date = localDateKey(session.finishedAt)
  const index = records.findIndex(
```arkts
item => item.date === date
```
  )

  const old = index >= 0
```arkts
? records[index]
: {
    date,
    answered: 0,
    correct: 0,
    sessionCount: 0,
    activeBankIds: []
  }
```

  const next: DailyTrainingSummary = {
```arkts
date,
answered: old.answered + session.answered,
correct: old.correct + session.correct,
sessionCount: old.sessionCount + 1,
activeBankIds: Array.from(new Set([
  ...old.activeBankIds,
  session.bankId
]))
```
  }

  const result = [...records]
  if (index >= 0) result[index] = next
  else result.push(next)
  return result
}

如果只需要最近 90 天趋势,可在持久化前裁剪更早记录。若要支持多年历史、复杂查询或数据迁移,RDB 比一个不断增长的 Preferences JSON 更合适。

十三、统计页当前没有折线图

LearningStatsPage 的真实 UI 由以下部分组成:

  • 渐变概览卡;
  • 三张数据摘要卡;
  • “各年级训练进度”标题;
  • 六个题库进度项;
  • 每个题库的进度条、正确率和状态标签。

页面没有 Canvas 折线图、柱状图或按日列表。“正确率趋势”目前是文章中的增强设计,不是现有页面截图可验证的能力。

如果新增趋势图,优先保证可读性:

  • 横轴使用最近 7 天或 14 天,避免标签拥挤;
  • 无训练日显示断点还是 0%,必须定义清楚;
  • 数据点提供答题数和答对数;
  • 深色模式下坐标、折线和提示文字保持对比度;
  • 屏幕阅读场景提供文本摘要,不只依赖颜色;
  • 图表为空时显示“完成一次训练后生成趋势”。

无训练日通常不应当作 0%。它代表没有样本,而不是全部答错。折线可以断开,也可以只连接有效训练日,但不能擅自补零。

十四、响应式布局已有窄屏和宽屏分支

页面通过两个条件决定布局:

private useGridLayout(): boolean {
  return this.currentBp === 'lg' &&
```arkts
this.pageWidth >= 700
```
}

currentBp 来自 AppStorage,pageWidth 通过根容器 onAreaChange 更新。宽屏概览四项横排,题库卡按 32% 宽度形成三列;窄屏概览改为两行两列,题库卡纵向排列。

双条件比只看设备类型更稳。平板进入小窗口后,即使设备断点仍偏大,只要实际宽度不足 700,就不会强行塞三列。

新增趋势图时也应复用实际容器宽度:

private chartHeight(): number {
  return this.useGridLayout() ? 280 : 220
}

宽度由父容器约束,Canvas 或图表组件不要使用屏幕固定宽度。窗口缩放、横竖屏切换后,应重新计算绘制区域,防止点位和文字重叠。

十五、常见统计异常与排查顺序

现象优先检查真实原因或修复
新用户显示正确率 0%totalAnswered 是否为 0用“暂无记录”区分无数据
总正确率与题库平均不同是否采用加权口径总答对数除以总答题数
近 5 次均分选错记录examHistory 是否倒序写入前插或计算前排序
进度出现 120/100finished 是否累计次数与唯一完成题数拆成两个指标
连续天数始终为 1是否只保留最新 updatedAt新增日汇总或会话流水
凌晨训练落到前一天是否截取 UTC 日期使用本地日历日期键
无训练日显示 0%是否把无样本当错误使用缺失点或断线
趋势被少量题目放大是否忽略样本量保留 answered/correct 并设阈值
清理一项后统计全空初始化是否整体 catch逐项解析、逐项兜底
平板小窗卡片拥挤是否只看设备断点同时检查实际页面宽度

排查时先记录原始数组长度和聚合分子、分母,不要只看最终百分比。显示 86% 并不能证明输入记录正确。

十六、可执行测试矩阵

统计服务适合用纯数据做单元测试:

const progress: BankProgress[] = [
  {
```arkts
bankId: 'b_grade1',
finished: 100,
correct: 90,
lastChapterId: 'c1',
updatedAt: '2026-07-20 10:00'
```
  },
  {
```arkts
bankId: 'b_grade2',
finished: 10,
correct: 5,
lastChapterId: 'c2',
updatedAt: '2026-07-21 10:00'
```
  }
]

const stats = StatService.summarize(
  progress,
  [],
  [],
  []
)

// totalAnswered = 110
// totalCorrect = 95
// accuracyPercent = 86

连续训练增强至少覆盖:

用例日期集合预期
无记录空0 天
今天训练今天1 天
今天、昨天连续两日2 天
今天、前天中间断一天1 天
昨天、前天今天未训练按产品规则为 2 或 0
跨月7 月 31 日、8 月 1 日2 天
跨年12 月 31 日、1 月 1 日2 天
同日多次三次同一天1 天

趋势测试要覆盖:

  • 零答题日不参与正确率除法;
  • 同一天多会话正确合并;
  • 每个点保留 answered 与 correct;
  • 时间排序与输入数组顺序无关;
  • 最近窗口和上一窗口使用加权正确率;
  • 样本不足时不输出提升或下降结论。

十七、发布前复核清单

面向 HarmonyOS 5.0 及以上环境,可以按以下清单验收:

  • UserDataManager.init() 在页面创建前完成;
  • Preferences 恢复后四组 AppStorage 数据与本地记录一致;
  • 累计正确率使用总答对数除以总答题数;
  • 无数据状态不与真实 0% 混淆;
  • 近 5 次均分只基于真实挑战历史;
  • 挑战历史顺序契约明确;
  • 题库“累计答题次数”和“唯一完成进度”没有混用;
  • 收藏数、错题数描述为当前集合数量;
  • 当前页面未宣称连续训练或趋势图已经实现;
  • 若新增连续训练,使用本地自然日并定义今天未训练的规则;
  • 若新增趋势,保存每日答题分子和分母;
  • 无训练日不会伪造为 0%;
  • 宽屏三列、窄屏单列和概览两行布局无溢出;
  • 深浅色、长题库名、零数据和超大累计值仍可读。

十八、迁移策略:不能用一个累计终值伪造历史

为现有用户增加 TrainingSession 或 DailyStat 时,最危险的做法是把旧的累计答题数平均摊到过去若干天。这样的迁移看似能让趋势图立即有数据,实际上日期、每日分母和连续天数都是猜出来的,用户看到的上升或下降也没有证据。稳妥方案是保留原来的 BankProgress 作为“迁移前累计基线”,从升级后的第一次真实训练开始追加带日期的新记录。页面可以继续展示全量累计值,同时把趋势区间标注为“自升级后开始统计”。

迁移需要版本标识,而不是依赖数组是否为空。空数组既可能表示首次安装,也可能表示用户刚刚清空了历史。建议在 Preferences 中保存独立的 schemaVersion,并记录首次启用日统计的本地日期。读取旧数据失败时仍保持原累计统计可用,新增趋势区域进入空状态,不把解析异常解释成“连续 0 天”。这是建议实现;当前源码没有 schemaVersion、会话流水或日汇总迁移逻辑。

interface StatsMigrationState {
  schemaVersion: number
  dailyStatsEnabledDate: string
}

interface DailyStat {
  localDate: string
  answered: number
  correct: number
}

十九、日期边界:连续训练必须按用户本地自然日计算

连续天数不是简单地用时间戳相减再除以 24 小时。用户在晚上十一点五十分和次日零点十分各练习一次,虽然只相隔二十分钟,却属于两个自然日;反过来,夏令时或时区变化也可能让相邻自然日不是严格的二十四小时。计算前应把每条有效训练映射成用户本地的 YYYY-MM-DD,去重、排序后再判断日期是否相邻。若产品没有跨设备同步,就使用设备本地时区并在规则中写清楚;若以后接入云同步,还要同时保存原始时间戳与记录时区,避免服务器时区重算造成打卡日漂移。

“连续训练 7 天”还必须回答一个产品问题:今天尚未训练时,是展示截至昨天的连续记录,还是立即显示 0?更友好的口径通常是今天有记录则从今天向前数,今天没有记录则允许从昨天向前数,并提示“今天训练后可延续”。但这只是推荐规则,不能混进当前页面已有能力。测试时至少覆盖同一天多次训练、跨零点、缺一天、空历史、未来时间、重复记录和手动修改系统时间。

二十、趋势验收:分母、窗口和空状态要同时可解释

正确率趋势应保留每天的答对数与答题数,再计算 correct / answered,不能直接平均每日百分比。例如第一天答 1 题且全对,第二天答 99 题且答对 49 题,两个百分比的算术平均是 75%,而两天合计正确率只有 50%。页面若展示七日趋势,应明确每个点是单日正确率、滚动平均还是七日累计;比较“上升”也应设置最小答题量,避免只答一题就产生夸张波动。

趋势图并非必需的第一步。数据不足两天时可以展示空状态和累计卡片;某天答题数为零时应显示缺口而不是 0% 正确率;存在数据时,点选信息要同时展示日期、答题数、答对数和正确率。窄屏下优先保证数值与标签可读,不能为了塞满七个点而让文字重叠。完成这些口径后,再把聚合结果交给 ArkUI 图表或自定义绘制层,页面只负责渲染,不在 @Builder 中临时修正业务数据。

总结

学习统计的可信度来自数据口径,而不是卡片数量。当前口算王源码通过 Preferences、AppStorage、UserDataManager 与 StatService 建立了完整的本地累计统计链路:练习结束累加题库进度,挑战完成追加历史,统计页展示累计答题、加权正确率、近 5 次均分及各题库进度。

这套模型适合回答“到目前为止答了多少、总体正确率多少”,却不足以回答“连续训练了几天、正确率如何变化”。要补齐后两项,需要新增按日汇总或训练会话,保留自然日、答题数和答对数,再用明确的日期规则和加权窗口计算。只有原始证据、聚合公式和页面文案三者一致,学习统计才是可复核的工程功能,而不是看起来漂亮的数字。


AI 辅助声明:本文在人工核对真实工程源码和统计口径后,使用 AI 辅助整理结构、润色表达并生成示意图;当前事实、建议扩展和未执行验证均已分别说明。

CSDN-SERIES:ALL-163188525

Logo

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

更多推荐