欢迎光临
我们一直在努力

HarmonyOS APP<玩转React>开源教程二十三:面试题库功能

第23次:面试题库功能

本文讲解题库系统的主干实现。它对应的不是单个文件,而是一条完整链路:InterviewQuizService.ets、QuizBankPage.ets、QuizPracticePage.ets,以及与错题本的联动。阅读时请把它当作一个完整的刷题系统来理解。


题库系统在项目里的真实定位

课程内测验强调“跟课学完顺手测一下”,而面试题库强调的是“体系化刷题”。因此它的设计比每日一题和普通课程测验都更完整:

  • 有模块分类
  • 有统计卡片
  • 有模块练习
  • 有错题本入口
  • 有练习总结页

先看整条链路:

#mermaid-svg-VHWIm1YvGnKT9TcH{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VHWIm1YvGnKT9TcH .error-icon{fill:#552222;}#mermaid-svg-VHWIm1YvGnKT9TcH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VHWIm1YvGnKT9TcH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH .marker.cross{stroke:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VHWIm1YvGnKT9TcH p{margin:0;}#mermaid-svg-VHWIm1YvGnKT9TcH .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster-label text{fill:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster-label span{color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster-label span p{background-color:transparent;}#mermaid-svg-VHWIm1YvGnKT9TcH .label text,#mermaid-svg-VHWIm1YvGnKT9TcH span{fill:#333;color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .node rect,#mermaid-svg-VHWIm1YvGnKT9TcH .node circle,#mermaid-svg-VHWIm1YvGnKT9TcH .node ellipse,#mermaid-svg-VHWIm1YvGnKT9TcH .node polygon,#mermaid-svg-VHWIm1YvGnKT9TcH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .rough-node .label text,#mermaid-svg-VHWIm1YvGnKT9TcH .node .label text,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape .label,#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape .label{text-anchor:middle;}#mermaid-svg-VHWIm1YvGnKT9TcH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .rough-node .label,#mermaid-svg-VHWIm1YvGnKT9TcH .node .label,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape .label,#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape .label{text-align:center;}#mermaid-svg-VHWIm1YvGnKT9TcH .node.clickable{cursor:pointer;}#mermaid-svg-VHWIm1YvGnKT9TcH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH .arrowheadPath{fill:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VHWIm1YvGnKT9TcH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VHWIm1YvGnKT9TcH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VHWIm1YvGnKT9TcH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VHWIm1YvGnKT9TcH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VHWIm1YvGnKT9TcH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster text{fill:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster span{color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VHWIm1YvGnKT9TcH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH rect.text{fill:none;stroke-width:0;}#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape p,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape .label rect,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VHWIm1YvGnKT9TcH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VHWIm1YvGnKT9TcH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VHWIm1YvGnKT9TcH :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

QuizBankPage

InterviewQuizService.init

WrongAnswerService.init

加载所有题目与统计

显示模块列表与正确率

进入 QuizPracticePage

recordAnswer 更新统计

addWrongAnswer / markAsMastered

WrongAnswerBookPage


一、为什么题库首页要先显示统计再显示模块

