HarmonyOS ArkTS 单位换算器的计算逻辑与交互设计
引子:换算器看起来简单,但有坑
单位换算器是工具箱里最"数学"的工具——输入一个数,选两个单位,得到结果。逻辑清晰,代码也不多。但做起来发现两个问题:温度换算和其他单位的换算逻辑不一样,数字格式化要考虑极端情况。
完整效果
数据模型: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
代码的策略是"先转摄氏度,再转目标单位":
- 如果源单位是华氏度:
c = (v - 32) × 5/9 - 如果源单位是开尔文:
c = v - 273.15 - 如果目标单位是华氏度:
c = c × 9/5 + 32 - 如果目标单位是开尔文:
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();
}
三种情况的处理
- 接近零:
|v| < 1e-9直接返回 ‘0’,避免显示0.0000000001这种无意义的精度 - 极端大或极端小:用科学计数法
toExponential(6),比如1e+15或1e-12 - 正常范围:
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.14,100.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();
}
切换分类时需要:
- 更新
curCat(当前分类名) - 更新
units(当前单位列表) - 重置
fromIdx和toIdx(避免索引越界) - 重新计算(更新结果)
点击单位切换
.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.3。fmt 方法用 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 对了,温度的特殊情况处理了,结果就对了。
更多推荐


所有评论(0)