img

引言

趣味问答是《奇妙科学乐园》中互动性最强的功能模块,也是6-12岁儿童检验科学知识掌握程度的趣味途径。Quiz 页面承载了完整的答题闭环流程:分类选择 → 题目加载 → 选项作答 → 即时反馈 → 成绩展示。这个看似简单的"选择-答题-看结果"三步曲,背后涉及多视图状态管理、答题引擎协同、选项反馈动画、计分逻辑串联以及结果页面跳转等复杂的技术实现。

从架构角度看,Quiz 页面采用了三视图条件渲染模式——通过 quizStartedcurrentQuestion 两个核心状态变量,在分类选择视图(SelectCategoryView)、答题视图(QuizView)和结果视图(ResultView)之间平滑切换。这种三态设计使得整个答题流程在一个页面内闭环完成,避免了多页面跳转带来的状态丢失和页面栈管理复杂性。

本文将以 Quiz.ets 的完整源码为主线,逐一拆解分类选择视图的 UI 结构与事件绑定、答题引擎 QuizEngine 的初始化与题目加载流程、选项渲染 QuizOptionItem 的选中/反馈状态切换、答题反馈(正确/错误)的即时视觉差异化、计分逻辑与错题记录的完整数据链、答题完成后的结果展示(答对/答错/正确率/评语),以及 QuizResult 独立结果页的星级评定与持久化记录。

源码仓库https://atomgit.com/2301_79280419/WonderSciencePark


学习目标

完成本文后,你将能够:

  • ✅ 掌握 Quiz 页面的三视图条件渲染架构:SelectCategoryView / QuizView / ResultView
  • ✅ 理解 @Builder 自定义组件构建器的定义与调用方式
  • ✅ 掌握 QuizEngine 答题引擎的 startQuiz / submitAnswer / nextQuestion 完整调用链
  • ✅ 实现 QuizOptionItem 选项组件的多状态渲染(默认/选中/正确/错误)
  • ✅ 设计答题反馈的即时视觉差异化(绿色正确 vs 红色错误 + 解释卡片)
  • ✅ 掌握答题会话 QuizSession 的数据结构与计分逻辑
  • ✅ 实现进度条 Progress 组件的动态更新
  • ✅ 理解答题完成后的结果跳转与 QuizResult 独立页面数据传递
  • ✅ 掌握 Fisher-Yates 洗牌算法在题目随机排序中的应用

需求分析

页面功能架构

Quiz 页面的功能模块与状态流转如下:

