学习统计页面最容易出现一种"数字都对,但结论不成立"的问题:累计答题数确实来自真实记录,正确率公式也没有写错,页面却把这些累计值包装成"连续学习""近期趋势"或"能力提升"。累计值只能说明到目前为止发生了多少次答题,无法回答用户连续训练了几天,也无法告诉用户正确率是在上升还是下降。
连续训练需要按自然日保存活动证据;正确率趋势需要多个时间窗口的答题分子和分母。若数据模型只保留每个题库的累计 finished、累计 correct 和最后更新时间,即使界面画出一条折线,也无法从累计终值还原每天的变化。
本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 LearningStatsPage.ets、StatService.ets、UserDataManager.ets、PracticePage.ets、ExamResultPage.ets 与 EntryAbility.ets。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前实现真实支持累计答题、累计正确率、挑战次数、训练题库数、收藏数、错题数、近 5 次挑战均分和各题库进度;连续训练天数与正确率趋势尚未实现,文中将其作为基于现有数据链路的增强方案,不把设计代码描述成现成功能。

本文会完成四个工程判断:
- 复核当前每个统计数字的真实来源和计算公式;
- 说明累计正确率、近 5 次均分与正确率趋势的差异;
- 设计能够计算连续训练天数和日正确率趋势的数据模型;
- 给出 ArkTS 聚合算法、持久化边界、多设备布局与测试矩阵。
一、统计页读取的是四组 AppStorage 数据
LearningStatsPage.ets 没有自己读取文件,也没有制造演示数字。页面通过 @StorageLink 订阅四组应用级状态:
ts
@StorageLink('bankProgress')
progressList: BankProgress[] = []
@StorageLink('examHistory')
examHistory: ExamHistory[] = []
@StorageLink('favoriteRecords')
favRecords: FavoriteRecord[] = []
@StorageLink('wrongRecords')
wrongRecords: WrongRecord[] = []
这四组状态分别承担不同统计口径:
| 状态 | 页面用途 | 是否包含时间序列 |
|---|---|---|
bankProgress |
累计答题、累计答对、题库进度 | 只保留每个题库最后更新时间 |
examHistory |
挑战次数、近 5 次均分 | 每次挑战都有完成时间 |
favoriteRecords |
当前收藏数量 | 有收藏时间,但不是训练证据 |
wrongRecords |
当前错题数量 | 有最近错误时间,但会去重 |
页面把计算交给 StatService:
ts
private stats() {
return StatService.summarize(
this.progressList,
this.examHistory,
this.favRecords,
this.wrongRecords
)
}
这是一个合理的职责边界。ArkUI 页面负责展示和响应式排列,统计公式集中在基础特性层。首页和"我的"页面也调用同一个 summarize(),从而避免同一个"正确率"在三个页面出现三种算法。
二、数据在应用启动时从 Preferences 恢复
EntryAbility 启动后调用 UserDataManager.init(this.context)。管理器使用 ArkData Preferences 打开名为 daily_math_drill 的本地存储,并把 JSON 字符串恢复到 AppStorage。
ts
static init(
context: common.UIAbilityContext | common.Context
): void {
UserDataManager.prefs =
preferences.getPreferencesSync(context, {
name: UserDataManager.STORE_NAME
})
const progStr = UserDataManager.prefs.getSync(
UserDataManager.K_PROGRESS,
'[]'
) as string
AppStorage.setOrCreate<BankProgress[]>(
'bankProgress',
JSON.parse(progStr) as BankProgress[]
)
}
因此统计页展示的是本机持久化数据,不是网络账户数据,也不是云端跨设备同步结果。用户卸载应用、清除应用数据或在另一台设备安装时,这些统计不会自动迁移。
当前初始化用一个大 try/catch 处理解析失败。任何一项 JSON 异常都可能进入兜底分支,把多组 AppStorage 设为空数组。更细致的做法是逐项解析并校验结构,使某个设置损坏时不影响全部学习记录:
ts
function parseArray<T>(
raw: string,
fallback: T[]
): T[] {
try {
const value = JSON.parse(raw) as Object
return Array.isArray(value)
? value as T[]
: fallback
} catch (_) {
return fallback
}
}
统计可信度不仅取决于公式,也取决于数据恢复是否完整。页面显示 0 时,应能区分"用户没有训练"和"持久化记录解析失败"。
三、累计答题和累计正确率如何计算
StatService.summarize() 遍历每个 BankProgress,把 finished 与 correct 相加:
ts
let totalAnswered = 0
let totalCorrect = 0
for (const p of progressList) {
totalAnswered += p.finished
totalCorrect += p.correct
}
const accuracyPercent = totalAnswered > 0
? Math.round(
totalCorrect / totalAnswered * 100
)
: 0
这里使用的是加权总体正确率,不是"各题库正确率的平均值"。假设一年级答了 100 题、正确 90 题,二年级答了 10 题、正确 5 题:
text
总体正确率 = (90 + 5) / (100 + 10)
= 86.36%
≈ 86%
如果先计算 90% 和 50% 再取平均,会得到 70%,小样本题库被赋予了与大样本题库相同权重。当前实现按总答对数除以总答题数,口径更适合"全部训练正确率"。
零答题保护也很重要。没有答题时返回 0,避免 NaN 或除零结果进入 UI:
ts
accuracyPercent:
totalAnswered > 0
? Math.round(
totalCorrect / totalAnswered * 100
)
: 0
但 0% 同时可能代表"尚未答题"和"答过但全部错误"。如果产品需要区分这两个状态,统计结果应增加 hasTrainingData,页面在无数据时显示"暂无记录",不要让新用户误以为自己正确率为零。

