当前位置: 首页 > news >正文

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静态题库
  • 下方:调用userPrefs(UserPreferences)保存答题成绩和错题记录
  • 侧方:调用achievementManager(AchievementManager)触发成就进度更新
  • 前端:被Quiz答题页面、WrongQuiz错题本页面、QuizResult结果页面消费

数据模型

题目的数据模型定义在model/Quiz.ets中:

//entry/src/main/ets/model/Quiz.ets export interface QuizQuestion { id: number;//题目唯一标识 category: string;//分类ID:space/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遵循三条核心设计原则:

  1. 单一会话:同一时间只维护一个QuizSession,新开答题会覆盖旧会话
  2. 不可回退submitAnswer提交后立即判定,nextQuestion后无法修改之前的答案
  3. 自动归档:最后一题提交并调用nextQuestion时,自动触发成绩保存和成就检测

💻 代码实现

一、单例模式与初始化

QuizEngine采用经典单例模式,确保全局只有一个引擎实例。这与UserPreferencesAchievementManagerScienceDataService保持一致。

// entry/src/main/ets/viewmodel/QuizEngine.etsexportclassQuizEngine{privatestaticinstance: QuizEngine;privatecurrentSession: QuizSession | null = null;privatequestions: QuizQuestion[] = [];// 全量题库privateisInitialized:boolean=false;// 初始化标记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 = loadJsonData<QuizQuestion[]>(context,'quizzes.json');this.isInitialized =true; }catch(error) { console.error('QuizEngine初始化失败',error);thrownewError('问答数据加载失败'); } }

关键点:

  • isInitialized标记防止EntryAbility.onCreate被多次调用时重复加载
  • 异常直接抛出,让上层感知初始化失败,避免"空题库"导致后续运行时崩溃
  • loadJsonData内部使用Map缓存已读取的文件内容,二次调用零开销

二、题目随机加载与 Fisher-Yates 洗牌

startQuiz是答题流程的起点,它负责从题库中筛选、随机选取指定数量的题目,并创建答题会话:

