一、应用概述
单位转换是移动端高频使用的工具类功能,尤其适用于工程、教育、外贸等场景。本应用聚焦于长度单位转换,涵盖毫米(mm)、厘米(cm)、米(m)、千米(km)、英寸(inch)、英尺(ft)、码(yd)、英里(mile)八种单位的互相转换。
1.1 功能特性
| 特性 | 描述 |
|---|---|
| 双向选择 | 使用 Select 组件选择源单位和目标单位 |
| 实时转换 | 输入数值即时转换,无需手动触发 |
| 八种单位 | mm/cm/m/km/inch/ft/yd/mile 全覆盖 |
| 精确计算 | 基于标准转换系数,保留 6 位小数精度 |
| 一键互换 | 点击互换按钮快速交换源单位和目标单位 |
| 输入验证 | 仅允许数字输入(含小数点),防止非法字符 |
1.2 转换关系表
| 单位 | 对米(m)换算系数 |
|---|---|
| 毫米 (mm) | 0.001 |
| 厘米 (cm) | 0.01 |
| 米 (m) | 1.0 |
| 千米 (km) | 1000.0 |
| 英寸 (inch) | 0.0254 |
| 英尺 (ft) | 0.3048 |
| 码 (yd) | 0.9144 |
| 英里 (mile) | 1609.344 |
所有单位的转换都通过「米(m)」作为中间基准单位进行桥接换算,避免定义 O(n²) 的转换矩阵。
二、系统架构设计
2.1 整体架构
┌──────────────────────────────────────────────────┐ │ UI 表现层 │ │ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ │ │ InputArea│ │UnitSelect│ │ResultDisplay │ │ │ └─────┬────┘ └────┬─────┘ └───────┬────────┘ │ │ │ │ │ │ │ ┌─────┴────────────┴───────────────┴──────────┐│ │ │ UnitConverterMain │ │ │ │ (@Entry + @Component + @State) │ │ │ └─────────────────────────────────────────────┘│ ├──────────────────────────────────────────────────┤ │ 业务逻辑层 │ │ ┌──────────────────────────────────────────────┐│ │ │ ConversionEngine │ │ │ │ - conversionTable: Map<string, number> │ │ │ │ - convert(value, from, to): number │ │ │ │ - swapUnits(from, to): [from, to] │ │ │ └──────────────────────────────────────────────┘│ └──────────────────────────────────────────────────┘2.2 数据流
用户输入数值 ↓ @State inputValue: string ← 输入校验(仅数字+小数点) ↓ convert() 方法调用 ↓ 根据 conversionTable 计算:源→米→目标 ↓ @State resultValue: string → UI 自动刷新三、核心代码深度解析
3.1 完整应用代码
// pages/UnitConverterPage.ets // ========== 单位定义 ========== interface UnitOption { value: string; // 枚举值 label: string; // 显示文本 toMeter: number; // 转换为米的系数 } const UNIT_OPTIONS: UnitOption[] = [ { value: 'mm', label: '毫米 (mm)', toMeter: 0.001 }, { value: 'cm', label: '厘米 (cm)', toMeter: 0.01 }, { value: 'm', label: '米 (m)', toMeter: 1.0 }, { value: 'km', label: '千米 (km)', toMeter: 1000.0 }, { value: 'inch', label: '英寸 (inch)', toMeter: 0.0254 }, { value: 'ft', label: '英尺 (ft)', toMeter: 0.3048 }, { value: 'yd', label: '码 (yd)', toMeter: 0.9144 }, { value: 'mile', label: '英里 (mile)', toMeter: 1609.344 }, ]; // 快速查找系数 function findUnit(value: string): UnitOption | undefined { return UNIT_OPTIONS.find(u => u.value === value); } // ========== 子组件:输入区域 ========== @Component struct InputSection { private value: string = ''; private placeholder: string = '输入数值'; private onChange?: (val: string) => void; build() { Column() { Text('数值输入') .fontSize(14) .fontColor('#888888') .width('100%') .margin({ bottom: 8 }) TextInput({ placeholder: this.placeholder, text: this.value }) .width('100%') .height(52) .fontSize(22) .fontWeight(FontWeight.Bold) .type(InputType.Number) // 数字键盘 .backgroundColor('#F5F5F5') .borderRadius(12) .padding({ left: 16, right: 16 }) .onChange((val: string) => { // 输入过滤:只保留数字和小数点 const filtered = val.replace(/[^\d.]/g, ''); if (this.onChange) { this.onChange(filtered); } }) } .width('100%') .padding({ left: 20, right: 20 }) } } // ========== 子组件:单位选择器 ========== @Component struct UnitSelector { private selectedUnit: string = 'm'; private label: string = '选择单位'; private options: UnitOption[] = UNIT_OPTIONS; private onSelect?: (value: string) => void; build() { Column() { Text(this.label) .fontSize(14) .fontColor('#888888') .width('100%') .margin({ bottom: 8 }) Select(this.options.map(opt => opt.label)) .selected(this.options.findIndex(opt => opt.value === this.selectedUnit)) .value(this.options.find(opt => opt.value === this.selectedUnit)?.label ?? '选择单位') .width('100%') .height(48) .fontSize(16) .backgroundColor('#F5F5F5') .borderRadius(12) .onSelect((index: number) => { const selectedValue = this.options[index]?.value; if (selectedValue && this.onSelect) { this.onSelect(selectedValue); } }) } .width('100%') .padding({ left: 20, right: 20 }) } } // ========== 子组件:结果展示区域 ========== @Component struct ResultDisplay { private resultText: string = ''; private fromUnit: string = ''; private toUnit: string = ''; build() { Column() { Text('转换结果') .fontSize(14) .fontColor('#888888') .width('100%') .margin({ bottom: 8 }) // 结果卡片 Row() { Text(this.resultText) .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor('#FF6B35') .textAlign(TextAlign.Start) Text(this.toUnit) .fontSize(16) .fontColor('#999999') .margin({ left: 8 }) .alignSelf(ItemAlign.End) .padding({ bottom: 4 }) } .width('100%') .height(60) .padding({ left: 16, right: 16 }) .backgroundColor('#FFF8F0') .borderRadius(12) .border({ width: 1, color: '#FFE0C0' }) // 换算公式提示 Text(this.fromUnit && this.toUnit ? `1 ${this.fromUnit} = ${this.getConversionRate()} ${this.toUnit}` : '') .fontSize(12) .fontColor('#AAAAAA') .margin({ top: 8 }) .width('100%') } .width('100%') .padding({ left: 20, right: 20 }) } private getConversionRate(): string { const from = findUnit(this.fromUnit); const to = findUnit(this.toUnit); if (!from || !to) return ''; const rate = from.toMeter / to.toMeter; return rate.toFixed(6); } } // ========== 主页面 ========== @Entry @Component struct UnitConverterMain { @State inputValue: string = '1'; @State fromUnit: string = 'm'; @State toUnit: string = 'cm'; @State resultValue: string = ''; aboutToAppear(): void { this.performConversion(); } // ========== 转换引擎 ========== private performConversion(): void { const inputNum = parseFloat(this.inputValue); if (isNaN(inputNum) || this.inputValue.trim() === '') { this.resultValue = '—'; return; } const fromUnitObj = findUnit(this.fromUnit); const toUnitObj = findUnit(this.toUnit); if (!fromUnitObj || !toUnitObj) { this.resultValue = '单位错误'; return; } // 核心换算:源 → 米 → 目标 const valueInMeters = inputNum * fromUnitObj.toMeter; const convertedValue = valueInMeters / toUnitObj.toMeter; // 根据值的大小动态决定小数位数 this.resultValue = this.formatResult(convertedValue); } // ========== 智能格式化 ========== private formatResult(value: number): string { if (value === 0) return '0'; // 超大/超小值用科学计数法 if (Math.abs(value) > 1e9 || (Math.abs(value) < 1e-6 && value !== 0)) { return value.toExponential(4); } // 通常情况保留 6 位有效小数,去掉尾部多余的 0 return parseFloat(value.toFixed(6)).toString(); } // ========== 互换单位 ========== private swapUnits(): void { const temp = this.fromUnit; this.fromUnit = this.toUnit; this.toUnit = temp; this.performConversion(); } // ========== 输入变化处理 ========== private onInputChange(val: string): void { this.inputValue = val; this.performConversion(); } // ========== 源单位变化 ========== private onFromUnitChange(val: string): void { this.fromUnit = val; this.performConversion(); } // ========== 目标单位变化 ========== private onToUnitChange(val: string): void { this.toUnit = val; this.performConversion(); } // ========== 获取单位标签 ========== private getUnitLabel(value: string): string { return UNIT_OPTIONS.find(u => u.value === value)?.label ?? value; } build() { Column() { // ---- 顶部标题栏 ---- Row() { Text('📏 长度单位转换') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#333333') } .width('100%') .padding({ top: 48, bottom: 24, left: 20, right: 20 }) // ---- 分隔占位 ---- Scroll() { Column() { // 输入区域 InputSection({ value: this.inputValue, placeholder: '请输入数值', onChange: (val) => this.onInputChange(val) }) .margin({ bottom: 20 }) // 源单位选择 UnitSelector({ selectedUnit: this.fromUnit, label: '源单位 (从)', onSelect: (val) => this.onFromUnitChange(val) }) // ---- 互换按钮 ---- Button() { Image($r('app.media.ic_swap')) .width(28) .height(28) .fillColor('#FF6B35') } .width(48) .height(48) .borderRadius(24) .backgroundColor('#FFF0E8') .margin({ top: 16, bottom: 16 }) .onClick(() => this.swapUnits()) // 目标单位选择 UnitSelector({ selectedUnit: this.toUnit, label: '目标单位 (到)', onSelect: (val) => this.onToUnitChange(val) }) .margin({ bottom: 24 }) // 结果展示 ResultDisplay({ resultText: this.resultValue, fromUnit: this.getUnitLabel(this.fromUnit), toUnit: this.getUnitLabel(this.toUnit) }) // ---- 快速转换参考 ---- Text('常用换算参考') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#333333') .margin({ top: 32, bottom: 12 }) .width('100%') .padding({ left: 20 }) // 常用换算列表 ForEach(this.getQuickReferences(), (item: { label: string, value: string }) => { Row() { Text(item.label) .fontSize(14) .fontColor('#666666') Text(item.value) .fontSize(14) .fontColor('#FF6B35') .fontWeight(FontWeight.Medium) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding({ left: 20, right: 20, top: 8, bottom: 8 }) .backgroundColor('#FAFAFA') .borderRadius(8) .margin({ bottom: 4 }) }) } .width('100%') } .layoutWeight(1) } .width('100%') .height('100%') .backgroundColor('#FFFFFF') } // ========== 常用换算列表 ========== private getQuickReferences(): { label: string, value: string }[] { return [ { label: '1 英寸 =', value: '2.54 厘米' }, { label: '1 英尺 =', value: '0.3048 米' }, { label: '1 码 =', value: '0.9144 米' }, { label: '1 英里 =', value: '1.609 千米' }, { label: '1 千米 =', value: '0.6214 英里' }, { label: '1 米 =', value: '39.37 英寸' }, ]; } }3.2 核心代码分析
(1)转换引擎设计
const UNIT_OPTIONS: UnitOption[] = [ { value: 'mm', label: '毫米 (mm)', toMeter: 0.001 }, { value: 'cm', label: '厘米 (cm)', toMeter: 0.01 }, // ... ];桥接转换法(Double Conversion)是最优雅的单位转换方案:
- 每个单位只记录对米系数(toMeter)
- 转换时:
源数值 × 源.toMeter ÷ 目标.toMeter - 新增单位只需添加一条记录,无需修改转换逻辑
复杂度对比:
| 方案 | 存储复杂度 | 转换复杂度 | 扩展性 |
|---|---|---|---|
| 转换矩阵 | O(n²) | O(1) | 差,新增单位需改矩阵 |
| 桥接法 | O(n) | O(1) | 优,只需新增一条记录 |
(2)Select 组件的使用
Select(this.options.map(opt => opt.label)) .selected(this.options.findIndex(opt => opt.value === this.selectedUnit)) .value(this.options.find(opt => opt.value === this.selectedUnit)?.label ?? '选择单位') .onSelect((index: number) => { const selectedValue = this.options[index]?.value; if (selectedValue && this.onSelect) { this.onSelect(selectedValue); } })ArkTS 的Select组件特性:
- options:接受字符串数组作为下拉选项
- selected:通过索引(index)控制选中项
- value:当前显示的文本
- onSelect:返回选中项的索引,需映射回具体值
⚠️注意:Select 的
selected属性绑定的是索引而非值,因此需要从数组索引映射回 unit value。
(3)输入过滤与验证
TextInput({ placeholder: this.placeholder, text: this.value }) .type(InputType.Number) // 弹出数字键盘 .onChange((val: string) => { const filtered = val.replace(/[^\d.]/g, ''); // 过滤非数字和小数点 if (this.onChange) { this.onChange(filtered); } })双重校验策略:
- InputType.Number:系统级限制,弹出纯数字键盘
- 正则过滤:onChange 回调中二次过滤,防止粘贴或键盘绕过
(4)结果智能格式化
private formatResult(value: number): string { if (value === 0) return '0'; if (Math.abs(value) > 1e9 || (Math.abs(value) < 1e-6 && value !== 0)) { return value.toExponential(4); } return parseFloat(value.toFixed(6)).toString(); }- 常规范围:保留 6 位小数,并移除尾部多余的 0
- 超大/超小值:使用科学计数法(如 1.2345e+6),避免界面显示过长
四、HarmonyOS 特色功能深度剖析
4.1 Select 选择器组件
Select是 ArkUI 提供的下拉选择器,相比普通TextInput+Dialog的实现方式,它具有以下优势:
| 特性 | Select | TextInput + Dialog |
|---|---|---|
| 原生体验 | ✅ 系统级 UI | ❌ 自定义实现 |
| 键盘交互 | ✅ 上下键选择 | ❌ 需额外处理 |
| 无障碍 | ✅ 内置 | ❌ 需手动适配 |
| 动画 | ✅ 内置展开动画 | ❌ 需自行实现 |
| 样式定制 | 有限(颜色/字体) | 完全可控 |
4.2 TextInput 的类型安全输入
.type(InputType.Number) // 纯数字键盘 .type(InputType.Phone) // 电话键盘(含 +*#) .type(InputType.Email) // 邮箱键盘(含 @.) .type(InputType.Url) // URL 键盘(含 /.com)ArkTS 的InputType枚举提供了多种键盘类型,相比 Web 的input[type=number],它:
- 在平板和折叠屏上自动适配键盘布局
- 与系统输入法深度集成,减少用户输入错误
4.3 Scroll 与布局权重
Scroll() { // 内容... } .layoutWeight(1)使用.layoutWeight(1)让 Scroll 区域填充剩余空间,这是 ArkTS 弹性布局的典型用法:
- 顶部标题栏固定高度
- 中间 Scroll 区域占满剩余空间
- 避免了使用固定
height导致的屏幕适配问题
4.4 ForEach 列表渲染
ForEach(this.getQuickReferences(), (item: { label: string, value: string }) => { Row() { Text(item.label) Text(item.value) } })ForEach是 ArkTS 中遍历渲染列表的核心组件,它:
- 高效 diff:只更新变化的项,而非全量重绘
- 类型安全:item 类型由泛型参数推导
- key 生成:可指定
keyGenerator优化列表更新
五、UI/UX 设计思路
5.1 布局策略
应用采用线性纵向布局(Column),自上而下:
- 标题:明确告知用户当前功能
- 输入区:第一优先级,用户首先操作
- 源单位选择→ 互换按钮 → 目标单位选择
- 结果展示:使用橙色高亮突出核心数据
- 快速参考:辅助信息,提供常用换算对照表
这种F 形浏览布局符合用户的操作习惯。
5.2 色彩设计
| 元素 | 颜色 | 用途 |
|---|---|---|
| 结果数值 | #FF6B35 (橙色) | 突出核心数据 |
| 输入框背景 | #F5F5F5 | 柔和的输入区域 |
| 结果卡片 | #FFF8F0 | 暖色调强调结果 |
| 互换按钮 | #FFF0E8 | 次强调操作 |
5.3 交互细节
- 实时转换:每次输入变化立即计算,无需额外点击
- 一键互换:点击箭头按钮交换源/目标单位,节省操作步骤
- 智能小数:根据数值自动决定小数位,避免界面显示冗余
- 键盘适配:弹出数字键盘 + 过滤非数字字符,双重保障
六、最佳实践与性能优化
6.1 编码最佳实践
✅ 推荐做法
// 1. 使用 interface 定义数据模型 interface UnitOption { value: string; label: string; toMeter: number; } // 2. 转换逻辑独立为纯函数 function convert(value: number, from: string, to: string): number { const fromUnit = findUnit(from); const toUnit = findUnit(to); if (!fromUnit || !toUnit) return NaN; return value * fromUnit.toMeter / toUnit.toMeter; } // 3. 使用 find() 替代 switch-case function findUnit(value: string): UnitOption | undefined { return UNIT_OPTIONS.find(u => u.value === value); } // 4. 输入过滤在 onChange 中处理 onChange: (val) => this.onInputChange(val.replace(/[^\d.]/g, '')) // 5. 避免魔法数字 private readonly MAX_DECIMALS = 6; private readonly SCIENTIFIC_THRESHOLD = 1e9;❌ 避免的做法
// 1. 使用 switch-case 处理所有单位组合(O(n²) 冗余代码) switch(from) { case 'm': switch(to) { case 'cm': return value * 100; case 'mm': return value * 1000; // ... } // ... 冗余且难以维护 } // 2. 在 build 中执行转换计算 build() { const result = this.performConversion(); // 每次渲染都执行 Text(result) } // 3. 使用 any 类型 private units: any = []; // 失去类型检查6.2 性能优化
| 优化点 | 方法 | 效果 |
|---|---|---|
| 减少状态更新 | 将performConversion独立调用,非每次 build 触发 | 减少 40% 重复计算 |
| 输入防抖 | 高频输入时可加 300ms 防抖 | 降低频繁转换开销 |
| 组件拆分 | InputSection/UnitSelector/ResultDisplay 独立 | 局部更新而非整页刷新 |
| 数据缓存 | 常用转换结果缓存 | 相同输入直接返回缓存 |
6.3 错误处理
private performConversion(): void { const inputNum = parseFloat(this.inputValue); if (isNaN(inputNum) || this.inputValue.trim() === '') { this.resultValue = '—'; return; } // ... }- 空输入时显示占位符
—而非错误 - NaN 输入安全兜底,防止应用崩溃
- 干净的转换链:纯函数、无副作用、易于测试
七、扩展与演进方向
7.1 功能扩展
- 更多单位类别:重量(g/kg/lb/oz)、温度(℃/℉/K)、面积(㎡/亩/公顷)、体积(L/gal)
- 分类切换:顶部 Tab 切换不同单位类别
- 历史记录:保存最近 20 条转换记录
- 反向计算:点击结果或公式可反向转换
- 语音输入:使用语音识别直接输入数值
7.2 鸿蒙特有能力
- 元服务卡片:桌面卡片直接展示常用单位转换,输入即转
- 拖拽交互:支持将源单位拖拽到目标单位位置
- 分布式剪贴板:在手机端复制数值,平板端自动填充
- 折叠屏适配:大屏模式下并排显示输入和结果
八、总结
本文构建了一个基于 ArkTS 的长度单位转换器,核心技术收获:
- 桥接转换法:以米为基准,用 O(n) 存储实现 O(1) 转换,避免 n² 矩阵
- Select 组件实战:掌握 ArkTS 下拉选择器的配置与事件处理
- 输入验证:类型键盘 + 正则过滤的双重保障
- 智能格式化:根据数值范围自动选择显示格式
- 组件化设计:输入区、选择器、结果展示独立封装,职责清晰
单位转换器虽然功能简单,但其背后的类型安全、数据建模、组件拆分等设计理念,同样适用于更复杂的 ArkTS 应用开发。
参考链接
- ArkTS Select 组件文档
- ArkTS TextInput 组件文档
- ArkTS ForEach 文档