引子:换算器看起来简单,但有坑

单位换算器是工具箱里最"数学"的工具——输入一个数,选两个单位,得到结果。逻辑清晰,代码也不多。但做起来发现两个问题:温度换算和其他单位的换算逻辑不一样,数字格式化要考虑极端情况。

完整效果
在这里插入图片描述

数据模型:Record + interface 的组合

interface UnitItem {
  name: string;      // 单位名称
  factor: number;    // 换算系数
  offset?: number;   // 偏移量(仅温度用)
}

const CATEGORIES: Record<string, UnitItem[]> = {
  '长度': [
    { name: '米 (m)', factor: 1 },
    { name: '千米 (km)', factor: 1000 },
    { name: '厘米 (cm)', factor: 0.01 },
    // ...
  ],
  '重量': [ ... ],
  '温度': [ ... ],
  '面积': [ ... ],
  '数据': [ ... ],
};

在这里插入图片描述

为什么用 Record 而不是 class

Record<string, UnitItem[]> 是 TypeScript 的工具类型,表示"键是字符串,值是 UnitItem 数组"。用它比定义一个 Category class 更简洁——不需要写构造函数,不需要实例化。

offset 字段的可选性

offset?: number? 表示这个字段可选。温度换算需要偏移量(比如摄氏度和华氏度之间有 32 的偏移),但长度、重量等不需要。可选字段让接口更灵活。

为什么温度的 factor 都是 1

温度的三个单位(摄氏度、华氏度、开尔文)的 factor 都是 1,因为温度换算不是简单的乘法。摄氏度转华氏度是 C × 9/5 + 32,有乘法有加法,不能用单一 factor 表示。

所以温度换算单独处理,不用 factor 公式。

换算算法:两套逻辑

convert(): void {
  const v = parseFloat(this.input);
  if (isNaN(v)) { this.result = '—'; return; }
  
  if (this.curCat === TEMP_MODE) {
    // 温度:先转摄氏度,再转目标单位
    let c = v;
    const from = this.units[this.fromIdx].name;
    const to = this.units[this.toIdx].name;
    if (from.includes('华氏')) c = (v - 32) * 5 / 9;
    else if (from.includes('开尔文')) c = v - 273.15;
    
    if (to.includes('华氏')) c = c * 9 / 5 + 32;
    else if (to.includes('开尔文')) c = c + 273.15;
    
    this.result = this.fmt(c);
  } else {
    // 其他:factor 公式
    const base = v * this.units[this.fromIdx].factor;
    this.result = this.fmt(base / this.units[this.toIdx].factor);
  }
}

在这里插入图片描述

factor 公式的原理

长度、重量、面积、数据的换算都基于"基本单位":

结果 = 输入 × 源单位factor ÷ 目标单位factor

比如 1 千米转厘米:

1 × 1000 (km→m) ÷ 0.01 (m→cm) = 100000 cm

每个单位的 factor 表示"1 个该单位等于多少基本单位"。通过"先转基本单位,再转目标单位",实现了任意两个单位之间的换算。

温度的特殊处理

温度不能用 factor 公式,因为摄氏度和华氏度之间有偏移:

°F = °C × 9/5 + 32
°C = (°F - 32) × 5/9
K = °C + 273.15

代码的策略是"先转摄氏度,再转目标单位":

  1. 如果源单位是华氏度:c = (v - 32) × 5/9
  2. 如果源单位是开尔文:c = v - 273.15
  3. 如果目标单位是华氏度:c = c × 9/5 + 32
  4. 如果目标单位是开尔文:c = c + 273.15

name.includes('华氏') 判断单位类型,而不是用索引或枚举。这样即使以后加新的温度单位(比如兰氏度),只需要加对应的 if 分支。

isNaN 的防御

if (isNaN(v)) { this.result = '—'; return; }

用户可能输入非数字(比如空字符串、字母),parseFloat 会返回 NaN。不处理的话,后续计算会得到 NaN,显示出来很难看。提前判断,显示 ‘—’ 作为占位符。

数字格式化:fmt 方法

fmt(v: number): string {
  if (Math.abs(v) < 1e-9) return '0';
  if (Math.abs(v) >= 1e12 || Math.abs(v) < 1e-9) return v.toExponential(6);
  return parseFloat(v.toFixed(8)).toString();
}

三种情况的处理

  1. 接近零|v| < 1e-9 直接返回 ‘0’,避免显示 0.0000000001 这种无意义的精度
  2. 极端大或极端小:用科学计数法 toExponential(6),比如 1e+151e-12
  3. 正常范围toFixed(8) 保留 8 位小数,再去掉末尾的零

为什么用 1e-9 而不是 0

浮点数计算有精度误差,0.1 + 0.2 不等于 0.3,而是 0.30000000000000004。所以不能直接判断 v === 0,要用 Math.abs(v) < 1e-9 判断"足够接近零"。

toFixed(8) 的选择

8 位小数是精度和可读性的平衡。太少(比如 2 位)会丢失精度,太多(比如 15 位)会让结果看起来很乱。

去掉末尾的零

parseFloat(v.toFixed(8)) 会去掉末尾的零。比如 3.14000000 会变成 3.14100.00000000 会变成 100

状态管理:六个 @State 变量