/** * 开始一次新的答题会话 *@paramcategoryId- 分类ID,'all'表示全部分类 *@paramquestionCount- 题目数量,默认10道 *@returns创建好的答题会话 */startQuiz(categoryId:string='all',questionCount:number=AppConstants.DEFAULT_QUIZ_COUNT):QuizSession{// 第一步:按分类筛选题池letquestionPool:QuizQuestion[];if(categoryId ==='all') { questionPool = [...this.questions];// 浅拷贝,不污染原始题库}else{ questionPool =this.questions.filter(q=>q.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 shuffleArray<T>(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 = loadJsonData<QuizQuestion[]>(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(),但不额外触发成就——因为错题练习本质上是对已答题目的复习。

经验四:选项字母编号使用 charCode

Text(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 题时切换到其他应用,回来后发现答题页面重新回到了分类选择页,之前的答题进度全部丢失。
原因QuizEnginecurrentSession保存在内存中,应用被系统回收后内存数据丢失。如果答题页面没有使用@Provide/AppStorage持久化会话状态,页面重建时无法恢复。
解决方案:对于长流程交互,在页面级使用@Provide或将关键状态序列化到 AppStorage,确保页面重建时能恢复。

// ❌ 错误写法:会话状态只存在组件 @State 中,页面重建即丢失@StatecurrentQuestion: QuizQuestion |null=null;@StatecurrentIndex: number =0;// 应用被回收后,这些状态全部丢失// ✅ 正确写法:将会话关键状态存入 AppStorage,页面重建时可恢复aboutToAppear() {// 尝试从 AppStorage 恢复未完成的会话constsavedSession = AppStorage.get<QuizSession>('quizSession');if(savedSession && !savedSession.isFinished) {this.currentSession = savedSession;this.quizStarted =true;this.currentQuestion = quizEngine.getCurrentQuestion(); } }

📝 总结

QuizEngine作为《奇妙科学乐园》问答系统的核心引擎,承担了题库管理、随机出题、答题判定、成绩归档四大职责。通过单例模式确保全局状态一致性,通过QuizSession会话模型实现状态机式的答题流程管理,通过与UserPreferencesAchievementManager的协作完成数据持久化和激励系统联动。

核心设计可以总结为三句话:

  1. 一次加载,全程内存操作——init时从 rawfile 加载全量题库,后续所有操作都在内存数组上完成
  2. 会话驱动,自动归档——QuizSession封装一次完整的答题生命周期,结束时自动触发成绩保存和成就检测
  3. 引擎与视图解耦——QuizEngine只返回数据,不持有任何 UI 状态;页面组件负责状态映射和交互反馈

在下一篇文章中,我们将深入AchievementManager成就系统,解析徽章解锁条件检测、数据持久化以及 Profile 页面成就徽章模块的空数据占位问题。


🔗 相关链接

  • 项目源码Atomgit仓库
  • 上一篇HarmonyOS应用<奇妙科学乐园>开发第57篇:Category模型与资源类型转换——JSON到Resource
  • 下一篇HarmonyOS应用<奇妙科学乐园>开发第59篇:AchievementManager成就系统——徽章解锁与持久化
http://www.jsqmd.com/news/1336795/

相关文章:

  • 2026年8月宁德市移动1000M单宽带怎么选_办理时要注意哪些关键细节_ - 找卡家园
  • 2026 年现阶段湖州专业的保温隔热棉工厂联系电话,夏天电费涨三成,居然是家里没装这玩意儿? - 实业推荐官
  • H3CNE命令行基础:网络设备配置与管理实战
  • 2026年8月浙江省联通1000M单宽带办理攻略 - 找卡家园
  • 5分钟掌握FanControl:告别风扇噪音的Windows智能散热解决方案
  • 2026年8月广东省移动300M单宽带避坑指南一篇说透 - 找卡家园
  • jpg转pdf在线转换免费工具盘点:这7款图片PDF互转够用又安全
  • Stable Diffusion 2.0 实战指南:从“降智”误解到高效工作流
  • Python+Selenium复用浏览器:原理、实战与避坑指南
  • AI动画短片制作全流程:从Stable Diffusion到ComfyUI工作流实战
  • 终极指南:5分钟掌握FanControl风扇控制软件,打造完美静音电脑
  • 树的直径:概念、算法与应用全解析
  • 2026年8月宁德市移动500M单宽带申请避坑全攻略 - 找卡家园
  • Jetson Nano启动故障排查:从Bootloader到SD卡修复全解析
  • 《都市天际线2》电影级画面调校指南:从渲染原理到实战技巧
  • 2026年8月广东省移动300M单宽带避坑与办理指南 - 找卡家园
  • 无人机倾斜摄影三维建模全流程:从飞行规划到Context Capture实战
  • md怎么转pdf?这7款PDF格式转换工具对比盘点帮你省下反复调整版面的时间
  • 英语—儿童肥胖—常见搭配短语—东方仙盟
  • 2026年想找宁波中频电炉厂家,这几家值得了解 - 奔跑123
  • 2026 年 7 月新发布:南充有实力的商铺室内外漏水检测企业电话,商铺漏得找不着根源?这玩意儿帮你揪出藏在墙里管里的渗水隐患! - 行业鉴选官
  • 给 Agent 装上护栏——harness 层设计
  • MySQL用户创建与权限管理实战指南
  • 本科生论文降AI率工具实测与技巧
  • 零基础网站建设完全指南:从0到1搭建个人品牌网站的全流程解析
  • 3分钟极速配置:26个精选阅读APP书源一键导入全攻略
  • 2026年8月浙江省联通500M单宽带小白避坑指南 - 找卡家园
  • 北京办公玻璃隔断厂口碑优选与交付标准解读 - 品牌优推
  • MATLAB实现BP神经网络回归预测与k折交叉验证
  • 2026 年新发布:郓城靠谱的红鹿奶山羊企业推荐,养羊也能赚出买房钱?这玩意儿比普通产奶羊多了啥秘诀?-坤达养殖 - 企业官方推荐【认证】