HarmonyOS NEXT 实战:从零开发多功能单位换算器
包名:com.example.myapplication
API版本:HarmonyOS NEXT (API 23+)
开发工具:DevEco Studio
一、前言:为什么要做单位换算器?
在日常生活和工作中,单位换算是非常常见的需求:烹饪时需要换算重量、装修时需要换算面积、出国时需要换算温度……虽然手机上有很多换算工具,但作为开发者,自己动手实现一个单位换算器,不仅能解决实际问题,还能深入理解鸿蒙开发的诸多核心技术点。
这个项目涉及:
- 数据结构设计:如何优雅地定义单位和换算规则
- 状态管理:@State 装饰器管理多维度状态
- 用户交互:Tab切换、单位选择、实时换算
- 算法实现:通用换算算法 + 温度特殊处理
- UI设计:卡片式布局、横向滚动选择器
本文将详细记录从设计到实现的完整过程,适合正在学习鸿蒙开发的同学参考。
二、功能设计
2.1 核心功能
| 多类别支持 | 长度、重量、温度、面积四大类 |
| 实时换算 | 输入即出结果,无需点击按钮 |
| 单位切换 | 横向滚动选择源/目标单位 |
| 交换功能 | 一键互换源单位和目标单位 |
| 智能格式化 | 自动处理极大/极小数字 |
2.2 支持的单位
长度类
| 毫米 | mm | 0.001 |
| 厘米 | cm | 0.01 |
| 米 | m | 1(基准) |
| 千米 | km | 1000 |
| 英寸 | in | 0.0254 |
| 英尺 | ft | 0.3048 |
重量类
| 毫克 | mg | 0.001 |
| 克 | g | 1(基准) |
| 千克 | kg | 1000 |
| 吨 | t | 1,000,000 |
| 磅 | lb | 453.59237 |
| 盎司 | oz | 28.349523125 |
温度类
| 摄氏度 | °C | 基准 |
| 华氏度 | °F | 特殊公式 |
| 开尔文 | K | 特殊公式 |
面积类
| 平方毫米 | mm² | 0.000001 |
| 平方厘米 | cm² | 0.0001 |
| 平方米 | m² | 1(基准) |
| 平方千米 | km² | 1,000,000 |
| 公顷 | ha | 10,000 |
| 英亩 | acre | 4046.8564224 |
2.3 UI设计
采用卡片式布局,整体风格简洁现代:
┌─────────────────────────────┐
│ 📐 单位换算器 │
├─────────────────────────────┤
│ 📏长度 │ ⚖️重量 │ 🌡️温度 │ 🔲面积 │ Tab栏
├─────────────────────────────┤
│ ┌─────────────────────┐ │
│ │ 从 │ │
│ │ [mm][cm][m][km]… │ │ 输入区
│ │ ┌─────────────────┐ │ │
│ │ │ 输入数值 │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────┘ │
│ ⇅ │ 交换按钮
│ ┌─────────────────────┐ │
│ │ 到 │ │
│ │ [mm][cm][m][km]… │ │ 输出区
│ │ = 100 cm │ │ 结果
│ └─────────────────────┘ │
└─────────────────────────────┘
三、项目初始化
3.1 创建项目
打开 DevEco Studio,选择 Empty Ability 模板:
- 项目名称:MyApplication
- Bundle Name:com.example.myapplication
- Compile SDK:API 23(HarmonyOS NEXT)
- Model:Stage 模型
3.2 项目结构
MyApplication/
├── AppScope/
│ └── app.json5 # 应用配置
├── entry/
│ └── src/main/
│ ├── ets/
│ │ ├── entryability/
│ │ │ └── EntryAbility.ets
│ │ └── pages/
│ │ └── Index.ets # 主页面 ⭐
│ ├── resources/
│ │ └── base/element/
│ │ └── string.json
│ └── module.json5
└── build-profile.json5
四、数据结构设计
4.1 单位信息接口
每个单位需要两个属性:名称和换算系数。
interface UnitInfo {
label: string // 显示名称,如 "米 (m)"
toBase: number // 换算到基准单位的系数
}
设计思路:
- toBase 表示1个该单位等于多少个基准单位
- 例如:厘米的 toBase = 0.01,表示 1cm = 0.01m
- 千米的 toBase = 1000,表示 1km = 1000m
4.2 类别接口
每个换算类别包含名称、图标、单位列表,以及可选的特殊换算函数。
interface Category {
name: string // 类别名称
icon: string // 图标 emoji
units: UnitInfo[] // 单位列表
convertFn?: (val: number, fromIdx: number, toIdx: number) => number
}
为什么需要 convertFn?
大多数单位(长度、重量、面积)可以通过简单的乘除法换算,但温度不同——摄氏度、华氏度、开尔文之间的换算需要特殊公式。convertFn 就是处理这种情况的。
4.3 完整数据定义
const CATEGORIES: Category[] = [
{
name: '长度', icon: '📏',
units: [
{ label: '毫米 (mm)', toBase: 0.001 },
{ label: '厘米 (cm)', toBase: 0.01 },
{ label: '米 (m)', toBase: 1 },
{ label: '千米 (km)', toBase: 1000 },
{ label: '英寸 (in)', toBase: 0.0254 },
{ label: '英尺 (ft)', toBase: 0.3048 },
]
},
{
name: '重量', icon: '⚖️',
units: [
{ label: '毫克 (mg)', toBase: 0.001 },
{ label: '克 (g)', toBase: 1 },
{ label: '千克 (kg)', toBase: 1000 },
{ label: '吨 (t)', toBase: 1_000_000 },
{ label: '磅 (lb)', toBase: 453.59237 },
{ label: '盎司 (oz)', toBase: 28.349523125 },
]
},
{
name: '温度', icon: '🌡️',
units: [
{ label: '摄氏度 (°C)', toBase: 0 },
{ label: '华氏度 (°F)', toBase: 0 },
{ label: '开尔文 (K)', toBase: 0 },
],
convertFn: (val: number, fromIdx: number, toIdx: number): number => {
// 先转摄氏度
let celsius: number
if (fromIdx === 0) celsius = val
else if (fromIdx === 1) celsius = (val – 32) * 5 / 9
else celsius = val – 273.15
// 再转目标单位
if (toIdx === 0) return celsius
if (toIdx === 1) return celsius * 9 / 5 + 32
return celsius + 273.15
}
},
{
name: '面积', icon: '🔲',
units: [
{ label: '平方毫米 (mm²)', toBase: 0.000001 },
{ label: '平方厘米 (cm²)', toBase: 0.0001 },
{ label: '平方米 (m²)', toBase: 1 },
{ label: '平方千米 (km²)', toBase: 1_000_000 },
{ label: '公顷 (ha)', toBase: 10_000 },
{ label: '英亩 (acre)', toBase: 4046.8564224 },
]
},
]
五、状态管理
5.1 状态变量定义
@Entry
@Component
struct UnitConverter {
// 当前选中的类别索引
@State activeCategory: number = 0
// 源单位索引
@State fromIdx: number = 0
// 目标单位索引
@State toIdx: number = 1
// 输入值
@State inputValue: string = ''
// 换算结果
@State resultValue: string = ''
}
设计思路:
- activeCategory:控制当前显示哪个类别的单位
- fromIdx / toIdx:选中单位的索引,用于从数据数组中取值
- inputValue:用户输入的原始字符串
- resultValue:换算后的结果字符串
5.2 状态联动
当用户切换类别时,需要重置单位索引:
switchCategory(idx: number): void {
this.activeCategory = idx
this.fromIdx = 0
this.toIdx = Math.min(1, CATEGORIES[idx].units.length – 1)
this.convert()
}
为什么用 Math.min?
因为不同类别的单位数量可能不同。温度只有3个单位,如果之前选了第5个单位,切换到温度会越界。
六、换算算法实现
6.1 通用换算算法
对于长度、重量、面积等线性单位,使用"基准单位法":
输入值 → 基准单位 → 目标单位
// 标准换算:输入 → 基准 → 输出
const base: number = num * cat.units[this.fromIdx].toBase
const res: number = base / cat.units[this.toIdx].toBase
示例:将 5 千米换算为米
6.2 温度特殊换算
温度不能简单乘除,需要先转成摄氏度,再转成目标单位:
convertFn: (val: number, fromIdx: number, toIdx: number): number => {
// 第一步:转摄氏度
let celsius: number
if (fromIdx === 0) celsius = val // 摄氏度
else if (fromIdx === 1) celsius = (val – 32) * 5 / 9 // 华氏度转摄氏度
else celsius = val – 273.15 // 开尔文转摄氏度
// 第二步:转目标单位
if (toIdx === 0) return celsius // 摄氏度
if (toIdx === 1) return celsius * 9 / 5 + 32 // 摄氏度转华氏度
return celsius + 273.15 // 摄氏度转开尔文
}
换算公式:
| °C → °F | C × 9/5 + 32 |
| °F → °C | (F – 32) × 5/9 |
| °C → K | C + 273.15 |
| K → °C | K – 273.15 |
6.3 完整换算函数
convert(): void {
const trimmed: string = this.inputValue.trim()
if (trimmed.length === 0) {
this.resultValue = ''
return
}
const num: number = parseFloat(trimmed)
if (isNaN(num)) {
this.resultValue = '—'
return
}
const cat: Category = CATEGORIES[this.activeCategory]
if (cat.convertFn) {
// 温度特殊换算
const res: number = cat.convertFn(num, this.fromIdx, this.toIdx)
this.resultValue = this.formatResult(res)
} else {
// 标准换算:输入 → 基准 → 输出
const base: number = num * cat.units[this.fromIdx].toBase
const res: number = base / cat.units[this.toIdx].toBase
this.resultValue = this.formatResult(res)
}
}
6.4 结果格式化
处理各种边界情况:
formatResult(val: number): string {
if (isNaN(val) || !isFinite(val)) return '—'
// 处理极小/极大数字
if (Math.abs(val) < 0.000001 && val !== 0) return val.toExponential(4)
if (Math.abs(val) > 999999999) return val.toExponential(4)
// 四舍五入到合适精度
const fixed: string = val.toFixed(6)
// 去掉末尾多余的 0 和小数点
return parseFloat(fixed).toString()
}
处理场景:
| 极小数字 | 0.0000001 | 科学计数法 1e-7 |
| 极大数字 | 10000000000 | 科学计数法 1e+10 |
| 正常数字 | 1.23456789 | 四舍五入 1.234568 |
| 多余零 | 1.500000 | 去零 1.5 |
七、UI布局实现
7.1 整体结构
build() {
Column() {
// 标题
Text('📐 单位换算器')
.fontSize(26)
.fontWeight(FontWeight.Bold)
// 类别标签
Row() {
ForEach(CATEGORIES, …)
}
// 换算卡片
Column() {
// 输入区域
Column() { … }
// 交换按钮
Column() { … }
// 输出区域
Column() { … }
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F7FA')
}
7.2 类别标签栏
使用 ForEach 遍历类别数组:
Row() {
ForEach(CATEGORIES, (cat: Category, idx: number) => {
Column() {
Text(cat.icon)
.fontSize(22)
Text(cat.name)
.fontSize(13)
.fontColor(this.activeCategory === idx ? '#5B8DEF' : '#888888')
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.borderRadius(10)
.backgroundColor(this.activeCategory === idx ? '#EEF3FD' : 'transparent')
.onClick(() => { this.switchCategory(idx) })
})
}
.width('94%')
.backgroundColor('#F0F2F5')
.borderRadius(12)
.padding(4)
交互效果:
- 未选中:透明背景,灰色文字
- 选中:浅蓝背景,蓝色文字
7.3 单位选择器
横向滚动的单位选择器:
Scroll() {
Row() {
ForEach(CATEGORIES[this.activeCategory].units, (unit: UnitInfo, idx: number) => {
Text(unit.label)
.fontSize(14)
.fontColor(this.fromIdx === idx ? '#FFFFFF' : '#555555')
.padding({ top: 6, bottom: 6, left: 14, right: 14 })
.backgroundColor(this.fromIdx === idx ? '#5B8DEF' : '#F0F2F5')
.borderRadius(16)
.margin({ right: 8 })
.onClick(() => {
this.fromIdx = idx
this.convert()
})
})
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.height(36)
特点:
- 选中项:蓝色背景 + 白色文字
- 未选中:灰色背景 + 深灰文字
- 支持横向滚动,适应多单位
7.4 输入框
TextInput({ placeholder: '输入数值', text: this.inputValue })
.height(52)
.width('100%')
.type(InputType.Number)
.backgroundColor('#F5F7FA')
.borderRadius(12)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.placeholderColor('#CCCCCC')
.margin({ top: 12 })
.onChange((val: string) => {
this.inputValue = val
this.convert() // 实时换算
})
关键属性:
- type(InputType.Number):数字键盘
- onChange:每次输入都触发换算,实现实时效果
7.5 交换按钮
Column() {
Button() {
Text('⇅')
.fontSize(24)
}
.width(44).height(44)
.backgroundColor('#EEF3FD')
.borderRadius(22)
.onClick(() => { this.swapUnits() })
}
.padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
交换逻辑:
swapUnits(): void {
const tmp: number = this.fromIdx
this.fromIdx = this.toIdx
this.toIdx = tmp
this.convert()
}
7.6 结果显示
if (this.inputValue.trim().length > 0 && this.resultValue.length > 0) {
Row() {
Text('= ')
.fontSize(28)
.fontColor('#5B8DEF')
.fontWeight(FontWeight.Bold)
Text(this.resultValue)
.fontSize(32)
.fontColor('#333333')
.fontWeight(FontWeight.Bold)
Text(' ' + CATEGORIES[this.activeCategory].units[this.toIdx].label.replace(/^[^\\s]+\\s/, ''))
.fontSize(16)
.fontColor('#888888')
.margin({ left: 4 })
}
.padding({ top: 14, bottom: 8 })
} else {
Text('请输入数值')
.fontSize(16)
.fontColor('#CCCCCC')
.padding({ top: 18, bottom: 18 })
}
单位名处理:
label.replace(/^[^\\s]+\\s/, '') 用于提取单位符号。
例如:"米 (m)" → "m","摄氏度 (°C)" → "(°C)"
八、运行效果

九、踩坑记录
9.1 踩坑一:单位索引越界
问题:在长度类别选了第6个单位(英尺),切换到温度类别时崩溃。
原因:温度只有3个单位,索引5越界。
解决:切换类别时重置索引,并检查边界:
switchCategory(idx: number): void {
this.activeCategory = idx
this.fromIdx = 0
this.toIdx = Math.min(1, CATEGORIES[idx].units.length – 1)
}
9.2 踩坑二:结果精度问题
问题:输入"0.1 + 0.2"类似的运算,结果显示一长串小数。
原因:JavaScript 浮点数精度问题。
解决:使用 toFixed(6) 限制精度,再用 parseFloat 去除多余零:
const fixed: string = val.toFixed(6)
return parseFloat(fixed).toString()
9.3 踩坑三:温度换算错误
问题:温度换算结果不正确。
原因:错误地使用了 toBase 系数法。
解决:温度需要特殊处理,使用 convertFn 函数实现自定义换算逻辑。
9.4 踩坑四:横向滚动不生效
问题:单位选择器无法横向滚动。
原因:外层 Row 没有设置正确的宽度约束。
解决:确保 Scroll 有明确的宽高:
Scroll() {
Row() { … }
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.height(36)
9.5 踩坑五:数字键盘显示错误
问题:输入框弹出的是全键盘而不是数字键盘。
原因:没有指定 type 属性。
解决:
TextInput({ placeholder: '输入数值' })
.type(InputType.Number)
十、优化建议
10.1 功能扩展
10.2 性能优化
10.3 用户体验
十一、总结
11.1 技术要点回顾
| 数据结构 | interface定义、数组遍历 |
| 状态管理 | @State装饰器、状态联动 |
| 算法实现 | 基准单位法、温度特殊换算 |
| UI组件 | ForEach、Scroll、TextInput |
| 交互设计 | Tab切换、横向滚动选择器 |
11.2 项目亮点
- ✅ 完整的单位换算逻辑(通用+特殊)
- ✅ 实时换算,输入即出结果
- ✅ 智能结果格式化
- ✅ 简洁的卡片式UI设计
- ✅ 横向滚动单位选择器
11.3 代码统计
- 核心代码:约 200 行(Index.ets 单文件)
- 数据定义:约 50 行
- 换算逻辑:约 30 行
- UI布局:约 120 行
十二、源码附录
完整数据定义
const CATEGORIES: Category[] = [
{
name: '长度', icon: '📏',
units: [
{ label: '毫米 (mm)', toBase: 0.001 },
{ label: '厘米 (cm)', toBase: 0.01 },
{ label: '米 (m)', toBase: 1 },
{ label: '千米 (km)', toBase: 1000 },
{ label: '英寸 (in)', toBase: 0.0254 },
{ label: '英尺 (ft)', toBase: 0.3048 },
]
},
// … 其他类别
]
核心换算函数
convert(): void {
const num = parseFloat(this.inputValue.trim())
if (isNaN(num)) {
this.resultValue = '—'
return
}
const cat = CATEGORIES[this.activeCategory]
if (cat.convertFn) {
this.resultValue = this.formatResult(cat.convertFn(num, this.fromIdx, this.toIdx))
} else {
const base = num * cat.units[this.fromIdx].toBase
const res = base / cat.units[this.toIdx].toBase
this.resultValue = this.formatResult(res)
}
}