四、BankProgress 是累计快照,不是答题流水
当前进度模型如下:
ts
export interface BankProgress {
bankId: string
finished: number
correct: number
lastChapterId: string
updatedAt: string
}
一次练习结束后,UserDataManager.updateProgress() 会找到同题库记录,把本次答题数和答对数累加:
ts
const updated: BankProgress = {
bankId,
finished: old.finished + addFinished,
correct: old.correct + addCorrect,
lastChapterId: chapterId,
updatedAt: nowStr()
}
这种结构非常适合快速展示累计值。页面不需要扫描几千条答题流水,就能立即获得题库已答题数和正确率。
它的限制同样明确:旧的 updatedAt 会被覆盖。假设用户周一答 20 题、周二答 30 题,最终只留下累计 50 题和周二的更新时间。仅凭这条记录,无法还原周一、周二分别答了多少题,也无法证明训练连续发生在两个自然日。
因此当前 BankProgress 不能直接计算连续训练天数,也不能生成日正确率趋势。要保留历史,必须新增会追加的会话记录或按日汇总记录。
五、挑战次数和近 5 次均分来自 ExamHistory
挑战结果使用另一组数据:
ts
export interface ExamHistory {
bankId: string
score: number
total: number
correct: number
durationSec: number
finishedAt: string
}
ExamResultPage 出现后调用 addExamHistory(),新记录放在数组头部:
ts
const result: ExamHistory[] = [{
bankId,
score,
total,
correct,
durationSec,
finishedAt: nowStr()
}, ...records]
因此 recentAvgScore() 直接取数组前 N 项:
ts
static recentAvgScore(
examHistory: ExamHistory[],
count: number = 5
): number {
if (examHistory.length === 0) return 0
const recent = examHistory.slice(
0,
Math.min(count, examHistory.length)
)
const sum = recent.reduce(
(value, exam) => value + exam.score,
0
)
return Math.round(sum / recent.length)
}
这里存在一个隐含契约:examHistory 必须始终按时间倒序。当前写入方法通过前插维持了顺序;如果以后导入备份、合并云端数据或执行迁移,最好在计算前按可解析时间排序,不要继续依赖数组位置。
还要区分"近 5 次均分"和"正确率趋势"。均分只有一个数字,它告诉用户最近五次挑战的平均表现;趋势需要至少两个有时间顺序的点,才能判断变化方向。当前统计页没有绘制趋势图,也没有比较前后窗口。
六、各题库进度和正确率使用独立分母
统计页逐个遍历 BANKS,对每个题库调用:
ts
static bankFinished(
progressList: BankProgress[],
bankId: string
): number {
const progress = progressList.find(
item => item.bankId === bankId
)
return progress ? progress.finished : 0
}
static bankAccuracy(
progressList: BankProgress[],
bankId: string
): number {
const progress = progressList.find(
item => item.bankId === bankId
)
if (!progress || progress.finished === 0) {
return 0
}
return Math.round(
progress.correct /
progress.finished * 100
)
}
卡片展示 finished / bank.totalCount、正确率和"未开始/学习中/已完成"标签。标签规则是:
ts
private progressLabel(bank: Bank): string {
const finished = StatService.bankFinished(
this.progressList,
bank.id
)
if (finished === 0) return '未开始'
if (finished >= bank.totalCount) return '已完成'
return '学习中'
}
这里的 finished 是累计答题次数,并不一定是"去重后完成题目数"。用户重复训练同一批题时,累计次数可能超过题库题目总数,页面就会显示类似 120/100。标签会进入"已完成",但分数文本仍可能超过总量。
如果业务想表达"累计练习次数",就不应拿它除以题库唯一题目数;如果想表达"题库完成进度",则需要保存已完成题目的唯一 ID 集合。两个指标不能共用一个字段名称。
七、studiedBankCount 的真实口径
summarize() 直接使用:
ts
studiedBankCount: progressList.length
在当前写入逻辑中,updateProgress() 只有完成练习后才创建记录,所以数组长度通常等于"有训练记录的题库数"。但从数据健壮性看,更准确的算法应过滤 finished > 0,并去重 bankId:
ts
const studiedBankIds = new Set<string>()
progressList.forEach((progress: BankProgress) => {
if (progress.finished > 0) {
studiedBankIds.add(progress.bankId)
}
})
const studiedBankCount = studiedBankIds.size
这样即使迁移数据包含零进度项或重复项,统计也不会虚高。文章和发布说明应称其为"训练题库数"或"已学习题库数",而不是"训练套数已完成",因为有一题记录也会计入。
八、连续训练需要新增日维度证据
要计算连续天数,至少需要知道用户在哪些自然日完成过有效训练。一个可复用的数据模型是训练会话:
ts
export interface TrainingSession {
sessionId: string
bankId: string
answered: number
correct: number
startedAt: number
finishedAt: number
mode: 'practice' | 'exam' | 'wrong'
}
每次完成练习时追加一条记录。连续训练只关心有效会话的日期集合,不关心同一天训练几次:
ts
function localDateKey(timestamp: number): string {
const date = new Date(timestamp)
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}`
}
这里使用本地日期,而不是直接截取 UTC 格式。中国时区凌晨训练时,toISOString().slice(0, 10) 可能落到前一天,导致连续天数计算错误。
随后把有效训练日去重:
ts
function trainingDateKeys(
sessions: TrainingSession[]
): string[] {
const keys = sessions
.filter((session: TrainingSession) =>
session.answered > 0
)
.map((session: TrainingSession) =>
localDateKey(session.finishedAt)
)
return Array.from(new Set(keys)).sort()
}
是否把"只打开页面""收藏一道题""查看错题"算作训练,需要产品先定义。更稳的口径是至少提交一道答案,避免用户仅启动应用就增加连续天数。
九、连续天数算法必须明确"截至今天"还是"最近一段"
连续训练通常有两种口径:
- 当前连续:必须今天或昨天有训练,从最近有效日向前计算;
- 历史最长连续:扫描全部日期,找到最长相邻区间。
计算当前连续天数时,可以先建立日期集合,再从今天向前:
ts
function previousLocalDay(date: Date): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate() - 1
)
}
function currentStreak(
sessions: TrainingSession[],
now: Date = new Date()
): number {
const keys = new Set(
trainingDateKeys(sessions)
)
let cursor = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
)
if (!keys.has(localDateKey(cursor.getTime()))) {
cursor = previousLocalDay(cursor)
}
let streak = 0
while (keys.has(localDateKey(cursor.getTime()))) {
streak += 1
cursor = previousLocalDay(cursor)
}
return streak
}
这段代码允许"今天尚未训练,但昨天之前连续"仍显示当前连续天数。若产品要求当天未训练立即归零,可以删除回退到昨天的分支。规则没有绝对对错,但必须写进验收用例。
跨月、跨年和闰日不要用毫秒数简单减 24 * 60 * 60 * 1000 后直接格式化。通过本地日历构造前一天,能降低夏令时地区的日期偏移风险。
十、正确率趋势需要保留分子和分母
趋势点不能只保存百分比。一天答 1 题全对是 100%,一天答 100 题对 90 题是 90%,如果直接平均百分比会得到 95%,却忽略样本量。
按日聚合时应保存:
ts
export interface DailyAccuracyPoint {
date: string
answered: number
correct: number
accuracyPercent: number
}
从会话记录计算最近七天:
ts
function buildDailyAccuracy(
sessions: TrainingSession[]
): DailyAccuracyPoint[] {
const map = new Map<string, {
answered: number
correct: number
}>()
sessions.forEach((session: TrainingSession) => {
if (session.answered <= 0) return
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())
.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",用户能理解样本量;后续修改四舍五入规则时,也不必丢弃历史。

十一、如何判断趋势,而不是凭肉眼描述
只有两个点时,最后一天比前一天高,并不代表稳定提升。可以比较两个时间窗口的加权正确率:
ts
function weightedAccuracy(
points: DailyAccuracyPoint[]
): number {
const answered = points.reduce(
(sum, point) => sum + point.answered,
0
)
const correct = points.reduce(
(sum, point) => sum + point.correct,
0
)
return answered > 0
? 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(
points.slice(0, middle)
)
const recent = weightedAccuracy(
points.slice(middle)
)
return Math.round(recent - previous)
}
产品还应设置最小样本量。例如最近窗口少于 20 道题时,只展示趋势线,不输出"提升明显"之类结论。统计结论必须与样本量绑定,不能因为一天答对一道题就宣称能力跃升。
当前源码没有上述趋势判断,也没有趋势结论文案。它只显示累计正确率和近 5 次挑战均分。这一边界在技术文章、应用介绍和上架材料中都应保持一致。
十二、持久化会话时要控制数据规模
Preferences 适合轻量设置和小规模记录。若长期保存每次训练会话,数组会不断增长,序列化和同步写入成本也会增加。可以采用"近期会话 + 日汇总"策略:
ts
export interface DailyTrainingSummary {
date: string
answered: number
correct: number
sessionCount: number
activeBankIds: string[]
}
每次训练结束只更新当天汇总:
ts
function mergeDailySummary(
records: DailyTrainingSummary[],
session: TrainingSession
): DailyTrainingSummary[] {
const date = localDateKey(session.finishedAt)
const index = records.findIndex(
item => item.date === date
)
const old = index >= 0
? records[index]
: {
date,
answered: 0,
correct: 0,
sessionCount: 0,
activeBankIds: []
}
const next: DailyTrainingSummary = {
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%。它代表没有样本,而不是全部答错。折线可以断开,也可以只连接有效训练日,但不能擅自补零。
十四、响应式布局已有窄屏和宽屏分支
页面通过两个条件决定布局:
ts
private useGridLayout(): boolean {
return this.currentBp === 'lg' &&
this.pageWidth >= 700
}
currentBp 来自 AppStorage,pageWidth 通过根容器 onAreaChange 更新。宽屏概览四项横排,题库卡按 32% 宽度形成三列;窄屏概览改为两行两列,题库卡纵向排列。
双条件比只看设备类型更稳。平板进入小窗口后,即使设备断点仍偏大,只要实际宽度不足 700,就不会强行塞三列。
新增趋势图时也应复用实际容器宽度:
ts
private chartHeight(): number {
return this.useGridLayout() ? 280 : 220
}
宽度由父容器约束,Canvas 或图表组件不要使用屏幕固定宽度。窗口缩放、横竖屏切换后,应重新计算绘制区域,防止点位和文字重叠。
十五、常见统计异常与排查顺序
| 现象 | 优先检查 | 真实原因或修复 |
|---|---|---|
| 新用户显示正确率 0% | totalAnswered 是否为 0 |
用"暂无记录"区分无数据 |
| 总正确率与题库平均不同 | 是否采用加权口径 | 总答对数除以总答题数 |
| 近 5 次均分选错记录 | examHistory 是否倒序 |
写入前插或计算前排序 |
| 进度出现 120/100 | finished 是否累计次数 |
与唯一完成题数拆成两个指标 |
| 连续天数始终为 1 | 是否只保留最新 updatedAt |
新增日汇总或会话流水 |
| 凌晨训练落到前一天 | 是否截取 UTC 日期 | 使用本地日历日期键 |
| 无训练日显示 0% | 是否把无样本当错误 | 使用缺失点或断线 |
| 趋势被少量题目放大 | 是否忽略样本量 | 保留 answered/correct 并设阈值 |
| 清理一项后统计全空 | 初始化是否整体 catch | 逐项解析、逐项兜底 |
| 平板小窗卡片拥挤 | 是否只看设备断点 | 同时检查实际页面宽度 |
排查时先记录原始数组长度和聚合分子、分母,不要只看最终百分比。显示 86% 并不能证明输入记录正确。
十六、可执行测试矩阵
统计服务适合用纯数据做单元测试:
ts
const progress: BankProgress[] = [
{
bankId: 'b_grade1',
finished: 100,
correct: 90,
lastChapterId: 'c1',
updatedAt: '2026-07-20 10:00'
},
{
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%; - 宽屏三列、窄屏单列和概览两行布局无溢出;
- 深浅色、长题库名、零数据和超大累计值仍可读。
总结
学习统计的可信度来自数据口径,而不是卡片数量。当前口算王源码通过 Preferences、AppStorage、UserDataManager 与 StatService 建立了完整的本地累计统计链路:练习结束累加题库进度,挑战完成追加历史,统计页展示累计答题、加权正确率、近 5 次均分及各题库进度。
这套模型适合回答"到目前为止答了多少、总体正确率多少",却不足以回答"连续训练了几天、正确率如何变化"。要补齐后两项,需要新增按日汇总或训练会话,保留自然日、答题数和答对数,再用明确的日期规则和加权窗口计算。只有原始证据、聚合公式和页面文案三者一致,学习统计才是可复核的工程功能,而不是看起来漂亮的数字。
本文由 AI 辅助整理,所有现有功能边界、代码路径与统计口径均依据项目真实源码复核。