HarmonyOS 应用开发《掌上英语》第27篇:学习统计持久化——StatisticsManager 的数据聚合方案

学习统计持久化------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();
  }
}

核心逻辑

  1. 遍历题目:逐题判断是否正确,同时按题型分类统计。
  2. 答案比较checkAnswerCorrect 比较 selectQuesrightQues 两个数组排序后是否完全一致。
  3. 四舍五入 :使用 Math.round(x * 100) / 100 保留两位小数。
  4. 降级处理:异常时返回空统计数据,不影响用户使用。

三、历史数据聚合

单次练习的数据需要与历史数据合并,才能反映用户的长期学习趋势:

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)}`);
  }
}

聚合策略

  • 累加型字段totalQuestionscorrectQuestionswrongQuestions):历史值 + 新值直接相加。
  • 覆盖型字段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 数组即可,无需修改核心聚合逻辑。

相关推荐
●VON1 小时前
鸿蒙 PC Markdown 编辑器内部隐私与安全评审
安全·华为·编辑器·harmonyos·鸿蒙
木木子222 小时前
# [特殊字符] 音乐播放器 — 鸿蒙ArkTS播放控制与列表管理
华为·harmonyos
2301_768103492 小时前
HarmonyOS趣味相机实战第24篇:前摄镜像、旋转补偿与识别框坐标统一
图像处理·harmonyos·arkts·camerakit·坐标映射
国服第二切图仔2 小时前
21-MCP服务
harmonyos
绝世番茄2 小时前
HarmonyOS List 上拉加载更多(LoadMore)深度实战指南
华为·list·harmonyos·鸿蒙
勇踏前人未索之境2 小时前
Unity打包运行于鸿蒙手机
unity·智能手机·harmonyos
●VON3 小时前
鸿蒙 PC Markdown 编辑器离线专业渲染管线
华为·编辑器·harmonyos·鸿蒙
Helen_cai3 小时前
OpenHarmony 项目统一全局样式、尺寸、色彩主题封装 ThemeUtil(API23)
开发语言·前端·javascript·华为·harmonyos
youtootech3 小时前
HarmonyOS《柚兔学伴》项目实战12-Lottie 动画集成
华为·harmonyos