首页 / 资讯中心 / 文章详情

HarmonyOS分数加减法训练器:ArkTS与状态管理实战

HarmonyOS分数加减法训练器:ArkTS与状态管理实战 ★ FEATURED ARTICLE
最近在整理 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 SDKAPI 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 里页面的本质是状态的投影。我把页面需要变化的数据全部声明为StateUI 在数据改变时自动刷新。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直接写在组件里当时觉得方便后来想扩展难度逻辑时发现自己被界面代码绑住了。后来把算法全部提到组件外面单独成函数代码清爽很多测试起来也方便。如果你也在写类似的练习类应用建议从一开始就按这个思路来。
阅读完成 · 觉得有帮助?
咨询建站