☰
HarmonyOS分数加减法训练器开发:从数学引擎到状态管理实战
2026/10/3 3:32:02 网站建设 项目流程

分数加减法训练器这个项目,名字听着不大,但真做起来,里面装的是HarmonyOS应用开发里相当核心的一套东西:数据结构设计、算法实现、状态管理、多设备适配,一个都不少。这篇文章我就把这个应用的完整实现思路和关键代码拆开聊,从数学引擎到出题逻辑再到界面交互,最后附上我踩过的坑,希望能给正在学HarmonyOS NEXT(API 12+,SDK 5.0.0(12))的朋友一点实际参考。

1. 需求拆解与整体设计思路

先说清楚这个应用到底要干什么。分数加减法训练器,典型的面向中小学数学练习场景的工具类应用。使用场景很明确:学生课后练习、家长辅导时的出题工具、或者老师课堂上的快速练习环节。它的核心价值不是炫技,而是解决一个非常实在的问题——随机出题、即时判分、重复练习。

我在拆解这个需求的时候,把它分成了三块核心能力:

  • 题目生成器:随机产生分数加减法题目,难度可控,不出现无意义或超纲的题目
  • 数学引擎:分数的约分、通分、加减运算、带分数与假分数互转,这部分是纯逻辑,不依赖任何UI组件
  • 答题与反馈系统:用户输入答案、系统判定对错、记录练习结果

为什么要单独把数学引擎拎出来?原因很简单:纯度。HarmonyOS的ArkTS语言本身对数据结构和算法的支持是完整的,但如果你把这些逻辑混在UI组件里写,后面想加功能、想修bug都会非常痛苦。我习惯的做法是先把数据结构定义好,再把纯函数写好、测好,最后才是搭界面。

技术选型上没有悬念:语言用ArkTS,UI框架用ArkUI的声明式语法。HarmonyOS NEXT从API 12开始,ArkUI的状态管理机制(@State、@Prop、@Link等)已经很成熟了,写这类工具应用非常顺手。项目结构上,我按功能分了三个模块:model(数据模型)、engine(运算引擎)、view(页面组件)。

如果你翻过一些HarmonyOS的示例工程,会发现官方推荐的包结构一般是entry/src/main/ets下分pages、common、model这些目录。我这个项目也遵循这个规范,engine单独放一个目录,方便单元测试。

2. 数学引擎:分数运算的核心实现

这一节是整个应用的灵魂,也是我花时间最多的地方。分数加减法训练器,所有题目的生成和判定都建立在一个可靠的分数表示和运算体系之上。

2.1 分数数据结构的定义

在ArkTS里定义一个分数类,思路跟其他语言差不多。我的做法是用一个不可变(immutable)的类,包含分子和分母两个字段,内部保证分数永远处于约分状态。为什么强调不可变?因为练习题目的场景里,分数对象一旦生成,就不希望后续运算意外修改它,这能避免很多隐蔽的逻辑错误。

// model/Fraction.ets export class Fraction { readonly numerator: number; // 分子 readonly denominator: number; // 分母,始终为正数 constructor(numerator: number, denominator: number) { if (denominator === 0) { throw new Error('分母不能为0'); } // 统一符号:负号始终放在分子上 let sign = 1; let num = numerator; let den = denominator; if (den < 0) { den = -den; num = -num; } if (num < 0) { sign = -1; num = -num; } const g = Fraction.gcd(num, den); this.numerator = sign * (num / g); this.denominator = den / g; } // 最大公约数(欧几里得算法) static gcd(a: number, b: number): number { a = Math.abs(a); b = Math.abs(b); while (b !== 0) { const t = b; b = a % b; a = t; } return a; } // 最小公倍数 static lcm(a: number, b: number): number { return Math.abs(a * b) / Fraction.gcd(a, b); } // 分数加法 add(other: Fraction): Fraction { const den = Fraction.lcm(this.denominator, other.denominator); const num = this.numerator * (den / this.denominator) + other.numerator * (den / other.denominator); return new Fraction(num, den); } // 分数减法 subtract(other: Fraction): Fraction { const den = Fraction.lcm(this.denominator, other.denominator); const num = this.numerator * (den / this.denominator) - other.numerator * (den / other.denominator); return new Fraction(num, den); } toString(): string { if (this.denominator === 1) { return `${this.numerator}`; } return `${this.numerator}/${this.denominator}`; } }