Quiz 页面(三视图条件渲染)
│
├── 视图1:SelectCategoryView(分类选择)
│   ├── AppBar(标题 + 返回)
│   ├── 标题区(图标 + "趣味科学问答" + 副标题)
│   └── 分类列表
│       ├── "全部挑战" 选项(随机所有分类)
│       └── ForEach categories → QuizCategoryOption
│
├── 视图2:QuizView(答题界面)
│   ├── 顶部控制栏(退出按钮 + 题号 + 空占位)
│   ├── Progress 进度条
│   ├── Scroll 滚动区
│   │   ├── 题目卡片(分类标签 + 图标 + 题目文字)
│   │   ├── ForEach options → QuizOptionItem(4个选项)
│   │   └── 反馈卡片(条件显示:正确/错误 + 解释文字)
│   └── 底部按钮栏("下一题"/"查看结果" / "请选择一个答案")
│
└── 视图3:ResultView(结果展示,页面内嵌版)
    ├── 结果图标(根据正确率动态切换)
    ├── 结果标题("太棒了!科学小达人!" 等)
    ├── 答对/答错/正确率统计卡片
    └── 操作按钮("再来一次" / "返回首页"

状态流转图

                    quizStarted=false
                          │
          ┌───────────────┘
          ▼
   SelectCategoryView
   (分类选择)
          │
    startQuiz() ──────→ quizStarted=true, currentQuestion≠null
          │
          ▼
      QuizView
     (答题界面)
          │
    selectOption() ────→ submitAnswer() ───→ showFeedback=true
          │                                      │
          │                              nextQuestion()
          │                              hasMore=true  → currentIndex++
          │                              hasMore=falsecurrentQuestion=null
          │                                      │
          │                              ┌───────┘
          │                              ▼
          │                        currentQuestion=null
          │                              │
          │                              ▼
          │                        ResultView
          │                       (结果展示)
          │                              │
          │         restartQuiz() ◄───────┘
          │              │
          └──────────────┘
         quizStarted=false, session=null

依赖关系

模块 作用 引入方式
QuizQuestion 问答题目数据模型 模型导入
QuizSession / quizEngine 答题引擎(单例) 服务导入
Category 分类数据模型 模型导入
scienceData 数据服务单例 服务导入
QuizOptionItem 选项渲染组件 组件导入
QuizCategoryOption 分类选项组件 组件导入
AppBar 通用导航栏组件 组件导入
Logger / RouterUtil 工具类 工具导入
ThemeColors / AppConstants / RouteUrls 常量配置 常量导入

核心实现

步骤1:页面状态声明与初始化

功能说明

Quiz 页面使用 9 个 @State 变量管理整个答题流程的状态。quizStarted 控制是否进入答题模式,currentQuestion 控制是否显示答题视图还是结果视图。aboutToAppear 中从路由参数获取 categoryId(允许从外部页面直接跳转到指定分类答题)。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

import { QuizQuestion } from '../model/Quiz';
import { QuizSession, quizEngine } from '../viewmodel/QuizEngine';
import { Category } from '../model/Category';
import { scienceData } from '../viewmodel/ScienceData';
import { QuizOptionItem } from '../components/quiz/QuizOptionItem';
import { QuizCategoryOption } from '../components/quiz/QuizCategoryOption';
import { Logger } from '../utils/Logger';
import { RouterUtil } from '../utils/RouterUtil';
import { AppConstants, ThemeColors } from '../constants/AppConstants';
import { AppBar } from '../components/base/AppBar';
import { RouteUrls } from '../constants/RouteUrls';

const TAG = 'QuizPage';

@Entry
@Component
struct Quiz {
    // ===== 分类选择视图状态 =====
    @State currentCategory: string = 'all';       // 当前选中分类
    @State categories: Category[] = [];           // 分类列表数据

    // ===== 答题引擎状态 =====
    @State session: QuizSession | null = null;   // 当前答题会话
    @State currentQuestion: QuizQuestion | null = null;  // 当前题目

    // ===== 答题过程状态 =====
    @State currentIndex: number = 0;              // 当前题目索引
    @State questionCount: number = 0;             // 总题目数
    @State selectedOption: number = -1;          // 当前选中选项(-1未选)
    @State showFeedback: boolean = false;        // 是否显示答题反馈
    @State isCorrect: boolean = false;           // 当前答题是否正确
    @State explanation: string = '';              // 当前题目的解释文字
    @State correctIdx: number = -1;              // 当前题目的正确选项索引
    @State quizStarted: boolean = false;         // 是否已开始答题

    aboutToAppear() {
        // 支持路由参数传入分类 ID(从外部页面跳转)
        const params = RouterUtil.getParams() as Record<string, Object>;
        if (params && params.categoryId) {
            this.currentCategory = params.categoryId as string;
        }
        // 加载全部分类数据
        this.categories = scienceData.getAllCategories();
        Logger.info(TAG, '问答页面加载');
    }
}

状态变量职责一览

状态变量 类型 默认值 职责
quizStarted boolean false 控制视图1→视图2的切换
currentQuestion QuizQuestion|null null 控制视图2→视图3的切换
currentCategory string 'all' 当前答题分类('all' 或具体分类 ID)
session QuizSession|null null 答题会话(包含题目列表、分数、答案记录)
currentIndex number 0 当前题目在会话中的索引(从 0 开始)
questionCount number 0 本轮答题总题数
selectedOption number -1 用户选中的选项索引(-1 表示未选择)
showFeedback boolean false 是否已提交答案并显示反馈
isCorrect boolean false 当前题目答题结果
explanation string '' 当前题目的解释说明文字
correctIdx number -1 当前题目正确选项的索引

步骤2:三视图条件渲染——build 方法

功能说明

Quiz 页面的 build 方法不包含具体 UI,而是通过三个条件分支调用对应的 @Builder 方法。这种设计模式将三个视图的 UI 完全隔离,代码职责清晰,便于独立维护。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    build() {
        // 条件渲染:根据状态决定显示哪个视图
        if (!this.quizStarted) {
            this.SelectCategoryView();       // 视图1:分类选择
        } else if (this.currentQuestion) {
            this.QuizView();                  // 视图2:答题界面
        } else {
            this.ResultView();               // 视图3:结果展示
        }
    }

条件渲染逻辑

判断顺序:
① quizStarted === false          → SelectCategoryView(分类选择)
② quizStarted === true
   AND currentQuestion !== null  → QuizView(答题界面)
③ quizStarted === true
   AND currentQuestion === null  → ResultView(结果展示)
// ✅ 正确:三个条件互斥,每个时刻只渲染一个视图
if (!this.quizStarted) {
    this.SelectCategoryView();
} else if (this.currentQuestion) {
    this.QuizView();
} else {
    this.ResultView();
}

// ❌ 错误:使用 if-else if-else if 不带 else,可能出现空白页面
if (!this.quizStarted) {
    this.SelectCategoryView();
} else if (this.currentQuestion) {
    this.QuizView();
} else if (this.session) {
    this.ResultView();
}
// 缺少最终 else,如果 session 为 null 且 quizStarted 为 true → 白屏

步骤3:分类选择视图——SelectCategoryView

功能说明

分类选择视图是用户进入问答页面的第一个界面。顶部使用 AppBar 通用导航栏,中部展示问答图标和标题,下方列出"全部挑战"和各分类选项。点击任意分类选项后立即开始答题。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    @Builder
    SelectCategoryView() {
        Column() {
            // 顶部导航栏
            AppBar({
                barTitle: '趣味问答',
                showBack: true,
                onBack: () => this.goBack(),
                barBorderBottom: false,
                barBgColor: ThemeColors.BG_SECONDARY
            });

            Scroll() {
                Column() {
                    // ===== 标题区 =====
                    Column() {
                        Image($r('app.media.icon_quiz'))
                            .width(48)
                            .height(48)
                            .objectFit(ImageFit.Contain)
                            .margin({ bottom: 12 });
                        Text('趣味科学问答')
                            .fontSize(22)
                            .fontWeight(FontWeight.Bold)
                            .fontColor(ThemeColors.TEXT_PRIMARY)
                            .margin({ bottom: 6 });
                        Text('选择一个分类,测试你的科学知识!')
                            .fontSize(14)
                            .fontColor(ThemeColors.TEXT_SECONDARY);
                    }
                    .width('100%')
                    .padding({ top: 20, bottom: 24 })
                    .alignItems(HorizontalAlign.Center);

                    // ===== 分类列表 =====
                    Column() {
                        // "全部挑战"固定选项
                        QuizCategoryOption({
                            categoryId: 'all',
                            icon: '',
                            name: '全部挑战',
                            desc: '随机抽取所有分类题目',
                            onCategoryClick: (id: string) => {
                                this.startQuizWithCategory(id);
                            }
                        });

                        // 动态渲染各分类选项
                        ForEach(
                            this.categories,
                            (category: Category) => {
                                QuizCategoryOption({
                                    categoryId: category.id,
                                    icon: category.icon,
                                    name: category.name,
                                    desc: category.description,
                                    onCategoryClick: (id: string) => {
                                        this.startQuizWithCategory(id);
                                    }
                                });
                            },
                            (category: Category) => category.id
                        );
                    }
                    .width('100%');
                }
                .width('100%')
                .padding({ left: 16, right: 16, bottom: 32 });
            }
            .width('100%')
            .layoutWeight(1)
            .scrollBar(BarState.Off)
            .backgroundColor(ThemeColors.BG_SECONDARY);
        }
        .width('100%')
        .height('100%')
        .backgroundColor(ThemeColors.BG_SECONDARY);
    }

QuizCategoryOption 组件结构

// 文件路径:entry/src/main/ets/components/quiz/QuizCategoryOption.ets

@Component
export struct QuizCategoryOption {
    categoryId: string = '';
    icon: string = '';
    name: string = '';
    desc: string = '';
    onCategoryClick?: (categoryId: string) => void = () => {};

    @Builder
    build() {
        Row() {
            // 左侧:分类图标
            Column() {
                Text(this.icon)
                    .fontSize(24);
            }
            .width(48)
            .height(48)
            .borderRadius(24)
            .backgroundColor('#fff3e0')     // 浅橙色背景
            .justifyContent(FlexAlign.Center)
            .margin({ right: 12 });

            // 中间:分类名称 + 描述
            Column() {
                Text(this.name)
                    .fontSize(16)
                    .fontWeight(FontWeight.Medium)
                    .fontColor(ThemeColors.TEXT_PRIMARY)
                    .margin({ bottom: 2 });
                Text(this.desc)
                    .fontSize(12)
                    .fontColor(ThemeColors.TEXT_TERTIARY);
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start);

            // 右侧:"开始 →" 引导文字
            Text('开始 →')
                .fontSize(13)
                .fontColor(ThemeColors.PRIMARY);
        }
        .width('100%')
        .padding(14)
        .backgroundColor(ThemeColors.BG_PRIMARY)
        .borderRadius(12)
        .border({ width: 1, color: ThemeColors.BORDER_COLOR })
        .margin({ bottom: 10 })
        .onClick(() => {
            if (this.onCategoryClick) {
                this.onCategoryClick(this.categoryId);
            }
        });
    }
}

步骤4:答题启动——QuizEngine 协同

功能说明

当用户选择一个分类后,startQuizWithCategory 方法更新分类 ID 并调用 startQuiz,后者与 QuizEngine 交互完成答题会话的创建:从题库中筛选对应分类的题目、Fisher-Yates 洗牌随机排序、截取指定数量的题目、初始化 QuizSession 会话对象。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    /**
     * 指定分类开始答题
     * @param categoryId - 分类 ID('all' 为全部挑战)
     */
    startQuizWithCategory(categoryId: string) {
        this.currentCategory = categoryId;
        this.startQuiz();
    }

    /**
     * 启动答题会话
     * 调用 QuizEngine 创建会话、加载第一题、重置所有状态
     */
    startQuiz() {
        // ① 通过答题引擎创建答题会话
        this.session = quizEngine.startQuiz(
            this.currentCategory,
            AppConstants.DEFAULT_QUIZ_COUNT    // 默认10题
        );
        // ② 获取题目总数
        this.questionCount = this.session?.questions.length || 0;
        // ③ 加载第一题
        this.currentQuestion = quizEngine.getCurrentQuestion();
        // ④ 重置答题过程状态
        this.currentIndex = 0;
        this.selectedOption = -1;
        this.showFeedback = false;
        // ⑤ 切换到答题视图
        this.quizStarted = true;
    }

QuizEngine.startQuiz 内部流程

// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets

/**
 * 开始答题会话
 * @param categoryId - 分类 ID('all' 表示全部)
 * @param questionCount - 题目数量
 * @returns QuizSession 答题会话对象
 */
startQuiz(categoryId: string = 'all', questionCount: number = 10): QuizSession {
    // ① 筛选题库:按分类过滤
    let questionPool: QuizQuestion[];
    if (categoryId === 'all') {
        questionPool = [...this.questions];           // 全部题目
    } else {
        questionPool = this.questions.filter(           // 指定分类
            (q: QuizQuestion) => q.category === categoryId
        );
    }

    // ② Fisher-Yates 洗牌算法随机排序
    const shuffled = this.shuffleArray(questionPool);

    // ③ 截取指定数量的题目
    const selectedQuestions = shuffled.slice(
        0, Math.min(questionCount, shuffled.length)
    );

    // ④ 创建答题会话
    const session: 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}`);
    return this.currentSession;
}

Fisher-Yates 洗牌算法

/**
 * Fisher-Yates 洗牌算法
 * 时间复杂度 O(n),空间复杂度 O(n)
 * 保证每个排列等概率出现
 */
private shuffleArray<T>(array: T[]): T[] {
    const result = [...array];               // 浅拷贝,不修改原数组
    for (let i = result.length - 1; i > 0; i--) {
        // 从 [0, i] 中随机选一个索引 j
        const j = Math.floor(Math.random() * (i + 1));
        // 交换 i 和 j 的位置
        const temp = result[i];
        result[i] = result[j];
        result[j] = temp;
    }
    return result;
}
// ✅ 正确:Fisher-Yates 洗牌保证均匀随机
private shuffleArray<T>(array: T[]): T[] {
    const result = [...array];               // 不修改原数组
    for (let i = result.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [result[i], result[j]] = [result[j], result[i]];  // 交换
    }
    return result;
}

// ❌ 错误1:sort + Math.random 不是均匀随机
array.sort(() => Math.random() - 0.5);
// 不同浏览器中 sort 比较函数调用次数不同,分布不均匀

// ❌ 错误2:直接修改原数组
private shuffleArray<T>(array: T[]): T[] {
    for (let i = array.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [array[i], array[j]] = [array[j], array[i]];  // 修改了原数组!
    }
    return array;  // 副作用:原数组被永久打乱
}

步骤5:答题视图——QuizView 完整结构

功能说明

答题视图是用户进行答题的核心界面,分为四个区域:顶部控制栏(退出按钮 + 题号显示)、进度条、可滚动的内容区(题目卡片 + 选项列表 + 反馈卡片)、底部操作按钮栏。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    @Builder
    QuizView() {
        Column() {
            if (this.session && this.currentQuestion) {
                // ===== 顶部控制栏 =====
                Column() {
                    Row() {
                        // 退出按钮:圆形半透明背景 + "✕"
                        Button() {
                            Text('✕')
                                .fontSize(16)
                                .fontColor(ThemeColors.TEXT_TERTIARY);
                        }
                        .type(ButtonType.Circle)
                        .width(32)
                        .height(32)
                        .backgroundColor(ThemeColors.BG_TERTIARY)
                        .onClick(() => {
                            this.restartQuiz();    // 退出答题,回到分类选择
                        });

                        // 题号显示
                        Column() {
                            Text('第 ' + (this.currentIndex + 1) + ' / ' + this.questionCount + ' 题')
                                .fontSize(13)
                                .fontColor(ThemeColors.TEXT_SECONDARY);
                        }
                        .layoutWeight(1)
                        .alignItems(HorizontalAlign.Center);

                        // 右侧空占位(保持对称)
                        Column() { }
                            .width(32);
                    }
                    .width('100%')
                    .margin({ bottom: 12 });

                    // 进度条
                    Progress({
                        value: this.currentIndex + 1,
                        total: this.questionCount
                    })
                        .width('100%')
                        .color(ThemeColors.PRIMARY)
                        .backgroundColor(ThemeColors.BG_TERTIARY)
                        .margin({ bottom: 20 });
                }
                .width('100%')
                .padding({ top: 16, left: 16, right: 16 });

Progress 进度条动态更新

// 进度值 = 当前索引 + 1(人类可读的第N题)
Progress({
    value: this.currentIndex + 1,     // 当前已完成题数
    total: this.questionCount         // 总题数
})

// 示例:总共10题,当前第3题 → value=3, total=10 → 进度30%
// ✅ 正确:使用 currentIndex + 1 表示当前进度
Progress({ value: this.currentIndex + 1, total: this.questionCount })

// ❌ 错误:使用 currentIndex 导致第一题进度为 0
Progress({ value: this.currentIndex, total: this.questionCount })
// 用户看到第一题时进度为 0%,体验异常

步骤6:题目卡片渲染

功能说明

题目卡片展示当前题目的分类标签、图标和题目文字。分类标签使用主色调背景 + 白色文字,营造信息提示的视觉层级。

源码实现

                Scroll() {
                    Column() {
                        // ===== 题目卡片 =====
                        Column() {
                            // 分类标签 + 图标
                            Row() {
                                Text(this.currentQuestion.icon)
                                    .fontSize(24)
                                    .margin({ right: 8 });
                                Text(this.currentQuestion.categoryName)
                                    .fontSize(12)
                                    .fontColor(ThemeColors.PRIMARY)
                                    .backgroundColor('#fff0f0')
                                    .padding({ left: 10, right: 10, top: 3, bottom: 3 })
                                    .borderRadius(9999);    // 胶囊分类标签
                            }
                            .width('100%')
                            .margin({ bottom: 12 });

                            // 题目文字
                            Text(this.currentQuestion.question)
                                .fontSize(18)
                                .fontWeight(FontWeight.Bold)
                                .fontColor(ThemeColors.TEXT_PRIMARY)
                                .width('100%')
                                .lineHeight(28)
                                .wordBreak(WordBreak.BREAK_ALL);
                        }
                        .width('100%')
                        .padding(18)
                        .backgroundColor(ThemeColors.BG_PRIMARY)
                        .borderRadius(16)
                        .border({ width: 1, color: ThemeColors.BORDER_COLOR })
                        .margin({ bottom: 16 });

QuizQuestion 数据模型

// 文件路径:entry/src/main/ets/model/Quiz.ets

/**
 * 问答题目数据模型
 * 每道题包含4个选项、1个正确索引和解释说明
 */
export interface QuizQuestion {
    id: number;              // 题目唯一标识
    category: string;        // 所属分类 ID
    categoryName: string;    // 所属分类名称
    question: string;        // 题目内容
    options: string[];       // 选项数组(4个选项)
    correctIndex: number;    // 正确答案索引(0-3)
    explanation: string;     // 解释说明
    difficulty: 'easy' | 'medium' | 'hard';  // 难度等级
    icon: string;            // 分类图标 emoji
}

步骤7:选项渲染——QuizOptionItem

功能说明

选项列表使用 ForEach 遍历当前题目的 options 数组,每个选项渲染为 QuizOptionItem 组件。该组件支持四种视觉状态:默认未选、已选中(蓝色)、正确答案(绿色,反馈后)、错误选项(红色,反馈后)。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

                        // ===== 选项列表 =====
                        Column() {
                            ForEach(
                                this.currentQuestion.options,
                                (option: string, index: number) => {
                                    QuizOptionItem({
                                        option: option,
                                        index: index,
                                        selected: this.selectedOption === index,
                                        showFeedback: this.showFeedback,
                                        isCorrect: this.isCorrect,
                                        correctIndex: this.correctIdx,
                                        onSelect: (idx: number) => {
                                            this.selectOption(idx);
                                        }
                                    });
                                },
                                // Key 生成:题目ID + 选项索引,确保唯一性
                                (_: string, index: number) =>
                                    'q' + (this.currentQuestion?.id ?? 0) +
                                    '-opt-' + index.toString()
                            );
                        }
                        .width('100%')
                        .margin({ bottom: 16 });

QuizOptionItem 组件多状态渲染

// 文件路径:entry/src/main/ets/components/quiz/QuizOptionItem.ets

@Component
export struct QuizOptionItem {
    option: string = '';          // 选项文字
    index: number = 0;           // 选项索引(0=A, 1=B, 2=C, 3=D)
    selected: boolean = false;    // 是否被用户选中
    showFeedback: boolean = false;    // 是否已提交答案
    isCorrect: boolean = false;      // 本次答题是否正确
    correctIndex: number = -1;       // 正确选项索引
    onSelect?: (index: number) => void = () => {};  // 选择回调

    @Builder
    build() {
        Row() {
            // 左侧字母标识圆圈
            Column() {
                Text(String.fromCharCode(65 + this.index))    // A/B/C/D
                    .fontSize(14)
                    .fontWeight(FontWeight.Medium)
                    .fontColor(this.getOptionTextColor());
            }
            .width(32)
            .height(32)
            .borderRadius(16)
            .backgroundColor(this.getOptionBgColor())
            .border({ width: 2, color: this.getOptionBorderColor() })
            .justifyContent(FlexAlign.Center)
            .margin({ right: 12 });

            // 选项文字
            Text(this.option)
                .fontSize(15)
                .fontColor(ThemeColors.TEXT_PRIMARY)
                .layoutWeight(1);

            // 反馈标识(仅在 showFeedback 时显示)
            if (this.showFeedback) {
                if (this.index === this.correctIndex) {
                    Text('✓')
                        .fontSize(18)
                        .fontColor(ThemeColors.SUCCESS)
                        .fontWeight(FontWeight.Bold);    // 绿色对勾
                } else if (this.selected && !this.isCorrect) {
                    Text('✗')
                        .fontSize(18)
                        .fontColor(ThemeColors.PRIMARY)
                        .fontWeight(FontWeight.Bold);    // 红色叉号
                }
            }
        }
        .width('100%')
        .padding(14)
        .backgroundColor(this.getOptionCardBg())
        .borderRadius(12)
        .border({ width: 2, color: this.getOptionBorderColor() })
        .margin({ bottom: 10 })
        .onClick(() => {
            if (!this.showFeedback && this.onSelect) {
                this.onSelect(this.index);    // 未反馈时才响应点击
            }
        });
    }

四种状态的视觉参数矩阵

状态 字母圆背景 字母字色 卡片背景 边框颜色 右侧标识
默认未选 #f5f5f5 TEXT_SECONDARY BG_PRIMARY transparent
用户选中 PRIMARY TEXT_WHITE #fff5f5 PRIMARY
正确答案 SUCCESS TEXT_WHITE #f1f8e9 SUCCESS ✓ 绿色
错误选项 PRIMARY TEXT_WHITE #ffebee PRIMARY ✗ 红色
// ✅ 正确:根据 showFeedback 和正确性分层判断
private getOptionCardBg(): string {
    if (!this.showFeedback) {
        // 未提交:选中为浅红,未选为白色
        return this.selected ? '#fff5f5' : ThemeColors.BG_PRIMARY;
    }
    if (this.index === this.correctIndex) {
        return '#f1f8e9';              // 正确答案:浅绿背景
    }
    if (this.selected && !this.isCorrect) {
        return '#ffebee';              // 错误选项:浅红背景
    }
    return ThemeColors.BG_PRIMARY;     // 其他:白色
}

// ❌ 错误:只判断 selected,不区分反馈状态
if (this.selected) {
    return '#fff5f5';   // 反馈后正确选项也显示浅红色
}

步骤8:答题反馈——正确/错误差异化

功能说明

用户选择选项后,selectOption 方法调用 QuizEngine 提交答案,获取正确性结果。反馈区域条件渲染:显示正确/错误的图标与文案、解释说明文字。整体卡片背景根据正确性切换绿色或红色。

提交答案逻辑

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    /**
     * 选择选项并提交答案
     * @param index - 选项索引
     */
    selectOption(index: number) {
        // 防重复提交:已显示反馈时不再响应
        if (this.showFeedback) return;

        // 记录用户选中
        this.selectedOption = index;

        // 调用答题引擎提交答案
        const result = quizEngine.submitAnswer(index);
        if (result) {
            this.isCorrect = result.isCorrect;        // 是否正确
            this.correctIdx = result.correctIndex;      // 正确答案索引
            this.explanation = result.explanation;       // 解释文字
            this.showFeedback = true;                    // 显示反馈
        }
    }

QuizEngine.submitAnswer 内部逻辑

// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets

/**
 * 提交答案
 * @param optionIndex - 用户选择的选项索引
 * @returns SubmitResult 提交结果(正确性、正确索引、解释)
 */
submitAnswer(optionIndex: number): SubmitResult | null {
    if (!this.currentSession || this.currentSession.isFinished) return null;

    const question = this.currentSession.questions[this.currentSession.currentIndex];
    if (!question) return null;

    // 判断正确性
    const isCorrect = optionIndex === question.correctIndex;
    // 记录答案
    this.currentSession.answers.push(optionIndex);

    if (isCorrect) {
        this.currentSession.correctCount++;          // 答对计数+1
    } else {
        this.currentSession.wrongCount++;            // 答错计数+1
        // 答错的题目加入错题本
        userPrefs.addWrongQuiz(question.id).catch((err: Error) => {
            Logger.error(TAG, '添加错题失败', err);
        });
    }

    Logger.debug(TAG, `答题: 第${this.currentSession.currentIndex + 1}题, `
        + `结果=${isCorrect ? '正确' : '错误'}`);

    return {
        isCorrect: isCorrect,
        correctIndex: question.correctIndex,
        explanation: question.explanation
    };
}

反馈卡片 UI 渲染

// 文件路径:entry/src/main/ets/pages/Quiz.ets

                        // ===== 答题反馈卡片(条件显示) =====
                        if (this.showFeedback) {
                            Column({ space: 8 }) {
                                // 反馈标题行
                                Row() {
                                    Image($r(
                                        this.isCorrect
                                            ? 'app.media.icon_trophy'
                                            : 'app.media.icon_empty'
                                    ))
                                        .width(18)
                                        .height(18)
                                        .objectFit(ImageFit.Contain)
                                        .fillColor(
                                            this.isCorrect
                                                ? ThemeColors.SUCCESS
                                                : ThemeColors.PRIMARY
                                        )
                                        .margin({ right: 6 });
                                    Text(this.isCorrect ? '回答正确!' : '回答错误')
                                        .fontSize(16)
                                        .fontWeight(FontWeight.Bold)
                                        .fontColor(
                                            this.isCorrect
                                                ? ThemeColors.SUCCESS
                                                : ThemeColors.PRIMARY
                                        );
                                }
                                .width('100%');

                                // 解释说明行
                                Row() {
                                    Image($r('app.media.icon_lightbulb'))
                                        .width(14)
                                        .height(14)
                                        .objectFit(ImageFit.Contain)
                                        .margin({ right: 4 });
                                    Text(this.explanation)
                                        .fontSize(14)
                                        .fontColor(ThemeColors.TEXT_SECONDARY)
                                        .layoutWeight(1);
                                }
                                .width('100%')
                                .alignItems(VerticalAlign.Top);
                            }
                            .width('100%')
                            .padding(14)
                            // 正确:浅绿背景 + 绿边框;错误:浅红背景 + 红边框
                            .backgroundColor(
                                this.isCorrect ? '#e8f5e9' : '#ffebee'
                            )
                            .borderRadius(12)
                            .border({
                                width: 1,
                                color: this.isCorrect ? '#a5d6a7' : '#ef9a9a'
                            })
                            .margin({ bottom: 16 });
                        }

反馈视觉对比

正确反馈:
┌─────────────────────────────────────┐
│ 🏆  回答正确!                        │  ← 绿色文字
│ 💡 太阳光从太阳到地球需要大约8分钟...  │  ← 灰色解释文字
└─────────────────────────────────────┘
背景色:#e8f5e9(浅绿)  边框:#a5d6a7(绿色)

错误反馈:
┌─────────────────────────────────────┐
│ ⊘   回答错误                          │  ← 红色文字
│ 💡 正确答案是:太阳是一个恒星...       │  ← 灰色解释文字
└─────────────────────────────────────┘
背景色:#ffebee(浅红)  边框:#ef9a9a(红色)

步骤9:底部操作按钮栏

功能说明

底部按钮栏根据答题状态动态切换:未选择答案时显示禁用状态的"请选择一个答案"按钮;已选择答案后显示"下一题"或"查看结果"按钮(最后一题时文字切换)。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

                // ===== 底部按钮栏 =====
                Row() {
                    if (this.showFeedback) {
                        // 已提交答案:显示"下一题""查看结果"
                        Button(
                            this.currentIndex < this.questionCount - 1
                                ? '下一题'
                                : '查看结果'
                        )
                            .width('100%')
                            .height(48)
                            .fontSize(16)
                            .fontWeight(FontWeight.Medium)
                            .type(ButtonType.Capsule)
                            .backgroundColor(ThemeColors.PRIMARY)
                            .onClick(() => {
                                this.nextQuestion();
                            });
                    } else {
                        // 未提交答案:禁用状态提示
                        Button('请选择一个答案')
                            .width('100%')
                            .height(48)
                            .fontSize(15)
                            .type(ButtonType.Capsule)
                            .backgroundColor('#e0e0e0')             // 灰色背景
                            .fontColor(ThemeColors.TEXT_TERTIARY)
                            .enabled(false);                         // 禁用按钮
                    }
                }
                .width('100%')
                .padding({ left: 16, right: 16, top: 12, bottom: 16 })
                .backgroundColor(ThemeColors.BG_PRIMARY)
                .border({ width: { top: 1 }, color: ThemeColors.BORDER_COLOR });

按钮状态对比

// ✅ 正确:禁用按钮明确告知用户需要操作
Button('请选择一个答案')
    .backgroundColor('#e0e0e0')
    .enabled(false);    // 灰色 + 不可点击

// ❌ 错误:不显示按钮,用户不知道需要选择答案
// 底部空白,无操作提示

步骤10:下一题与答题完成

功能说明

nextQuestion 方法调用 QuizEngine 推进到下一题。如果有更多题目,更新 currentIndex 和 currentQuestion;如果没有更多题目,将 currentQuestion 设为 null,触发视图切换到 ResultView。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    /**
     * 推进到下一题
     * 无更多题目时 currentQuestion 置为 null,自动切换到结果视图
     */
    nextQuestion() {
        const hasMore = quizEngine.nextQuestion();
        if (hasMore) {
            // 还有下一题:加载题目、重置状态
            const nextQ = quizEngine.getCurrentQuestion();
            this.currentIndex++;
            this.selectedOption = -1;          // 清除选中
            this.showFeedback = false;        // 隐藏反馈
            this.isCorrect = false;
            this.explanation = '';
            this.correctIdx = -1;
            this.currentQuestion = nextQ;
        } else {
            // 答题完成:清除当前题目,触发结果视图
            this.currentQuestion = null;
            this.showFeedback = false;
        }
    }

QuizEngine.nextQuestion 内部逻辑

// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets

/**
 * 推进到下一题
 * @returns true 还有下一题 / false 答题完成
 */
nextQuestion(): boolean {
    if (!this.currentSession) return false;

    if (this.currentSession.currentIndex < this.currentSession.questions.length - 1) {
        this.currentSession.currentIndex++;   // 索引+1
        return true;                          // 还有题目
    } else {
        this.currentSession.isFinished = true;  // 标记完成
        this.saveScore();                       // 保存成绩
        return false;                            // 答题完成
    }
}

成绩保存与成就联动

// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets

/**
 * 保存答题成绩
 * 包含:持久化成绩记录 + 触发成就系统判定
 */
private saveScore(): void {
    if (!this.currentSession) return;

    const total = this.currentSession.questions.length;
    const correct = this.currentSession.correctCount;
    const isPerfect = correct === total && total > 0;

    // ① 持久化成绩到 UserPreferences
    const scoreRecord: QuizScoreRecord = {
        totalQuestions: total,
        correctCount: correct,
        timestamp: Date.now(),
        category: this.currentSession.category
    };
    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}%`);
}

