第21次:测验服务层实现

这一篇我们只讲服务层,对应文件是 entry/src/main/ets/services/QuizService.ets。它主要负责课程测验,而不是面试题库。也就是说,本篇要看的重点是:如何管理测验数据、验证答案、计算分数、保存历史记录。


为什么测验必须有服务层

如果你把测验逻辑全部写在页面里,会很快陷入混乱:

  • 答案验证写在页面里
  • 分数计算写在页面里
  • 历史记录写在页面里
  • 通过与否判断写在页面里

一旦以后你想做“最佳成绩”“测验历史”“是否通过某节课测验”这些能力,页面会越来越臃肿。所以当前项目把测验逻辑统一收进 QuizService,这是非常合理的。

QuizPage / 其他测验页面

QuizService

init(quizzes)

validateAnswer

calculateScore

submitQuiz

loadQuizHistory / saveQuizHistory


一、服务里维护了哪两类核心数据

QuizService 里有两个关键状态:

  • quizzes: Map<string, Quiz>
  • quizHistory: QuizResult[]

这两个状态分别解决不同问题:

quizzes

负责保存题库本体。用 Map 而不是数组,是为了后续根据 quizId 查找更方便。

quizHistory

负责保存用户做题结果,比如:

  • 这次测验得了多少分
  • 一共有多少题
  • 是否通过
  • 用户答案是什么
  • 完成时间
  • 花了多久

把题目本体和用户历史分开存,是典型的业务分层思路。题目是“静态教学数据”,历史是“动态用户行为数据”,两者不要混着管理。


二、为什么 init(quizzes) 要先把数组转成 Map

服务初始化时会把传进来的 Quiz[] 转成 Map<string, Quiz>。这个操作看起来不起眼,但很有意义。

因为后面很多操作都围绕 quizId 展开,比如:

  • getQuiz(quizId)
  • submitQuiz(quizId, answers, timeSpent)
  • getBestScore(quizId)
  • hasPassedQuiz(quizId)

如果你一直用数组,每次都要遍历查找;改成 Map 后,结构更适合“以 ID 为中心”的访问方式。

这也是服务层设计中很重要的一点:数据结构要为后续操作服务,而不是只图一开始省事。


三、答案验证和分数计算是怎么拆分的

QuizService 没有把所有逻辑都写成一个大方法,而是拆成了几个小而明确的方法:

  • validateAnswer(question, userAnswer)
  • calculateScore(quiz, answers)
  • getIncorrectAnswers(quiz, answers)

这种拆法特别值得学。因为它带来的好处非常明显:

  1. 每个方法职责单一。
  2. 页面层可以按需复用。
  3. 单元测试更容易写。

例如,calculateScore() 内部会逐题比较用户答案,最后算百分比;而 getIncorrectAnswers() 则专门返回错误题目的索引列表,方便页面高亮或后续分析。

这说明“服务层的好坏”不在于方法多不多,而在于每个方法是不是边界清晰。


四、submitQuiz() 为什么是整个服务的核心

如果说这一份服务只有一个最关键的方法,那就是 submitQuiz()

它做的事情非常完整:

  1. 通过 quizId 找到测验对象。
  2. 调用 calculateScore() 算出得分。
  3. 根据 passingScore 或应用默认及格线判断是否通过。
  4. 生成 QuizResult 结果对象。
  5. 把结果写入 quizHistory
  6. 调用 saveQuizHistory() 持久化。

这个方法体现了一个非常成熟的设计思路:页面提交的是“用户行为”,服务产出的是“业务结果”。

页面并不应该自己去拼 QuizResult,因为那样历史格式很容易失控。统一由服务层生成结果对象,整个系统的数据结构才稳定。

StorageUtil QuizService 页面 StorageUtil QuizService 页面 submitQuiz(quizId, answers, timeSpent) calculateScore() 生成 QuizResult 保存 quizHistory 返回结果对象