看一下这个类,构造器里做了三件重要的事:判空(分母为0直接抛异常)、符号归一化(符号永远在分子上)、约分。这三点保证了后续所有运算都不会出幺蛾子。

2.2 通分与约分的实现细节

通分其实就是找最小公倍数,约分就是找最大公约数。这两个都在上面的代码里了,但我还是想单独拿出来说说,因为它们的重要性太容易被低估。

欧几里得算法求最大公约数,实现就几行,但它是整个分数运算的地基。有个关键细节:我在gcd里用了Math.abs(),因为负数参与取模运算时,结果的正负号在不同语言里表现可能不同,ArkTS里取模会保留被除数的符号,这会导致最终gcd计算出来的结果看起来合理,但实际符号混乱。提前取绝对值是最省心的做法。

约分动作放在构造函数里,这是一个刻意的设计。如果你把约分放在每次运算之后手动调用,很容易忘记,或者因为运算链太长而出现中间结果的分子分母暴涨。放进构造函数,等于给数据源上了保险。而且因为类里的字段是readonly,外界拿到的一定是最简分数,不会出现"算着算着分数还能约分"这种状态不一致的尴尬。

提示:不要把gcd和lcm方法设成私有。我最初是这么干的,后来写单元测试的时候发现没法单独验证这两个基础函数,最后还是把它们改成了static公开方法。测试友好性在日常开发里非常重要。

2.3 假分数与带分数的转换

分数加减法训练器要面向小学生,题目和输入都应该是带分数形式(比如1 3/4,而不是7/4),这套转换逻辑必不可少。这里说的"带分数",由整数部分和真分数部分组成,比如2 1/3就是2 + 1/3。

转换逻辑并不复杂,方向有两个:

// 假分数 -> 带分数 export interface MixedNumber { whole: number; // 整数部分 numerator: number; // 分子 denominator: number; // 分母 } export function improperToMixed(fraction: Fraction): MixedNumber { const whole = Math.floor(fraction.numerator / fraction.denominator); const remain = fraction.numerator % fraction.denominator; return { whole: whole, numerator: remain === 0 ? 0 : Math.abs(remain), denominator: fraction.denominator }; } // 带分数 -> 假分数 export function mixedToImproper(mixed: MixedNumber): Fraction { if (mixed.numerator === 0) { return new Fraction(mixed.whole, 1); } const sign = mixed.whole < 0 ? -1 : 1; const wholeAbs = Math.abs(mixed.whole); return new Fraction( sign * (wholeAbs * mixed.denominator + mixed.numerator), mixed.denominator ); }

这里有三个容易出错的细节:

第一个是负数带分数。-1 1/3到底是-(1 + 1/3)还是-1 + 1/3?在小学数学定义里,带分数读作"负一又三分之一",含义是负数加上一个真分数,即-(1 + 1/3)。我上面的代码按这个语义来,先把整数部分取绝对值,乘分母加分子,最后统一负号,就是为了确保这点。

第二个是纯整数的边界情况。如果分数是6/3(约分后是2/1),那么它的带分数形式应该是2,而不是2 0/1。所以improperToMixed里做了一个remain === 0的判断,分子直接置0,正则表达式和UI校验都会舒服很多。

第三个是分母为0的真分数?不存在这种情况。构造器已经拦住了。

2.4 运算结果的正则化处理

加减法做完之后,结果可能又是一个假分数。比如5/6 + 5/6 = 10/6,约分后是5/3。在显示和判定答案的时候,到底用假分数显示还是带分数显示?