步骤11:结果视图——ResultView(页面内嵌版)

功能说明

答题完成后,Quiz 页面内嵌一个简化版的结果视图,展示答对/答错数量、正确率、鼓励性标题和操作按钮。用户可以选择"再来一次"回到分类选择,或"返回首页"回到主页。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    @Builder
    ResultView() {
        Column() {
            if (this.session) {
                Column() {
                    // 结果图标(根据正确率动态切换)
                    Row() {
                        Image(this.getResultIconResource())
                            .width(64)
                            .height(64)
                            .objectFit(ImageFit.Contain);
                    }
                    .margin({ bottom: 16 });

                    // 结果标题(鼓励性文案)
                    Text(this.getResultTitle())
                        .fontSize(24)
                        .fontWeight(FontWeight.Bold)
                        .fontColor(ThemeColors.TEXT_PRIMARY)
                        .margin({ bottom: 6 });

                    // 答题概要
                    Text('你答对了 ' + this.session.correctCount + ' / '
                        + this.session.questions.length + ' 题')
                        .fontSize(15)
                        .fontColor(ThemeColors.TEXT_SECONDARY)
                        .margin({ bottom: 20 });

                    // ===== 统计卡片 =====
                    Row() {
                        // 答对
                        Column() {
                            Text(this.session.correctCount.toString())
                                .fontSize(28)
                                .fontWeight(FontWeight.Bold)
                                .fontColor(ThemeColors.SUCCESS);
                            Text('答对')
                                .fontSize(12)
                                .fontColor(ThemeColors.TEXT_TERTIARY);
                        }
                        .layoutWeight(1)
                        .alignItems(HorizontalAlign.Center);

                        // 分隔线
                        Column()
                            .width(1)
                            .height(40)
                            .backgroundColor(ThemeColors.BORDER_COLOR);

                        // 答错
                        Column() {
                            Text(this.session.wrongCount.toString())
                                .fontSize(28)
                                .fontWeight(FontWeight.Bold)
                                .fontColor(ThemeColors.PRIMARY);
                            Text('答错')
                                .fontSize(12)
                                .fontColor(ThemeColors.TEXT_TERTIARY);
                        }
                        .layoutWeight(1)
                        .alignItems(HorizontalAlign.Center);

                        // 分隔线
                        Column()
                            .width(1)
                            .height(40)
                            .backgroundColor(ThemeColors.BORDER_COLOR);

                        // 正确率
                        Column() {
                            Text(this.getPercentage() + '%')
                                .fontSize(28)
                                .fontWeight(FontWeight.Bold)
                                .fontColor(ThemeColors.WARNING);
                            Text('正确率')
                                .fontSize(12)
                                .fontColor(ThemeColors.TEXT_TERTIARY);
                        }
                        .layoutWeight(1)
                        .alignItems(HorizontalAlign.Center);
                    }
                    .width('100%')
                    .padding({ top: 16, bottom: 16 })
                    .backgroundColor('#f9f9f9')
                    .borderRadius(12);
                }
                .width('100%')
                .padding(24)
                .backgroundColor(ThemeColors.BG_PRIMARY)
                .borderRadius(20)
                .alignItems(HorizontalAlign.Center);

                // ===== 操作按钮 =====
                Column() {
                    Button('再来一次')
                        .width('100%')
                        .height(48)
                        .fontSize(16)
                        .fontWeight(FontWeight.Medium)
                        .type(ButtonType.Capsule)
                        .backgroundColor(ThemeColors.PRIMARY)
                        .margin({ bottom: 10 })
                        .onClick(() => {
                            this.restartQuiz();     // 回到分类选择
                        });

                    Button('返回首页')
                        .width('100%')
                        .height(48)
                        .fontSize(16)
                        .fontWeight(FontWeight.Medium)
                        .type(ButtonType.Capsule)
                        .backgroundColor(ThemeColors.BG_PRIMARY)
                        .fontColor(ThemeColors.PRIMARY)
                        .border({ width: 1, color: ThemeColors.PRIMARY })
                        .onClick(() => {
                            RouterUtil.replaceUrl(
                                { url: RouteUrls.MAIN_TABS }, 'Quiz'
                            );
                        });
                }
                .width('100%')
                .margin({ top: 20 });
            }
        }
        .width('100%')
        .height('100%')
        .padding(20)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(ThemeColors.BG_SECONDARY);
    }

