鸿蒙原生开发——从零构建单位换算器
一、引言
单位换算是每个人都需要的工具。美国人用英里和英尺,欧洲人用公里和米;厨师用盎司和磅,健身者用千克和克;科学家用开尔文和摄氏度,普通人用华氏度和摄氏度。这些单位系统各自存在了数百年的历史原因,已经不可能在全球范围内统一。
从技术角度看,单位换算的核心挑战有三个:
第一,不同类别的单位有不同的换算规则。长度和重量是线性换算——只要乘以一个系数即可(如 1 千克 = 1000 克)。温度是非线性换算——摄氏度转华氏度的公式是 °F = °C × 9/5 + 32,包含了乘法和加法,不能简单地乘以一个系数。
第二,单位在不同屏幕上的呈现。4 个单位按钮排成一行在小屏幕上可能放不下,需要支持自动折行。而 3 个分类标签需要水平排列,空间富裕时用 Row,窄屏时需要考虑适配。
第三,输入和输出的双向性。用户可能输入任意一端的值,需要实时计算另一端的值。而且用户可能随时切换"哪一端是输入",需要一个快捷的"交换"操作。
本文将用 ArkUI 从零构建一个单位换算器,支持三类单位(长度 / 重量 / 温度),每类 3-4 种单位,实时双向换算,一键交换输入和输出。
阅读完本文,你将能够:
- 设计分层单位换算架构(分类层 + 单位层 + 换算函数层)
- 使用"基准单位"模式统一处理同类别的线性换算
- 处理温度的非线性换算(公式而非系数)
- 实现输入和输出的双向实时计算
- 用
Flex({ wrap: FlexWrap.Wrap })构建自适应的单位选择按钮组
二、数据模型与换算架构
2.1 分类与单位的分层定义
换算器的数据结构分为两层:分类(Category)包含多个单位(Unit):
interface UnitDef {
symbol: string; // 单位符号,如 "kg", "°F"
name: string; // 中文名称,如 "千克", "华氏度"
}
interface CategoryDef {
name: string; // 分类名,如 "长度"
icon: string; // 图标,如 "📏"
units: UnitDef[]; // 该分类下的单位列表
}
三层嵌套结构的根是分类数组:
const CATEGORIES: CategoryDef[] = [
{
name: '长度', icon: '📏',
units: [
{ symbol: 'm', name: '米' },
{ symbol: 'km', name: '千米' },
{ symbol: 'mi', name: '英里' },
{ symbol: 'ft', name: '英尺' },
],
},
{
name: '重量', icon: '⚖️',
units: [
{ symbol: 'kg', name: '千克' },
{ symbol: 'g', name: '克' },
{ symbol: 'lb', name: '磅' },
{ symbol: 'oz', name: '盎司' },
],
},
{
name: '温度', icon: '🌡️',
units: [
{ symbol: '°C', name: '摄氏度' },
{ symbol: '°F', name: '华氏度' },
{ symbol: 'K', name: '开尔文' },
],
},
];
分类用 icon + name 的组合呈现为可点击的标签,单位用 symbol 呈现为可选的按钮。symbol 而非 name 作为按钮文字有两个原因:第一,symbol 更短(如 “km” vs “千米”),在手机上更容易在一行内放下多个按钮;第二,熟悉国际单位制的用户(目标用户群的主要部分)看到 symbol 能比中文名更快地定位。
2.2 基准单位换算模式
对于长度和重量的线性换算,我们使用"基准单位"模式:
- 选每个类别中的一个单位作为"基准"(长度选米,重量选克)
- 将任意单位的值转换为基准单位
- 再从基准单位转换为目标单位
输入值 → toBase() → 基准值 → fromBase() → 输出值
长度类:所有单位先转换为"米",再从"米"转换为目标单位:
function toBase(value: number, unit: string, catIndex: number): number {
if (catIndex === 0) { // 长度 → 米
if (unit === 'km') return value * 1000;
if (unit === 'mi') return value * 1609.344;
if (unit === 'ft') return value * 0.3048;
return value;
}
// ...
}
重量类:所有单位先转换为"克",再从"克"转换为目标单位:
if (catIndex === 1) { // 重量 → 克
if (unit === 'kg') return value * 1000;
if (unit === 'lb') return value * 453.592;
if (unit === 'oz') return value * 28.3495;
return value;
}
注意磅和盎司的系数不是整数——453.592 克每磅,28.3495 克每盎司。这些是国际标准转换系数,精确到足够日常使用。不要简化成 454 或 28,因为累积误差在多次换算后会变得明显。
基准单位模式的好处是扩展性——如果要新增一个单位(比如长度类增加"英寸"),只需在 toBase 和 fromBase 中各加一个 if 分支,不需要修改任何其他逻辑。
2.3 温度的非线性换算
温度不能用简单的系数乘法,因为三个温标有不同的零点。摄氏度、华氏度、开尔文之间的转换公式如下:
°C → °F: F = C × 9/5 + 32
°F → °C: C = (F - 32) × 5/9
°C → K: K = C + 273.15
K → °C: C = K - 273.15
温度的处理方式与长度/重量不同——先用专门的转换函数转到摄氏度(作为温度体系的"基准"),再从摄氏度转到目标单位:
function toCelsius(value: number, unit: string): number {
if (unit === '°F') return (value - 32) * 5 / 9;
if (unit === 'K') return value - 273.15;
return value; // 已经是 °C
}
function fromCelsius(celsius: number, unit: string): number {
if (unit === '°F') return celsius * 9 / 5 + 32;
if (unit === 'K') return celsius + 273.15;
return celsius;
}
虽然温度的逻辑与长度/重量不同,但它们共享相同的接口——toBase() 和 fromBase() 函数在 catIndex === 2(温度)时调用 toCelsius 和 fromCelsius,对外暴露了一致的调用方式。
2.4 统一的 convert 函数
有了 toBase 和 fromBase,换算过程变为清晰的两次调用:
function convert(value: number, from: string, to: string, catIndex: number): number {
const base = toBase(value, from, catIndex);
return fromBase(base, to, catIndex);
}
这是整个换算系统的核心——一行代码完成任何同类单位之间的换算。from 和 to 可以是相同类别中的任意两个单位(包括同单位,同单位换算结果等于原值),catIndex 告知系统使用哪套换算规则。
三、双向实时计算
3.1 输入驱动的计算
换算器采用"输入驱动"模式——用户修改输入值时,结果自动更新:
TextInput({ text: this.inputText, placeholder: '输入数值' })
.type(InputType.Number)
.onChange((value: string) => {
this.inputText = value;
this.updateResult();
})
onChange 回调中的 updateResult() 是关键——每次输入变化都触发重新计算。这比"输入完后点换算按钮"的模式更即时,用户可以连续输入多位数字,看到结果随之变化。
3.2 updateResult 的逻辑
updateResult(): void {
const val = parseFloat(this.inputText);
if (isNaN(val) || this.inputText.trim() === '') {
this.resultText = '—';
return;
}
const r = convert(val, this.fromUnit, this.toUnit, this.catIndex);
this.resultText = formatResult(r);
}
三种情况:
- 输入为空或非数字:显示"—",表示等待有效输入
- 有效输入:调用
convert()→formatResult()→ 更新resultText - 源单位或目标单位变化:同样调用
updateResult()(在单位按钮的onClick中触发)
结果区域使用 Text 而非 TextInput 展示,确保用户不能编辑计算结果——它只反映输入 → 换算的输出。
3.3 单位交换
用户点击中间的交换按钮(⇅)时,输入和输出两端交换:
swapUnits(): void {
const tmp = this.fromUnit;
this.fromUnit = this.toUnit;
this.toUnit = tmp;
const val = parseFloat(this.inputText);
if (!isNaN(val)) {
const r = convert(val, this.fromUnit, this.toUnit, this.catIndex);
this.inputText = formatResult(r);
}
this.updateResult();
}
交换的不仅是单位标签,还包括数值——输入框的值被替换为当前换算结果的数值。例如用户输入 “1 km → 0.621 mi”,点击交换后变为 “0.621 mi → 1 km”。这保持了换算的一致性——交换前后表示的是同一段距离,只是视角反过来了。
3.4 结果格式化
浮点运算会产生类似 0.621371192 这样的长尾数。formatResult() 负责将其格式化为可读的字符串:
function formatResult(n: number): string {
if (isNaN(n)) return '—';
if (!isFinite(n)) return '—';
const abs = Math.abs(n);
if (abs === 0) return '0';
if (abs < 0.000001 || abs >= 1000000) return n.toExponential(6);
if (abs < 1) return n.toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
return n.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
}
五个段落处理不同的数值范围:
| 范围 | 策略 | 示例 |
|---|---|---|
| NaN / Infinity | 返回 “—” | 无效输入 |
| 0 | 直接返回 “0” | 0 ≠ 0.0000 |
| < 0.000001 | 科学计数法(6 位有效数字) | 4.2e-7 |
| < 1 | 小数点后 6 位,去掉末尾零 | 0.5 而非 0.500000 |
| ≥ 1 且 < 1000000 | 小数点后 4 位,去掉末尾零 | 3.1416 |
| ≥ 1000000 | 科学计数法 | 1.23e+6 |
replace(/0+$/, '').replace(/\.$/, '') 去掉小数点后的末尾零和可能残留的小数点。例如 1.5000 → 1.5,2.0000 → 2。
科学计数法(toExponential(6))用于极小或极大的数值,保持显示屏上内容的可读性——6.022e+23 比 602200000000000000000000 好读一千倍。
四、UI 设计
4.1 整体布局
ConverterPage
├── 深色标题栏:"📐 单位换算"
├── 分类标签行:长度 | 重量 | 温度(圆角胶囊按钮)
├── 输入卡片(白色圆角)
│ ├── "输入" 标签
│ ├── TextInput(28sp 加粗,数字键盘)
│ └── 源单位选择(Flex wrap 按钮组)
├── 交换按钮(⇅,圆形,居中,带阴影)
├── 结果卡片(白色圆角)
│ ├── "结果" 标签
│ ├── Text 显示区(28sp 加粗,只读)
│ └── 目标单位选择(Flex wrap 按钮组)
4.2 分类标签的设计
三个分类标签使用圆角胶囊(BorderRadius.FULL)样式,当前选中的分类用蓝色填充背景 + 蓝色文字:
选中态:蓝色浅底 + 蓝色文字 + 加粗
未选中态:灰色浅底 + 灰色文字 + 常规
分类切换时,单位会被重置为该分类的第一个和最后一个单位(如"米"和"英尺"),输入值重置为 1:
switchCategory(idx: number): void {
this.catIndex = idx;
this.fromUnit = CATEGORIES[idx].units[0].symbol;
this.toUnit = CATEGORIES[idx].units[CATEGORIES[idx].units.length - 1].symbol;
this.inputText = '1';
this.updateResult();
}
选择"第一个"和"最后一个"单位作为初始的源/目标单位,是为了给用户展示一个"有意义的换算"而非 “1 m = 1 m”。例如切换到重量时看到 “1 kg = 35.274 oz”,立刻就知道换算功能在工作。
4.3 单位按钮的双色系统
源单位和目标单位使用不同的选中颜色——这是本文在视觉设计上的一个重要决策:
- 源单位选中:蓝色实心 + 白色文字(
#1677FF) - 目标单位选中:绿色实心 + 白色文字(
#52C41A)
蓝 vs 绿的区分让用户在界面上能立即分辨出"哪边是输入,哪边是输出"。在交换操作时,两种颜色对调,视觉上强化了"方向变了"的信号。如果源和目标单位使用相同的选中色,用户容易混淆当前的操作方向。
源单位(蓝色)和文本输入框在同一个白色卡片中,目标单位(绿色)和结果显示在另一个白色卡片中。两个卡片形成对称的结构,视觉上暗示了"输入 → 输出"的方向性。
4.4 Flex wrap 适配窄屏
单位按钮使用 Flex({ wrap: FlexWrap.Wrap }),让按钮在屏幕宽度不够时自动折行:
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(CATEGORIES[this.catIndex].units, (unit: UnitDef) => {
Text(unit.symbol)
.fontSize(FontSize.BODY)
.fontColor(this.fromUnit === unit.symbol ? '#FFFFFF' : '#888899')
.fontWeight(FontWeight.Bold)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(BorderRadius.FULL)
.backgroundColor(this.fromUnit === unit.symbol ? '#1677FF' : '#F0F0F5')
.margin({ right: Spacing.SM, bottom: Spacing.SM })
.onClick(() => {
this.fromUnit = unit.symbol;
this.updateResult();
})
})
}
.width('100%')
四个关键设计细节:
.margin({ bottom: Spacing.SM })提供折行后的行间距。没有这个 margin 的话,折到下一行的按钮会和上一行紧贴。Flex而非Row——Row不支持 wrap,按钮会溢出屏幕不可见。- 每个单位按钮使用
BorderRadius.FULL(完全圆角),与分类标签的圆角胶囊样式保持一致。 - 未选中按钮灰色(
#F0F0F5),选中按钮根据位置不同使用蓝色或绿色——这是单位换算器最独特的视觉元素。
五、完整代码结构
ConverterPage
├── Column(根布局)
│ ├── Row(标题栏:📐 单位换算)
│ ├── Row(分类标签:📏长度 | ⚖️重量 | 🌡️温度)
│ ├── Column(输入卡片)
│ │ ├── Text("输入"标签)
│ │ ├── TextInput(数值输入,数字键盘)
│ │ └── Flex wrap(源单位按钮组,选中蓝色)
│ ├── Row(交换按钮 ⇅,居中,带阴影)
│ ├── Column(结果卡片)
│ │ ├── Text("结果"标签)
│ │ ├── Text(结果展示,只读,加粗)
│ │ └── Flex wrap(目标单位按钮组,选中绿色)
│ └── 空白区域(layoutWeight 填充)
└── 全局函数
├── toBase() — 任意单位 → 基准单位
├── fromBase() — 基准单位 → 任意单位
├── convert() — 完整换算(toBase + fromBase)
├── toCelsius() / fromCelsius() — 温度特殊处理
└── formatResult() — 结果格式化
六、总结
本文从零构建了一个单位换算器。与前七篇的应用不同,单位换算器的核心在于分层换算架构 + 双向实时计算 + 非线性单位的特殊处理。
核心要点回顾:
-
分层数据模型:分类层(CategoryDef)定义单位组和换算规则,单位层(UnitDef)定义每个单位的符号和名称。三层嵌套数(分类数组 → 单位数组 → 单位对象)清晰表达了数据的层级关系。
-
基准单位模式:所有同类单位先通过
toBase()转换到基准单位(长度→米,重量→克,温度→摄氏度),再通过fromBase()从基准单位转换到目标单位。新增单位只需在toBase和fromBase中各加一个分支,其他逻辑不受影响。 -
温度的非线性处理:
°F = C × 9/5 + 32包含乘法和位移,不能简化为系数乘法。toCelsius()和fromCelsius()专门处理这三个温标之间的转换,通过catIndex与线性换算共享同一调用接口。 -
输入驱动的实时计算:
TextInput.onChange→updateResult()→convert()→formatResult()→ 显示结果。用户每输入一个数字,结果即时更新。交换按钮双向互换单位和数值,保持换算一致性。 -
源/目标双色区分:源单位选中蓝色(
#1677FF),目标单位选中绿色(#52C41A)。颜色差异让用户立即分辨输入和输出,交换操作时颜色对调提供视觉反馈。这是本文在 UI 设计上最重要的创新——用颜色编码来区分信息流向。 -
结果格式化的数值范围策略:0 直接输出、小数去掉末尾零、极小/极大值用科学计数法。
replace(/0+$/, '').replace(/\.$/, '')是高性价比的显示优化——一行正则免去了手动判断小数位的麻烦。
单位换算器是一个看起来"简单"但设计细节丰富的工具——分类管理、换算公式、输入处理、结果格式化,每个环节都有多个边界情况需要处理。它是实用型 App 的典型代表:功能不花哨,但每个细节都影响用户的实际使用体验。
更多推荐


所有评论(0)