我的做法是:所有内部运算一律用假分数(Fraction),只有展示和输入时才用带分数。这样有一个巨大的好处——题目的正确答案可以精确比较,且只有唯一形态。如果两种形态混着来,判定对错的逻辑里就得多一层"转换后比较",容易产生边界bug。

我把整个规则定死:引擎内部只认Fraction,输出给UI的时候由页面组件负责调用improperToMixed转成带分数;UI接收用户输入时,由mixedToImproper转回Fraction再进行比较。两边各司其职,谁也不越界。

3. 随机出题器的设计与难度控制

题目生成器看起来是"随机数拼接",真正写的时候你会碰到一堆"逻辑上可行但教学上不合理"的问题。比如分母范围设1到10,结果出了个9/8 + 9/8这种对小学三年级学生来说明显超纲的题。难度控制的本质,就是对随机数的约束条件做设计。

3.1 难度分级的思路

我把训练器分成三个难度,每个难度对应不同的参数范围:

难度分母范围是否允许假分数运算类型
入门2~9否(仅真分数)仅加法
进阶2~12是(结果可为带分数)加法和减法
挑战3~20是加减混合,结果可为负数

参数范围不是拍脑袋定的,背后有几个教学逻辑:

  • 入门级分母最大9,因为九九乘法表范围大概是1到9,这样约分和通分所需的基础乘法孩子能掌握
  • 进阶级引入减法,但要保证结果非负,否则低于当前教学阶段
  • 挑战级允许结果出现负数,适合已经学了"负数"概念的高年级

3.2 随机题目的算法实现

出题的算法核心是"倒着出题"。很多小白写随机题目,会先随机两个分数再算结果,但这样没法保证结果不超出题目要求的范围(比如结果不能为负、不能太复杂)。我采用的是反向构造:

  1. 先随机一个期望的答案(限定在合法范围内,比如真分数或正整数)
  2. 再随机一个已知操作数
  3. 根据运算类型反推出另一个操作数

以减法为例,如果要保证结果非负,就先让"被减数"大于等于"减数"。具体做法更简单粗暴:先随机两个操作数,如果被减数小于减数就交换,或者直接拒绝这题重新生成。因为范围很小,拒绝采样的概率不会高到影响性能,代码还简单,可读性高。

出题器完整逻辑大致是这样的:

// engine/QuizGenerator.ets import { Fraction } from '../model/Fraction'; import { MixedNumber } from '../model/MixedNumber'; export enum Difficulty { BASIC, INTERMEDIATE, ADVANCED } export interface QuizItem { left: Fraction; right: Fraction; operator: '+' | '-'; answer: Fraction; difficulty: Difficulty; id: number; } // 生成单个题目 function generateOne(difficulty: Difficulty, id: number): QuizItem { let denominatorMin: number; let denominatorMax: number; let allowImproper: boolean; let allowNegative: boolean; let ops: ('+' | '-')[]; switch (difficulty) { case Difficulty.BASIC: denominatorMin = 2; denominatorMax = 9; allowImproper = false; allowNegative = false; ops = ['+']; break; case Difficulty.INTERMEDIATE: denominatorMin = 2; denominatorMax = 12; allowImproper = true; allowNegative = false; ops = ['+', '-']; break; case Difficulty.ADVANCED: denominatorMin = 3; denominatorMax = 20; allowImproper = true; allowNegative = true; ops = ['+', '-']; break; } // 拒绝采样:最多尝试 100 次,如果还不行就退化成简单题 for (let i = 0; i < 100; i++) { const op = ops[Math.floor(Math.random() * ops.length)]; // 生成两个操作数 const d1 = randInt(denominatorMin, denominatorMax); const n1 = randInt(1, allowImproper ? d1 * 2 : d1 - 1); const d2 = randInt(denominatorMin, denominatorMax); const n2 = randInt(1, allowImproper ? d2 * 2 : d2 - 1); // 构造Fraction时自动约分 let left = new Fraction(n1, d1); let right = new Fraction(n2, d2); // 校验减法结果非负 if (op === '-' && !allowNegative) { if (left.numerator * right.denominator < right.numerator * left.denominator) { const temp = left; left = right; right = temp; } } const answer = op === '+' ? left.add(right) : left.subtract(right); // 校验最终结果是否符合难度约束 if (!allowImproper && (answer.denominator > answer.numerator)) { // 真分数且答案的分子大于分母 -> 结果为假分数,跳过 if (answer.numerator >= answer.denominator) { continue; } } if (!allowNegative && answer.numerator < 0) { continue; } return { left, right, operator: op, answer, difficulty, id }; } // 兜底:返回一个最简单的题 return { left: new Fraction(1, 2), right: new Fraction(1, 4), operator: '+', answer: new Fraction(3, 4), difficulty, id }; } function randInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; }