结果标题与图标策略

/**
 * 根据正确率计算百分比
 */
private getPercentage(): number {
    if (!this.session || this.session.questions.length === 0) return 0;
    return Math.round(
        (this.session.correctCount / this.session.questions.length) * 100
    );
}

/**
 * 根据正确率获取结果图标
 * >=90% 或 >=70%:奖杯图标
 * >=50%:问答图标
 * <50%:空状态图标
 */
private getResultIconResource(): ResourceStr {
    const pct = this.getPercentage();
    if (pct >= 90) return $r('app.media.icon_trophy');
    if (pct >= 70) return $r('app.media.icon_trophy');
    if (pct >= 50) return $r('app.media.icon_quiz');
    return $r('app.media.icon_empty');
}

/**
 * 根据正确率获取鼓励性标题
 */
private getResultTitle(): string {
    const pct = this.getPercentage();
    if (pct >= 90) return '太棒了!科学小达人!';
    if (pct >= 70) return '很不错哦,继续加油!';
    if (pct >= 50) return '还不错,再接再厉!';
    return '没关系,多多学习!';
}

正确率与反馈对应关系:

正确率 图标 标题文案 语气
>=90% 奖杯 太棒了!科学小达人! 高度表扬
>=70% 奖杯 很不错哦,继续加油! 鼓励表扬
>=50% 问答 还不错,再接再厉! 正向鼓励
<50% 空状态 没关系,多多学习! 温和鼓励

