资讯动态

【句匠|09】HarmonyOS ArkTS 学习统计实战:汇总正确率、连续学习和薄弱类型

发布时间:2026/10/27 18:36:53 来源:尧图企业网站定制
学习类应用做到后期统计页很容易变成“把几个数字摆出来”。这种做法上线后问题很明显用户看到总答题数却不知道它来自哪些题库看到正确率却不知道空数据时怎么算看到考试均分却不知道取的是全部历史还是最近几次页面在平板或 2in1 宽屏打开时如果仍然强行单列排列也会浪费大量空间。句匠项目里的LearningStatsPage.ets没有引入服务端画像也没有做复杂的数据分析模型。它把真实可用的本地学习数据分成四组bankProgress、examHistory、favoriteRecords、wrongRecords。页面通过StorageLink读取 AppStorage 中的数组再交给libraryb的StatService汇总最后用概览卡、数据卡和题库进度列表呈现出来。这个链路小但边界清楚适合复盘 HarmonyOS 5.0 以上 ArkTS 应用里“本地学习统计页”应该怎样拆分职责。本文唯一标记com.jiaweikang.one18。以下内容只基于句匠项目真实源码entry/src/main/ets/pages/LearningStatsPage.ets、libraryb/src/main/ets/utils/StatService.ets、librarya/src/main/ets/utils/UserDataManager.ets和题库目录entry/src/main/ets/mock/MockBanks.ets。需要先说明边界当前统计页不计算签到连续天数也不做题型维度的薄弱项排序它呈现的是答题正确率、考试次数、收藏/错题数量、近 5 次考试均分以及各题库完成度。连续学习的“持续积累”在这个页面里主要体现为题库进度和历史考试数据真正的签到连续天数属于DailyCheckInPage.ets的职责。一、统计页真正要解决的不是数字展示而是数据口径实际项目里学习统计页最容易出错的地方不是 UI而是口径不一致。首页显示一个正确率个人中心显示另一个正确率题库详情页又用第三套算法用户一眼就会觉得数据不可信。句匠的做法是把全局统计放到StatService.summarize页面只负责把结果渲染出来。这篇文章会围绕四个问题展开问题源码里的处理方式结果总答题数从哪里来遍历BankProgress.finished与练习完成记录一致正确率如何避免除零totalAnswered 0才计算空数据时显示 0考试均分取什么范围recentAvgScore(examHistory, 5)默认只看近 5 次宽屏如何避免单列浪费currentBp lg pageWidth 700宽屏切换三列题库卡片流程关系可以概括为下面这张图。这个页面的价值不在于算法复杂而在于它让页面、统计服务、持久化服务和题库目录各自只做一件事。页面不直接写 Preferences统计服务不关心 UI持久化服务不参与页面布局题库目录只提供静态题库基础信息。二、用 StorageLink 读取全局学习数据LearningStatsPage的第一层数据来自 AppStorage。页面通过StorageLink绑定四组学习记录和一个断点状态。StorageLink(bankProgress) progressList: BankProgress[] [] StorageLink(examHistory) examHistory: ExamHistory[] [] StorageLink(favoriteRecords) favRecords: FavoriteRecord[] [] StorageLink(wrongRecords) wrongRecords: WrongRecord[] [] StorageLink(currentBreakpoint) currentBp: string sm State pageWidth: number 360这段代码的边界很明确字段作用页面是否直接修改progressList每个题库的已答题数、答对题数和最后章节否examHistory模拟考试历史包含分数、正确数、用时否favRecords收藏题目记录否wrongRecords错题记录否currentBp应用断点用于响应式布局否pageWidth当前页面宽度本页局部状态是这里没有在页面里直接调用 Preferences也没有在 UI 组件内拼接本地存储键名。这一点很重要。统计页只消费已经加载到 AppStorage 的状态数据初始化由UserDataManager.init()完成。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 const examStr UserDataManager.prefs.getSync(UserDataManager.K_EXAM, []) as string AppStorage.setOrCreateBankProgress[](bankProgress, JSON.parse(progStr) as BankProgress[]) AppStorage.setOrCreateExamHistory[](examHistory, JSON.parse(examStr) as ExamHistory[]) }这段初始化逻辑属于librarya的公共能力层。页面只知道bankProgress和examHistory两个全局状态存在不需要知道它们被保存在名为dialect_quiz的 Preferences 文件里。这样做可以避免一个常见问题当后续把 Preferences 迁移到 RDB 或增加备份策略时统计页不需要跟着改存储细节。三、StatService.summarize 负责统一全局统计口径统计服务的核心方法是summarize。它接收四组数组返回一个LearningStats对象。export interface LearningStats { totalAnswered: number totalCorrect: number accuracyPercent: number examCount: number favoriteCount: number wrongCount: number studiedBankCount: number } export class StatService { static summarize( progressList: BankProgress[], examHistory: ExamHistory[], favorites: FavoriteRecord[], wrongs: WrongRecord[] ): LearningStats { let totalAnswered 0 let totalCorrect 0 for (const p of progressList) { totalAnswered p.finished totalCorrect p.correct } return { totalAnswered, totalCorrect, accuracyPercent: totalAnswered 0 ? Math.round(totalCorrect / totalAnswered * 100) : 0, examCount: examHistory.length, favoriteCount: favorites.length, wrongCount: wrongs.length, studiedBankCount: progressList.length, } } }这段代码的工程价值有三个。第一统计口径集中。LearningStatsPage、HomePage、MinePage都可以调用同一个方法避免页面各自计算。第二空数据有明确兜底。totalAnswered为 0 时正确率直接返回 0不会出现NaN%。第三返回模型是扁平的。页面渲染数字时不需要再理解BankProgress、ExamHistory、FavoriteRecord的内部结构只拿LearningStats即可。如果这个方法写在页面里短期看少一个文件长期会导致两个问题统计逻辑难以复用页面 UI 树也会混进大量数据遍历代码。ArkUI 的声明式页面应该尽量保持“读状态、调服务、渲染结果”的节奏。四、概览卡把核心学习状态压缩成四个指标OverviewCard是页面的首屏核心。它展示总答题数、正确率、考试次数和已学习题库数。Builder OverviewCard() { Column({ space: 16 }) { Row() { Column({ space: 4 }) { Text(学习概览) .fontSize(Sizes.H2_FONT) .fontWeight(FontWeight.Bold) .fontColor(Color.White) Text(持续积累掌握更多地方表达) .fontSize(Sizes.CAPTION_FONT) .fontColor(#E6FFFFFF) } Blank() } if (this.useGridLayout()) { Row() { this.OverviewItem(${this.stats().totalAnswered}, 已答题) this.OverviewItem(${this.stats().accuracyPercent}%, 正确率) this.OverviewItem(${this.stats().examCount}, 考试次数) this.OverviewItem(${this.stats().studiedBankCount}, 学习题库) } } else { Column({ space: 12 }) { Row({ space: 12 }) { this.OverviewItem(${this.stats().totalAnswered}, 已答题) this.OverviewItem(${this.stats().accuracyPercent}%, 正确率) } Row({ space: 12 }) { this.OverviewItem(${this.stats().examCount}, 考试次数) this.OverviewItem(${this.stats().studiedBankCount}, 学习题库) } } } } }这里的“持续积累”是页面文案层面的学习反馈不等同于签到连续天数。源码没有在LearningStatsPage里读取checkInStreak所以不能把它说成“连续打卡天数”。如果产品后续要把连续学习天数接入统计页应该新增一个受控字段例如StorageLink(checkInStreak) streak: number 0并在 UI 上单独标明数据来源。概览卡采用宽屏横向四项、窄屏两行两列的方式避免了手机端文字拥挤也避免了平板端卡片过高。这个判断来自useGridLayout()。private useGridLayout(): boolean { return this.currentBp lg this.pageWidth 700 }这个条件没有只依赖断点也没有只依赖pageWidth。断点反映应用层判断页面宽度反映当前窗口实际尺寸。两个条件同时成立时才切换为宽屏布局更适合 2in1 小窗口、分屏、平板横竖屏切换等场景。五、数据卡补足收藏、错题和近 5 次均分概览卡之外DataSummary用三张小卡显示收藏题目、错题数和近 5 次均分。Builder DataSummary() { Row({ space: 12 }) { this.DataCard(${this.stats().favoriteCount}, 收藏题目, Colors.ACCENT) this.DataCard(${this.stats().wrongCount}, 错题数, Colors.ERROR) this.DataCard(${StatService.recentAvgScore(this.examHistory)}, 近5次均分, Colors.SUCCESS) } .width(100%) .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE }) }三个指标的定位不同指标对用户的意义源码来源收藏题目用户主动标记的复习材料favoriteRecords.length错题数当前仍需复习的薄弱题集合wrongRecords.length近 5 次均分最近考试表现趋势StatService.recentAvgScorerecentAvgScore的实现没有使用全部历史而是默认取最近 5 次。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((s, e) s e.score, 0) return Math.round(sum / recent.length) }这个方法依赖一个前提examHistory最新记录排在数组前面。这个前提由UserDataManager.addExamHistory保证。static addExamHistory(records: ExamHistory[], bankId: string, score: number, total: number, correct: number, durationSec: number): ExamHistory[] { const result: ExamHistory[] [{ bankId, score, total, correct, durationSec, finishedAt: nowStr() }, ...records] UserDataManager.persist(UserDataManager.K_EXAM, result) return result }如果后续有人把考试历史改成尾部追加recentAvgScore的口径就会被破坏。这类约定应该写在服务方法注释或测试里考试历史数组按时间倒序排列slice(0, count)表示最近 N 次。六、题库进度卡把总览落回每个题库全局统计只是入口真正能指导复习的是每个题库的完成度。LearningStatsPage遍历BANKS为每个题库生成一张进度卡。ForEach(BANKS, (bank: Bank) { this.BankProgressItem(bank) }, (bank: Bank) bank.id)BankProgressItem负责展示题库封面、题库名称、已完成题数、进度条、正确率和状态标签。Builder BankProgressItem(bank: Bank) { Row({ space: 12 }) { Image(bank.cover) .width(58) .height(58) .borderRadius(12) .objectFit(ImageFit.Cover) Column({ space: 8 }) { Row() { Text(bank.name) .fontSize(Sizes.BODY_FONT) .fontWeight(FontWeight.Bold) .fontColor(Colors.TEXT_PRIMARY) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Blank() Text(${StatService.bankFinished(this.progressList, bank.id)}/${bank.totalCount}) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.TEXT_HINT) } ProgressBar({ ratio: bank.totalCount 0 ? StatService.bankFinished(this.progressList, bank.id) / bank.totalCount : 0, height_: 6 }) Row() { Text(正确率 ${StatService.bankAccuracy(this.progressList, bank.id)}%) .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.PRIMARY) Blank() Text(this.progressLabel(bank)) .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.TEXT_HINT) } } .layoutWeight(1) } }这段 UI 里有几个细节值得保留。第一题库名称使用maxLines(1)和textOverflow。题库名变长时不会挤掉右侧完成数。第二进度条比例有除零保护。bank.totalCount 0才计算比例否则回落到 0。第三正确率通过StatService.bankAccuracy获取不在页面里重复找题库进度。static bankAccuracy(progressList: BankProgress[], bankId: string): number { const p progressList.find(r r.bankId bankId) if (!p || p.finished 0) return 0 return Math.round(p.correct / p.finished * 100) } static bankFinished(progressList: BankProgress[], bankId: string): number { const p progressList.find(r r.bankId bankId) return p ? p.finished : 0 }如果题库从未练习过正确率显示 0%完成数显示0/totalCount。这比显示空白更稳定也便于用户理解“还没开始”。七、进度状态标签避免用户只看百分比progressLabel是一个小方法但它解决了统计页常见的表达问题。百分比适合工程计算不一定适合用户理解。状态标签把进度转换成“未开始、学习中、已完成”。private progressLabel(bank: Bank): string { const finished StatService.bankFinished(this.progressList, bank.id) if (finished 0) return 未开始 if (finished bank.totalCount) return 已完成 return 学习中 }这段代码有两个边界。条件标签用户理解finished 0未开始该题库还没有学习记录finished bank.totalCount已完成已答题数达到或超过题库总量其他情况学习中已开始但未完成这里没有试图判断“薄弱类型”。因为BankProgress只保存题库级别的已答和答对数量不保存按Question.type聚合后的错误分布。错题数量来自wrongRecords.length但它只包含题目 ID 和题库 ID。export interface WrongRecord { questionId: string bankId: string wrongAt: string }如果要真正实现“薄弱类型”需要把错题记录与题库题目列表关联按题型统计错误数量再计算占比。例如可以新增一个统计服务方法interface WeakTypeStat { type: string wrongCount: number } static weakTypes(wrongs: WrongRecord[], questions: Question[]): WeakTypeStat[] { const counter new Mapstring, number() for (const wrong of wrongs) { const q questions.find(item item.id wrong.questionId) if (!q) continue counter.set(q.type, (counter.get(q.type) ?? 0) 1) } return Array.from(counter.entries()) .map(([type, wrongCount]) ({ type, wrongCount })) .sort((a, b) b.wrongCount - a.wrongCount) }这段示例不是当前页面已上线能力而是从现有数据结构自然扩展出来的方向。文章写到这里必须把边界说清楚当前LearningStatsPage还没有调用这个方法也没有渲染薄弱类型列表。八、宽屏三列布局让题库进度更适合多设备HarmonyOS 应用如果只按手机宽度写页面在平板和 2in1 上会显得松散。LearningStatsPage对题库进度区域做了两套布局宽屏使用Flex三列窄屏使用Column单列。if (this.useGridLayout()) { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { ForEach(BANKS, (bank: Bank) { Column() { this.BankProgressItem(bank) } .width(32%) .margin({ bottom: 10 }) }, (bank: Bank) bank.id) } .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE }) } else { Column({ space: 10 }) { ForEach(BANKS, (bank: Bank) { this.BankProgressItem(bank) }, (bank: Bank) bank.id) } .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE }) }width(32%)留出了列间间距FlexWrap.Wrap允许题库数量变化时自动换行。对学习统计这种信息密集页面来说这比固定两列或固定宽度更稳。页面还通过onAreaChange更新宽度.onAreaChange((oldArea: Area, newArea: Area) { const width Number(newArea.width) if (width 0) { this.pageWidth width } })这个处理能覆盖窗口尺寸变化。用户在 2in1 设备上拖动窗口、平板横竖屏切换、应用分屏时页面宽度会重新进入布局判断。这里需要注意一点onAreaChange只更新页面宽度不直接修改currentBreakpoint。断点系统仍由全局逻辑负责本页面只消费断点。九、结构拆分页面、统计服务、持久化和题库目录各自独立统计页的结构可以用下面这张图理解。四个角色的职责如下。角色文件职责页面层LearningStatsPage.ets读取状态、选择布局、渲染统计 UI统计服务StatService.ets汇总总答题数、正确率、考试次数、收藏数、错题数持久化服务UserDataManager.ets从 Preferences 初始化 AppStorage并在答题、考试、收藏、错题变化时持久化题库目录MockBanks.ets提供题库基础信息、封面、总题数和章节目录这套拆分有一个现实好处当统计口径变化时通常只需要改StatService当 UI 适配变化时只需要改LearningStatsPage当数据持久化变化时优先改UserDataManager当题库增加时改BANKS即可。如果把这些逻辑都堆到页面里会出现三个维护风险风险具体表现更稳的做法UI 树变长Builder 中混入大量 reduce/filter提取到服务方法多页面口径不一致首页、统计页、我的页各算各的统一调用StatService.summarize存储细节泄漏页面直接读写 Preferences key通过UserDataManager管理这也是 HarmonyOS ArkTS 项目里比较推荐的方向页面保留短生命周期状态和渲染逻辑业务口径放服务层持久化放数据管理层。十、从练习页到统计页的数据闭环统计页本身不写入学习数据。它依赖练习页和考试结果页把数据写好。例如练习结束时PracticePage会更新题库进度。const correctCount this.records.filter(r r.correct).length this.progressList UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, this.chapterId)UserDataManager.updateProgress会按题库 ID 找到已有进度累加已答题数和答对题数。static updateProgress(records: BankProgress[], bankId: string, addFinished: number, addCorrect: number, chapterId: string): BankProgress[] { const idx records.findIndex(r r.bankId bankId) let next: BankProgress[] if (idx 0) { const old records[idx] const updated: BankProgress { bankId, finished: old.finished addFinished, correct: old.correct addCorrect, lastChapterId: chapterId, updatedAt: nowStr(), } next [...records] next[idx] updated } else { next [{ bankId, finished: addFinished, correct: addCorrect, lastChapterId: chapterId, updatedAt: nowStr() }, ...records] } UserDataManager.persist(UserDataManager.K_PROGRESS, next) return next }这个闭环可以描述为用户在练习页提交答案。练习页生成AnswerRecord[]。练习页计算本次答对数量。UserDataManager.updateProgress写入bankProgress。统计页通过StorageLink(bankProgress)读取新进度。StatService.summarize重新计算总答题数和正确率。这条链路完全在本地完成不依赖网络也没有上传用户学习记录。对于一个本地英语学习应用来说这个边界更容易通过隐私和上架审核也更容易解释给用户。十一、页面状态和空数据的处理方式LearningStatsPage没有单独写empty分支。它的空数据策略是让数字自然回落到 0让题库列表仍然显示所有题库。这种设计适合学习统计页因为即使用户没有学习记录也应该能看到有哪些题库可以开始。关键兜底如下场景源码兜底没有任何学习进度totalAnswered 0accuracyPercent 0没有考试历史recentAvgScore返回 0某题库没有进度bankFinished返回 0bankAccuracy返回 0某题库总题数为 0进度条 ratio 返回 0这类统计页不一定需要大面积空状态插画。用户需要看到“还没有数据”之外还需要知道下一步可以学哪些题库。把题库列表保留出来比直接显示一个空白页更实用。如果后续要增强空数据体验可以在概览卡里增加轻量提示而不是替换整个页面if (this.stats().totalAnswered 0) { Text(完成一次练习后这里会自动生成正确率和题库进度。) .fontSize(Sizes.CAPTION_FONT) .fontColor(#E6FFFFFF) }这段是建议扩展不是当前源码已实现能力。扩展时仍应保留题库列表让用户能直接选择学习目标。十二、验证这条统计链路时看哪些点本地统计页的验证不应该只看页面能不能打开。至少要覆盖数据写入、统计汇总、布局切换和边界数据。验证项操作期望结果空数据首进清空进度、考试、收藏和错题概览数字为 0题库列表仍显示完成一次练习在任一题库答题并结束总答题数增加正确率按答对数量更新完成一次考试提交模拟考试并进入结果页考试次数增加近 5 次均分更新添加收藏和错题在练习页收藏题目、答错题目收藏题目和错题数增加宽屏布局平板横屏或 2in1 宽窗口打开题库进度使用三列布局窄屏布局手机竖屏打开概览卡两行两列题库列表单列调试时可以先看 AppStorage 数据是否存在再看StatService返回值。不要一开始就怀疑 UI。private stats() { return StatService.summarize(this.progressList, this.examHistory, this.favRecords, this.wrongRecords) }如果progressList本身为空UI 不可能凭空显示已答题数。需要回到练习页提交链路检查UserDataManager.updateProgress是否被调用以及 Preferences 是否成功flushSync()。十三、常见问题与修复方向问题可能原因排查方式修复方向正确率一直是 0finished没有写入或为 0查看bankProgress是否更新检查练习结束时updateProgress参数近 5 次均分不变examHistory没有新增查看考试结果页是否调用addExamHistory确认考试完成后进入结果页宽屏仍然单列currentBp不是lg或页面宽度小于 700打印断点和pageWidth检查断点系统和窗口宽度题库卡片标题被挤压长名称缺少省略处理看Text(bank.name)样式保留maxLines与textOverflow统计页显示旧数据AppStorage 没有同步或页面未重新进入检查数据写入后状态是否回传保持StorageLink绑定不复制成局部数组这里最容易被忽略的是“复制状态”。如果进入页面时把progressList复制到一个State displayProgressList后续全局数据变化就可能不同步。当前源码直接使用StorageLink能减少这类问题。十四、可扩展但不能伪装成已实现的能力学习统计页后续可以继续扩展但扩展点要和现有数据结构匹配。想扩展的能力当前是否已实现最小改造连续打卡天数不在LearningStatsPage中读取checkInStreak并单独渲染薄弱题型排序未渲染关联WrongRecord与题目列表按Question.type聚合学习趋势折线图未实现从ExamHistory.finishedAt和score生成时间序列章节级完成度未在本页展示接入chapterProgress并按题库展开云端同步统计未实现需要网络、账号、隐私声明和冲突合并策略当前文章不声称这些已经上线。源码已经具备题库进度、错题、收藏、考试历史等基础数据后续要加趋势和薄弱项是合理方向但需要新增方法和 UI而不是在文案里直接把wrongRecords.length说成薄弱类型分析。十五、总结统计页要先统一口径再谈图表句匠的学习统计页给出的工程经验很直接不要急着堆图表先把数据来源和统计口径统一。LearningStatsPage通过StorageLink读取全局学习数据StatService负责汇总总答题数、正确率、考试次数、收藏数、错题数和已学习题库数UserDataManager负责 Preferences 持久化BANKS提供题库目录和总题数。这条链路的可复核点包括StatService.summarize统一全局统计口径。recentAvgScore默认取近 5 次考试。bankFinished和bankAccuracy为每个题库提供进度数据。progressLabel把完成度转换为未开始、学习中、已完成。useGridLayout结合断点和页面宽度切换单双栏布局。UserDataManager.updateProgress与addExamHistory形成练习和考试数据闭环。对于 HarmonyOS 5.0 以上 ArkTS 应用这种结构比“页面里直接 reduce 一堆数组”更稳。后续无论增加连续打卡、薄弱题型、趋势图还是章节完成度都能沿着现有的页面层、统计服务层和持久化层继续扩展。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