这里的判断逻辑我提几个重点:

allowImproper的校验中,如果当前是入门级不允许假分数,那答案的分子必须小于分母。但有个坑:答案出现整数时怎么办?比如1/2 + 1/2 = 1,分子分母约分后是1/1,这个"答案"既不是真分数也不是假分数,但它绝对简单。所以我的校验条件写得比较宽松:只要答案的分子小于分母就放行,等于分母也放行,大于分母(假分数)才拦下。

减法交换这一步也值得细说。入门和进阶模式里,减法结果必须非负,最直接的做法就是比较两个分数的大小,然后交换。比较分数大小不能直接比分子,要先交叉相乘。我在代码里写的是交叉相乘比较,这就是小学课本里的"通分比较法",非常经典。

3.3 避免题目重复与学习干扰

如果连续做20道题,出来好几道1/2 + 1/3,孩子很快会觉得"题库就这么点"。我加了一个简单的本地去重:维护一个已出题目的特征字符串集合,特征串由"left分子_left分母_运算符_right分子_right分母"拼接而成。生成题目后先查重,重复就重新生成。

这里注意一点:不要去重答案,只去重题目本身。因为同一道题用不同的数字组合可以得到相同答案,但这种题对孩子来说其实是有练习价值的。去重题目的字符串特征我特意去掉了答案字段,就是不想把这个给过滤掉。

另外,我还在生成器里做了"连续10题内不出现分母相同的一对题"这种软约束。实现思路很简单:维护一个固定长度的队列,新题生成后检查队列里前10个题目的分母对是否和当前的一样,如果一样就重新生成。这个功能不是必须的,但实测下来孩子的体验明显好很多,不会频繁出现"这题跟刚才那题长得好像,只是数字换了"的抱怨。

4. 答题交互与状态管理

数学引擎再强,最终也要落到界面上。HarmonyOS的ArkUI组件模型和状态管理是这套应用的另一个重头戏。

4.1 答题界面的组件结构

答题界面我拆成了三个区域:题目显示区、答案输入区、反馈与操作区。题目显示区负责展示"分数 + 运算符 + 分数",答案输入区是核心交互区域,反馈区显示对错、得分和下一题按钮。

答案输入区的设计值得多花点心思。孩子要输入带分数,你不可能让他去点键盘输 "1 3/4" 这种带空格的字符串,太容易输入错误。我采用了三段式输入:整数部分一个输入框,分子一个输入框,分母一个输入框。纯整数的答案就只填整数部分,分数答案是整数部分留空(或填0)加上分子分母。这个交互模式在平板上特别舒服,手机上需要稍微压缩一下间距,才不会显得拥挤。

4.2 状态管理的设计与页面代码

ArkUI的声明式开发,核心思想是"状态驱动UI"。我把答题过程涉及的数据全部定义成@State变量,页面的渲染只依赖这些状态。