步骤12:重新开始——状态重置

功能说明

用户点击"再来一次"或顶部退出按钮时,restartQuiz 方法将所有状态重置为初始值,视图自动切换回分类选择界面。

源码实现

// 文件路径:entry/src/main/ets/pages/Quiz.ets

    /**
     * 重置答题状态,回到分类选择视图
     */
    restartQuiz() {
        this.quizStarted = false;          // 回到视图1
        this.session = null;                // 清空会话
        this.currentQuestion = null;        // 清空当前题目
        this.selectedOption = -1;          // 重置选中
        this.showFeedback = false;         // 隐藏反馈
    }
// ✅ 正确:完整重置所有答题状态
restartQuiz() {
    this.quizStarted = false;
    this.session = null;
    this.currentQuestion = null;
    this.selectedOption = -1;
    this.showFeedback = false;
}

// ❌ 错误:部分状态未重置,回到分类选择后残留答题数据
restartQuiz() {
    this.quizStarted = false;
    this.session = null;
    // 缺少:currentQuestion、selectedOption、showFeedback 重置
    // 可能导致下次答题时 showFeedback 仍为 true
}

步骤13:QuizResult 独立结果页

功能说明

QuizResult 是独立的 @Entry 结果页面,支持通过路由参数接收答题数据,提供更丰富的结果展示:星级评定、用时统计、分类标签、主题色切换(优秀金色 vs 一般主色)。该页面可从每日挑战、错题练习等多个入口跳转。

