ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS应用<奇妙科学乐园>开发第58篇:QuizEngine答题引擎——题目加载/答题/计分

HarmonyOS应用<奇妙科学乐园>开发第58篇:QuizEngine答题引擎——题目加载/答题/计分 引言在上一篇文章中我们为《奇妙科学乐园》实现了ScienceDataService核心数据服务完成了全量数据从 rawfile 到内存的加载与缓存体系。有了数据基础设施之后应用中最具互动性的功能模块——趣味问答系统终于可以开始搭建了。趣味问答是《奇妙科学乐园》面向6-12岁儿童的核心互动功能之一。与传统的静态展示不同问答系统需要处理选题-作答-判定-反馈-计分-归档一整套动态流程同时还要支持分类筛选、错题重做、每日挑战等多种模式。这背后需要一个职责清晰、状态管理严谨的引擎来统筹一切。这个引擎就是QuizEngine。它以单例模式运行在整个应用生命周期中负责从 rawfile 的quizzes.json加载全部题库管理答题会话QuizSession的完整生命周期并在答题结束时将成绩写入UserPreferences持久化存储、触发AchievementManager的成就检测。本文将从数据模型设计出发逐步解析QuizEngine的题目加载与随机化、答题会话管理、正确率计算与成绩保存、成就触发联动等核心机制并结合Quiz答题页面和WrongQuiz错题本页面的实际交互代码呈现完整的问答系统实现。 学习目标完成本文后你将能够✅ 理解QuizEngine的单例架构和初始化流程✅ 掌握 Fisher-Yates 洗牌算法在题目随机加载中的实现✅ 理解QuizSession会话模型的状态流转设计✅ 掌握答题判定、正确率计算、成绩保存的完整链路✅ 了解答题完成时如何联动AchievementManager触发成就检测✅ 学会设计每日挑战等确定性随机题目选取方案️ 核心设计整体架构QuizEngine在应用架构中处于 ViewModel 层承上启下上方通过RawFileUtil从 rawfile 加载quizzes.json静态题库下方调用userPrefsUserPreferences保存答题成绩和错题记录侧方调用achievementManagerAchievementManager触发成就进度更新前端被Quiz答题页面、WrongQuiz错题本页面、QuizResult结果页面消费数据模型题目的数据模型定义在model/Quiz.ets中//entry/src/main/ets/model/Quiz.ets export interface QuizQuestion { id: number;//题目唯一标识 category: string;//分类IDspace/nature/ocean/tech/weather/human categoryName: string;//分类中文名宇宙太空/自然生物/... question: string;//题目正文 options: string[];//选项数组固定4个 correctIndex: number;//正确答案索引0-3 explanation: string;//解析文案 difficulty:easy|medium|hard;//难度等级 icon: string;//分类emoji图标 }QuizEngine自身定义了三个关键接口//entry/src/main/ets/viewmodel/QuizEngine.ets//答题会话——一次完整的答题过程 export interface QuizSession { questions: QuizQuestion[];//本次答题的题目列表 currentIndex: number;//当前题目索引 correctCount: number;//答对题数 wrongCount: number;//答错题数 answers: number[];//用户作答记录 isFinished: boolean;//是否已答完 startTime: number;//答题开始时间戳 category?: string;//答题分类可选 }//单次答题提交结果 export interface SubmitResult { isCorrect: boolean;//是否答对 correctIndex: number;//正确答案索引 explanation: string;//题目解析 }//答题成绩汇总 export interface ScoreResult { correct: number;//答对数 total: number;//总题数 percentage: number;//正确率0-100 }设计原则QuizEngine遵循三条核心设计原则单一会话同一时间只维护一个QuizSession新开答题会覆盖旧会话不可回退submitAnswer提交后立即判定nextQuestion后无法修改之前的答案自动归档最后一题提交并调用nextQuestion时自动触发成绩保存和成就检测 代码实现一、单例模式与初始化QuizEngine采用经典单例模式确保全局只有一个引擎实例。这与UserPreferences、AchievementManager、ScienceDataService保持一致。// entry/src/main/ets/viewmodel/QuizEngine.etsexportclassQuizEngine{privatestaticinstance: QuizEngine;privatecurrentSession: QuizSession | null null;privatequestions: QuizQuestion[] [];// 全量题库privateisInitialized:booleanfalse;// 初始化标记privateconstructor(){}// 私有构造禁止外部实例化/** * 获取单例实例 * 外部统一通过 quizEngine 导出变量使用 */staticgetInstance(): QuizEngine {if(!QuizEngine.instance) { QuizEngine.instance newQuizEngine(); }returnQuizEngine.instance; } }// 模块级单例导出外部直接 import 使用exportconstquizEngine QuizEngine.getInstance();初始化在EntryAbility.onCreate中完成从 rawfile 加载全量题库到内存/** * 初始化答题引擎从rawfile加载问答数据 *paramcontext - 应用上下文 */init(context: common.UIAbilityContext | common.Context):void{if(this.isInitialized) {return;// 防止重复初始化}try{// 通过 RawFileUtil 统一加载内部有文件缓存this.questions loadJsonDataQuizQuestion[](context,quizzes.json);this.isInitialized true; }catch(error) { console.error(QuizEngine初始化失败,error);thrownewError(问答数据加载失败); } }关键点isInitialized标记防止EntryAbility.onCreate被多次调用时重复加载异常直接抛出让上层感知初始化失败避免空题库导致后续运行时崩溃loadJsonData内部使用Map缓存已读取的文件内容二次调用零开销二、题目随机加载与 Fisher-Yates 洗牌startQuiz是答题流程的起点它负责从题库中筛选、随机选取指定数量的题目并创建答题会话/** * 开始一次新的答题会话 *paramcategoryId- 分类IDall表示全部分类 *paramquestionCount- 题目数量默认10道 *returns创建好的答题会话 */startQuiz(categoryId:stringall,questionCount:numberAppConstants.DEFAULT_QUIZ_COUNT):QuizSession{// 第一步按分类筛选题池letquestionPool:QuizQuestion[];if(categoryId all) { questionPool [...this.questions];// 浅拷贝不污染原始题库}else{ questionPool this.questions.filter(qq.category categoryId); }// 第二步Fisher-Yates 洗牌随机打乱题目顺序constshuffled this.shuffleArray(questionPool);// 第三步截取指定数量constselectedQuestions shuffled.slice(0,Math.min(questionCount, shuffled.length) );// 第四步构建答题会话constsession:QuizSession {questions: selectedQuestions,currentIndex:0,correctCount:0,wrongCount:0,answers: [],isFinished:false,startTime:Date.now(),category: categoryId };this.currentSession session;Logger.info(TAG,开始答题: 分类${categoryId}, 题目数${selectedQuestions.length});returnthis.currentSession; }Fisher-Yates 洗牌算法的实现/** *Fisher-Yates洗牌算法 * 从数组末尾向前遍历每次随机选一个位置交换 * 时间复杂度 O(n)空间复杂度 O(n)拷贝数组 * paramarray- 待洗牌的数组 * returns 打乱后的新数组不修改原数组 */ private shuffleArrayT(array: T[]): T[] {constresult [...array]; // 拷贝不修改原数组for(leti result.length -1; i 0; i--) {constj Math.floor(Math.random() * (i 1)); // 交换位置 i 和 jconsttemp result[i];result[i] result[j];result[j] temp; }returnresult; }Math.min(questionCount, shuffled.length)这个边界保护非常重要——当某个分类下的题目不足10道时不会越界报错而是有多少出多少。三、答题判定与反馈submitAnswer是答题交互的核心方法每次用户选择一个选项后调用/** * 提交答案并判定对错 *paramoptionIndex - 用户选择的选项索引0-3 *returns答题结果包含是否正确、正确答案索引和解析 */submitAnswer(optionIndex: number): SubmitResult |null{// 会话有效性检查if(!this.currentSession ||this.currentSession.isFinished)returnnull;constquestion this.currentSession.questions[this.currentSession.currentIndex];if(!question)returnnull;// 判定对错constisCorrect optionIndex question.correctIndex;// 记录用户答案this.currentSession.answers.push(optionIndex);if(isCorrect) {this.currentSession.correctCount; }else{this.currentSession.wrongCount;// 答错时将题目ID加入错题本userPrefs.addWrongQuiz(question.id).catch((err: Error) { Logger.error(TAG,添加错题失败, err); }); } Logger.debug(TAG, 答题: 第${this.currentSession.currentIndex 1}题, 结果${isCorrect ?正确:错误});// 返回判定结果不含答案由前端根据correctIndex自行展示constresult: SubmitResult { isCorrect: isCorrect, correctIndex: question.correctIndex, explanation: question.explanation };returnresult; }设计要点返回值不含正确答案的文字只返回correctIndex。前端QuizOptionItem组件根据索引自行高亮正确选项避免字符串匹配的脆弱性错题异步写入userPrefs.addWrongQuiz是异步操作用.catch兜底不阻塞答题流程幂等性userPrefs.addWrongQuiz内部会检查去重多次提交同一道错题不会产生重复记录四、下一题与自动归档nextQuestion控制答题的推进逻辑最后一题时触发自动归档/** * 切换到下一题 *returns是否还有下一题false表示答题结束 */nextQuestion(): boolean {if(!this.currentSession)returnfalse;if(this.currentSession.currentIndex this.currentSession.questions.length -1) {// 还有下一题this.currentSession.currentIndex;returntrue; }else{// 最后一题已答完标记会话结束并归档this.currentSession.isFinished true;this.saveScore();returnfalse; } }saveScore方法完成成绩的持久化和成就联动/** * 保存答题成绩到UserPreferences并触发成就检测 */privatesaveScore():void{if(!this.currentSession)return;consttotal this.currentSession.questions.length;constcorrect this.currentSession.correctCount;constisPerfect correct total total 0;// 满分标记// 构建成绩记录constscoreRecord:QuizScoreRecord {totalQuestions: total,correctCount: correct,timestamp:Date.now(),category:this.currentSession.category};// 异步写入用户偏好内部有500ms批量写入优化userPrefs.addQuizScore(scoreRecord).catch((err:Error) {Logger.error(TAG,保存答题成绩失败, err); });// 触发成就管理器检测achievementManager.recordQuizResult(correct, total, isPerfect);Logger.info(TAG,答题完成: 正确${correct}/${total}, 正确率${total 0?Math.round((correct / total) *100) :0}%); }这里有一个精妙的设计——isPerfect满分标记会传递给AchievementManager用于触发完美答题类成就。这个布尔值只在saveScore中计算一次不放在SubmitResult中返回因为它只在整个会话结束时才有意义。五、错题重做模式startWrongQuiz提供了错题重做的入口与startQuiz共享同一套答题流程/** * 开始错题练习 *paramwrongIds - 错题ID列表 *returns错题练习会话 */startWrongQuiz(wrongIds: number[]): QuizSession {// 根据ID从全量题库筛选出错题constwrongQuestions this.questions.filter(q wrongIds.includes(q.id));constshuffled this.shuffleArray(wrongQuestions);constsession: QuizSession { questions: shuffled, currentIndex:0, correctCount:0, wrongCount:0, answers: [], isFinished:false, startTime: Date.now()// 注意错题练习不设置category字段};this.currentSession session; Logger.info(TAG, 开始错题练习: 共${shuffled.length}道错题);returnthis.currentSession; }错题重做时WrongQuiz页面会在答对后主动将题目从错题本移除// entry/src/main/ets/pages/WrongQuiz.ets 中的核心逻辑selectOption(index: number) {if(this.showFeedback)return;this.selectedOption index;constresult quizEngine.submitAnswer(index);if(result) {this.isCorrect result.isCorrect;this.correctIdx result.correctIndex;this.explanation result.explanation;this.showFeedback true;// 答对了就移除错题if(result.isCorrect this.currentQuestion) { userPrefs.removeWrongQuiz(this.currentQuestion.id).catch(() {}); } } }六、每日挑战——确定性随机每日挑战要求同一天的题目对所有人相同但又不能按顺序出题否则用户会记住顺序。解决方案是基于日期的确定性随机/** * 获取每日挑战题目 * 同一天返回的题目顺序固定不同天题目不同 *returns每日挑战的题目数组 */getDailyChallengeQuestions():QuizQuestion[] {consttoday newDate();constdayOfYear this.getDayOfYear(today);// 用日期对年天数的余数作为起始偏移constseed dayOfYear %this.questions.length;constresult:QuizQuestion[] [];for(leti 0; i AppConstants.DAILY_CHALLENGE_COUNT; i) {// 每次跳跃7个位置质数步长避免题目聚集constidx (seed i *7) %this.questions.length; result.push(this.questions[idx]); }returnresult; }/** * 计算当前日期是这一年中的第几天 *paramdate- 日期对象 *returns年内天数1-366 */privategetDayOfYear(date:Date):number{conststart newDate(date.getFullYear(),0,0);constdiff date.getTime() - start.getTime();constoneDay 1000*60*60*24;returnMath.floor(diff / oneDay); }步长 7 是一个质数可以保证在题库数量不大的情况下连续5道题之间不会出现重复或过于密集的聚集。当然这不是密码学意义上的安全随机但对儿童科普应用来说完全够用。七、前端答题页面集成Quiz答题页面通过三段式build()方法管理页面状态切换// entry/src/main/ets/pages/Quiz.etsbuild() {if(!this.quizStarted) {this.SelectCategoryView();// 分类选择页}elseif(this.currentQuestion) {this.QuizView();// 答题进行中}else{this.ResultView();// 答题结果展示} }答题选项组件QuizOptionItem根据showFeedback状态切换三种视觉模式// entry/src/main/ets/components/quiz/QuizOptionItem.etsprivategetOptionBgColor(): string {if(!this.showFeedback) {// 未提交选中项高亮未选中灰色if(this.selected)returnThemeColors.PRIMARY;return#f5f5f5; }// 已提交正确选项绿色错误选项红色if(this.index this.correctIndex)returnThemeColors.SUCCESS;if(this.selected !this.isCorrect)returnThemeColors.PRIMARY;return#f5f5f5; }进度条使用Progress组件实时显示答题进度Progress({ value: this.currentIndex 1, total: this.questionCount }) .width(100%) .color(ThemeColors.PRIMARY) .backgroundColor(ThemeColors.BG_TERTIARY) .margin({ bottom: 20 });八、正确率计算与结果展示答题结束后getScore方法返回汇总数据/** * 获取当前会话的答题成绩 *returns成绩汇总包含答对数、总题数、正确率 */getScore(): ScoreResult |null{if(!this.currentSession)returnnull;consttotal this.currentSession.questions.length;constcorrect this.currentSession.correctCount;return{ correct: correct, total: total, percentage: total 0? Math.round((correct / total) *100) :0}; }前端结果页面根据正确率做四档评级// entry/src/main/ets/pages/Quiz.etsprivategetResultTitle(): string {constpct this.getPercentage();if(pct 90)return太棒了科学小达人;if(pct 70)return很不错哦继续加油;if(pct 50)return还不错再接再厉;return没关系多多学习; }privategetPercentage(): number {if(!this.session ||this.session.questions.length 0)return0;returnMath.round((this.session.correctCount /this.session.questions.length) *100); }独立的QuizResult页面还增加了星级评定逻辑// entry/src/main/ets/pages/QuizResult.etsgetStarLevel(): number {if(this.percentage 90)return3;// 三星if(this.percentage 70)return2;// 两星if(this.percentage 50)return1;// 一星return0;// 零星}⚖️ 正反对比❌ 错误方式一每次答题都从文件读取题目// ❌ 每次开始答题都读取文件——性能灾难startQuiz(categoryId:string): QuizSession {// 每次都走IO读取JSON解析极其低效const rawData context.resourceManager.getRawFileContentSync(quizzes.json); const allQuestions JSON.parse(newutil.TextDecoder().decodeToString(rawData));// ...}// ✅ 初始化时一次性加载到内存后续直接使用privatequestions: QuizQuestion[] [];init(context: common.UIAbilityContext):void{this.questions loadJsonDataQuizQuestion[](context,quizzes.json); } startQuiz(categoryId:string): QuizSession {letquestionPool this.questions.filter(q q.category categoryId);// 直接从内存数组筛选零IO开销}❌ 错误方式二直接修改原始数组// ❌ 直接在原始数组上 splice/shuffle污染全量题库startQuiz(categoryId:string): QuizSession { const shuffled this.shuffleArray(this.questions);// 修改了原始引用// 第二次调用时题目顺序已经被打乱且不可恢复}// ✅ 始终拷贝后操作原始题库保持不变startQuiz(categoryId: string): QuizSession { let questionPool: QuizQuestion[];if(categoryId all) { questionPool [...this.questions];// 浅拷贝}else{ questionPool this.questions.filter(q q.category categoryId); }constshuffled this.shuffleArray(questionPool);// shuffleArray内部也拷贝}❌ 错误方式三用数组索引作为洗牌种子// ❌ 每日挑战用 Math.random()同一天每次打开题目不同getDailyChallengeQuestions(): QuizQuestion[] {constshuffled this.shuffleArray(this.questions);returnshuffled.slice(0,5);// 每次随机无法保证每日唯一}// ✅ 基于日期的确定性选取同一天题目相同getDailyChallengeQuestions(): QuizQuestion[] {constdayOfYear this.getDayOfYear(new Date());constseed dayOfYear %this.questions.length;constresult: QuizQuestion[] [];for(let i 0; i AppConstants.DAILY_CHALLENGE_COUNT; i) {constidx (seed i *7) %this.questions.length; result.push(this.questions[idx]); }returnresult; }❌ 错误方式四成绩保存放在 submitAnswer 中// ❌ 每答一题就保存一次——频繁IO写入submitAnswer(optionIndex:number): SubmitResult {// ...判定逻辑...// 每题都写入10道题写10次userPrefs.addQuizScore({total: 1,correct:isCorrect? 1 : 0,...}); }// ✅ 全部答完后一次性保存触发时机明确privatesaveScore(): void { const scoreRecord: QuizScoreRecord { totalQuestions: this.currentSession.questions.length, correctCount: this.currentSession.correctCount, timestamp:Date.now(), category: this.currentSession.category }; userPrefs.addQuizScore(scoreRecord);// 会话结束时保存一次achievementManager.recordQuizResult(correct,total,isPerfect); }❌ 错误方式五不对空题库做边界保护// ❌ 某分类下没有题目时slice(-1)返回空数组页面白屏constselectedQuestions shuffled.slice(0, questionCount);// ✅ 用 Math.min 保护边界constselectedQuestions shuffled.slice(0, Math.min(questionCount, shuffled.length) );// 即使 shuffled 为空slice(0, 0) 也安全返回空数组 踩坑与经验经验一Fisher-Yates 必须从后向前遍历我们最初尝试过从前往后遍历 Math.random() 判断是否交换的简单方法结果分布不均匀——后面的元素被交换的概率偏低。Fisher-Yates 算法之所以经典是因为它保证了每种排列出现的概率完全相等1/n!前提是必须从后向前遍历。经验二QuizSession 与页面状态的双向同步Quiz页面中State currentQuestion和引擎内部的currentSession.currentIndex需要保持同步。我们的做法是引擎只负责状态变更页面负责 UI 映射。selectOption调用quizEngine.submitAnswer()后从返回值中提取反馈信息更新页面状态nextQuestion调用quizEngine.nextQuestion()后通过quizEngine.getCurrentQuestion()获取新题目。经验三错题重做不触发成就startWrongQuiz创建的会话没有设置category字段且在nextQuestion触发saveScore时saveScore会正常保存成绩记录但wrongQuiz的成绩对首次答题完美答题等成就的语义有干扰。我们的解决方案是让WrongQuiz页面答对后直接调用userPrefs.removeWrongQuiz()但不额外触发成就——因为错题练习本质上是对已答题目的复习。经验四选项字母编号使用 charCodeText(String.fromCharCode(65 this.index)) //0-A,1-B,2-C,3-D这比维护一个[A, B, C, D]数组更优雅且自动适配任意数量的选项。经验五QuizResult 页面的防御性默认值QuizResult页面通过路由参数接收成绩数据在 Previewer 中无法传递参数因此所有State都设了默认值StatecorrectCount: number 0;StatetotalCount: number 0;Statepercentage: number 0;// Previewer 中直接打开不会崩溃只是显示全零结果⚠️ 常见问题Q1: 每次开始答题时题目顺序都一样没有随机效果现象用户连续两次进入同一分类的答题发现题目出现的顺序完全相同。原因startQuiz方法中直接对this.questions原始数组进行shuffleArray操作没有先拷贝。第一次洗牌后原始数组顺序已被打乱第二次洗牌是在已打乱的基础上再次打乱但由于 Fisher-Yates 算法的确定性如果随机种子相同可能导致顺序一致。更常见的原因是shuffleArray内部没有拷贝直接修改了原始引用。解决方案在洗牌前使用[...array]浅拷贝原始数组。// ❌ 错误写法直接在原始数组上洗牌污染全量题库startQuiz(categoryId: string): QuizSession {constshuffled this.shuffleArray(this.questions);// 修改了原始引用// 第二次调用时原始题库已被打乱且不可恢复}// ✅ 正确写法先拷贝再洗牌原始题库保持不变startQuiz(categoryId: string): QuizSession { let questionPool: QuizQuestion[];if(categoryId all) { questionPool [...this.questions];// 浅拷贝不污染原始题库}else{ questionPool this.questions.filter(q q.category categoryId); }constshuffled this.shuffleArray(questionPool);// 在拷贝上洗牌}Q2: 每日挑战每次打开应用题目都不同现象每日挑战功能要求同一天对所有人都出相同的题目但实际每次打开应用、每次进入每日挑战页面题目都不一样。原因每日挑战的题目选取使用了Math.random()或shuffleArray这是非确定性随机无法保证同一天题目相同。解决方案基于日期计算确定性种子用固定步长选取题目。// ❌ 错误写法使用 Math.random()每次打开题目不同getDailyChallengeQuestions(): QuizQuestion[] {constshuffled this.shuffleArray(this.questions);// 非确定性returnshuffled.slice(0,5);// 每次随机无法保证每日唯一}// ✅ 正确写法基于日期的确定性选取同一天题目相同getDailyChallengeQuestions(): QuizQuestion[] {constdayOfYear this.getDayOfYear(new Date());constseed dayOfYear %this.questions.length;constresult: QuizQuestion[] [];for(let i 0; i AppConstants.DAILY_CHALLENGE_COUNT; i) {constidx (seed i *7) %this.questions.length;// 质数步长避免聚集result.push(this.questions[idx]); }returnresult; }Q3: 答题过程中应用被切到后台再恢复会话状态丢失现象用户答到第 5 题时切换到其他应用回来后发现答题页面重新回到了分类选择页之前的答题进度全部丢失。原因QuizEngine的currentSession保存在内存中应用被系统回收后内存数据丢失。如果答题页面没有使用Provide/AppStorage持久化会话状态页面重建时无法恢复。解决方案对于长流程交互在页面级使用Provide或将关键状态序列化到 AppStorage确保页面重建时能恢复。// ❌ 错误写法会话状态只存在组件 State 中页面重建即丢失StatecurrentQuestion: QuizQuestion |nullnull;StatecurrentIndex: number 0;// 应用被回收后这些状态全部丢失// ✅ 正确写法将会话关键状态存入 AppStorage页面重建时可恢复aboutToAppear() {// 尝试从 AppStorage 恢复未完成的会话constsavedSession AppStorage.getQuizSession(quizSession);if(savedSession !savedSession.isFinished) {this.currentSession savedSession;this.quizStarted true;this.currentQuestion quizEngine.getCurrentQuestion(); } } 总结QuizEngine作为《奇妙科学乐园》问答系统的核心引擎承担了题库管理、随机出题、答题判定、成绩归档四大职责。通过单例模式确保全局状态一致性通过QuizSession会话模型实现状态机式的答题流程管理通过与UserPreferences和AchievementManager的协作完成数据持久化和激励系统联动。核心设计可以总结为三句话一次加载全程内存操作——init时从 rawfile 加载全量题库后续所有操作都在内存数组上完成会话驱动自动归档——QuizSession封装一次完整的答题生命周期结束时自动触发成绩保存和成就检测引擎与视图解耦——QuizEngine只返回数据不持有任何 UI 状态页面组件负责状态映射和交互反馈在下一篇文章中我们将深入AchievementManager成就系统解析徽章解锁条件检测、数据持久化以及 Profile 页面成就徽章模块的空数据占位问题。 相关链接项目源码Atomgit仓库上一篇HarmonyOS应用奇妙科学乐园开发第57篇:Category模型与资源类型转换——JSON到Resource下一篇HarmonyOS应用奇妙科学乐园开发第59篇:AchievementManager成就系统——徽章解锁与持久化
返回列表