// pages/TrainingPage.ets import { Fraction } from '../model/Fraction'; import { QuizItem, Difficulty } from '../engine/QuizGenerator'; import { improperToMixed } from '../engine/FractionConverter'; @Entry @Component struct TrainingPage { @State currentQuiz: QuizItem | null = null; @State wholeValue: string = ''; @State numValue: string = ''; @State denValue: string = ''; @State feedback: string = ''; @State isCorrect: boolean = false; @State currentScore: number = 0; @State questionCount: number = 0; @State selectedDifficulty: Difficulty = Difficulty.BASIC; private quizGenerator: QuizGenerator = new QuizGenerator(); aboutToAppear(): void { this.nextQuestion(); } nextQuestion(): void { this.currentQuiz = this.quizGenerator.generateOne(this.selectedDifficulty, this.questionCount + 1); this.wholeValue = ''; this.numValue = ''; this.denValue = ''; this.feedback = ''; this.questionCount++; } submitAnswer(): void { if (!this.currentQuiz) { return; } // 将三段输入组合成Fraction const whole = parseInt(this.wholeValue || '0', 10); const num = parseInt(this.numValue || '0', 10); const den = parseInt(this.denValue || '1', 10); if (Number.isNaN(whole) || Number.isNaN(num) || Number.isNaN(den)) { this.feedback = '请输入有效的数字'; return; } // 转换为假分数 let userAnswer: Fraction; if (num === 0) { userAnswer = new Fraction(whole, 1); } else { const sign = whole < 0 ? -1 : 1; const wholeAbs = Math.abs(whole); userAnswer = new Fraction(sign * (wholeAbs * den + num), den); } // 判定 const right = this.currentQuiz.answer; if (userAnswer.numerator === right.numerator && userAnswer.denominator === right.denominator) { this.feedback = '回答正确!'; this.isCorrect = true; this.currentScore++; } else { const expMixed = improperToMixed(right); let expStr: string; if (expMixed.numerator === 0) { expStr = `${expMixed.whole}`; } else { expStr = expMixed.whole === 0 ? `${expMixed.numerator}/${expMixed.denominator}` : `${expMixed.whole} ${expMixed.numerator}/${expMixed.denominator}`; } this.feedback = `不对哦,正确答案是 ${expStr}`; this.isCorrect = false; } } build() { Column({ space: 16 }) { // 难度选择 Row({ space: 8 }) { ForEach(['入门', '进阶', '挑战'], (label: string, index: number) => { Button(label) .onClick(() => { this.selectedDifficulty = index as Difficulty; this.questionCount = 0; this.currentScore = 0; this.nextQuestion(); }) .backgroundColor(this.selectedDifficulty === index ? '#007DFF' : '#CCCCCC') }) } // 题目展示 if (this.currentQuiz) { Row({ space: 8 }) { Text(this.fractionToDisplay(this.currentQuiz.left)) Text(this.currentQuiz.operator) Text(this.fractionToDisplay(this.currentQuiz.right)) Text('=') } .fontSize(32) .fontWeight(FontWeight.Bold) } // 答案输入区 Row({ space: 8 }) { TextInput({ text: this.wholeValue, placeholder: '整数' }) .onChange((value: string) => { this.wholeValue = value; }) .layoutWeight(2) TextInput({ text: this.numValue, placeholder: '分子' }) .onChange((value: string) => { this.numValue = value; }) .layoutWeight(2) Text('/') TextInput({ text: this.denValue, placeholder: '分母' }) .onChange((value: string) => { this.denValue = value; }) .layoutWeight(2) } .width('80%') // 提交与反馈 Button('提交答案') .onClick(() => this.submitAnswer()) .width('80%') if (this.feedback !== '') { Text(this.feedback) .fontColor(this.isCorrect ? '#00AA00' : '#FF0000') .fontSize(20) } // 得分与下一题 Row({ space: 20 }) { Text(`答对 ${this.currentScore} / ${this.questionCount}`) Button('下一题') .onClick(() => this.nextQuestion()) .enabled(this.isCorrect || this.feedback !== '') } } .width('100%') .height('100%') .padding(16) } fractionToDisplay(fraction: Fraction): string { const mixed = improperToMixed(fraction); if (mixed.numerator === 0) { return `${mixed.whole}`; } if (mixed.whole === 0) { return `${mixed.numerator}/${mixed.denominator}`; } return `${mixed.whole} ${mixed.numerator}/${mixed.denominator}`; } }