源码实现(核心结构)

// 文件路径:entry/src/main/ets/pages/QuizResult.ets

@Entry
@Component
struct QuizResult {
    @State correctCount: number = 0;
    @State totalCount: number = 0;
    @State wrongCount: number = 0;
    @State percentage: number = 0;
    @State category: string = '';
    @State isDaily: boolean = false;
    @State usedTime: number = 0;
    @State hasUsedTime: boolean = false;

    aboutToAppear() {
        // 从路由参数安全获取所有数据
        const params = RouterUtil.getParams() as Record<string, Object>;
        if (params) {
            if (params.correctCount !== undefined && params.correctCount !== null) {
                this.correctCount = params.correctCount as number;
            }
            if (params.totalCount !== undefined && params.totalCount !== null) {
                this.totalCount = params.totalCount as number;
            }
            // ... 其他参数安全获取
        }
    }

    /**
     * 计算星级评定
     * >=90% 三星,>=70% 两星,>=50% 一星,<50% 零星
     */
    getStarLevel(): number {
        if (this.percentage >= 90) return 3;
        if (this.percentage >= 70) return 2;
        if (this.percentage >= 50) return 1;
        return 0;
    }

    build() {
        Column() {
            AppBar({
                barTitle: '答题结果',
                showBack: true,
                onBack: () => this.goBack(),
                barBgColor: ThemeColors.BG_SECONDARY
            });

            Scroll() {
                Column() {
                    // ===== 星级评定区域 =====
                    Row({ space: 12 }) {
                        ForEach([1, 2, 3], (star: number) => {
                            Image($r('app.media.icon_star'))
                                .width(40)
                                .height(40)
                                .objectFit(ImageFit.Contain)
                                // 达到星级的亮金色,未达到的灰色
                                .fillColor(
                                    star <= this.getStarLevel()
                                        ? ThemeColors.WARNING
                                        : ThemeColors.TEXT_HINT
                                );
                        }, (star: number) => 'result-star-' + star.toString());
                    }
                    .justifyContent(FlexAlign.Center);

                    // ===== 评级标题(主题色切换) =====
                    Text(this.getResultTitle())
                        .fontSize(28)
                        .fontWeight(FontWeight.Bold)
                        .fontColor(this.getMainColor());    // 优秀=金色,一般=主色

                    // ===== 统计卡片:答对/答错/正确率 =====
                    // ...(与 Quiz.ets ResultView 类似结构)

                    // ===== 用时卡片(条件显示) =====
                    if (this.hasUsedTime) {
                        Column() {
                            Row() {
                                Image($r('app.media.icon_quiz'))
                                    .width(18).height(18);
                                Text('用时');
                                Text(this.formatUsedTime(this.usedTime))
                                    .fontSize(20)
                                    .fontWeight(FontWeight.Bold);
                            }
                        }
                        .padding(16)
                        .borderRadius(16);
                    }

                    // ===== 操作按钮 =====
                    Button('再来一次')
                        .backgroundColor(this.getMainColor())
                        .onClick(() => { RouterUtil.back('QuizResult'); });

                    Button('返回首页')
                        .backgroundColor(ThemeColors.BG_PRIMARY)
                        .border({ width: 1, color: ThemeColors.PRIMARY })
                        .onClick(() => {
                            RouterUtil.replaceUrl(
                                { url: RouteUrls.MAIN_TABS }, 'QuizResult'
                            );
                        });
                }
            }
        }
    }
}

