摘要:中国方言题库的学习统计页没有维护一套额外计数器,而是从
bankProgress、examHistory、favoriteRecords和wrongRecords四组本地事实数据实时汇总。StatService负责全局答题数、加权正确率、考试次数、收藏数、错题数和已学习题库数,页面再按题库展示完成量与正确率,并在大屏断点下切换为三列布局。本文面向 HarmonyOS 5.0 及以上版本,基于真实 ArkTS 源码复核统计公式、Preferences 到 AppStorage 的数据链、近五次平均分、进度条钳制和多设备布局,同时指出累计答题次数可能超过题库题量等真实语义边界。

一、统计页应消费事实,不应手工同步计数器
统计类页面最常见的错误,是每完成一次动作就同时修改多个计数器:
text
答题记录 +1
总答题数 +1
题库答题数 +1
今日答题数 +1
任何一步遗漏都会产生漂移。中国方言题库采用更简单的路线:答题流程只更新题库进度、考试历史、收藏和错题等事实集合,统计页按需汇总。
唯一校验标记:统计值从事实集合推导,不再维护第二套手工计数器。
二、真实源码分为页面、统计服务和持久化三层
本文复核:
text
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
entry/src/main/ets/common/components/ProgressBar.ets
entry/src/main/ets/entryability/EntryAbility.ets
职责分工是:
text
UserDataManager
-> 读取和持久化本地事实
AppStorage / @StorageLink
-> 在页面间同步事实数组
StatService
-> 执行纯统计计算
LearningStatsPage
-> 按断点渲染概览和题库进度
页面没有直接访问 Preferences,也没有在 Builder 中复制一套持久化逻辑。
三、四组事实数据分别代表什么
页面声明:
ts
@StorageLink('bankProgress')
progressList: BankProgress[] = []
@StorageLink('examHistory')
examHistory: ExamHistory[] = []
@StorageLink('favoriteRecords')
favRecords: FavoriteRecord[] = []
@StorageLink('wrongRecords')
wrongRecords: WrongRecord[] = []
四组数据语义不同:
| 数据 | 代表事实 | 统计用途 |
|---|---|---|
BankProgress[] |
每个题库累计答题与答对量 | 总答题、总正确、题库进度 |
ExamHistory[] |
每次考试结算快照 | 考试次数、近五次均分 |
FavoriteRecord[] |
当前收藏的问题 | 收藏数量 |
WrongRecord[] |
当前仍在错题集的问题 | 错题数量 |
"收藏数"和"错题数"是当前集合长度,不是历史上累计收藏过或答错过多少次。
四、Preferences 如何进入 AppStorage
应用启动时,EntryAbility.onCreate() 调用:
ts
UserDataManager.init(this.context)
UserDataManager 从名为 dialect_quiz 的 Preferences 读取 JSON 字符串,然后初始化:
ts
AppStorage.setOrCreate<FavoriteRecord[]>(
'favoriteRecords',
JSON.parse(favStr) as FavoriteRecord[]
)
AppStorage.setOrCreate<WrongRecord[]>(
'wrongRecords',
JSON.parse(wrongStr) as WrongRecord[]
)
AppStorage.setOrCreate<BankProgress[]>(
'bankProgress',
JSON.parse(progStr) as BankProgress[]
)
AppStorage.setOrCreate<ExamHistory[]>(
'examHistory',
JSON.parse(examStr) as ExamHistory[]
)
统计页通过 @StorageLink 消费这些值。数据修改后,相关页面能观察到新数组,不需要再次打开数据库。
五、BankProgress 是累计尝试量,不是去重题目数
模型定义:
ts
export interface BankProgress {
bankId: string
finished: number
correct: number
lastChapterId: string
updatedAt: string
}
更新时执行累加:
ts
finished: old.finished + addFinished
correct: old.correct + addCorrect
因此 finished 表示累计完成的答题次数。用户重复练习同一道题,计数仍会增加。它不是"题库中已经去重掌握的题目数量"。
标题中"学习进度"在当前实现里的准确含义是"累计答题量相对题库题量的展示进度",不能把它当成严格的知识点完成率。
六、StatService 返回统一 LearningStats
统计结果接口:
ts
export interface LearningStats {
totalAnswered: number
totalCorrect: number
accuracyPercent: number
examCount: number
favoriteCount: number
wrongCount: number
studiedBankCount: number
}
页面通过:
ts
private stats() {
return StatService.summarize(
this.progressList,
this.examHistory,
this.favRecords,
this.wrongRecords
)
}
获得同一份统计口径。首页、我的页面也复用 StatService.summarize(),避免多个页面各算各的。