submitAnswer这个方法里,我做了完整的输入解析与判定。有几个细节值得展开:

  • 输入"分子"和"分母"时,如果没有填写,默认按0和1处理。也就是说用户可以直接只填整数部分,表达"整数答案"
  • parseInt的结果必须用Number.isNaN判断,不能用if (!value),因为parseInt('0')是合法的0,不能被当成"没输入"
  • 组合带分数转假分数时,负号的语义一定要统一:先是整数部分加负号,分子分母都是正数

注意:@State修饰的currentQuiz是QuizItem | null,在build里访问前必须判空。ArkUI对空值态的处理很严格,不判空编译器不一定报错,但运行时会crash。这也是鸿蒙开发里一个很典型的新手坑。

4.3 提升交互体验的细节处理

既然是给孩子用的应用,交互体验的细节能明显拉开差距。我做了几个不起眼但实际体验差别很大的优化:

自动跳到下一格。当用户在整数框输入完数字后,自动把焦点移到分子输入框;分子输入完,自动移到分母框;分母输入完,直接弹出提交确认。用ArkUI的话说,就是给TextInput设置focusControl配合FocusControl.requestFocus接口。这对触屏操作特别友好,孩子不需要额外点一下才能切输入框。

输入即时反馈。在输入分子和分母的时候,如果分母填的是0,我在onChange事件里直接显示一行红色提示"分母不能为0",同时把提交按钮置灰。不做这个拦截,提交时构造函数就会抛异常,体验特别糟糕。处理逻辑上是在校验函数里先行判断,不依赖异常。

打分与进度记录。每道题完成之后,我把成绩追加到一个练习历史数组里,并在界面底部展示最近10道题的对错分布。这个功能用@State保存,后续完全可以接PersistentStorage(API 12里是PersistentStorageV2)持久化到本地。

4.4 分数显示的边界场景

最后一屏界面小节,我想专门聊聊显示层的处理。fractionToDisplay这个方法虽然短,但背后藏着很多边界情况:

第一是"整数 + 真分数"的组合显示。比如3/2应该显示为1 1/2,如果直接显示3/2,对低龄用户不友好;如果显示1 1/2,那么输入判定时又需要孩子能正确输入带分数。这就要回到3.1节的难度设计——入门模式不允许假分数,所以这条逻辑只用在中高级。

第二是答案信息的展示还要兼顾"唯一性"。在submitAnswer里,正确答案的字符串是从improperToMixed转出来的标准化字符串,跟题目解析用的显示函数入口一样。保证正确性判断和展示的一致性,避免出现"答案判定为错误,但显示的正确答案跟用户输入的看起来又一样"这种尴尬情况。

第三是整数结果的处理。3/4 + 1/4 = 1,这个1要用纯整数形式展示,不能用1 0/4或者4/4。我在improperToMixed里已经保证了numerator===0时只展示整数部分。

5. 常见问题与排查技巧实录

这个项目做下来,实话实说踩了几个坑。有些是API版本差异,有些是设计层面的疏漏,整理成一张速查表,方便遇到同样问题的人自查:

问题现象原因分析解决方案
输入分母0后崩溃Fraction构造器抛异常,未在UI层拦截在提交校验中显式检查分母是否为0,给用户提示后拒绝提交
中等难度出题出现"1/2 + 1/2"这类答案太简单出题只约束了操作数范围,未校验答案的"教学合理度"在generateOne中补充对结果的约束,比如答案分子分母的比值范围
分数的减法结果出现负数未在出题逻辑里比较操作数大小构造题目时先做交叉相乘比较,确保被减数大于减数
TextInput输入负数时界面显示异常TextInput的输入类型默认不支持减号用自定义校验或者改用数字键盘类型,并在onChange里做正则过滤
平板上布局被拉伸手机上的固定宽度布局直接跑平板上确实会变形使用layoutWeight和百分比宽度,别写死像素宽度
进度记录在应用退出后丢失未接持久化存储,数据存在内存里使用PersistentStorage持久化关键数据(API 12用V2版本)

