学习统计持久化------StatisticsManager 的数据聚合方案

引言
学习数据统计是教育类 App 的核心功能之一。用户完成一次练习后,系统需要快速计算出正确率、各题型得分率、平均答题时长等指标,并将这些数据持久化,以便用户在"学习报告"等页面查看历史趋势。本文将以 StatisticsManager 的实现为例,分析如何设计一套可扩展、可聚合的学习统计方案。
一、数据模型设计
统计数据的核心是 PracticeStatistics 接口,它包含了全局统计和分题型统计两个维度:
typescript
export interface PracticeStatistics {
// 全局统计数据
totalQuestions: number // 总答题数
correctQuestions: number // 正确题数
wrongQuestions: number // 错误题数
correctRate: number // 正确率(百分比)
vocabularyMasteryRate: number // 词汇掌握率
listeningCorrectRate: number // 听力正确率
readingCorrectRate: number // 阅读正确率
clozeCorrectRate: number // 完形填空正确率
avgAnswerTime: number // 平均答题时长(秒)
// 分题型统计
typeStatistics: TypeStat[]
}
export interface TypeStat {
type: string // 题型编号:'1'词汇 '2'听力 '3'阅读 '4'完形 '5'语法 '6'口语
total: number // 该题型总题数
correct: number // 正确数
wrong: number // 错误数
}
设计亮点 :typeStatistics 数组而非固定字段。这样可以支持未来新增题型而无需修改数据模型------符合开闭原则。
二、单次练习统计计算
calculateStatistics 方法接收一次练习的题目列表和总时长,实时计算各项指标:
typescript
public calculateStatistics(ques: TopicItemType[], duration: number): PracticeStatistics {
try {
let totalQuestions = ques.length;
let correctQuestions = 0;
let wrongQuestions = 0;
let typeStats: TypeStat[] = [];
ques.forEach(item => {
if (item.isAnswer) {
// 判断答案是否正确
let isCorrect = this.checkAnswerCorrect(item.selectQues, item.rightQues);
if (isCorrect) {
correctQuestions++;
} else {
wrongQuestions++;
}
// 按题型累加统计
let typeKey = item.type || '其他';
let found = false;
for (let i = 0; i < typeStats.length; i++) {
if (typeStats[i].type === typeKey) {
typeStats[i].total++;
if (isCorrect) typeStats[i].correct++;
else typeStats[i].wrong++;
found = true;
break;
}
}
if (!found) {
typeStats.push({
type: typeKey, total: 1,
correct: isCorrect ? 1 : 0,
wrong: isCorrect ? 0 : 1
});
}
}
});
let correctRate = totalQuestions > 0 ? (correctQuestions / totalQuestions * 100) : 0;
// 各题型专项正确率
let listeningCorrectRate = this.getTypeCorrectRate(typeStats, '2');
let readingCorrectRate = this.getTypeCorrectRate(typeStats, '3');
let clozeCorrectRate = this.getTypeCorrectRate(typeStats, '4');
let vocabularyMasteryRate = this.getTypeCorrectRate(typeStats, '1');
let avgAnswerTime = totalQuestions > 0 ? Math.floor(duration / totalQuestions) : 0;
let statistics: PracticeStatistics = {
totalQuestions, correctQuestions, wrongQuestions,
correctRate: Math.round(correctRate * 100) / 100,
vocabularyMasteryRate: Math.round(vocabularyMasteryRate * 100) / 100,
listeningCorrectRate: Math.round(listeningCorrectRate * 100) / 100,
readingCorrectRate: Math.round(readingCorrectRate * 100) / 100,
clozeCorrectRate: Math.round(clozeCorrectRate * 100) / 100,
avgAnswerTime,
typeStatistics: typeStats
};
// 合并到历史数据
this.saveStatistics(statistics);
return statistics;
} catch (e) {
Logger.error('StatisticsManager', `计算统计数据失败: ${JSON.stringify(e)}`);
return this.getEmptyStatistics();
}
}
核心逻辑:
- 遍历题目:逐题判断是否正确,同时按题型分类统计。
- 答案比较 :
checkAnswerCorrect比较selectQues和rightQues两个数组排序后是否完全一致。 - 四舍五入 :使用
Math.round(x * 100) / 100保留两位小数。 - 降级处理:异常时返回空统计数据,不影响用户使用。
三、历史数据聚合
单次练习的数据需要与历史数据合并,才能反映用户的长期学习趋势:
typescript
private saveStatistics(statistics: PracticeStatistics): void {
try {
let historyStats = this.getHistoryStatistics();
let mergedStats: PracticeStatistics = {
totalQuestions: historyStats.totalQuestions + statistics.totalQuestions,
correctQuestions: historyStats.correctQuestions + statistics.correctQuestions,
wrongQuestions: historyStats.wrongQuestions + statistics.wrongQuestions,
correctRate: statistics.totalQuestions > 0 ? statistics.correctRate : historyStats.correctRate,
vocabularyMasteryRate: statistics.vocabularyMasteryRate,
listeningCorrectRate: statistics.listeningCorrectRate,
readingCorrectRate: statistics.readingCorrectRate,
clozeCorrectRate: statistics.clozeCorrectRate,
avgAnswerTime: statistics.avgAnswerTime,
typeStatistics: this.mergeTypeStatistics(historyStats.typeStatistics, statistics.typeStatistics)
};
PreferenceUtil.getInstance().put(this.STATISTICS_KEY, mergedStats);
} catch (e) {
Logger.error('StatisticsManager', `保存统计数据失败: ${JSON.stringify(e)}`);
}
}
聚合策略:
- 累加型字段 (
totalQuestions、correctQuestions、wrongQuestions):历史值 + 新值直接相加。 - 覆盖型字段 (
correctRate、各专项正确率):以最新一次计算为准。 - 分题型统计 :通过
mergeTypeStatistics方法进行深度合并。
typescript
private mergeTypeStatistics(history: TypeStat[], current: TypeStat[]): TypeStat[] {
// 先深度复制历史数据
let result: TypeStat[] = [];
for (let i = 0; i < history.length; i++) {
result.push({
type: history[i].type,
total: history[i].total,
correct: history[i].correct,
wrong: history[i].wrong
});
}
// 合并当前数据
for (let i = 0; i < current.length; i++) {
let found = false;
for (let j = 0; j < result.length; j++) {
if (result[j].type === current[i].type) {
result[j].total += current[i].total;
result[j].correct += current[i].correct;
result[j].wrong += current[i].wrong;
found = true;
break;
}
}
if (!found) {
result.push({
type: current[i].type,
total: current[i].total,
correct: current[i].correct,
wrong: current[i].wrong
});
}
}
return result;
}
这里手动深度复制而非直接赋值,是因为 TypeStat 是引用类型,直接复制会共享底层对象,导致历史数据被意外修改。
四、历史数据读取与展示
typescript
public getHistoryStatistics(): PracticeStatistics {
try {
let stats = PreferenceUtil.getInstance().get(this.STATISTICS_KEY, undefined) as PracticeStatistics;
if (!stats) {
return this.getEmptyStatistics();
}
return stats;
} catch (e) {
Logger.error('StatisticsManager', `获取历史统计数据失败: ${JSON.stringify(e)}`);
return this.getEmptyStatistics();
}
}
public getQuestionTypeStats(): QuestionTypeStat[] {
try {
let stats = this.getHistoryStatistics();
let result: QuestionTypeStat[] = [];
for (let i = 0; i < stats.typeStatistics.length; i++) {
let typeStat = stats.typeStatistics[i];
result.push({
typeName: this.getTypeName(typeStat.type),
total: typeStat.total,
correct: typeStat.correct,
wrong: typeStat.wrong,
correctRate: typeStat.total > 0
? Math.round(typeStat.correct / typeStat.total * 10000) / 100 : 0
});
}
return result.sort((a, b) => b.total - a.total);
} catch (e) {
Logger.error('StatisticsManager', `获取题型统计失败: ${JSON.stringify(e)}`);
return [];
}
}
getQuestionTypeStats 将内部的数字题型编号转换为可读的中文名称,并按总题数降序排列,方便 UI 层展示。
五、最佳实践
5.1 空值安全
首次使用或数据被清空时,getHistoryStatistics 返回空统计数据而非 null,避免调用方做空值检查:
typescript
private getEmptyStatistics(): PracticeStatistics {
return {
totalQuestions: 0, correctQuestions: 0, wrongQuestions: 0,
correctRate: 0, vocabularyMasteryRate: 0,
listeningCorrectRate: 0, readingCorrectRate: 0, clozeCorrectRate: 0,
avgAnswerTime: 0, typeStatistics: []
};
}
5.2 精度控制
所有百分比值使用 Math.round(x * 100) / 100 确保保留两位小数,避免浮点数精度问题导致 UI 上显示一长串小数。
5.3 题型映射集中管理
题型编号到中文名称的映射集中在 getTypeName 方法中,避免在 UI 层散落 switch-case:
typescript
private getTypeName(type: string): string {
switch (type) {
case '1': return '单词拼写'
case '2': return '听力理解'
case '3': return '阅读理解'
case '4': return '完形填空'
case '5': return '语法练习'
case '6': return '口语练习'
default: return `题型${type}`
}
}
六、总结
StatisticsManager 展现了一个典型的数据聚合场景:从原始数据(题目列表)到计算指标(正确率),再到历史数据合并(累加 + 覆盖),最后到 UI 友好的展示格式(题型名称转换 + 排序)。这种分层设计使得统计逻辑高度内聚、易于测试,同时在需要新增题型或统计维度时,只需扩展 TypeStat 数组即可,无需修改核心聚合逻辑。