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遵循三条核心设计原则:
- 单一会话:同一时间只维护一个
QuizSession,新开答题会覆盖旧会话 - 不可回退:
submitAnswer提交后立即判定,nextQuestion后无法修改之前的答案 - 自动归档:最后一题提交并调用
nextQuestion时,自动触发成绩保存和成就检测
💻 代码实现
一、单例模式与初始化
QuizEngine采用经典单例模式,确保全局只有一个引擎实例。这与UserPreferences、AchievementManager、ScienceDataService保持一致。
// 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 题时切换到其他应用,回来后发现答题页面重新回到了分类选择页,之前的答题进度全部丢失。
原因:QuizEngine的currentSession保存在内存中,应用被系统回收后内存数据丢失。如果答题页面没有使用@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会话模型实现状态机式的答题流程管理,通过与UserPreferences和AchievementManager的协作完成数据持久化和激励系统联动。
核心设计可以总结为三句话:
- 一次加载,全程内存操作——
init时从 rawfile 加载全量题库,后续所有操作都在内存数组上完成 - 会话驱动,自动归档——
QuizSession封装一次完整的答题生命周期,结束时自动触发成绩保存和成就检测 - 引擎与视图解耦——
QuizEngine只返回数据,不持有任何 UI 状态;页面组件负责状态映射和交互反馈
在下一篇文章中,我们将深入AchievementManager成就系统,解析徽章解锁条件检测、数据持久化以及 Profile 页面成就徽章模块的空数据占位问题。
🔗 相关链接
- 项目源码:Atomgit仓库
- 上一篇:HarmonyOS应用<奇妙科学乐园>开发第57篇:Category模型与资源类型转换——JSON到Resource
- 下一篇:HarmonyOS应用<奇妙科学乐园>开发第59篇:AchievementManager成就系统——徽章解锁与持久化