星级评定逻辑

正确率 ≥ 90%  → ★★★  "太棒了"  → 金色主题
正确率 ≥ 70%  → ★★    "很厉害"  → 金色主题
正确率 ≥ 50%  → ★     "继续加油"  → 主色主题
正确率 < 50%  → ☆☆☆  "再接再厉"  → 主色主题

主题色动态切换

/**
 * 判断成绩是否优秀(>=70%)
 */
isExcellent(): boolean {
    return this.percentage >= 70;
}

/**
 * 动态主题色:优秀用金色,一般用主色
 */
getMainColor(): string {
    return this.isExcellent() ? ThemeColors.WARNING : ThemeColors.PRIMARY;
}

最佳实践

1. 防重复提交设计

// ✅ 正确:showFeedback 标志位阻止重复提交
selectOption(index: number) {
    if (this.showFeedback) return;    // 已提交,直接返回
    this.selectedOption = index;
    const result = quizEngine.submitAnswer(index);
    // ...
}

// ❌ 错误:无防重复提交,快速点击可多次提交
selectOption(index: number) {
    const result = quizEngine.submitAnswer(index);
    // 每次点击都会提交,可能影响计分
}

2. ForEach Key 生成策略

// ✅ 正确:题目ID + 选项索引,确保全局唯一
(_: string, index: number) =>
    'q' + (this.currentQuestion?.id ?? 0) + '-opt-' + index.toString()
