HarmonyOS APP<玩转React>开源教程二十一:测验服务层实现
第21次:测验服务层实现
这一篇我们只讲服务层,对应文件是
entry/src/main/ets/services/QuizService.ets。它主要负责课程测验,而不是面试题库。也就是说,本篇要看的重点是:如何管理测验数据、验证答案、计算分数、保存历史记录。
为什么测验必须有服务层
如果你把测验逻辑全部写在页面里,会很快陷入混乱:
- 答案验证写在页面里
- 分数计算写在页面里
- 历史记录写在页面里
- 通过与否判断写在页面里
一旦以后你想做“最佳成绩”“测验历史”“是否通过某节课测验”这些能力,页面会越来越臃肿。所以当前项目把测验逻辑统一收进 QuizService,这是非常合理的。
一、服务里维护了哪两类核心数据
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)
这种拆法特别值得学。因为它带来的好处非常明显:
- 每个方法职责单一。
- 页面层可以按需复用。
- 单元测试更容易写。
例如,calculateScore() 内部会逐题比较用户答案,最后算百分比;而 getIncorrectAnswers() 则专门返回错误题目的索引列表,方便页面高亮或后续分析。
这说明“服务层的好坏”不在于方法多不多,而在于每个方法是不是边界清晰。
四、submitQuiz() 为什么是整个服务的核心
如果说这一份服务只有一个最关键的方法,那就是 submitQuiz()。
它做的事情非常完整:
- 通过
quizId找到测验对象。 - 调用
calculateScore()算出得分。 - 根据
passingScore或应用默认及格线判断是否通过。 - 生成
QuizResult结果对象。 - 把结果写入
quizHistory。 - 调用
saveQuizHistory()持久化。
这个方法体现了一个非常成熟的设计思路:页面提交的是“用户行为”,服务产出的是“业务结果”。
页面并不应该自己去拼 QuizResult,因为那样历史格式很容易失控。统一由服务层生成结果对象,整个系统的数据结构才稳定。
五、历史记录为什么单独做持久化
课程测验不是一次性行为。用户可能:
- 重复刷题
- 想知道自己最佳成绩
- 想判断某门课程是否已经通过
所以 QuizService 专门提供了:
loadQuizHistory()getQuizHistory()getBestScore(quizId)hasPassedQuiz(quizId)clearHistory()
这说明服务并不只是“做完一张卷子就结束”,它还要支持后续复盘和统计。
从产品思维看,这一点非常重要。学习类应用要帮助用户形成长期积累,不是只让用户点一次提交按钮。
六、课程测验和面试题库有什么区别
这里一定要分清楚,因为项目里既有 QuizService,也有 InterviewQuizService。
QuizService
面向课程内测验,强调:
- 某一节课对应的测验
- 是否通过
- 历史成绩
InterviewQuizService
面向题库练习,强调:
- 模块统计
- 总答题数
- 正确率
- 错题本联动
也就是说,这两者都叫“测验”,但产品定位不一样,所以服务层设计也不一样。教程里如果不区分,读者很容易混淆。
七、你应该怎样自己走一遍这套逻辑
- 先打开
QuizService.ets,从字段定义开始看。 - 依次理解
init、validateAnswer、calculateScore、submitQuiz。 - 对照模型文件,看
Quiz、QuizItem、QuizResult分别是什么。 - 找一个实际页面去追踪
submitQuiz()的调用位置。 - 思考如果页面想显示“历史最高分”,应该调用哪个服务方法。
按照这个顺序理解,你会发现服务层不再抽象,反而非常实用。
八、本篇常见坑
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);
按这个顺序动手
- 打开
entry/src/main/ets/services/QuizService.ets。 - 先看
validateAnswer和calculateScore。 - 再看
submitQuiz,确认结果对象是在服务层而不是页面层生成的。
课后练习
- 如果要在课程详情页显示“本节测验历史最高分”,请写出你会调用的服务方法组合。
- 思考
getIncorrectAnswers()为什么返回索引而不是直接返回题目对象。 - 试着自己补一张表,列出
Quiz、QuizItem、QuizResult三种模型分别服务于哪个阶段。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)