前阵子整理HarmonyOS应用开发的教学案例我又把目光放回了一个特别基础但很能讲明白的小功能小数的比较。很多人觉得这不就是判断一下 6.52 和 7.08 谁大谁小吗确实如果只是在代码里做一两次字符串比较那十分钟就写完了。但要是把它包装成一个跳远比赛成绩比较的应用让用户通过界面输入一群运动员的跳远成绩程序自动排出名次问题就多起来了输入框的字符串怎么安全转成数值浮点数在底层的精度会不会让 6.52 和 6.52 出现“看起来相等却不相等”的尴尬想排序又不想打乱原有的输入顺序该怎么设计数据结构这篇文章把我的完整实现思路和踩坑记录都写下来代码是基于ArkTS和ArkUI写的直接可以在DevEco Studio里跑适合正在学习HarmonyOS应用开发的朋友也适合想做数字比较、排序类教学工具的老师参考。1. 为什么拿跳远比赛来学小数比较1.1 小数在生活里的最佳载体我做过不少教学类的小应用发现“小数”这个概念单独讲很枯燥但一旦放进具体场景理解成本立刻降下来。跳远比赛就是特别合适的场景运动员的成绩清一色都是带小数点的米数比如 6.52 米、7.08 米、5.90 米。而且跳远成绩的规则是精确到厘米也就是保留两位小数这正好对应了十进制小数里的“两位小数”概念。这个场景的好处是所有人都能感知到——哪怕完全不懂编程的人看到一组跳远成绩也能说出“7.08 比 6.52 远”。教学的目的就是让用户自己动手录入数据然后让程序代替人去做比较和排名。如果换成“求解二元一次方程”“计算矩阵特征值”这种场景学习门槛太高反而会模糊掉“小数比较”这个核心知识点。1.2 应用本身要解决的核心问题这个应用需要完成的核心任务是接收多组运动员姓名和跳远成绩对成绩做从大到小排序再给每位运动员生成名次最后把排名结果显示在页面上。听起来简单但实际开发中至少要处理四个问题第一输入框拿到的数据是字符串不能直接拿去比较必须先转成数值类型。第二跳远成绩是带小数的浮点数在计算机内部用二进制存储存在精度误差直接比较可能出问题。第三排序后要保留并列名次的处理规则——如果两个人的成绩相同名次应该并列。第四界面要展示两块内容原始录入的运动员列表以及排序后的排名结果这两块数据要分开管理。这个应用很适合作为HarmonyOS入门阶段的第二个或者第三个综合练习。它不像登录注册那么复杂但能覆盖状态管理、数组操作、条件渲染、事件绑定这些高频知识点练完以后再做更大的项目会顺手很多。2. 页面与数据模型设计2.1 数据模型别小看一个字段类型我在设计这个案例时先定义运动员的数据结构而不是一上来就画页面。这个习惯很重要数据结构想清楚了页面和逻辑自然就顺了。interface Athlete { id: number; name: string; score: number; }字段没有用复杂的东西但有几个点需要说明。第一id字段是我特意加的。HarmonyOS的ForEach渲染列表时需要一个稳定的key来标识每一条数据。如果只用名字那输入两个同名运动员就会导致key重复界面渲染就会出现诡异现象。用自增id就完全避免了这个问题。第二score字段必须是number类型。很多初学者容易把成绩存在字符串里等到排序时再用parseFloat临时转这样虽然也能工作但代码很容易变得混乱尤其是当一处转了一处没转的时候bug非常隐蔽。第三排名结果不要直接塞进原数组。我单独定义了一个RankResult接口interface RankResult { name: string; score: number; rank: number; }原始数据负责展示“有哪些运动员参赛”排序后的数据负责展示“谁拿了第几名”两者互不干扰界面渲染时也更容易控制。2.2 页面布局与状态管理页面我用的是ArkUI里最常见的纵向布局顶部标题、中间输入区域、参赛列表、排名按钮、排名结果。组件选型上输入姓名和成绩用TextInput添加操作和排名操作用Button列表用List加ForEach。状态管理是这个页面的核心我用了三个State变量State athletes: Athlete[] []; State sortedAthletes: RankResult[] []; State nameInput: string ; State scoreInput: string ; State errorMsg: string ; State private nextId: number 1;athletes保存录入的原始运动员数据sortedAthletes保存排名结果nameInput和scoreInput负责绑定输入框的当前值errorMsg用来显示校验错误nextId生成运动员的唯一id。这里有个很容易犯的错误State只对数组的引用变化敏感如果你直接修改数组的某个元素比如this.athletes[0].score 7界面是不会更新的。所以我在添加运动员时用的是push一个新对象在生成排名结果时直接创建了一个新的数组整体赋值给sortedAthletes。这两种操作都会让状态发生变化界面才能正常刷新。2.3 输入校验的边界设计跳远成绩必须是大于零的数字而且最多两位小数。我在处理输入时用了两个层面的校验。第一层空值校验姓名不能为空成绩不能为空。第二层格式校验成绩必须符合“整数 小数点 最多两位小数”的格式。这里我写了一个正则const scorePattern /^[0-9](\.[0-9]{1,2})?$/;如果用户输入的字符串无法通过这个正则直接提示“请输入正确的成绩例如6.52”。之所以限制最多两位小数是因为跳远比赛的精确度就是厘米级别0.01米是规则允许的最小单位。如果用户输入了6.523这种要么提示错误要么应该做四舍五入我在这个案例里明确做了提示避免成绩出现规则外的精度。这里还想多说一句很多人会嫌校验麻烦觉得直接让用户输入自己负责就好。但实际做教育类工具必须做好校验因为错误数据进入排行榜后整个例子的教学意义就没了。用户一看到排名里出现负数和乱码第一反应就是应用有bug。3. 核心算法小数比较和排名逻辑3.1 浮点数的底层问题与解决策略这是整个案例最值得展开讲的地方。要比较小数首先得知道小数在计算机里是怎么存的。计算机用二进制表示数值而很多十进制小数在二进制里是无限循环。比如 0.1 在二进制里就写不完就像 1/3 在十进制里写不完一样。所以浮点数在内存里只是一个近似值。这就导致了一个典型问题console.log(0.1 0.2 0.3); // 实际上是 false跳远成绩里6.52 这个数在二进制里也是近似存储的。虽然同一个字符串转换出来的数值是完全相同的直接比较不会有问题但如果程序中后续对成绩做了计算比如求平均成绩、把6.52乘以100转成整数就很容易踩进精度误差的坑里。我在这个案例中采用的策略是比较时把成绩换算成整数。private toIntegerScore(score: number): number { return Math.round(score * 100); }比如 6.52 转成 6527.08 转成 7084.50 转成 450。这样所有比较都发生在整数层面完全避开浮点数误差。为什么可以放心地乘100因为跳远成绩最多只有两位小数乘100后一定得到整数。用Math.round处理是因为像 6.52 * 100 这样的浮点计算大概率会得到 652.0000000000001 这种值直接比较还是会出问题四舍五入后就干净了。3.2 排序实现与并列名次处理排序我使用的是数组自带的sort方法排序规则是成绩从大到小。let temp this.athletes.slice(); temp.sort((a, b) { return this.toIntegerScore(b.score) - this.toIntegerScore(a.score); });这里有两个关键点需要展开解释。第一为什么先要slice()一下因为sort方法会直接修改原数组。这个案例里原始运动员列表是用来展示录入顺序的如果原地排序用户添加完之后看到的列表顺序会被打乱体验很糟糕。复制数组是最简单的保护方式。第二排序比较函数里为什么不用b.score - a.score表面上看这个减法也能用但如果成绩里混进了带更多小数的数据或者后续要做平均值之类的处理浮点误差会累积。用整数相减返回值一定是整数排序结果稳定可靠。并列名次的处理逻辑稍微有点绕。我先建一个rank数组然后遍历排序后的数组逐个判断当前成绩和上一个成绩是否相等。如果相等就沿用上一个名次如果不相等就取当前下标加1作为名次。let ranks: number[] []; for (let i 0; i temp.length; i) { if (i 0 this.isEqual(temp[i].score, temp[i - 1].score)) { ranks.push(ranks[i - 1]); } else { ranks.push(i 1); } }isEqual方法同样基于整数比较private isEqual(a: number, b: number): boolean { return this.toIntegerScore(a) this.toIntegerScore(b); }这里用Math.round(a * 100) Math.round(b * 100)确保6.52和6.52、6.520和6.520都能被识别为同一成绩。3.3 为什么结果需要一个独立数组在教学过程中有同学问过我这样一个问题既然已经排序了为什么还要再弄一个sortedAthletes数组直接在原数组上加一个rank字段不行吗从功能上说加rank字段是可行的。但从界面维护和状态管理来说拆开更干净。原数组负责显示录入顺序排序数组负责显示名次结果如果后面要增加“删除某位运动员”的功能删除只影响原数组排名结果在删除后重新计算就行不需要在两个数组之间同步字段。这种数据层的解耦在更大的应用里会变成常态所以我希望从这个小例子就把这种思维带进去。另外RankResult选择新建对象而不是直接引用原对象也是为了避免出现“改了一个对象的属性两个地方都变了”的连锁反应。在ArkUI的状态管理里深拷贝和浅拷贝的问题随时会出现提前养成好习惯能省下很多调试时间。4. 完整代码实现拆解4.1 完整代码清单我直接贴出能在DevEco Studio里跑通的完整代码。界面没有做复杂的自定义组件全部放在一个Entry组件里方便初学者看懂每一步。interface Athlete { id: number; name: string; score: number; } interface RankResult { name: string; score: number; rank: number; } Entry Component struct JumpCompare { State athletes: Athlete[] []; State sortedAthletes: RankResult[] []; State nameInput: string ; State scoreInput: string ; State errorMsg: string ; State private nextId: number 1; // 把两位小数转成整数避免浮点比较精度问题 private toIntegerScore(score: number): number { return Math.round(score * 100); } private isEqual(a: number, b: number): boolean { return this.toIntegerScore(a) this.toIntegerScore(b); } // 添加运动员并立刻重新排名 private addAthlete() { const name this.nameInput.trim(); const scoreStr this.scoreInput.trim(); if (name ) { this.errorMsg 请输入运动员姓名; return; } if (scoreStr ) { this.errorMsg 请输入跳远成绩; return; } const scorePattern /^[0-9](\.[0-9]{1,2})?$/; if (!scorePattern.test(scoreStr)) { this.errorMsg 请输入正确的成绩例如6.52; return; } const score parseFloat(scoreStr); this.athletes.push({ id: this.nextId, name: name, score: score }); this.nextId; this.nameInput ; this.scoreInput ; this.errorMsg ; this.calculateRank(); } // 排序并生成并列名次 private calculateRank() { const temp this.athletes.slice(); temp.sort((a, b) { return this.toIntegerScore(b.score) - this.toIntegerScore(a.score); }); const ranks: number[] []; for (let i 0; i temp.length; i) { if (i 0 this.isEqual(temp[i].score, temp[i - 1].score)) { ranks.push(ranks[i - 1]); } else { ranks.push(i 1); } } this.sortedAthletes temp.map((item, index) { return { name: item.name, score: item.score, rank: ranks[index] }; }); } build() { Column() { Text(跳远比赛成绩比较) .fontSize(26) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 20 }) // 输入区域 Row() { TextInput({ placeholder: 姓名, text: this.nameInput }) .onChange((value: string) { this.nameInput value; }) .layoutWeight(1) .margin({ right: 8 }) TextInput({ placeholder: 成绩米, text: this.scoreInput }) .onChange((value: string) { this.scoreInput value; }) .layoutWeight(1) .margin({ right: 8 }) Button(添加) .onClick(() { this.addAthlete(); }) } .width(95%) // 错误提示 if (this.errorMsg ! ) { Text(this.errorMsg) .fontColor(Color.Red) .fontSize(14) .margin({ top: 6 }) } // 录入的运动员列表 Text(参赛运动员) .fontSize(18) .fontWeight(FontWeight.Medium) .margin({ top: 20, bottom: 8 }) List() { ForEach(this.athletes, (item: Athlete) { ListItem() { Row() { Text(item.name) Text(item.score.toFixed(2) 米) } .justifyContent(SpaceBetween) .width(90%) .padding({ top: 6, bottom: 6 }) } }, (item: Athlete) item.id.toString()) } .height(30%) .width(100%) Button(开始排名) .onClick(() { this.calculateRank(); }) .margin({ top: 16, bottom: 16 }) .width(60%) // 排名结果区域 if (this.sortedAthletes.length 0) { Text(比赛排名) .fontSize(18) .fontWeight(FontWeight.Medium) .margin({ bottom: 8 }) Column() { ForEach(this.sortedAthletes, (item: RankResult) { Row() { Text(第 item.rank 名) Text(item.name) Text(item.score.toFixed(2) 米) } .width(90%) .justifyContent(SpaceBetween) .padding({ top: 6, bottom: 6 }) }, (item: RankResult) item.name item.rank item.score) } } } .width(100%) .height(100%) } }4.2 代码分段讲解代码看起来不短但拆开看每一块都很简单。先说数据声明部分。state变量集中放在组件顶部这是ArkUI推荐的写法。注意id字段用private修饰只是表示这个字段的生成逻辑只在组件内部使用不是给外部访问的。然后是addAthlete方法。这个方法做了三件事校验、push、清空输入。校验通过正则保证了成绩格式parseFloat负责把字符串转成数值。有一个细节校验通过之后再调用parseFloat因为正则已经确保字符串是合法的数字格式parseFloat一定不会返回NaN。如果你忘了校验就直接parseFloat遇到“6.52米”这种字符串parseFloat的解析结果是6.52但遇到“abc”就是NaN最后的排名结果就会出现一堆NaN和null界面直接崩掉。calculateRank方法是整个应用的核心。先slice复制数组再sort排序再遍历算并列名次。这里我多解释一句并列名次的规则如果有三个人成绩分别是7.08、6.52、6.52那么排序后的第二名和第三名并列第2名最后一个人名次是第3而不是第4。现在的ranks数组生成逻辑中遍历到第三个元素的时候因为成绩和前一个相等直接沿用ranks[1]的值也就是2。遍历结束后名次序列就是1、2、2。build部分用的是ArkUI声明式语法。TextInput的onChange会在输入内容变化时回调我把值直接赋给this.nameInput。添加之后把nameInput和scoreInput都设成空字符串输入框就会自动变空方便用户连续录入。List加ForEach用来显示运动员列表key用item.id稳定可靠。排名结果区放在一个if条件渲染里只有sortedAthletes数组不为空的时候才显示这样打开应用时页面不会出现空表格体验更友好。4.3 运行过程演示实际跑起来的效果是这样的启动应用后页面顶部显示标题中间是姓名和成绩两个输入框加一个添加按钮。我先录四个运动员运动员A输入7.08点击添加参赛运动员列表立刻出现一行“运动员A 7.08米”运动员B输入6.52添加运动员C输入6.52添加运动员D输入5.90添加。列表按照录入顺序显示没有被排序。然后点击“开始排名”排名区域出现四行结果第一名运动员A7.08米第二名运动员B6.52米第三名运动员C6.52米第四名运动员D5.90米。注意这里B和C因为成绩相同名次都是2虽然他们分别显示在第2行和第3行但rank字段都是2不是2和3。这个并列效果就是我在计算名次时特意处理的。如果中途想添加第五个运动员直接输入姓名和成绩再点添加排名区域会自动重新计算不需要再手动点一次“开始排名”因为addAthlete末尾调用了calculateRank。5. 常见问题与排查实录5.1 为什么输入浮点数后排序不对有朋友照着做的时候遇到过这个问题录入6.52和6.51排序结果居然出错甚至出现6.51排在6.52前面的情况。排查了半天发现成绩输入以后被存成了字符串。在ArkTS里如果直接写this.athletes.push({ name: name, score: scoreInput })那score字段的实际类型是string而不是number。排序时用b.score - a.score减号会触发隐式转换字符串“6.52”和“6.51”减完后确实能得到0.01看起来能排对。但一旦遇到特殊数据和浮点误差结果就飘忽不定。最直观的表现是有些成绩排序正确有些成绩排序错误而且错误没有规律。我的解决方案是在addAthlete里就用parseFloat把字符串转成number并存进score字段。同时用Number.isNaN做一次兜底校验避免parseFloat解析失败时把NaN存进去。如果你用了parseFloat还是发现个别成绩比较不对那多半是浮点误差问题。比如某些小数的整数转换会有微小偏差。这时一定要用Math.round处理后再比较不要图省事直接相减。5.2 并列名次计算中的细节并列名次这里容易出两个问题。第一个问题成绩相等但名次没有并列。原因多半是直接用temp[i].score temp[i - 1].score判断。虽然同一个字符串parseFloat后的值是完全相同的但如果你在界面上展示前对成绩做过toFixed或者做过加减乘除两个成绩的值就可能变得不完全相等。比如6.52经过6.52 0这种操作数值虽然没变但某些编译环境下可能微调精度。所以我在isEqual里用整数比较彻底绕开这个问题。第二个问题并列之后后面的名次跳号不正确。比如成绩为7.08、6.52、6.52三个人最后一个应该排第3但某些写法会排成第4。原因是在循环里用了i 1作为名次而不是维护一个“上一个名次”变量。我在代码里用ranks数组记录每一位的名次判断时用ranks[i - 1]这样就能正确延续并列名次。5.3 输入框残留和重复添加数据运行应用时如果不小心连续点击“添加”按钮会出现同一个运动员被添加多次的情况。TextInput本身不会清空所以每次点击添加都应该手动重置输入框。我在addAthlete的末尾统一做了清空操作这个习惯建议保留下来不然用户连续录入时体验很别扭。另一个相关的问题录入的姓名里带了首尾空格比如输入“ 运动员A ”显示的时候没什么问题但排序和key会受影响。我在校验之前做了trim处理姓名和成绩都清理干净再进入逻辑。这个小细节很多人会忽略但实际是导致“明明是不同的人key却冲突”的常见原因。还有一个非常隐蔽的坑测试时没有给运动员设置唯一id直接用name当key。录入两个名字相同的运动员后ForEach开始报出“重复键”的错误警告排名结果也不刷新。这个问题在开发阶段不仔细看不会注意到所以我在数据结构里提前安排了id字段这是经验教训换来的。6. 从这个小程序还能延伸出什么6.1 多轮比赛与历史成绩这个案例目前只支持单次比赛。如果想让应用能做多轮比赛可以给Athlete再加一个round字段或者引入比赛场次的概念。每次比赛结束把排名结果保存到另一个历史数组里用来生成总积分榜。积分规则可以参考田径规则第1名得5分第2名得3分第3名得1分同时记录每轮的最好成绩。这个扩展能顺便把数组的扁平化和聚合操作都练到。6.2 换成其他小数应用场景跳远比赛的逻辑其实可以迁移到很多场景。比如改成“小数的大小比较”通用教学工具让用户输入任意小数程序自动排序也可以改成商品价格比较输入几件商品的单价自动报出最贵和最便宜的商品。核心的排序、并列处理、界面渲染代码都不用改只调整字段名称和输入提示即可。这种迁移练习对初学者尤其有价值可以快速理解一个案例里到底哪些代码是通用的哪些代码是和业务强相关的。6.3 个人经验小结做完这个实例我最想提醒大家的其实是一句话小数的比较看似简单但实际项目里最容易出问题的就是对浮点数的精度处理。无论做什么应用只要涉及金额、测量值、百分比都建议先把小数转换成整数再参与比较和计算。跳远比赛这个例子虽然小却把这类问题的标准解法完整演示了一遍值得多敲几遍。如果后续想在界面上做得更丰富可以把排名结果用柱状图的方式画出来每位运动员对应一根横条长度和成绩成正比成绩越高条越长。这样视觉上更直观也能顺势把Canvas绘制的知识点带进来。我这个版本先保持纯列表展示把注意力集中在数据层和排序逻辑上等你吃透了再往上加组件修改起来会非常快。