七、总答题数和总正确数如何计算
实现为:
ts
let totalAnswered = 0
let totalCorrect = 0
for (const p of progressList) {
totalAnswered += p.finished
totalCorrect += p.correct
}
这是一种按题库进度聚合的累计量。如果四川话完成 30 次、粤语完成 20 次:
text
totalAnswered = 30 + 20 = 50
它不依赖题库总数,也不扫描全部题目,计算复杂度为:
text
O(progressList.length)
八、全局正确率是加权正确率
公式:
ts
accuracyPercent:
totalAnswered > 0
? Math.round(
totalCorrect / totalAnswered * 100
)
: 0
它先汇总所有正确数和答题数,再计算比例。假设:
text
四川话:1/1,正确率 100%
粤语:50/100,正确率 50%
全局正确率是:
text
(1 + 50) / (1 + 100) = 50.5% -> 50%
而不是:
text
(100% + 50%) / 2 = 75%
前者按真实答题样本加权,更符合"所有答题的总体正确率"。
九、零答题保护避免 NaN
当 totalAnswered === 0 时,服务返回 0:
ts
totalAnswered > 0
? Math.round(totalCorrect / totalAnswered * 100)
: 0
因此新用户看到 0%,不会出现 NaN% 或 Infinity%。统计服务统一处理后,多个页面无需重复防御。
仍需注意损坏数据,例如 correct > finished 会产生超过 100% 的正确率。当前 Preferences 数据来自内部更新路径,正常不会出现;如增加导入恢复,应在数据层校验。
十、考试次数为何直接取历史长度
服务返回:
ts
examCount: examHistory.length
每进入一次结果页,UserDataManager.addExamHistory() 会追加一条记录。因此页面展示的是本地历史条数。
如果结果页生命周期重复触发导致同一场考试重复写入,考试次数也会被放大。统计服务不会主动去重,因为 ExamHistory 没有 sessionId。要解决,应在写入源头增加幂等键,而不是在统计页猜哪些记录重复。
十一、收藏数量是当前收藏集合长度
统计:
ts
favoriteCount: favorites.length
收藏模型:
ts
export interface FavoriteRecord {
questionId: string
bankId: string
createdAt: string
}
toggleFavorite() 找到同一 questionId 时移除,否则在数组头部新增。项目题目 ID 带题库前缀,因此当前集合能表达跨题库收藏。
这不是"收藏操作次数"。用户收藏后取消,数量会回落。
十二、错题数量代表当前待复习项
服务:
ts
wrongCount: wrongs.length
添加错题时先过滤同题,再新增:
ts
const filtered = records.filter(
r => r.questionId !== questionId
)
const result = [
{ questionId, bankId, wrongAt: nowStr() },
...filtered
]
因此同一道题答错多次仍只占一条。在错题练习中答对后,页面调用 removeWrong() 删除它。
所以"错题数"更接近当前错题本中的待复习数量,而不是历史错误次数。
十三、已学习题库数的真实口径
服务使用:
ts
studiedBankCount: progressList.length
只要某题库有一条 BankProgress 就被计入。正常更新路径首次答题才创建记录,因此通常等于有学习行为的题库数。
但它没有检查:
ts
p.finished > 0
也没有验证 bankId 是否仍存在于 BANKS。如果导入了零进度或旧版本残留题库,数量可能偏大。更严格的实现是:
ts
const validBankIds = new Set(
BANKS.map(bank => bank.id)
)
const studiedBankCount = progressList.filter(
p =>
p.finished > 0 &&
validBankIds.has(p.bankId)
).length
当前源码尚未采用这种过滤。
十四、近五次平均分依赖历史顺序
recentAvgScore():
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(
(s, e) => s + e.score,
0
)
return Math.round(sum / recent.length)
}
UserDataManager.addExamHistory() 把新记录放在数组头部,所以 slice(0, 5) 确实得到最近五次。
若未来从文件导入历史,必须先保证顺序,或者按 finishedAt 排序;否则"近五次"只代表数组前五条。
十五、题库正确率如何处理未开始状态
实现:
ts
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
)
}
没有进度或答题数为零时显示 0%。页面同时使用 progressLabel() 显示"未开始",因此用户能区分"未开始的 0%"和"已经答题但全错的 0%"吗?
当前两者文案不同:
text
未开始:进度标签"未开始",正确率 0%
全错:进度标签"学习中",正确率 0%
这组组合足以表达差异。
十六、每个题库的已答题数来自 bankId
服务:
ts
static bankFinished(
progressList: BankProgress[],
bankId: string
): number {
const p = progressList.find(
r => r.bankId === bankId
)
return p ? p.finished : 0
}
页面对 BANKS 中每个题库调用该方法。题库模型含 regionId,当前六个题库分别对应四川、粤语、东北、上海、闽南、客家等地区或方言分支,因此页面按题库展示,也形成了地区学习分布。
但源码标题是"各题库学习进度",并没有直接按 Region 聚合多个题库。若未来一个地区下有多个题库,需要额外按 regionId 汇总。
十七、题库总题量来自真实题目目录
BANKS 初始 totalCount 为 0,随后 syncCatalogCounts() 遍历实际题目:
ts
for (const bank of BANKS) {
const questions = getQuestions(bank.id)
bank.totalCount = questions.length
}
所以页面分母:
ts
bank.totalCount
来自当前题库真实题目数量,不是手写宣传数字。
十八、累计答题可能超过题库总题量
页面展示:
ts
`${finished}/${bank.totalCount}`
由于 finished 是累计尝试次数,用户反复练习后可能出现:
text
320/300
progressLabel() 会在:
ts
finished >= bank.totalCount
时显示"已完成"。这在视觉上表示学习量已覆盖总题量阈值,但不能证明 300 道不同题都做过。
若产品需要去重完成度,应保存已答 questionId 集合,不能从累计次数推导。
十九、进度条会把 ratio 钳制到 0 到 1
页面传入:
ts
ratio:
bank.totalCount > 0
? finished / bank.totalCount
: 0
公共 ProgressBar 再执行:
ts
Math.max(
0,
Math.min(1, this.ratio)
)
因此即使 finished > totalCount,前景宽度最多 100%,不会溢出容器。数字仍会显示真实累计值,不会被静默改写。
二十、统计概览由四个核心指标组成
概览卡显示:
text
已答题
正确率
考试次数
学习题库
紧凑布局使用两行两列;大屏布局使用一行四列。指标值全部来自 StatService.summarize(),没有写死演示数字。
页面每次调用 this.stats() 都重新汇总。当前数组很小,开销有限;如果历史达到数千条,可在状态变化时生成一次 ViewModel,避免一次构建重复计算。
二十一、数据摘要卡展示收藏、错题和均分
DataSummary():
ts
this.DataCard(
`${this.stats().favoriteCount}`,
'收藏题目',
Colors.ACCENT
)
this.DataCard(
`${this.stats().wrongCount}`,
'错题数',
Colors.ERROR
)
this.DataCard(
`${StatService.recentAvgScore(
this.examHistory
)}`,
'近5次均分',
Colors.SUCCESS
)
三张卡片使用不同语义色,但标签文字仍明确说明含义,不只依靠颜色区分。长标签通过单行省略保护布局。
二十二、大屏为何同时检查断点和宽度
布局判断:
ts
private useGridLayout(): boolean {
return (
this.currentBp === 'lg' &&
this.pageWidth >= 700
)
}
既要求断点为 lg,又要求实际页面宽度至少 700。这样可以避免断点状态滞后或容器嵌套导致页面实际宽度不足时仍强行三列。
pageWidth 通过根容器:
ts
.onAreaChange(
(oldArea: Area, newArea: Area) => {
const width = Number(newArea.width)
if (width > 0) {
this.pageWidth = width
}
}
)
实时更新,支持窗口缩放。

