HarmonyOS应用<奇妙科学乐园>开发第96篇:每日挑战页开发——5题限时/倒计时/成绩记录

📖 引言
在上一篇文章中,我们完成了打卡弹窗组件 CheckInDialog 的开发,实现了周历视图、连续学习天数展示和签到持久化。打卡机制解决了"如何让孩子每天回来"的问题,而今天要实现的每日挑战则解决"回来之后做什么"的问题。
每日挑战(Daily Challenge)是儿童教育应用中最核心的留存设计之一。固定5道题、120秒倒计时、三星评级机制,通过"时间压力+目标明确+即时反馈"三个维度,让孩子在短时间内获得完整的"挑战-答题-评价"闭环体验。《奇妙科学乐园》的每日挑战页 DailyChallenge.ets 是一个功能密度极高的页面——它集成了倒计时器、题目切换、答案判定、星级计算、成绩持久化和错题记录六大功能,状态管理复杂度远超普通列表页。
本文将完整拆解 DailyChallenge.ets 的实现,从倒计时定时器管理到答题状态机,从星级计算到成绩持久化,逐一解析每个技术决策背后的考量。你将看到一个"限时答题"页面在 HarmonyOS ArkTS 中的完整落地过程。
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握
setInterval/clearInterval在 ArkTS 中的使用和生命周期管理 - ✅ 实现基于
@State驱动的倒计时器,理解定时器与 UI 状态的同步机制 - ✅ 设计答题状态机:未答/已选/反馈中/下一题四种状态的无缝切换
- ✅ 实现三星评级算法:根据正确率动态计算 1~3 星
- ✅ 理解成绩持久化的防重复记录机制(
scoreRecorded标志位) - ✅ 掌握每日题目生成算法:基于"年天数"的确定性随机
- ✅ 运用
fillColor属性为 SVG 图标动态着色
💡 需求分析
功能模块设计
| 模块 | 功能描述 | 技术要点 |
|---|---|---|
| 倒计时器 | 120秒倒计时,低于30秒变色预警,时间到自动结束 | setInterval/clearInterval、@State响应式更新 |
| 题目展示 | 5道题顺序作答,显示题号/分类/选项ABCD | ForEach渲染、QuizOptionItem组件复用 |
| 答案判定 | 选择后立即显示对错反馈和解析 | showFeedback状态控制、correctIndex标记 |
| 星级评级 | 实时计算星级(全对3星/80%+ 2星/其他1星) | calcStarLevel算法、fillColor动态着色 |
| 成绩记录 | 答题完成后保存成绩到preferences,防重复记录 | QuizScoreRecord、scoreRecorded标志位 |
| 结果页 | 展示三星评级、答对/答错/用时统计 | ResultView @Builder、getUsedTime计算 |
页面状态流转
DailyChallenge 页面状态机
│
├─ questions.length === 0?
│ └─ Yes → EmptyView(暂无题目)
│
├─ pageState === 'quiz'?
│ ├─ aboutToAppear() → 加载题目 + 启动倒计时
│ ├─ selectOption() → 判定答案 → 显示反馈
│ ├─ nextQuestion() → 切换下一题 或 finishChallenge()
│ └─ countdown === 0 → finishChallenge()
│
└─ pageState === 'result'?
├─ ResultView() → 显示成绩
├─ restartChallenge() → 重置所有状态 → 重新开始
└─ 返回首页 → RouterUtil.replaceUrl()
数据流设计
QuizEngine.getDailyChallengeQuestions()
↓ 返回 QuizQuestion[]
DailyChallenge 初始化题目
↓ questions[0] → currentQuestion
setInterval 每秒 countdown--
↓ countdown === 0
finishChallenge()
↓ scoreRecorded === false?
├─ Yes → userPrefs.addQuizScore(scoreRecord)
│ → achievementManager.recordQuizResult()
│ → scoreRecorded = true
└─ No → 跳过(防止重复记录)
每日题目生成算法
getDailyChallengeQuestions() 算法:
1. 计算今天的"年天数" dayOfYear(1~366)
2. 用 dayOfYear % questions.length 作为起始种子
3. 每次间隔 7 个位置取题(避免相邻日期题目重复)
4. 取出 5 道(DAILY_CHALLENGE_COUNT)题目
效果:同一天所有用户看到相同题目,不同天题目不同
🛠️ 核心实现
步骤1: 常量定义与组件状态声明
功能说明
每日挑战页面的配置参数(倒计时秒数、题目总数)通过模块级常量定义,避免在组件内部出现"魔法数字"。组件状态使用 @State 装饰器声明,涵盖了题目数据、答题进度、倒计时、星级评定、页面状态等全部维度。
完整代码
// 文件路径:entry/src/main/ets/pages/DailyChallenge.ets
import { QuizQuestion } from '../model/Quiz';
import { quizEngine } from '../viewmodel/QuizEngine';
import { userPrefs, QuizScoreRecord } from '../viewmodel/UserPreferences';
import { achievementManager } from '../viewmodel/AchievementManager';
import { QuizOptionItem } from '../components/quiz/QuizOptionItem';
import { Logger } from '../utils/Logger';
import { RouterUtil } from '../utils/RouterUtil';
import { ThemeColors } from '../constants/AppConstants';
import { RouteUrls } from '../constants/RouteUrls';
const TAG = 'DailyChallenge';
// 每日挑战最大倒计时(秒)—— 2分钟答题时间
const MAX_COUNTDOWN = 120;
// 每日挑战总题数
const DAILY_TOTAL = 5;
@Entry
@Component
struct DailyChallenge {
// 题目相关状态
@State questions: QuizQuestion[] = [];
@State currentIndex: number = 0;
@State currentQuestion: QuizQuestion | null = null;
@State selectedOption: number = -1;
@State showFeedback: boolean = false;
@State isCorrect: boolean = false;
@State correctIdx: number = -1;
@State explanation: string = '';
// 答题记录:0=未答, 1=正确, 2=错误
@State answerStates: number[] = [0, 0, 0, 0, 0];
@State correctCount: number = 0;
@State wrongCount: number = 0;
// 倒计时
@State countdown: number = MAX_COUNTDOWN;
// 星级:0=未评, 1/2/3=对应星级
@State starLevel: number = 0;
// 页面状态:'quiz'=答题中, 'result'=结果页
@State pageState: string = 'quiz';
// 是否已记录成绩(防止重复记录)
@State scoreRecorded: boolean = false;
// 定时器ID(非响应式,不需要触发UI刷新)
private timerId: number = -1;
代码解析
1. 常量提取的设计决策
// ✅ 正确:魔法数字提取为常量
const MAX_COUNTDOWN = 120;
const DAILY_TOTAL = 5;
// ❌ 错误:直接在组件内使用魔法数字
if (this.countdown > 120) { ... } // 120代表什么?
if (this.currentIndex < 5 - 1) { ... } // 5代表什么?
原理/说明:
MAX_COUNTDOWN和DAILY_TOTAL是业务语义明确的配置参数- 提取为常量后,修改题目数量只需改一处,所有引用自动同步
- 代码可读性大幅提升,
MAX_COUNTDOWN - this.countdown比120 - this.countdown语义清晰
2. answerStates 数组的状态编码
// 答题记录状态编码
@State answerStates: number[] = [0, 0, 0, 0, 0];
// 0 = 未答(灰色圆点)
// 1 = 正确(绿色圆点)
// 2 = 错误(红色圆点)
原理/说明:
- 使用数字编码而非布尔值,因为存在三种状态(未答/正确/错误)
- 底部导航圆点根据
answerStates[index]的值决定颜色 - 实时更新的数组驱动
ForEach重新渲染,实现答题进度的即时视觉反馈
步骤2: 生命周期管理与倒计时器
功能说明
aboutToAppear 中加载题目并启动倒计时,aboutToDisappear 中清除定时器防止内存泄漏。倒计时使用 setInterval 每秒递减,时间耗尽时自动结束挑战。
完整代码
aboutToAppear() {
// 从QuizEngine获取每日挑战题目
this.questions = quizEngine.getDailyChallengeQuestions();
if (this.questions.length > 0) {
this.currentQuestion = this.questions[0];
// 初始化答题记录数组(5个0表示全部未答)
this.answerStates = new Array(DAILY_TOTAL).fill(0);
}
// 启动倒计时
this.startCountdown();
Logger.info(TAG, '每日挑战页面加载');
}
aboutToDisappear() {
// 清除定时器,防止内存泄漏
this.stopCountdown();
}
/**
* 启动倒计时定时器
* 每秒执行一次,countdown递减,到0自动结束挑战
*/
startCountdown(): void {
this.stopCountdown(); // 先清除已有定时器,防止重复创建
this.timerId = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
} else {
// 时间到,自动结束挑战
this.stopCountdown();
this.finishChallenge();
}
}, 1000);
}
/**
* 停止倒计时定时器
*/
stopCountdown(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
代码解析
1. 定时器的防御性编程
// ✅ 正确:startCountdown先调用stopCountdown
startCountdown(): void {
this.stopCountdown(); // 防御:清除已有定时器
this.timerId = setInterval(() => { ... }, 1000);
}
// ✅ 正确:stopCountdown检查timerId有效性
stopCountdown(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1; // 重置为无效值
}
}
// ❌ 错误:不检查直接clearInterval
stopCountdown(): void {
clearInterval(this.timerId); // timerId=-1时可能抛异常
}
原理/说明:
startCountdown中先调用stopCountdown是"防御性编程"——防止restartChallenge()等场景下重复创建定时器timerId = -1作为无效标识,只有timerId !== -1时才执行clearIntervalaboutToDisappear中必须清除定时器,否则页面退出后定时器仍在运行,造成内存泄漏
2. 时间到自动结束 vs 手动结束
// setInterval回调中,countdown到0时自动触发结束
if (this.countdown > 0) {
this.countdown--;
} else {
this.stopCountdown();
this.finishChallenge(); // 自动结束(用户可能还有未答题目)
}
原理/说明:
- 时间耗尽时调用
finishChallenge(),此时correctCount + wrongCount可能小于5 finishChallenge内部根据已答题数计算星级,不强制要求5题全部作答- 这种设计对儿童友好——即使时间不够,也能看到已完成部分的成绩
步骤3: 答案判定与状态更新
功能说明
用户选择选项后,立即进行本地答案判定:标记正确/错误、显示解析反馈、更新答题记录数组。同时记录错题到 UserPreferences,并通过 calcStarLevel 实时更新星级。
完整代码
/**
* 选择选项并判定答案
* @param index - 选项索引(0=A, 1=B, 2=C, 3=D)
*/
selectOption(index: number): void {
// 防止重复选择:已显示反馈或无题目时忽略
if (this.showFeedback || !this.currentQuestion) return;
this.selectedOption = index;
// 本地判定答案正确性
this.correctIdx = this.currentQuestion.correctIndex;
this.isCorrect = index === this.currentQuestion.correctIndex;
this.explanation = this.currentQuestion.explanation;
this.showFeedback = true; // 触发反馈视图显示
// 更新答题记录数组
if (this.isCorrect) {
this.correctCount++;
this.answerStates[this.currentIndex] = 1; // 标记为正确
} else {
this.wrongCount++;
this.answerStates[this.currentIndex] = 2; // 标记为错误
// 记录错题到用户偏好(异步操作,不阻塞UI)
userPrefs.addWrongQuiz(this.currentQuestion.id).catch((err: Error) => {
Logger.error(TAG, '记录每日挑战错题失败', err);
});
}
// 实时更新星级(答题过程中根据当前正确率计算)
this.starLevel = this.calcStarLevel();
}
/**
* 下一题或结束挑战
*/
nextQuestion(): void {
if (this.currentIndex < DAILY_TOTAL - 1) {
this.currentIndex++;
this.currentQuestion = this.questions[this.currentIndex] || null;
// 重置答题状态(重要:切换题目时必须清除上一题的反馈状态)
this.selectedOption = -1;
this.showFeedback = false;
this.isCorrect = false;
this.correctIdx = -1;
this.explanation = '';
} else {
// 已答完所有题目,结束挑战
this.finishChallenge();
}
}
代码解析
1. showFeedback 防重复选择机制
// ✅ 正确:通过 showFeedback 状态防止重复点击
selectOption(index: number): void {
if (this.showFeedback || !this.currentQuestion) return; // 守卫条件
this.selectedOption = index;
// ... 判定逻辑
}
// ❌ 错误:不做防重复判断
selectOption(index: number): void {
this.selectedOption = index; // 可能被多次调用
this.correctCount++; // 正确数会被重复累加!
}
原理/说明:
showFeedback在选择后立即置为true,阻止后续点击- 这是"一次性按钮"模式——在 ArkTS 中通过状态标志位而非
enabled(false)实现 - 双重守卫:
showFeedback和!this.currentQuestion分别防止重复选择和空题目异常
2. 错题记录的异步处理
// 错题记录采用异步方式,不阻塞UI更新
userPrefs.addWrongQuiz(this.currentQuestion.id).catch((err: Error) => {
Logger.error(TAG, '记录每日挑战错题失败', err);
});
原理/说明:
addWrongQuiz返回Promise<boolean>,使用.catch()捕获异常- 错题记录失败不影响答题流程——这是"降级容错"设计
- 错题记录在
UserPreferences中通过scheduleSave()延迟500ms批量写入,避免频繁IO操作
步骤4: 星级计算与成绩持久化
功能说明
星级算法根据正确率动态计算:全对3星、80%以上2星、其他1星。答题过程中实时更新(让孩子看到即时反馈),最终结束后再次确认。成绩通过 scoreRecorded 标志位确保只记录一次。
完整代码
/**
* 计算星级:全对3星,80%以上2星,其他1星
* @returns 星级数字 1-3
*/
calcStarLevel(): number {
const answered = this.correctCount + this.wrongCount;
if (answered === 0) return 0; // 尚未作答,不评星
const rate = this.correctCount / DAILY_TOTAL; // 正确率
if (rate >= 1) return 3; // 全对 → 3星
if (rate >= 0.8) return 2; // 80%以上 → 2星
return 1; // 其他 → 至少1星
}
/**
* 结束挑战:停止倒计时,计算结果,记录成绩
*/
finishChallenge(): void {
this.stopCountdown();
this.starLevel = this.calcStarLevel();
this.pageState = 'result'; // 切换到结果页
// 记录每日挑战成绩(仅记录一次)
if (!this.scoreRecorded) {
this.scoreRecorded = true; // 立即标记,防止并发重复
try {
const total = this.correctCount + this.wrongCount;
const isPerfect = this.correctCount === DAILY_TOTAL;
// 构建成绩记录对象
const scoreRecord: QuizScoreRecord = {
totalQuestions: total,
correctCount: this.correctCount,
timestamp: Date.now(),
category: 'daily_challenge' // 标记为每日挑战类型
};
// 保存答题成绩到用户偏好(异步)
userPrefs.addQuizScore(scoreRecord).catch((err: Error) => {
Logger.error(TAG, '保存每日挑战成绩失败', err);
});
// 记录成就进度(同步调用,立即生效)
achievementManager.recordQuizResult(this.correctCount, total, isPerfect);
Logger.info(TAG, `每日挑战完成: 正确${this.correctCount}/${total}, 星级=${this.starLevel}`);
} catch (error) {
Logger.error(TAG, '记录每日挑战成绩失败', error);
}
}
}
代码解析
1. scoreRecorded 防重复记录机制
// ✅ 正确:标志位在写入前立即标记
if (!this.scoreRecorded) {
this.scoreRecorded = true; // 先标记,再执行IO
userPrefs.addQuizScore(scoreRecord)...
}
// ❌ 错误:IO完成后再标记(可能出现竞态)
if (!this.scoreRecorded) {
await userPrefs.addQuizScore(scoreRecord); // await期间可能再次调用
this.scoreRecorded = true; // 太晚了!
}
原理/说明:
finishChallenge()可能在两个路径被调用:时间到自动结束 + 手动答完5题结束scoreRecorded标志位确保无论从哪条路径进入,成绩只记录一次- 标志位在 IO 操作之前设置(而非之后),这是防止竞态条件的标准做法
- 时间到自动结束时,
correctCount + wrongCount < 5,成绩记录仍然正确
2. 星级算法的业务含义
5题全对(5/5 = 100%) → rate >= 1 → 3星 → "太棒了!科学小达人!"
4题正确(4/5 = 80%) → rate >= 0.8 → 2星 → "很不错哦,继续加油!"
3题及以下 → rate < 0.8 → 1星 → "再接再厉,明天再来!"
原理/说明:
- 80% 的阈值是教育心理学中"熟练掌握"的常见标准
- 1星保底机制确保孩子不会得到"0星"——这对6-12岁儿童的心理保护至关重要
- 星级在答题过程中实时更新(
selectOption末尾调用),让孩子感受到进度
步骤5: 答题界面 QuizView 构建
功能说明
答题界面包含顶部导航栏、倒计时器区域(时间+进度条+星星)、题目卡片(Q标签+分类+题文)、ABCD选项列表、解析反馈区和底部操作区(导航圆点+提交按钮)。整个界面通过 @Builder 方法组织。
完整代码
@Builder
QuizView() {
Column() {
// 顶部导航栏:返回按钮 + "每日挑战"标题 + 奖杯图标
Row() {
Button() {
Image($r('app.media.icon_back'))
.width(20)
.height(20)
.objectFit(ImageFit.Contain)
.fillColor(ThemeColors.TEXT_PRIMARY); // SVG图标着色
}
.type(ButtonType.Circle)
.width(36)
.height(36)
.backgroundColor(ThemeColors.BG_TERTIARY)
.onClick(() => {
this.goBack();
});
Text('每日挑战')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_PRIMARY)
.layoutWeight(1)
.textAlign(TextAlign.Center);
Image($r('app.media.icon_trophy'))
.width(22)
.height(22)
.objectFit(ImageFit.Contain);
}
.width('100%')
.padding({ top: 16, left: 16, right: 16, bottom: 8 });
// 倒计时器区域
Column() {
// 倒计时数字(低于30秒变色预警)
Text(this.formatTime(this.countdown))
.fontSize(48)
.fontWeight(FontWeight.Bold)
.fontColor(this.countdown <= 30 ? ThemeColors.PRIMARY : ThemeColors.TEXT_PRIMARY)
.margin({ bottom: 4 });
// 进度指示文字
Text('第 ' + (this.currentIndex + 1) + ' 题 / 共 ' + DAILY_TOTAL + ' 题')
.fontSize(13)
.fontColor(ThemeColors.TEXT_SECONDARY)
.margin({ bottom: 8 });
// 进度条
Progress({ value: this.currentIndex + 1, total: DAILY_TOTAL })
.width('100%')
.color(ThemeColors.PRIMARY)
.backgroundColor(ThemeColors.BG_TERTIARY);
}
.width('100%')
.padding({ left: 16, right: 16, top: 4, bottom: 12 })
.alignItems(HorizontalAlign.Center);
// 星星评级(答题过程中实时显示)
Row({ space: 8 }) {
ForEach([1, 2, 3], (star: number) => {
Image($r('app.media.icon_star'))
.width(24)
.height(24)
.objectFit(ImageFit.Contain)
.fillColor(star <= this.starLevel ? ThemeColors.WARNING : ThemeColors.TEXT_HINT);
}, (star: number) => 'star-' + star.toString());
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ bottom: 12 });
// 可滚动内容区域
Scroll() {
Column() {
if (this.currentQuestion) {
// 题目卡片
Column() {
// Q badge + 分类标签
Row() {
Text('Q')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_WHITE)
.backgroundColor(ThemeColors.PRIMARY)
.width(28)
.height(28)
.borderRadius(14)
.textAlign(TextAlign.Center)
.margin({ right: 8 });
Text(this.currentQuestion.icon)
.fontSize(18)
.margin({ right: 4 });
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 });
// ABCD四选一选项(复用QuizOptionItem组件)
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);
}
});
}, (_: string, index: number) =>
'daily-q' + (this.currentQuestion?.id ?? 0) + '-opt-' + index.toString());
}
.width('100%')
.margin({ bottom: 16 });
// 解析反馈区域
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 });
}
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 });
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.backgroundColor(ThemeColors.BG_SECONDARY);
// 底部区域:导航圆点 + 提交按钮
Column() {
// 底部导航圆点(正确/当前/未答三种状态)
Row({ space: 8 }) {
ForEach(this.answerStates, (state: number, index: number) => {
Column()
.width(state === 0 ? 8 : 10)
.height(state === 0 ? 8 : 10)
.borderRadius(5)
.backgroundColor(this.getDotColor(state, index));
}, (_: number, index: number) => 'dot-' + index.toString());
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ bottom: 12 });
// 提交/下一题按钮
Row() {
if (this.showFeedback) {
Button(this.currentIndex < DAILY_TOTAL - 1 ? '下一题' : '查看结果')
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.type(ButtonType.Capsule)
.linearGradient({
angle: 135,
colors: [[ThemeColors.PRIMARY, 0], [ThemeColors.PRIMARY_LIGHT, 1]]
})
.fontColor(ThemeColors.TEXT_WHITE)
.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: 4, bottom: 16 });
}
.width('100%')
.padding({ top: 8 })
.backgroundColor(ThemeColors.BG_PRIMARY)
.border({ width: { top: 1 }, color: ThemeColors.BORDER_COLOR });
}
.width('100%')
.height('100%')
.backgroundColor(ThemeColors.BG_SECONDARY);
}
代码解析
1. 底部圆点的三色状态设计
/**
* 获取底部导航圆点颜色
* @param state - 0=未答, 1=正确, 2=错误
* @param index - 题目索引
*/
private getDotColor(state: number, index: number): string {
if (state === 1) return ThemeColors.SUCCESS; // 绿色 = 正确
if (state === 2) return ThemeColors.PRIMARY; // 红色 = 错误
if (index === this.currentIndex) return ThemeColors.WARNING; // 橙色 = 当前题
return ThemeColors.TEXT_HINT; // 灰色 = 未作答
}
原理/说明:
- 四种颜色对应四种状态:绿色(已答对)、红色(已答错)、橙色(当前题)、灰色(待答)
- 当前题的橙色标记引导孩子注意"现在该答哪题"
- 已答题和未答题通过大小区分:
state === 0 ? 8 : 10——已答题稍大,形成视觉层次
2. 按钮的两种状态切换
// ✅ 正确:根据showFeedback切换按钮状态
if (this.showFeedback) {
// 已选择答案 → 显示"下一题"或"查看结果"(可点击)
Button(this.currentIndex < DAILY_TOTAL - 1 ? '下一题' : '查看结果')
.linearGradient({ ... })
.onClick(() => { this.nextQuestion(); });
} else {
// 未选择答案 → 显示"请选择一个答案"(禁用态)
Button('请选择一个答案')
.backgroundColor('#e0e0e0')
.enabled(false);
}
原理/说明:
- 未选择答案时按钮为灰色禁用态,视觉上明确提示"请先答题"
- 最后一题时按钮文案变为"查看结果",语义清晰
- 渐变色按钮使用
linearGradient而非纯色,提升视觉品质
步骤6: 结果页 ResultView 与重开机制
功能说明
结果页展示三星评级、鼓励文案、答对/答错/用时三项统计卡片,以及"再来一次"和"返回首页"两个操作按钮。restartChallenge 方法重置所有状态回到初始值。
完整代码
@Builder
ResultView() {
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.starLevel ? ThemeColors.WARNING : ThemeColors.TEXT_HINT);
}, (star: number) => 'result-star-' + star.toString());
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ bottom: 16 });
// 结果标题文案(根据星级动态变化)
Text(this.getResultTitle())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_PRIMARY)
.margin({ bottom: 6 });
// 正确数统计
Text('你答对了 ' + this.correctCount + ' / ' + DAILY_TOTAL + ' 题')
.fontSize(15)
.fontColor(ThemeColors.TEXT_SECONDARY)
.margin({ bottom: 20 });
// 统计卡片:答对/答错/用时
Row() {
// 答对列
Column() {
Text(this.correctCount.toString())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.SUCCESS)
.margin({ bottom: 2 });
Text('答对')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
// 答错列
Column() {
Text(this.wrongCount.toString())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.PRIMARY)
.margin({ bottom: 2 });
Text('答错')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
// 用时列
Column() {
Text(this.getUsedTime())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.WARNING)
.margin({ bottom: 2 });
Text('用时')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor('#f9f9f9')
.borderRadius(12)
.margin({ bottom: 20 });
// 操作按钮组
Column() {
Button('再来一次')
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.type(ButtonType.Capsule)
.linearGradient({
angle: 135,
colors: [[ThemeColors.PRIMARY, 0], [ThemeColors.PRIMARY_LIGHT, 1]]
})
.fontColor(ThemeColors.TEXT_WHITE)
.margin({ bottom: 10 })
.onClick(() => {
this.restartChallenge();
});
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 }, 'DailyChallenge');
});
}
.width('100%');
}
.width('100%')
.height('100%')
.padding(24)
.justifyContent(FlexAlign.Center)
.backgroundColor(ThemeColors.BG_SECONDARY);
}
/**
* 根据星级获取结果标题
*/
private getResultTitle(): string {
if (this.starLevel === 3) return '太棒了!科学小达人!';
if (this.starLevel === 2) return '很不错哦,继续加油!';
return '再接再厉,明天再来!';
}
/**
* 重新开始每日挑战
* 重置所有状态到初始值,重新启动倒计时
*/
restartChallenge(): void {
this.currentIndex = 0;
this.currentQuestion = this.questions.length > 0 ? this.questions[0] : null;
this.selectedOption = -1;
this.showFeedback = false;
this.isCorrect = false;
this.correctIdx = -1;
this.explanation = '';
this.correctCount = 0;
this.wrongCount = 0;
this.answerStates = new Array(DAILY_TOTAL).fill(0);
this.countdown = MAX_COUNTDOWN; // 重置倒计时
this.starLevel = 0; // 重置星级
this.pageState = 'quiz'; // 回到答题状态
this.scoreRecorded = false; // 允许再次记录成绩
this.startCountdown(); // 重新启动倒计时
}
代码解析
1. restartChallenge 的全量状态重置
// ✅ 正确:逐一重置所有状态,不遗漏
restartChallenge(): void {
this.currentIndex = 0; // 题号归零
this.correctCount = 0; // 正确数归零
this.wrongCount = 0; // 错误数归零
this.answerStates = new Array(DAILY_TOTAL).fill(0); // 重置答题记录
this.countdown = MAX_COUNTDOWN; // 倒计时归位
this.starLevel = 0; // 星级清除
this.pageState = 'quiz'; // 回到答题页
this.scoreRecorded = false; // 允许重新记录成绩
this.startCountdown(); // 重新启动定时器
}
// ❌ 错误:遗漏scoreRecorded重置
restartChallenge(): void {
// ... 重置其他状态
this.startCountdown();
// 忘记 this.scoreRecorded = false
// 结果:第二次答题完成时不会记录成绩!
}
原理/说明:
restartChallenge需要重置 12 个状态变量——遗漏任何一个都会导致逻辑异常scoreRecorded = false是最容易遗漏的:不重置的话,重新开始后再答完不会保存成绩- 倒计时需要在最后调用
startCountdown(),因为startCountdown内部会先调用stopCountdown,确保不会出现两个定时器并行
步骤7: 每日题目生成算法解析
功能说明
QuizEngine.getDailyChallengeQuestions() 实现了"每天固定题目"的算法。基于"年天数"(day of year)计算种子,按固定间隔取题,确保同一天所有用户看到相同题目,不同天题目不同。
完整代码
// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets
/**
* 获取每日挑战题目(基于日期的确定性随机)
* 同一天返回相同题目序列,不同天返回不同题目
*/
getDailyChallengeQuestions(): QuizQuestion[] {
const today = new Date();
const dayOfYear = this.getDayOfYear(today);
// 用年天数对题目总数取余,得到起始种子
const seed = dayOfYear % this.questions.length;
const result: QuizQuestion[] = [];
for (let i = 0; i < AppConstants.DAILY_CHALLENGE_COUNT; i++) {
// 每次间隔7个位置取题(避免相邻日期题目重复)
const idx = (seed + i * 7) % this.questions.length;
result.push(this.questions[idx]);
}
return result;
}
/**
* 计算当前日期是本年的第几天(1~366)
*/
private getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0); // 本年1月0日(即去年12月31日)
const diff = date.getTime() - start.getTime(); // 毫秒差
const oneDay = 1000 * 60 * 60 * 24; // 一天的毫秒数
return Math.floor(diff / oneDay); // 向下取整得到天数
}
代码解析
1. 算法的确定性保证
假设题库有20道题,DAILY_CHALLENGE_COUNT=5:
1月1日:dayOfYear=1, seed=1
→ 题目索引:1, 8, 15, 2, 9
1月2日:dayOfYear=2, seed=2
→ 题目索引:2, 9, 16, 3, 10
1月3日:dayOfYear=3, seed=3
→ 题目索引:3, 10, 17, 4, 11
原理/说明:
dayOfYear % questions.length保证种子在有效范围内- 间隔7(质数)避免与题目总数产生公约数,确保取题分散
- 算法完全确定性——不依赖
Math.random(),同一天任何设备、任何时间打开都看到相同题目 - 这种设计适合"每日一题"类场景——用户可以互相讨论今天的题目答案
⚠️ 常见问题与解决方案
问题1: Image 组件的 fillColor 对 SVG 着色无效
现象:
星星图标 icon_star.svg 使用 fillColor 设置颜色后,预览器中图标仍然显示原始颜色,颜色没有变化。
原因:
SVG 文件内部可能使用了 fill="currentColor" 或者固定的颜色值。如果 SVG 的 fill 属性写死为具体颜色值(如 fill="#FFD700"),则 fillColor 属性无法覆盖。
错误代码:
// ❌ 错误:对内部有固定fill的SVG使用fontColor(不是正确的属性名)
Image($r('app.media.icon_star'))
.fontColor(ThemeColors.WARNING); // fontColor只对Text生效,Image无效
正确代码:
// ✅ 正确:Image组件使用fillColor给SVG着色
Image($r('app.media.icon_star'))
.width(24)
.height(24)
.objectFit(ImageFit.Contain)
.fillColor(ThemeColors.WARNING); // fillColor是Image组件的SVG着色属性
规则/建议:
Image组件给 SVG 着色使用fillColor,不是fontColorfontColor是Text组件的属性,对Image组件无效- 确保 SVG 文件内部的
fill属性为currentColor或不设置,否则fillColor无法覆盖 - 在设计 SVG 资源时,建议统一使用黑色描边/填充,运行时通过
fillColor动态着色
问题2: setInterval 定时器在页面退出后仍运行
现象:
退出每日挑战页后,控制台仍在持续输出日志,或者倒计时归零后触发了 finishChallenge(),但页面已经不存在了。
原因:setInterval 创建的定时器不会随页面销毁自动清除。如果 aboutToDisappear() 中没有调用 stopCountdown(),定时器将继续在后台运行。
错误代码:
// ❌ 错误:没有在aboutToDisappear中清除定时器
aboutToDisappear() {
// 忘记调用 this.stopCountdown()
}
// ❌ 错误:stopCountdown实现不完整
stopCountdown(): void {
clearInterval(this.timerId); // timerId=-1时clearInterval行为未定义
// 没有重置timerId
}
正确代码:
// ✅ 正确:aboutToDisappear中清除定时器
aboutToDisappear() {
this.stopCountdown(); // 页面销毁时必须清除定时器
}
// ✅ 正确:stopCountdown检查有效性并重置
stopCountdown(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1; // 重置为无效值,防止重复清除
}
}
规则/建议:
- 所有
setInterval/setTimeout必须在aboutToDisappear中清除 - 使用
-1作为无效标识,清除前检查有效性 - 清除后重置为
-1,防止重复清除导致异常
问题3: 成绩被重复记录到 preferences
现象:
同一轮挑战结束后,UserPreferences 中出现了两条相同时间戳的 QuizScoreRecord。
原因:finishChallenge() 可能在短时间内被调用两次(例如倒计时归零触发的同时用户点击了最后一题的"查看结果"),如果 scoreRecorded 标志位设置时机不对,会导致成绩重复写入。
错误代码:
// ❌ 错误:在异步操作之后才设置标志位
finishChallenge(): void {
if (!this.scoreRecorded) {
// 不先标记,异步操作期间可能再次进入
await userPrefs.addQuizScore(scoreRecord);
this.scoreRecorded = true; // 太晚了!
}
}
正确代码:
// ✅ 正确:标志位在执行任何操作前立即设置
finishChallenge(): void {
if (!this.scoreRecorded) {
this.scoreRecorded = true; // 立即标记,防止竞态
userPrefs.addQuizScore(scoreRecord).catch(...);
achievementManager.recordQuizResult(...);
}
}
规则/建议:
- 防重复写入的标志位必须在业务逻辑执行前设置
- 不要在
await之后才设置标志位——等待期间其他代码可能再次调用 - 对于可能被多条路径触发的函数(定时器到期 + 手动点击),尤其需要注意
问题4: ForEach 中使用 index 作为 key 导致渲染异常
现象:
答题过程中切换到下一题时,选项内容出现闪烁或顺序错乱。
原因:ForEach 的第三个参数是 key 生成函数。如果使用纯 index 作为 key,当题目切换时,由于选项内容变化但 index 不变,ArkTS 的 diff 算法会错误地复用旧组件。
错误代码:
// ❌ 错误:仅用index作为key
ForEach(this.currentQuestion.options, (option: string, index: number) => {
QuizOptionItem({ option: option, index: index, ... });
}, (_: string, index: number) => index.toString()); // 纯index,不稳定
正确代码:
// ✅ 正确:使用题目ID + 选项索引组合作为key
ForEach(this.currentQuestion.options, (option: string, index: number) => {
QuizOptionItem({ option: option, index: index, ... });
}, (_: string, index: number) =>
'daily-q' + (this.currentQuestion?.id ?? 0) + '-opt-' + index.toString());
// key 示例:daily-q-42-opt-0, daily-q-42-opt-1 ...
规则/建议:
ForEach的 key 必须在列表数据变化时保持唯一且稳定- 使用"题目ID + 选项索引"组合,确保不同题目的选项有不同的 key
- 当题目切换时,旧 key 全部失效,新 key 全部创建,避免组件错误复用
问题5: 答题记录数组未随题目初始化正确重置
现象:
进入每日挑战页时,底部导航圆点显示的颜色不正确,可能某些圆点已经是绿色或红色。
原因:answerStates 的初始值为 [0, 0, 0, 0, 0],但如果 DAILY_TOTAL 常量与数组长度不一致,或者在 restartChallenge 中没有重新创建数组,就会导致状态残留。
错误代码:
// ❌ 错误:restartChallenge中直接修改数组元素而非重新创建
restartChallenge(): void {
for (let i = 0; i < this.answerStates.length; i++) {
this.answerStates[i] = 0; // 直接修改,@State可能不会触发ForEach更新
}
}
// ❌ 错误:初始值与常量不一致
@State answerStates: number[] = [0, 0, 0]; // 只有3个,但DAILY_TOTAL=5
正确代码:
// ✅ 正确:重新创建数组,触发@State完整更新
this.answerStates = new Array(DAILY_TOTAL).fill(0);
// ✅ 正确:初始值与常量一致
@State answerStates: number[] = [0, 0, 0, 0, 0]; // 与DAILY_TOTAL=5一致
规则/建议:
- 数组类
@State变量的更新需要创建新数组引用,而非直接修改元素 new Array(DAILY_TOTAL).fill(0)创建全新数组,确保ForEach完全重新渲染- 初始值应与常量保持一致,避免隐式 bug
📝 本章小结
核心知识点
本文详细讲解了每日挑战页面 DailyChallenge.ets 的完整实现,主要包括:
1. 倒计时器管理
- 使用
setInterval实现每秒递减的倒计时 aboutToDisappear中必须清除定时器防止内存泄漏- 防御性编程:
startCountdown内部先调用stopCountdown
2. 答题状态机设计
- 四种状态:未答(0)/已选待反馈/反馈中(showFeedback=true)/下一题
showFeedback作为防重复选择的守卫条件nextQuestion中必须重置所有答题相关状态
3. 成绩持久化与防重复记录
scoreRecorded标志位在业务执行前设置,防止竞态条件QuizScoreRecord包含 totalQuestions、correctCount、timestamp、categoryfinishChallenge可被"时间到"和"手动答完"两条路径触发
最佳实践总结
✅ 定时器生命周期管理
// 先停后启,确保不会出现多个定时器并行
startCountdown(): void {
this.stopCountdown();
this.timerId = setInterval(() => { ... }, 1000);
}
// 页面销毁时清除
aboutToDisappear() {
this.stopCountdown();
}
✅ SVG 图标动态着色
// Image组件使用fillColor而非fontColor
Image($r('app.media.icon_star'))
.fillColor(star <= this.starLevel ? ThemeColors.WARNING : ThemeColors.TEXT_HINT);
✅ ForEach 稳定 Key 生成
// 组合题目ID和选项索引,确保key的唯一性和稳定性
ForEach(options, (option, index) => { ... },
(_, index) => 'daily-q-' + questionId + '-opt-' + index);
下一步预告
在下一篇文章中,我们将:
- 🎨 拆解个人中心页
Profile.ets的完整实现 - 📚 讲解用户统计展示、成就徽章预览、菜单列表分组
- 🏷️ 分享成就徽章 margin 遮挡问题和菜单箭头从 Text 到 SVG 的优化经验
🔗 相关链接
- 项目源码: Atomgit仓库
💡 提示: 建议结合项目源码中的 DailyChallenge.ets、QuizEngine.ets、UserPreferences.ets 三个文件对照阅读,理解数据从题目生成到成绩持久化的完整链路。
更多推荐


所有评论(0)