最近在整理 HarmonyOS 应用实例,刚好做完一个分数加减法训练器,准备把这套从算法到界面完整复盘出来。这个实例很适合刚接触 ArkTS、ArkUI 的开发者,因为它的核心逻辑不依赖任何复杂的系统 API,主要就是数据建模、随机题目生成、分数运算,还有基于状态驱动的页面刷新,这几个点恰好能把 HarmonyOS 声明式开发的核心路径完整走一遍。
我选择“分数加减法”这个题材,还有一个现实原因:家里小朋友正在学分数通分,市面上的练习 App 广告多、诱导多,干脆自己写一个纯净版。做下来发现,这个训练器比很多通用练习工具更适合自己定制:题目范围可控、难度可分档、输入方式能兼容带分数和假分数,而且判定逻辑完全透明。如果你也刚接触 HarmonyOS 应用开发,跟着这个实例做一遍,基本上就能掌握一个单页面工具类应用的完整开发套路。
1. 实例定位与整体设计
1.1 这个训练器到底解决什么问题
先说清楚需求。分数加减法的核心难点是通分和约分,练习时如果只停留在纸面刷题,学生很容易被重复计算拖垮,家长手动出题也非常费劲。这个训练器要解决的就是三件事:自动出题、自动判断对错、自动展示正确结果。
所谓“训练器”,本质上就两个环节:生成合法题目、校验用户答案。相比一般计算器,它多了一个随机性和判定逻辑。随机不是随便两个整数拼接,必须保证生成的分数是合法的真分数、减法结果不为负数、结果能化简成标准形式。判定也不是简单的字符串相等,用户可能输入3/4,也可能输入0.75或6/8,这里不能靠字符串比对,必须把输入解析成数学上的数值后再比较。
这个需求很适合做成单页应用,不需要路由、不需要复杂持久化,一个@Entry组件就能承载全部交互。对初学者来说,能完整看到“数据状态变化 → UI 自动刷新”这条链路,是比界面好看更重要的收获。
1.2 从需求倒推技术方案
技术选型上,我用的是 ArkTS 搭配 ArkUI,开发环境是 DevEco Studio 5.0.0,对应 HarmonyOS NEXT SDK(API 12+,即5.0.0(12))。这个版本里 ArkTS 的严格模式对类型要求比较高,写惯了 TypeScript 的人会明显感觉“手被绑住”,但反过来,它逼迫你一开始就把数据结构定义清楚。
我的方案拆解如下:
- 分数用两个整数表示:分子、分母,封装成一个
Fraction类。 - 题目也用独立对象表示,包含两个操作数、运算符、正确结果的分子分母。
- 随机题目生成函数负责构造
Question实例。 - 页面组件只负责展示状态和接收输入,不写入算法细节。
- 答案校验先解析用户输入,再统一化简比较,不直接比较字符串。
这样分层的好处在于:算法可以单独测试,页面代码不会越写越臃肿。如果以后想把训练器扩展到乘法、除法,只需要在算法层增加对应函数,页面几乎不用改。
1.3 功能拆解与状态流转
最终落地的功能包括:题目展示、答案输入、确认判题、显示正确答案、下一题、基础/提高两种难度、答题统计、满 10 题一轮结束。单个页面内需要有这些可变化的数据:
- 当前题目
currentQuestion,题目更换时 UI 自动刷新。 - 输入框内容
inputText。 - 反馈文案
feedbackText和判定结果lastCorrect。 - 是否已经判题
showAnswer,控制能否进入下一题。 - 总题数
totalCount和正确数correctCount。 - 难度档位
difficulty。
交互流程就是:进入页面 → 显示第 1 题 → 用户输入答案 → 点击“确认” → 给出对错反馈并显示正确答案 → 点击“下一题” → 刷新题目。答满 10 题后,再点下一题会开启新一轮训练,同时统计清零,这样一轮一轮循环,非常符合课堂练习的使用习惯。
2. 分数运算这一层怎么写得稳
2.1 分数数据结构和最大公约数
分数运算的根基是数据结构。我一开始也想用普通对象{ num: 3, den: 4 }来存,后来发现在 ArkTS 严格模式下,普通对象字面量不如写一个 class 来得稳。class 的好处是成员类型明确、构造函数统一、后续扩展方法也方便。
class Fraction { num: number = 0; den: number = 1; constructor(num: number, den: number) { this.num = num; this.den = den; } }所有分数运算的第一步都是约分。约分依赖最大公约数,这里用辗转相除法,代码短且效率足够,练手的数字范围最多也就是几十,完全不用考虑性能优化。
function gcd(a: number, b: number): number { let x = Math.abs(a); let y = Math.abs(b); while (y !== 0) { const t = x % y; x = y; y = t; } return x; } function normalize(fraction: Fraction): Fraction { if (fraction.den === 0) { return new Fraction(0, 0); } const g = gcd(fraction.num, fraction.den); const n = Math.trunc(fraction.num / g); const d = Math.trunc(fraction.den / g); return new Fraction(n, d); }normalize函数是整个算法层最核心的兜底逻辑。不管是题目生成的中间结果,还是用户输入解析后的分数,最后都要走一遍约分,才能保证比较和显示的规范。
2.2 通分、化简、比较大小
分数加减法的数学原理并不复杂,但代码实现时最容易犯的错误是拿两个已经约分过的分数直接相加减后忘记再次化简。正确做法是:任何时候先按通分公式计算,结果统一交给normalize处理。
function addFractions(a: Fraction, b: Fraction): Fraction { const num = a.num * b.den + b.num * a.den; const den = a.den * b.den; return normalize(new Fraction(num, den)); } function subFractions(a: Fraction, b: Fraction): Fraction { const num = a.num * b.den - b.num * a.den; const den = a.den * b.den; return normalize(new Fraction(num, den)); }这里用的是最朴素的通分方法:分母直接相乘,分子交叉相乘后相加或相减。对于训练器场景,分母范围限制在 15 以内,乘积不会很大,完全不需要刻意去求最小公倍数。求最小公倍数反而会引入额外复杂度,对性能提升也没有意义。
判断两个分数是否相等,经过统一约分后,只需要比较分子分母是否完全一致:
function isSameFraction(a: Fraction, b: Fraction): boolean { return a.num === b.num && a.den === b.den; }但生成减法题目时,还需要先比较两个操作数的大小,确保结果不是负数。交叉相乘比较即可,不需要转成小数:
function compareFractions(a: Fraction, b: Fraction): number { const left = a.num * b.den; const right = b.num * a.den; if (left > right) { return 1; } if (left < right) { return -1; } return 0; }这种做法在小学分数范围里是精确的,不涉及浮点数误差。
2.3 题目生成与减法防负处理
题目生成的随机策略比较讲究。如果直接把分子分母都随机出来,很容易出现7/3这种假分数,对初学阶段的学生很不友好。我选择让题目里的两个操作数都保持真分数,也就是分子小于分母。
function randomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } function randomProperFraction(maxNum: number, maxDen: number): Fraction { const den = randomInt(2, maxDen); const upper = Math.min(maxNum, den - 1); const num = randomInt(1, upper); return new Fraction(num, den); }这里需要注意一个细节:分母先随机,分子再根据分母决定取值范围,这样生成的分数才保证是真分数。比如分母随机到 5,分子就在 1 到 4 之间取;分母随机到 2,分子只能是 1。
减法还有一道防线:随机出来的两个分数大小不确定,如果第一个比第二个小,交换两个操作数,确保结果非负。题目生成逻辑如下:
function generateQuestion(maxNum: number, maxDen: number): Question { let a = randomProperFraction(maxNum, maxDen); let b = randomProperFraction(maxNum, maxDen); const op = Math.random() < 0.5 ? '+' : '-'; if (op === '-') { if (compareFractions(a, b) < 0) { const temp = a; a = b; b = temp; } } const ans = op === '+' ? addFractions(a, b) : subFractions(a, b); return new Question( a.num, a.den, op, b.num, b.den, ans.num, ans.den ); }这里不采用“重新随机直到合法”的循环方式,而是直接交换,原因很简单:随机生成两个分数后,两者大小关系天然有一半概率需要交换,直接交换能稳定控制耗时,代码可读性也好。
3. ArkUI 界面与交互:声明式思维落地
3.1 页面骨架与状态管理
在 ArkUI 里,页面的本质是状态的投影。我把页面需要变化的数据全部声明为@State,UI 在数据改变时自动刷新。
@Entry @Component struct FractionTrainer { @State currentQuestion: Question = new Question(1, 2, '+', 1, 3, 5, 6); @State inputText: string = ''; @State feedbackText: string = ''; @State rightAnswer: string = ''; @State showAnswer: boolean = false; @State totalCount: number = 0; @State correctCount: number = 0; @State questionIndex: number = 1; @State difficulty: number = 1; @State lastCorrect: boolean = false; }页面主体用Column从上到下排列,使用space控制间距。整体背景用浅灰色,卡片用白色,这样视觉上有层次感,学习成本也很低。结构上大致是:顶部标题、题号、题目卡片、输入框、按钮行、反馈区、统计区。
有人可能会把“正确答案”也放在一个@State里,实际上没有必要。当showAnswer为true时,正确答案已经可以从currentQuestion计算出来,我额外用rightAnswer只是为了展示时少写一点重复代码,这属于个人习惯,不必照搬。
3.2 题目区和输入区实现
题目区的核心是把currentQuestion里的分子分母拼出来。这里用直接文本显示3/4,够用且实现简单。如果要更有数学课本里的分数排版效果,可以改成上下两行的 Column 结构,但会带来更多布局代码,训练器场景不必要。
Row({ space: 12 }) { Text(fractionText(this.currentQuestion.aNum, this.currentQuestion.aDen)) .fontSize(36) .fontWeight(FontWeight.Bold) Text(this.currentQuestion.op) .fontSize(32) Text(fractionText(this.currentQuestion.bNum, this.currentQuestion.bDen)) .fontSize(36) .fontWeight(FontWeight.Bold) Text('=') .fontSize(36) Text('?') .fontSize(36) } .justifyContent(FlexAlign.Center)fractionText的作用是把分数转成可读字符串,同时处理整数和 0 的特例:
function fractionText(num: number, den: number): string { if (num === 0) { return '0'; } if (num % den === 0) { return Math.trunc(num / den).toString(); } return num + '/' + den; }输入框用TextInput,同时绑定值和onChange。这里我特别把text: this.inputText也传进去了,目的是让代码手动清空输入框时,界面能同步刷新。如果不绑定text,直接this.inputText = ''有时候在输入框组件上不会立即生效,这是我在实际调试中踩过的点。
3.3 答案校验、反馈与统计
点击“确认”按钮后,进入校验流程。判定的大原则是:先解析,再化简,最后比较分子分母。
checkAnswer() { if (this.showAnswer) { return; } const parsed = parseAnswer(this.inputText); if (parsed.den === 0) { this.feedbackText = '输入格式不对,请参考占位符示例'; this.lastCorrect = false; return; } const ans = new Fraction(this.currentQuestion.ansNum, this.currentQuestion.ansDen); const right = isSameFraction(parsed, ans); this.totalCount = this.totalCount + 1; if (right) { this.correctCount = this.correctCount + 1; this.feedbackText = '回答正确,继续加油!'; this.lastCorrect = true; } else { this.feedbackText = '回答错误'; this.lastCorrect = false; } this.showAnswer = true; this.rightAnswer = fractionText(this.currentQuestion.ansNum, this.currentQuestion.ansDen); }一个容易被忽视的细节是:格式错误不算答题次数。如果把非法输入也算进totalCount,用户发现输错一个字符就消耗一次机会,体验会很差。所以我只在解析成功后才累加统计项。
统计区用两个 Row 展示“练习进度”和“正确率”,数据直接来源于totalCount和correctCount。这里用Blank()把文字推到两侧,比手动调justifyContent更省事。
3.4 难度切换与重新开局
我在页面顶部加了一组难度切换按钮,分为“基础”和“提高”。“基础”模式下分子范围小一些,适合刚接触通分的学生;“提高”模式下操作数范围扩大,对计算能力要求更高。
Row({ space: 10 }) { Button('基础') .backgroundColor(this.difficulty === 1 ? '#317AF7' : '#E6E6E6') .fontColor(this.difficulty === 1 ? '#FFFFFF' : '#1A1A1A') .onClick(() => { this.difficulty = 1; this.resetQuiz(); }) Button('提高') .backgroundColor(this.difficulty === 2 ? '#317AF7' : '#E6E6E6') .fontColor(this.difficulty === 2 ? '#FFFFFF' : '#1A1A1A') .onClick(() => { this.difficulty = 2; this.resetQuiz(); }) }resetQuiz里重置所有统计量,并重新出第一题。这个方法的语义很明确:切换难度就是重新开局,避免旧题目的统计记录和新难度混在一起,清清爽爽。
4. 完整代码与运行效果
4.1 直接可跑的 Index.ets
下面的代码是完整版,可以直接放在entry/src/main/ets/pages/Index.ets里运行。我刻意没有拆成多个文件,对初学者更友好,一个页面、一套逻辑,阅读成本最低。
class Fraction { num: number = 0; den: number = 1; constructor(num: number, den: number) { this.num = num; this.den = den; } } class Question { aNum: number = 1; aDen: number = 2; op: string = '+'; bNum: number = 1; bDen: number = 3; ansNum: number = 5; ansDen: number = 6; constructor(aNum: number, aDen: number, op: string, bNum: number, bDen: number, ansNum: number, ansDen: number) { this.aNum = aNum; this.aDen = aDen; this.op = op; this.bNum = bNum; this.bDen = bDen; this.ansNum = ansNum; this.ansDen = ansDen; } } function gcd(a: number, b: number): number { let x = Math.abs(a); let y = Math.abs(b); while (y !== 0) { const t = x % y; x = y; y = t; } return x; } function normalize(fraction: Fraction): Fraction { if (fraction.den === 0) { return new Fraction(0, 0); } const g = gcd(fraction.num, fraction.den); const n = Math.trunc(fraction.num / g); const d = Math.trunc(fraction.den / g); return new Fraction(n, d); } function addFractions(a: Fraction, b: Fraction): Fraction { const num = a.num * b.den + b.num * a.den; const den = a.den * b.den; return normalize(new Fraction(num, den)); } function subFractions(a: Fraction, b: Fraction): Fraction { const num = a.num * b.den - b.num * a.den; const den = a.den * b.den; return normalize(new Fraction(num, den)); } function compareFractions(a: Fraction, b: Fraction): number { const left = a.num * b.den; const right = b.num * a.den; if (left > right) { return 1; } if (left < right) { return -1; } return 0; } function isSameFraction(a: Fraction, b: Fraction): boolean { return a.num === b.num && a.den === b.den; } function randomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } function randomProperFraction(maxNum: number, maxDen: number): Fraction { const den = randomInt(2, maxDen); const upper = Math.min(maxNum, den - 1); const num = randomInt(1, upper); return new Fraction(num, den); } function generateQuestion(maxNum: number, maxDen: number): Question { let a = randomProperFraction(maxNum, maxDen); let b = randomProperFraction(maxNum, maxDen); const op = Math.random() < 0.5 ? '+' : '-'; if (op === '-') { if (compareFractions(a, b) < 0) { const temp = a; a = b; b = temp; } } const ans = op === '+' ? addFractions(a, b) : subFractions(a, b); return new Question( a.num, a.den, op, b.num, b.den, ans.num, ans.den ); } function fractionText(num: number, den: number): string { if (num === 0) { return '0'; } if (num % den === 0) { return Math.trunc(num / den).toString(); } return num + '/' + den; } function buildFraction(numText: string, denText: string): Fraction { const n = parseInt(numText); const d = parseInt(denText); if (isNaN(n) || isNaN(d) || d === 0) { return new Fraction(0, 0); } return normalize(new Fraction(n, d)); } function parseAnswer(input: string): Fraction { const text = input.trim(); if (text.length === 0) { return new Fraction(0, 0); } const parts = text.replace(/\s+/g, ' ').split(' '); if (parts.length === 1) { const part = parts[0]; if (part.indexOf('/') >= 0) { const fd = part.split('/'); if (fd.length !== 2) { return new Fraction(0, 0); } return buildFraction(fd[0], fd[1]); } const n = parseInt(part); if (isNaN(n)) { return new Fraction(0, 0); } return normalize(new Fraction(n, 1)); } if (parts.length === 2) { const whole = parseInt(parts[0]); const fd = parts[1].split('/'); if (fd.length !== 2 || isNaN(whole)) { return new Fraction(0, 0); } const b = buildFraction(fd[0], fd[1]); if (b.den === 0) { return new Fraction(0, 0); } return normalize(new Fraction(whole * b.den + b.num, b.den)); } return new Fraction(0, 0); } @Entry @Component struct FractionTrainer { @State currentQuestion: Question = new Question(1, 2, '+', 1, 3, 5, 6); @State inputText: string = ''; @State feedbackText: string = ''; @State rightAnswer: string = ''; @State showAnswer: boolean = false; @State totalCount: number = 0; @State correctCount: number = 0; @State questionIndex: number = 1; @State difficulty: number = 1; @State lastCorrect: boolean = false; build() { Column({ space: 16 }) { Text('分数加减法训练器') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .margin({ top: 32 }) Text('第 ' + this.questionIndex + ' 题') .fontSize(16) .fontColor('#888888') Row({ space: 10 }) { Button('基础') .backgroundColor(this.difficulty === 1 ? '#317AF7' : '#E6E6E6') .fontColor(this.difficulty === 1 ? '#FFFFFF' : '#1A1A1A') .onClick(() => { this.difficulty = 1; this.resetQuiz(); }) Button('提高') .backgroundColor(this.difficulty === 2 ? '#317AF7' : '#E6E6E6') .fontColor(this.difficulty === 2 ? '#FFFFFF' : '#1A1A1A') .onClick(() => { this.difficulty = 2; this.resetQuiz(); }) } Column({ space: 12 }) { Row({ space: 12 }) { Text(fractionText(this.currentQuestion.aNum, this.currentQuestion.aDen)) .fontSize(36) .fontWeight(FontWeight.Bold) Text(this.currentQuestion.op) .fontSize(32) Text(fractionText(this.currentQuestion.bNum, this.currentQuestion.bDen)) .fontSize(36) .fontWeight(FontWeight.Bold) Text('=') .fontSize(36) Text('?') .fontSize(36) } .justifyContent(FlexAlign.Center) } .width('90%') .padding(20) .borderRadius(16) .backgroundColor('#F7F8FC') TextInput({ placeholder: '输入答案,如 3/4、2 或 1 1/2', text: this.inputText }) .onChange((value: string) => { this.inputText = value; }) .fontSize(20) .width('90%') .height(52) .backgroundColor('#FFFFFF') .borderRadius(8) Text(this.feedbackText) .fontSize(18) .fontColor(this.lastCorrect ? '#07A451' : '#E84026') .visibility(this.feedbackText.length > 0 ? Visibility.Visible : Visibility.Hidden) Row({ space: 16 }) { Button('确认') .onClick(() => { this.checkAnswer(); }) .backgroundColor('#317AF7') Button('下一题') .enabled(this.showAnswer) .onClick(() => { this.nextQuestion(); }) .backgroundColor('#E6E6E6') .fontColor('#1A1A1A') } .justifyContent(FlexAlign.Center) if (this.showAnswer) { Row() { Text('正确答案:' + this.rightAnswer) .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#8161E8') } } Column({ space: 8 }) { Row() { Text('练习进度') .fontSize(16) Blank() Text(this.totalCount + ' 题') .fontSize(16) } .width('100%') Row() { Text('正确率') .fontSize(16) Blank() Text(this.correctCount + ' / ' + this.totalCount) .fontSize(16) } .width('100%') } .padding(16) .borderRadius(16) .backgroundColor('#FFFFFF') .width('90%') } .width('100%') .height('100%') .backgroundColor('#F0F2F5') } checkAnswer() { if (this.showAnswer) { return; } const parsed = parseAnswer(this.inputText); if (parsed.den === 0) { this.feedbackText = '输入格式不对,请参考占位符示例'; this.lastCorrect = false; return; } const ans = new Fraction(this.currentQuestion.ansNum, this.currentQuestion.ansDen); const right = isSameFraction(parsed, ans); this.totalCount = this.totalCount + 1; if (right) { this.correctCount = this.correctCount + 1; this.feedbackText = '回答正确,继续加油!'; this.lastCorrect = true; } else { this.feedbackText = '回答错误'; this.lastCorrect = false; } this.showAnswer = true; this.rightAnswer = fractionText(this.currentQuestion.ansNum, this.currentQuestion.ansDen); } nextQuestion() { if (this.totalCount >= 10) { this.resetQuiz(); return; } this.questionIndex = this.questionIndex + 1; this.inputText = ''; this.feedbackText = ''; this.showAnswer = false; this.lastCorrect = false; const maxNum = this.difficulty === 1 ? 5 : 12; const maxDen = this.difficulty === 1 ? 9 : 15; this.currentQuestion = generateQuestion(maxNum, maxDen); } resetQuiz() { this.totalCount = 0; this.correctCount = 0; this.questionIndex = 1; this.inputText = ''; this.feedbackText = ''; this.showAnswer = false; this.lastCorrect = false; const maxNum = this.difficulty === 1 ? 5 : 12; const maxDen = this.difficulty === 1 ? 9 : 15; this.currentQuestion = generateQuestion(maxNum, maxDen); } }这段代码跑起来后,基础难度下题目大致长这样:3/4 + 1/2 = ?,提高难度下可能变成7/9 - 2/5 = ?。用户输入5/4或1 1/4都会被解析成同一个值,判定逻辑不会误判。
4.2 运行效果与操作流程
实际运行时,页面顶部是标题和题号,接着是难度切换按钮。题目卡片区域字体会比较大,方便在平板上做练习。输入框下方是反馈文字,答对显示绿色,答错显示红色,同时“正确答案”一行紫色文字会出现。按钮区只有“确认”,“下一题”在未判题前是灰色不可点的。
连续答完 10 题后,点“下一题”会自动重新开局。这里我用totalCount >= 10作为判断条件,因为totalCount只在成功判题后累加,所以能达到 10 的一定是已经提交过的,不会出现误触发。
5. 常见问题与避坑实录
5.1 输入怎么兼容带分数
这是我最开始忽略的问题。用户面对5/4这样的答案时,既可能输入5/4,也可能输入1 1/4。我用空格把输入切分为两部分:如果只有一部分,按分数或整数处理;如果有两部分,第一部分是整数部分,第二部分是分数部分,最终统一转成假分数后化简。
解析逻辑里有一个细节:带分数整数部分和分数部分中间的空格,用户可以输入一个,也可以输入多个,我先把连续的空白字符替换成单个空格,再统一分割。这一步不做的话,用户手滑多打一个空格就会判格式错误。
5.2 减法结果为什么不会出现负数
设计上,训练器面向的是小学阶段,暂时不希望出现负数结果。生成减法题目时,如果第一个分数小于第二个分数,直接交换两个操作数。交叉相乘比较大小是精确的,等号两边分母不同也没关系,数学上完全等价于通分后的比较。
如果你想加上负数训练,只需要取消交换逻辑,同时把答对的答案判定扩展到负数显示,再把输入解析里加一个小小的负号处理即可。
5.3 为什么都用普通 class 而不是 interface 或对象字面量
写 ArkTS 时我尽量避开对象字面量做复杂数据结构。原因很简单:ArkTS 严格模式对类型约束比普通 TypeScript 强得多,对象字面量很容易在编译期触发类型检查问题,而且不好扩展方法。用class定义Fraction、Question后,成员类型一目了然,构造入口统一,后续加序列化逻辑也方便。
这个问题在真机上更容易暴露。如果你直接在 DevEco Studio 里跑预览器,可能觉得对象字面量也没问题,但是一旦上真机或者开启严格编译,潜在问题就来了。我的习惯是:所有跨函数传递的数据结构,全部先定义成 class。
5.4 键盘弹出遮挡输入框
HarmonyOS 真机上,输入答案时软件键盘弹出,如果页面没有做避让,底部统计区可能被键盘盖住。实际项目里我会给根Column加上expandSafeArea或使用页面的keyboardAvoidMode设置来处理,具体取决于 SDK 版本。
不过这个训练器的输入框在页面上半部分,键盘弹出后基本不会挡住题目和按钮,因此我没有额外处理。如果你的版本或者布局调整后遇到遮挡,优先检查windowStage.getMainWindow().setWindowLayoutFullScreen相关的设置,而不是盲目改布局。
5.5 后续还能怎么扩展
这个实例的可扩展性非常强。算法层已经拆开,加乘法就得先加mulFractions函数,再在题目生成里增加一个运算符分支;想支持小数练习,只需要把用户输入解析扩展到十进制字符串;想加入倒计时,可以引入定时器状态;想做成多轮闯关,可以把统计逻辑抽出来放进一个成绩对象里。
我个人的建议是:把这个项目当作模板,改造成你自己需要的“儿童口算训练器”“小数四则运算训练器”“约分练习器”都不难。核心算法和页面框架完全可以复用。
最后再分享一个小经验。写这类工具类应用时,最值得花时间的是把“数据层”和“界面层”的边界划清楚。我在做第一个版本时,把parseAnswer直接写在组件里,当时觉得方便,后来想扩展难度逻辑时发现自己被界面代码绑住了。后来把算法全部提到组件外面,单独成函数,代码清爽很多,测试起来也方便。如果你也在写类似的练习类应用,建议从一开始就按这个思路来。