// 生成:q5-opt-0, q5-opt-1, q5-opt-2, q5-opt-3

// ❌ 错误:仅用索引,切换题目时可能复用旧 Key
(_: string, index: number) => 'opt-' + index.toString()
// 题目1opt-0, opt-1, opt-2, opt-3
// 题目2opt-0, opt-1, opt-2, opt-3 ← Key 重复,Diff 算法无法正确更新

3. 选项点击防护

// ✅ 正确:反馈已显示时禁用选项点击
.onClick(() => {
    if (!this.showFeedback && this.onSelect) {
        this.onSelect(this.index);
    }
})

// ❌ 错误:无防护,反馈后点击其他选项仍触发回调
.onClick(() => {
    this.onSelect(this.index);
})

4. 状态重置完整性

// ✅ 正确:restartQuiz 重置所有答题相关状态
restartQuiz() {
    this.quizStarted = false;
    this.session = null;
    this.currentQuestion = null;
    this.selectedOption = -1;
    this.showFeedback = false;
    // 不需要重置:isCorrect、explanation、correctIdx
    // 因为它们只在 showFeedback=true 时使用,不影响其他视图
}

// ❌ 错误:遗漏关键状态重置
restartQuiz() {
    this.quizStarted = false;
    // 缺少 session、currentQuestion 重置
    // 下次 startQuiz 时 session 指向旧数据
}

5. 路由参数安全获取

// ✅ 正确:逐一检查参数存在性和非空性
if (params.correctCount !== undefined && params.correctCount !== null) {
    this.correctCount = params.correctCount as number;
}

// ❌ 错误:直接类型断言不校验
this.correctCount = params.correctCount as number;
// Previewer 空参数时 undefined as number → NaN

常见问题与排查

Q1:分类选择后页面白屏

原因currentQuestion 为 null 导致 QuizView 不渲染,但 quizStarted 已为 true,不显示 SelectCategoryView。

排查步骤

// 检查 startQuiz 方法中 session 是否成功创建
startQuiz() {
    this.session = quizEngine.startQuiz(this.currentCategory, 10);
    // 添加日志排查
    Logger.info(TAG, `session: ${JSON.stringify(this.session)}`);
    Logger.info(TAG, `questions: ${this.session?.questions.length}`);
    this.currentQuestion = quizEngine.getCurrentQuestion();
    Logger.info(TAG, `currentQuestion: ${this.currentQuestion?.id}`);
}

Q2:选项点击后无反应

原因1showFeedback 已为 true(防重复提交生效)。

原因2:QuizEngine 未初始化(quizEngine.init(context) 未在 EntryAbility.onCreate 中调用)。

// EntryAbility.ets 中确保初始化
quizEngine.init(this.context);

Q3:答题完成后成绩未保存

原因saveScorenextQuestion 返回 false 时触发,如果用户直接退出页面(返回键),saveScore 不会执行。

解决方案:在 aboutToDisappear 中检查并保存未完成的成绩。

aboutToDisappear() {
    if (this.session && !this.session.isFinished) {
        // 用户中途退出,仍保存已答题目的成绩
        this.session.isFinished = true;
        quizEngine.getSession()?.isFinished;  // 需要引擎支持
    }
}

Q4:进度条不更新

原因:Progress 组件的 value 未正确绑定到 currentIndex + 1

// ✅ 正确:value 绑定到当前进度
Progress({ value: this.currentIndex + 1, total: this.questionCount })

// ❌ 错误:value 使用静态值
Progress({ value: 3, total: this.questionCount })
// 进度永远不变

总结

Quiz 趣味问答页是《奇妙科学乐园》中技术复杂度较高的页面,涉及状态管理、引擎协同、动态渲染和成绩持久化等多个维度的技术实现。通过本文的完整拆解,我们梳理了以下核心技术要点:

  1. 三视图条件渲染:通过 quizStartedcurrentQuestion 两个核心状态变量,在一个页面内实现分类选择、答题和结果展示三个视图的切换,避免了多页面跳转的状态管理复杂性。

  2. @Builder 构建器模式:SelectCategoryView、QuizView、ResultView 三个 @Builder 方法各自独立管理 UI 结构,代码职责清晰,build 方法只负责条件分发。

  3. QuizEngine 答题引擎:单例模式管理答题会话,Fisher-Yates 洗牌算法保证题目随机性,submitAnswer 同时处理计分与错题记录,nextQuestion 自动触发成绩保存与成就联动。

  4. QuizOptionItem 四态渲染:通过 selected、showFeedback、isCorrect、correctIndex 四个 props 驱动默认/选中/正确/错误四种视觉状态,差异化反馈即时明确。

  5. 即时反馈差异化设计:正确反馈使用绿色背景 + 奖杯图标 + "回答正确!",错误反馈使用红色背景 + 空状态图标 + "回答错误",解释说明帮助儿童理解正确答案。

  6. 防重复提交:showFeedback 标志位在选项组件和页面方法双重防护,确保每题只能提交一次答案。

  7. 进度条动态更新:Progress 组件绑定 currentIndex + 1,实时反映答题进度。

  8. 成绩多入口适配:Quiz 页面内嵌简化结果视图 + QuizResult 独立结果页支持路由参数传递,适配每日挑战、错题练习等多场景。


源码仓库https://atomgit.com/2301_79280419/WonderSciencePark
上一篇第94篇 - 知识详情页TopicDetail开发
下一篇第96篇 - QuizResult答题结果页开发

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