QuizBankPage 的内容顺序非常有讲究:

  • 顶部导航
  • 我的统计卡片
  • 错题本入口
  • 模块题库列表
  • 这个顺序不是为了好看,而是为了建立学习反馈感。用户进入题库首页时,先看到的是:

    • 我一共答了多少题
    • 我答对了多少题
    • 我的正确率是多少

    然后再看到模块列表,才更容易知道下一步去哪里练。这个设计非常符合学习产品的逻辑,因为用户不是来“浏览信息”的,而是来“持续进步”的。


    二、InterviewQuizService 的职责比你想象的大

    当前题库服务做的事情非常完整:

    • 初始化全部题目
    • 读取整体统计数据
    • 记录每题答题结果
    • 维护模块级统计
    • 维护整体正确率
    • 记录每道题最后一次答题状态
    • 支持重置统计

    这里最值得学习的是它不仅存“总答题数”,还存“每个模块的统计”。这意味着用户后续可以看到自己是 Hooks 模块弱,还是组件模块弱,而不是只知道一个笼统的全局正确率。

    这类“全局统计 + 局部统计”并存的设计,在题库类应用中非常常见,也非常实用。


    三、答题记录为什么不是简单累加

    recordAnswer(questionId, answer, isCorrect) 是这套系统最关键的方法之一。它的设计并不是“每答一次就无脑加一”,而是更细致地处理了同一道题的重复作答:

    • 第一次作答:计入总答题数和正确/错误数。
    • 如果之前答错,后来答对:要修正统计。
    • 如果之前答过且结果没变:不重复累计。

    这说明当前统计系统更接近“题目掌握情况”,而不是“机械刷题次数”。这种设计对学习产品更友好,因为它鼓励用户修正错误,而不是把统计做得越来越失真。


    四、题库首页为什么还要初始化错题本服务

    QuizBankPage 在加载时不仅调用 InterviewQuizService.init(),还调用 WrongAnswerService.init()。这是因为题库首页需要同时展示:

    • 我的答题统计
    • 错题本入口
    • 当前未掌握错题数量

    也就是说,题库首页本质上已经是“练习中心”,而不是单纯的目录页。它既展示当前能力,也展示后续复习入口。

    这一层入口在 QuizBankPage.ets 里对应的是下面这段真实加载逻辑:

    private async loadData(): Promise<void> {
    this.isLoading = true;

    await InterviewQuizService.init();
    await WrongAnswerService.init();

    this.statistics = InterviewQuizService.getOverallStatistics();
    this.wrongAnswerCount = WrongAnswerService.getUnmasteredCount();

    const moduleList = InterviewQuizService.getAllModules();
    const tempModules: ModuleInfo[] = [];
    for (const m of moduleList) {
    const questions = InterviewQuizService.getQuestionsByModule(m.moduleId);
    const moduleInfo: ModuleInfo = {
    moduleId: m.moduleId,
    moduleName: m.moduleName,
    icon: m.icon,
    questionCount: questions.length,
    stats: InterviewQuizService.getModuleStatistics(m.moduleId)
    };
    tempModules.push(moduleInfo);
    }
    this.modules = tempModules;

    this.isLoading = false;
    }

    UserWrongAnswerServiceInterviewQuizServiceQuizBankPageUserWrongAnswerServiceInterviewQuizServiceQuizBankPage#mermaid-svg-PetfBSXRiUDWCmRe{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PetfBSXRiUDWCmRe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PetfBSXRiUDWCmRe .error-icon{fill:#552222;}#mermaid-svg-PetfBSXRiUDWCmRe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PetfBSXRiUDWCmRe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PetfBSXRiUDWCmRe .marker.cross{stroke:#333333;}#mermaid-svg-PetfBSXRiUDWCmRe svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PetfBSXRiUDWCmRe p{margin:0;}#mermaid-svg-PetfBSXRiUDWCmRe .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PetfBSXRiUDWCmRe text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PetfBSXRiUDWCmRe .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-PetfBSXRiUDWCmRe .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe .sequenceNumber{fill:white;}#mermaid-svg-PetfBSXRiUDWCmRe #sequencenumber{fill:#333;}#mermaid-svg-PetfBSXRiUDWCmRe #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe .messageText{fill:#333;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PetfBSXRiUDWCmRe .labelText,#mermaid-svg-PetfBSXRiUDWCmRe .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .loopText,#mermaid-svg-PetfBSXRiUDWCmRe .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PetfBSXRiUDWCmRe .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-PetfBSXRiUDWCmRe .noteText,#mermaid-svg-PetfBSXRiUDWCmRe .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PetfBSXRiUDWCmRe .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PetfBSXRiUDWCmRe .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PetfBSXRiUDWCmRe .actorPopupMenu{position:absolute;}#mermaid-svg-PetfBSXRiUDWCmRe .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-PetfBSXRiUDWCmRe .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PetfBSXRiUDWCmRe .actor-man circle,#mermaid-svg-PetfBSXRiUDWCmRe line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-PetfBSXRiUDWCmRe :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}init()init()getOverallStatistics()getUnmasteredCount()展示统计卡片、错题入口、模块列表


    五、模块列表为什么要显示每个模块的正确率

    当前 ModuleItem 中显示了:

    • 模块图标
    • 模块名称
    • 题目总数
    • 当前模块正确率

    这不是简单的信息堆砌,而是给用户非常明确的练习反馈:

    • 题量决定刷题成本
    • 正确率反映掌握程度

    当用户看到某个模块题目不多但正确率很低时,就知道那里是弱项;如果某个模块正确率已经很高,可能就可以暂时放一放。这就是“数据驱动学习路径”的基本思路。


    六、QuizPracticePage 为什么支持两种模式

    这一页非常值得学,因为它没有被写成“只能练模块题”。

    当前它支持两种进入方式:

    • 通过 moduleId 进入模块练习模式
    • 通过 isFromWrongBook 进入错题练习模式

    这种设计很聪明,因为:

    • 页面 UI 基本一样
    • 差异主要体现在题目来源
    • 复用一个页面就能覆盖两种练习场景

    也就是说,页面层的复用不是靠硬拷贝,而是靠“参数化驱动不同模式”。


    七、练习页里的答题闭环是怎样形成的

    QuizPracticePage 的答题流程非常完整:

  • 加载题目并随机打乱顺序。
  • 展示当前题目和选项。
  • 用户选择答案。
  • 提交后立即判断对错。
  • 调用 InterviewQuizService.recordAnswer() 更新统计。
  • 如果答错,调用 WrongAnswerService.addWrongAnswer()。
  • 如果来自错题本且答对,调用 WrongAnswerService.markAsMastered()。
  • 最后一题做完后显示总结页。
  • 这说明题库系统真正形成了:

    • 练习
    • 统计
    • 错题
    • 复习

    四段闭环,而不是只有“答题”这一个动作。

    把它放回真实源码里看,会更容易理解这条链路是如何闭合的:

    private async handleSubmit(): Promise<void> {
    const question = this.getCurrentQuestion();
    if (!question || this.selectedAnswer < 0) return;

    this.isCorrect = InterviewQuizService.validateAnswer(question.id, this.selectedAnswer);
    this.isSubmitted = true;

    this.answers.push(this.selectedAnswer);
    this.results.push(this.isCorrect);

    await InterviewQuizService.recordAnswer(question.id, this.selectedAnswer, this.isCorrect);

    if (!this.isCorrect) {
    await WrongAnswerService.addWrongAnswer(question, this.selectedAnswer);
    } else if (this.isFromWrongBook) {
    await WrongAnswerService.markAsMastered(question.id);
    }
    }

    注意这里除了更新统计,还把用户本次选择和结果顺手记录进了 answers、results,这样页面后续才能继续切题并生成总结页。


    八、总结页为什么非常重要

    练习页最后的 SummaryView() 展示了:

    • 总题数
    • 正确题数
    • 正确率
    • 用时

    这个总结页的意义在于,它给用户一次完整练习画上了一个可感知的句号。没有总结页,用户只会觉得“题做完了”;有总结页,用户会觉得“我完成了一次训练”。

    对于学习产品来说,这种仪式感其实很重要。它能帮助用户形成持续刷题的动力。


    九、自己实操时最推荐的顺序

  • 打开题库首页,先看统计卡片和模块列表。
  • 进入任意模块做几道题。
  • 故意答错一两道,观察结果卡片和解析。
  • 完成全部题目后看总结页。
  • 回到题库首页,观察统计是否更新。
  • 进入错题本,再从错题本发起练习,看答对后是否会被标记为已掌握。
  • 这一套走完后,你对题库链路的理解会非常扎实。


    十、本篇常见坑

    1. 只做题目列表,不做统计

    没有统计的题库很难形成成长反馈。

    2. 同一道题重复作答时直接无脑累加

    这样会让统计越来越失真。

    3. 错题本和练习页分家过度

    当前项目的优点恰恰在于两者通过参数模式复用了同一个练习页。

    4. 练习完成后不做总结页

    会大大削弱一次练习的完成感。


    本篇小结

    面试题库功能并不是一个单独页面,而是一整套学习闭环:

    • QuizBankPage 负责入口和总览
    • InterviewQuizService 负责统计与题库规则
    • QuizPracticePage 负责实际练习
    • WrongAnswerService 负责错题沉淀与复习

    如果你把这一套结构真正理解透了,后面看错题本功能就会非常自然,因为它本来就是题库系统的一部分。


    课后练习

  • 自己总结 QuizService 和 InterviewQuizService 在业务目标上的区别。
  • 画出“模块练习模式”和“错题练习模式”在 QuizPracticePage 中的分流图。
  • 思考如果你要增加“只练未做过题目”模式,最适合把过滤逻辑写在服务层还是页面层,为什么。
  • 赞(0)
    未经允许不得转载:171主机测评 » HarmonyOS APP<玩转React>开源教程二十三:面试题库功能
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址