二十三、三列题库卡片如何适应宽屏
大屏使用:
ts
Flex({
wrap: FlexWrap.Wrap,
justifyContent:
FlexAlign.SpaceBetween
})
每个题库卡外层:
ts
.width('32%')
.margin({ bottom: 10 })
一行三列留出间隙,题库数量超过三时自动换行。紧凑模式则用单列 Column。
这种适配覆盖 phone、tablet 和 2in1 的基础信息密度变化,没有为每种设备复制页面。
二十四、题库卡的文本溢出保护
题库名称:
ts
.maxLines(1)
.textOverflow({
overflow: TextOverflow.Ellipsis
})
摘要卡标签也有同样保护。题库图固定 58x58,右侧内容使用:
ts
.layoutWeight(1)
所以长名称会优先省略,不会把进度数字挤出卡片。宽屏三列下尤其需要这种约束。
二十五、页面为什么全部可滚动
主体放在:
ts
Scroll() {
Column({ space: 18 }) {
// 概览、摘要、各题库
}
}
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)
手机横屏、小窗或系统字体放大时,所有题库仍可通过滚动访问。底部使用 Blank().height(20) 保留视觉间距。
当前页面没有读取底部导航安全区;它作为普通二级页使用 TopBar,但在手势导航和特殊窗口下仍应实机确认最后一张卡是否有足够避让。
二十六、统计服务为何适合单元测试
StatService 方法:
- 不访问 UI;
- 不读 Context;
- 不直接访问 Preferences;
- 输入数组,返回数字或对象;
- 没有异步回调。
因此可以用简单样本验证:
ts
const progress: BankProgress[] = [
{
bankId: 'b_sichuan',
finished: 10,
correct: 8,
lastChapterId: '',
updatedAt: ''
},
{
bankId: 'b_yue',
finished: 20,
correct: 10,
lastChapterId: '',
updatedAt: ''
}
]
预期:
text
totalAnswered = 30
totalCorrect = 18
accuracyPercent = 60
studiedBankCount = 2
二十七、近五次均分的边界测试
至少覆盖:
text
空历史 -> 0
1 次 80 分 -> 80
3 次 80/90/100 -> 90
6 次 -> 只取数组前 5 条
count=0 -> 当前实现会产生 0/0
最后一个边界很重要。方法默认 count=5,页面不会传 0;但公共 API 若被其他模块调用 count=0,recent=[],随后除以零会得到 NaN。
可以增加:
ts
if (count <= 0) return 0
当前页面路径安全,但服务契约仍有加固空间。
二十八、数据损坏时应在哪一层处理
UserDataManager.init() 把所有读取放在一个 try/catch 中。任意 JSON 解析失败会回退初始化多组空数组。这样能避免启动崩溃,但可能因为一个字段损坏而丢失其他可用统计。
更细粒度的策略是逐键解析:
ts
function parseArray<T>(
raw: string,
fallback: T[]
): T[] {
try {
const value = JSON.parse(raw) as T[]
return Array.isArray(value)
? value
: fallback
} catch (_) {
return fallback
}
}
统计页只消费已归一化的数据,不应在 UI 层到处判断 JSON 错误。
二十九、收藏数量与地区数量如何扩展
标题关注"地区学习进度与收藏数量"。当前收藏只展示全局总数。如果要按地区统计收藏,可利用记录中的 bankId 和题库的 regionId:
text
FavoriteRecord.bankId
-> Bank.id
-> Bank.regionId
再按 regionId 分组。当前每个主要题库对应一个地区,分组结果会接近按题库统计。
扩展时不要直接从题目中文名称解析地区。应使用稳定 ID 映射,避免文案调整破坏统计。
三十、当前统计不是活跃天数或学习时长
页面没有保存每日学习日志,也没有汇总 durationSec 为总学习时长。ExamHistory 只记录考试用时,普通章节和随机练习的 timerSec 没有进入统计服务。
因此现有能力不能描述为:
- 连续学习天数;
- 今日学习时长;
- 总学习小时数;
- 地区掌握度预测;
- 用户排名。
这些指标都需要新增真实数据模型和更新链路。
三十一、性能上避免在 build 中多次 summarize
OverviewCard() 中多次调用:
ts
this.stats().totalAnswered
this.stats().accuracyPercent
this.stats().examCount
this.stats().studiedBankCount
每次都会重新遍历 progressList。当前只有少量题库,影响可忽略;但代码可读性和可测试性仍可改善。
可在构建前准备:
ts
const stats = this.stats()
ArkUI Builder 对普通局部语句有约束时,可以改为缓存 @State ViewModel,并在事实数组变化时更新。不要为了小数组过早引入复杂响应式框架。
三十二、统计测试矩阵
| 场景 | 预期 |
|---|---|
| 全部数组为空 | 所有统计为 0,页面不崩溃 |
| 单题库 10 答 8 对 | 总答题 10,正确率 80% |
| 多题库样本不等 | 使用加权正确率 |
| 收藏后取消 | 收藏数回落 |
| 同题重复答错 | 错题集合只保留一条 |
| 错题模式答对 | 错题数减少 |
| 无考试历史 | 近五次均分 0 |
| 超过 5 次考试 | 只取最新前 5 条 |
| 累计答题超过题量 | 数字真实显示,进度条不溢出 |
| lg 且宽度 >=700 | 三列题库布局 |
| lg 但宽度不足 | 保持单列 |
| 窗口缩放 | 页面Width更新并切换布局 |
三十三、发布前真实性与合规检查
- "已答题"明确是累计答题次数;
- "学习题库"不等于全部题库完成;
- "错题数"是当前错题集,不是历史错误次数;
- "收藏题目"是当前收藏集合;
- "近5次均分"依赖历史按新到旧排列;
- 不宣称学习时长、连续天数、排名或掌握度;
- 清除本地数据后统计同步归零;
- 深浅色下概览卡与数据卡对比度合格;
- 手机、平板、2in1、小窗和横屏布局不截断;
- 大字体下题库名称和指标标签不重叠;
- Preferences 只保存必要本地学习数据;
- 安装、启动、答题、统计更新、重启恢复和卸载流程稳定。
三十四、结语
LearningStatsPage 的工程价值不在于显示了多少张卡,而在于数据口径集中:BankProgress 保存累计答题事实,ExamHistory 保存考试结算,收藏和错题保存当前集合;StatService 用纯函数汇总;页面通过 @StorageLink 响应数据变化,并在大屏条件下切换布局。
同样要承认当前边界:题库"完成量"是累计尝试次数,可能超过真实题量;学习题库数直接取进度数组长度;近五次均分依赖数组顺序;页面没有学习时长、活跃天数或排名。统计可信的前提,不是数字看起来丰富,而是每个数字都能沿着真实记录追溯到计算公式。
AI 辅助声明:本文部分内容由 AI 辅助整理,所有功能描述、代码片段、统计口径和工程结论均依据项目真实源码人工复核;未伪造学习时长、用户排名、收藏量或平台数据。