还有一个我想特别展开的坑:关于@State数组的修改。在早期版本里,我想把"练习历史"用一个@State practiceHistory: string[]保存,然后在答题后this.practiceHistory.push(newItem)。从语法上没有任何问题,界面也能正常更新。但是后来加了"撤销上一题"的功能,需要从数组末尾弹出一个元素,pop()之后发现UI没有刷新。原因是ArkUI的状态管理对数组的push、pop这类原地变更方法检测不灵敏(官方文档也明确说了,要触发UI更新,应该给@State数组赋一个新数组引用)。

所以我的修正写法是:

this.history = [...this.history, newItem]; // 新增 this.history = this.history.slice(0, -1); // 删除最后一个

这算是HarmonyOS开发里非常经典的一个"背了JavaScript的债"的坑,因为JS里数组原地操作是常态,但在ArkUI的状态管理体系里,必须遵循"状态值不变、引用变"的原则。

另一个值得说的,是关于浮点精度的坑。最初版本的比较逻辑,我图省事用了浮点计算:left + right === answer,用两个分数的十进制值做差来判断是否相等。在小范围的分数里很少出错,但一旦出现类似1/3 + 2/3 = 1的情况,浮点的0.3333333333 + 0.6666666666 = 0.9999999999会直接判定错误。后来我彻底改成了分子分母交叉相乘的整数比较方式,彻底杜绝了浮点误差。这也是为什么我在Fraction类里固执地保留分子分母、全程用整数运算的原因——分数运算的本质是整数运算,任何转成浮点的做法都是在往坑里跳。

最后还有一个小提示是关于代码隔离。我建议任何HarmonyOS项目,凡是涉及算法类的逻辑(比如这里的分数运算),尽量都写成不依赖任何ArkUI组件的纯函数或纯类。这样你可以在DevEco Studio里直接写单元测试(ohosTest目录),或者单独写个测试页快速验证,不用每次点开应用跑手动用例。做分数加减法训练器的时候,我靠这个方法至少定位了三个边界bug,效率比纯手动测试高很多。

6. 后续扩展思路

如果这个训练器被用户用得不错,可以考虑往下几个方向扩展,技术上都不难:

多运算符混合。现在一题只有一个运算符,可以升级成"分数四则运算",把乘除法加进来。数学引擎里补充multiply和divide方法,出题器那边多一个算符枚举。注意除法时要校验除数不为0,以及显示" ÷ "时带分数要转换成假分数再显示,不然孩子容易读错。

错题本功能。把答错的题存下来,配上错误答案和正确答案,通过PersistentStorage持久化,下次启动还能看到。这是很多家长明确提过的需求。实现的话主要就是存储模型的设计,建议按日期+难度维度组织数据,方便按天回顾。

语音合成播报。用HarmonyOS的AVPlayer或者系统的TextToSpeech接口,把题目读出来,对低年级的孩子来说省去了读题的困难。这个功能我用过类似方案,API 12的语音能力稳定性还不错,中文发音也过得去。

多设备协同。如果以后要往深了做,可以接跨端流转,手机出题、平板答题,或者老师端统一控制下发题目。这涉及HarmonyOS的分布式能力和数据流转接口,工程复杂度上一个台阶,但作为进阶项目练习的价值很高。

我个人在实际开发过程中的一个体会是:这类工具型应用,技术难度不高,但如果把数据处理做扎实了,比很多看起来很炫但逻辑一推就倒的Demo要有价值得多。像分数运算这样的小引擎,稍加改造就能复用到单位换算、百分比计算、比例问题等一堆教育类场景里,花在上面的时间是值得的。如果你正在学HarmonyOS NEXT,我建议别急着追各种云服务、AI能力的接口,先把状态管理和纯逻辑模块这两块基本功打实,再做这类小而完整的应用,收获得会比想象中多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询