@State cats: string[] = Object.keys(CATEGORIES);  // 分类列表
@State curCat: string = '长度';                     // 当前分类
@State units: UnitItem[] = CATEGORIES['长度'];      // 当前单位列表
@State fromIdx: number = 0;                         // 源单位索引
@State toIdx: number = 1;                           // 目标单位索引
@State input: string = '1';                         // 输入值
@State result: string = '';                         // 结果

为什么 units 也要是 @State

units 是从 CATEGORIES[curCat] 取出来的。如果不设为 @State,切换分类时 units 不会更新,UI 还是显示旧的单位列表。

fromIdx 和 toIdx 的初始值

初始值是 0 和 1(第一个和第二个单位)。切换分类时重置为 0 和 1,确保不会出现"索引越界"的问题。

交互逻辑:三个核心方法

swap:交换源和目标

swap(): void {
  const t = this.fromIdx;
  this.fromIdx = this.toIdx;
  this.toIdx = t;
  this.convert();
}

交换两个索引,然后重新计算。用户可以快速反转换算方向(比如从"千米→米"变成"米→千米")。

selCat:切换分类

selCat(cat: string): void {
  this.curCat = cat;
  this.units = CATEGORIES[cat];
  this.fromIdx = 0;
  this.toIdx = 1;
  this.convert();
}

切换分类时需要:

  1. 更新 curCat(当前分类名)
  2. 更新 units(当前单位列表)
  3. 重置 fromIdxtoIdx(避免索引越界)
  4. 重新计算(更新结果)

点击单位切换

.onClick(() => {
  this.fromIdx = (this.fromIdx + 1) % this.units.length;
  this.convert();
})

点击"从"区域,索引循环加 1。比如当前是"米",点击后变成"千米",再点击变成"厘米"。用取余实现循环。

UI 结构:五个区域

┌─────────────────────────────┐
│  ← 📐 单位换算              │  ← 导航栏
├─────────────────────────────┤
│  [长度] [重量] [温度] ...   │  ← 分类标签
├─────────────────────────────┤
│  输入数值                   │
│  ┌─────────────────────┐   │
│  │ 1                   │   │  ← 输入框
│  └─────────────────────┘   │
├─────────────────────────────┤
│  米 (m)    [切换]  千米(km)│  ← 单位选择
│    从              到       │
├─────────────────────────────┤
│         1000                │  ← 结果
│        千米 (km)            │
└─────────────────────────────┘

分类标签

Scroll + Row + ForEach 实现水平滚动的标签栏。选中的标签用绿色背景(#34C759),未选中的用深色背景(#2A2A3E)。

输入框

TextInput 组件,onChange 事件实时触发换算。用户每输入一个字符,结果都会更新。

单位选择

两个 Column 分别显示"从"和"到"的单位。点击整个区域会切换到下一个单位(循环切换)。中间的"切换"按钮交换两个单位。

结果显示

结果用 40px 的大字体显示,绿色(#34C759),让用户一眼就能看到。maxLines(2) 限制最多两行,避免极端数字撑开布局。

踩坑记录

坑 1:温度换算的顺序

如果先判断目标单位,再判断源单位,可能会出错。比如"华氏度→开尔文":

错误:先转目标(开尔文),再转源(华氏度)
正确:先转源(华氏度→摄氏度),再转目标(摄氏度→开尔文)

必须先"归一"到摄氏度,再从摄氏度转到目标。

坑 2:fromIdx 越界

切换分类后,如果 fromIdx 没有重置,可能会超过新单位列表的长度。比如从"数据"(6 个单位)切到"温度"(3 个单位),fromIdx 如果是 5,就会越界。

坑 3:输入非数字

用户可能输入"abc"或空字符串。parseFloat 返回 NaN,后续计算会出错。必须提前判断。

坑 4:科学计数法的显示

toExponential(6) 会显示类似 1.234567e+12 的格式。普通用户可能看不懂。如果要更友好的显示,可以加千位分隔符。

坑 5:浮点数精度

0.1 + 0.2 = 0.30000000000000004,不是 0.3fmt 方法用 1e-9 判断"接近零",避免了这个问题。

代码改进建议

1. 单位选择用 Picker

当前点击区域循环切换单位,用户不知道下一个是什么。可以用 Picker 组件,让用户从列表中选择:

Picker({ range: this.units.map(u => u.name), selected: this.fromIdx })
  .onChange((idx: number) => { this.fromIdx = idx; this.convert(); })

2. 历史记录

保存最近的换算记录,让用户可以快速查看。可以用 Preferences API 存储。

3. 复制结果

长按结果可以复制到剪贴板。可以用 clipboard 模块:

import { clipboard } from '@kit.BasicServicesKit';
clipboard.setData(this.result);

4. 更多分类

当前只有 5 个分类。可以扩展更多(比如速度、压力、时间)。

5. 自定义单位

让用户自己添加单位(比如公司内部的换算比例)。

总结

单位换算器的核心是"两套算法"——长度、重量等用 factor 公式,温度用偏移公式。数据模型用 Record<string, UnitItem[]> 组织,UI 用 @State 驱动更新。数字格式化处理了极端情况,交互逻辑覆盖了切换、交换、输入三个场景。

适用边界:这个部分适合用作 ArkUI 数据驱动计算的学习案例,涵盖了 Record 类型、可选字段、条件分支、数字格式化、实时计算等核心知识点。但如果要上架应用商店,还需要补充 Picker 组件、历史记录、复制功能、更多分类、自定义单位等内容。

说白了,换算器就是"把公式套对"。factor 对了,温度的特殊情况处理了,结果就对了。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