五、历史记录为什么单独做持久化

课程测验不是一次性行为。用户可能:

  • 重复刷题
  • 想知道自己最佳成绩
  • 想判断某门课程是否已经通过

所以 QuizService 专门提供了:

  • loadQuizHistory()
  • getQuizHistory()
  • getBestScore(quizId)
  • hasPassedQuiz(quizId)
  • clearHistory()

这说明服务并不只是“做完一张卷子就结束”,它还要支持后续复盘和统计。

从产品思维看,这一点非常重要。学习类应用要帮助用户形成长期积累,不是只让用户点一次提交按钮。


六、课程测验和面试题库有什么区别

这里一定要分清楚,因为项目里既有 QuizService,也有 InterviewQuizService

QuizService

面向课程内测验,强调:

  • 某一节课对应的测验
  • 是否通过
  • 历史成绩

InterviewQuizService

面向题库练习,强调:

  • 模块统计
  • 总答题数
  • 正确率
  • 错题本联动

也就是说,这两者都叫“测验”,但产品定位不一样,所以服务层设计也不一样。教程里如果不区分,读者很容易混淆。


七、你应该怎样自己走一遍这套逻辑

  1. 先打开 QuizService.ets,从字段定义开始看。
  2. 依次理解 initvalidateAnswercalculateScoresubmitQuiz
  3. 对照模型文件,看 QuizQuizItemQuizResult 分别是什么。
  4. 找一个实际页面去追踪 submitQuiz() 的调用位置。
  5. 思考如果页面想显示“历史最高分”,应该调用哪个服务方法。

按照这个顺序理解,你会发现服务层不再抽象,反而非常实用。


八、本篇常见坑

1. 用页面直接计算分数

这样会导致多个页面各算各的,规则难以统一。

2. 题目数据和答题历史混放

静态题库和动态历史必须分开。

3. 不保存 timeSpent

学习类产品里,用时是很有价值的行为数据,至少应该预留字段。

4. 把课程测验和面试题库混为一谈

两者虽然相似,但业务目标不同,服务设计也不同。


本篇小结

这一篇最核心的收获是:测验服务层的职责不是“帮页面做个判断”,而是完整承接一套测验业务流程,包括题目初始化、答案验证、分数计算、结果产出和历史保存。

只要你把 QuizService 这种写法吃透,后面无论是考试系统、问卷系统还是练习系统,服务层设计都会顺很多。


跟着真实源码继续往下看

当前项目计算分数的真实代码如下:

static calculateScore(quiz: Quiz, answers: number[]): number {
  if (quiz.questions.length === 0) {
    return 0;
  }

  let correctCount = 0;
  for (let i = 0; i < quiz.questions.length; i++) {
    if (i < answers.length && QuizService.validateAnswer(quiz.questions[i], answers[i])) {
      correctCount++;
    }
  }

  return Math.round((correctCount / quiz.questions.length) * 100);
}

提交测验结果的真实实现如下:

static async submitQuiz(quizId: string, answers: number[], timeSpent: number): Promise<QuizResult> {
  const quiz = QuizService.quizzes.get(quizId);
  if (!quiz) {
    throw new Error(`Quiz not found: ${quizId}`);
  }

  const score = QuizService.calculateScore(quiz, answers);
  const passed = score >= (quiz.passingScore ?? AppConstants.QUIZ_PASSING_SCORE);

按这个顺序动手

  1. 打开 entry/src/main/ets/services/QuizService.ets
  2. 先看 validateAnswercalculateScore
  3. 再看 submitQuiz,确认结果对象是在服务层而不是页面层生成的。

课后练习

  1. 如果要在课程详情页显示“本节测验历史最高分”,请写出你会调用的服务方法组合。
  2. 思考 getIncorrectAnswers() 为什么返回索引而不是直接返回题目对象。
  3. 试着自己补一张表,列出 QuizQuizItemQuizResult 三种模型分别服务于哪个阶段。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