【口算王|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() }是否把“只打开页面”“收藏一道题”“查看错题”算作训练,需要产品先定义。更稳的口径是至少提交一道答案,避免用户仅启动应用就增加连续天数。
九、连续天数算法必须明确“截至今天”还是“最近一段”
连续训练通常有两种口径:
- 当前连续:必须今天或昨天有训练,从最近有效日向前计算;
- 历史最长连续:扫描全部日期,找到最长相邻区间。
计算当前连续天数时,可以先建立日期集合,再从今天向前:
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/100 | finished是否累计次数 | 与唯一完成题数拆成两个指标 |
| 连续天数始终为 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