HarmonyOS ArkTS 实战:实现一个全能单位换算应用

项目效果

本文实现的是一个基于 HarmonyOS 和 ArkTS 的全能单位换算应用。项目中使用 ArkUI 组件完成页面布局,通过 @State 管理状态数据,实现长度、重量、温度、面积、体积等多种单位换算,实时计算结果,分类切换和双向输入等功能。

最终运行效果如下:
在这里插入图片描述

页面主要包含以下内容:

  • 顶部应用标题;
  • 单位分类标签页;
  • 输入数值区域;
  • 源单位选择;
  • 目标单位选择;
  • 实时换算结果;
  • 交换单位按钮;
  • 常用单位快捷选择;
  • 页面整体采用 ArkUI 声明式布局。

本文重点是演示如何在 HarmonyOS 项目中使用 ArkTS 和 ArkUI 实现一个实用工具类单页面应用。项目代码主要写在 entry/src/main/ets/pages/Index.ets 文件中,适合作为 HarmonyOS ArkTS 入门到进阶之间的练习案例。

前言

在日常生活和工作中,我们经常需要进行各种单位换算,比如长度、重量、温度、货币等。手动计算既麻烦又容易出错,一个好用的单位换算工具可以快速准确地完成各种换算,提高效率。

从应用开发角度来看,这个项目不依赖后端接口,也不需要数据库,但能练习 ArkTS 中的状态管理、标签页切换、输入框绑定、实时计算、下拉选择和数学运算等内容。

本文基于 HarmonyOS 和 ArkTS 实现一个全能单位换算应用。用户可以选择不同的单位分类,输入数值,选择源单位和目标单位,实时看到换算结果,还可以快速交换单位。

这个项目的核心不是简单的数学计算,而是灵活的单位系统和良好的用户体验。每一次分类切换、数值输入和单位选择,本质上都是对换算状态的管理。状态变化后,页面会自动刷新,这正是 ArkUI 声明式开发的基本思想。

一、项目目标

本次实践主要实现以下目标:

  • 创建 HarmonyOS ArkTS 页面;
  • 使用 @Entry@Component 定义页面组件;
  • 使用 @State 管理页面状态;
  • 使用标签页切换不同单位分类;
  • 支持长度、重量、温度、面积、体积五种分类;
  • 实现实时换算,输入即出结果;
  • 支持源单位和目标单位选择;
  • 一键交换单位;
  • 数字键盘友好的输入;
  • 常用单位快捷显示;
  • 使用 @Builder 封装分类标签和单位选择器;
  • 完成一个可以运行的单位换算页面。

这个项目虽然是单页面应用,但它支持多种单位类型,有完整的输入、选择、换算和交换功能,比普通静态页面更适合练习 ArkTS。

二、技术栈

类型 内容
开发方向 HarmonyOS 应用开发
开发语言 ArkTS
UI 框架 ArkUI
SDK 版本 HarmonyOS API 23 及以上
工程模型 Stage 模型
核心组件 Text / TextInput / Button / Tabs / TabContent / Column / Row / Select
状态管理 @State
数据处理 单位换算 / 数学计算 / 实时响应
项目入口 entry/src/main/ets/pages/Index.ets
运行平台 模拟器或真机

本项目是 HarmonyOS 原生 ArkTS 项目。页面主体由 ArkUI 组件构建,核心逻辑写在 Index.ets 文件中,不依赖后端接口,也不需要额外配置数据库。

三、为什么选择单位换算项目

全能单位换算应用适合作为 ArkTS 练习项目,主要有以下几个原因。

第一,实用性强。单位换算是非常高频的需求,无论是学习、工作还是生活都会用到。

第二,业务场景真实。烹饪、购物、旅行、学习等场景都需要进行单位换算,是非常实用的工具。

第三,适合练习状态管理。当前分类、输入值、源单位、目标单位都属于页面状态。状态变化后,页面会自动刷新。

第四,适合练习标签页组件。Tabs组件是常用的导航组件,掌握它可以应用到很多应用中。

第五,适合练习实时计算。输入变化时立即计算结果,提供流畅的用户体验。

第六,扩展空间比较大。基础功能完成后,可以继续增加货币换算(实时汇率)、时间换算、速度换算、数据存储换算和自定义单位。

在本项目中,单位配置和换算状态是核心数据。页面中的结果显示和单位选择都由它们计算或渲染得到。

四、功能规则说明

支持的单位分类:

分类 包含单位
长度 毫米、厘米、米、千米、英寸、英尺、英里
重量 毫克、克、千克、吨、盎司、磅、斤
温度 摄氏度、华氏度、开尔文
面积 平方毫米、平方厘米、平方米、平方千米、公顷、亩、平方英尺
体积 毫升、升、立方米、立方厘米、加仑、盎司

换算规则:

  • 以一个基准单位为中介进行换算
  • 温度使用特殊公式换算
  • 输入数值变化时立即重新计算
  • 支持小数输入
  • 结果保留4位小数

交换功能:点击交换按钮可以快速交换源单位和目标单位,同时重新计算结果。

五、项目结构

本项目主要修改首页文件:

entry
└── src
    └── main
        └── ets
            └── pages
                └── Index.ets

其中:

文件 作用
Index.ets 编写页面结构、状态数据和单位换算逻辑

本文不涉及复杂路由,也不需要额外创建多个页面。对于练习项目来说,把主要逻辑集中在一个 Index.ets 文件中更方便理解。

如果后续继续扩展,可以考虑把换算卡片、单位选择器和分类标签拆分成独立组件。

六、核心实现思路

本项目的核心流程如下:

  1. 定义各单位到基准单位的换算系数;
  2. 使用 @State 保存当前分类、输入值、源单位索引、目标单位索引;
  3. 用户切换分类标签;
  4. 用户输入数值;
  5. 用户选择源单位和目标单位;
  6. 将输入值从源单位换算到基准单位;
  7. 再从基准单位换算到目标单位;
  8. 实时显示换算结果;
  9. 点击交换按钮交换源单位和目标单位;
  10. 温度使用专门的换算公式。

项目中最重要的状态变量如下:

@State currentCategory: number = 0
@State inputValue: string = '1'
@State fromUnit: number = 2
@State toUnit: number = 1

其中:

状态变量 作用
currentCategory 当前选中的分类索引
inputValue 输入的数值
fromUnit 源单位索引
toUnit 目标单位索引

单位换算系数是本项目中最重要的数据,所有换算都基于这些系数进行。

七、Index.ets 完整代码

打开文件:

entry/src/main/ets/pages/Index.ets

将其中内容替换为下面代码:

@Entry
@Component
struct Index {
  @State currentCategory: number = 0
  @State inputValue: string = '1'
  @State fromUnit: number = 2
  @State toUnit: number = 1

  private categories: string[] = ['长度', '重量', '温度', '面积', '体积']
  
  private units: string[][] = [
    ['毫米', '厘米', '米', '千米', '英寸', '英尺', '英里'],
    ['毫克', '克', '千克', '吨', '盎司', '磅', '斤'],
    ['摄氏度', '华氏度', '开尔文'],
    ['平方毫米', '平方厘米', '平方米', '平方千米', '公顷', '亩', '平方英尺'],
    ['毫升', '升', '立方米', '立方厘米', '加仑', '液量盎司']
  ]

  private toBase: number[][] = [
    [0.001, 0.01, 1, 1000, 0.0254, 0.3048, 1609.344],
    [0.000001, 0.001, 1, 1000, 0.0283495, 0.453592, 0.5],
    [0, 0, 0],
    [0.000001, 0.0001, 1, 1000000, 10000, 666.667, 0.092903],
    [0.001, 1, 1000, 0.001, 3.78541, 0.0295735]
  ]

  private convert(value: number, from: number, to: number, category: number): number {
    if (category === 2) {
      return this.convertTemperature(value, from, to)
    }
    
    let baseValue = value * this.toBase[category][from]
    return baseValue / this.toBase[category][to]
  }

  private convertTemperature(value: number, from: number, to: number): number {
    let celsius: number
    if (from === 0) celsius = value
    else if (from === 1) celsius = (value - 32) * 5 / 9
    else celsius = value - 273.15

    if (to === 0) return celsius
    else if (to === 1) return celsius * 9 / 5 + 32
    else return celsius + 273.15
  }

  private getResult(): string {
    let num = parseFloat(this.inputValue)
    if (isNaN(num)) return '0'
    
    let result = this.convert(num, this.fromUnit, this.toUnit, this.currentCategory)
    return result.toFixed(4).replace(/\.?0+$/, '')
  }

  private swapUnits(): void {
    let temp = this.fromUnit
    this.fromUnit = this.toUnit
    this.toUnit = temp
  }

  private onCategoryChange(index: number): void {
    this.currentCategory = index
    this.fromUnit = 0
    this.toUnit = 1
  }

  @Builder
  UnitSelector(selected: number, onChange: (index: number) => void) {
    Select([...this.units[this.currentCategory].map((u, i) => ({ value: u, id: i }))])
      .selected(selected)
      .value(this.units[this.currentCategory][selected])
      .font({ size: 16, weight: FontWeight.Medium })
      .fontColor('#182431')
      .selectedOptionFont({ size: 16, weight: FontWeight.Medium })
      .optionFont({ size: 16 })
      .backgroundColor('#F5F7FA')
      .height(50)
      .borderRadius(12)
      .padding({ left: 16, right: 16 })
      .onSelect((index: number) => {
        onChange(index)
      })
  }

  @Builder
  QuickUnitButton(text: string, unitIndex: number, isFrom: boolean) {
    let selected = isFrom ? this.fromUnit : this.toUnit
    Button(text)
      .height(36)
      .padding({ left: 12, right: 12 })
      .fontSize(13)
      .fontColor(selected === unitIndex ? Color.White : '#4B5563')
      .backgroundColor(selected === unitIndex ? '#0A59F7' : '#F3F4F6')
      .borderRadius(18)
      .onClick(() => {
        if (isFrom) {
          this.fromUnit = unitIndex
        } else {
          this.toUnit = unitIndex
        }
      })
  }

  build() {
    Column() {
      Text('单位换算')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .margin({ top: 22 })
      Text('基于 HarmonyOS ArkTS 的全能换算工具')
        .fontSize(14)
        .fontColor('#6B7280')
        .margin({ top: 8, bottom: 20 })

      Tabs({ barPosition: BarPosition.Start }) {
        ForEach(this.categories, (category: string, index: number) => {
          TabContent() {
            Column() {
              Column() {
                Text('输入数值')
                  .fontSize(14)
                  .fontColor('#6B7280')
                  .width('100%')
                  .margin({ bottom: 8 })
                
                TextInput({
                  text: this.inputValue,
                  placeholder: '请输入数值'
                })
                  .height(56)
                  .fontSize(24)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#182431')
                  .backgroundColor('#F5F7FA')
                  .borderRadius(12)
                  .padding({ left: 16, right: 16 })
                  .type(InputType.Number)
                  .onChange((value: string) => {
                    this.inputValue = value
                  })
              }
              .width('100%')
              .padding(16)
              .backgroundColor(Color.White)
              .borderRadius(16)
              .margin({ bottom: 16 })
              .shadow({
                radius: 8,
                color: '#08000000',
                offsetX: 0,
                offsetY: 2
              })

              Column() {
                Text('从')
                  .fontSize(14)
                  .fontColor('#6B7280')
                  .width('100%')
                  .margin({ bottom: 8 })
                this.UnitSelector(this.fromUnit, (i: number) => { this.fromUnit = i })

                Row() {
                  this.QuickUnitButton(this.units[this.currentCategory][0], 0, true)
                  Blank().width(8)
                  this.QuickUnitButton(this.units[this.currentCategory][1], 1, true)
                  Blank().width(8)
                  this.QuickUnitButton(this.units[this.currentCategory][2], 2, true)
                }
                .width('100%')
                .margin({ top: 12 })
              }
              .width('100%')
              .padding(16)
              .backgroundColor(Color.White)
              .borderRadius(16)
              .margin({ bottom: 16 })
              .shadow({
                radius: 8,
                color: '#08000000',
                offsetX: 0,
                offsetY: 2
              })

              Button('⇅ 交换单位')
                .width('100%')
                .height(44)
                .fontSize(15)
                .fontColor('#0A59F7')
                .backgroundColor('#EFF6FF')
                .borderRadius(22)
                .margin({ bottom: 16 })
                .onClick(() => {
                  this.swapUnits()
                })

              Column() {
                Text('到')
                  .fontSize(14)
                  .fontColor('#6B7280')
                  .width('100%')
                  .margin({ bottom: 8 })
                this.UnitSelector(this.toUnit, (i: number) => { this.toUnit = i })

                Row() {
                  this.QuickUnitButton(this.units[this.currentCategory][0], 0, false)
                  Blank().width(8)
                  this.QuickUnitButton(this.units[this.currentCategory][1], 1, false)
                  Blank().width(8)
                  this.QuickUnitButton(this.units[this.currentCategory][2], 2, false)
                }
                .width('100%')
                .margin({ top: 12 })
              }
              .width('100%')
              .padding(16)
              .backgroundColor(Color.White)
              .borderRadius(16)
              .margin({ bottom: 16 })
              .shadow({
                radius: 8,
                color: '#08000000',
                offsetX: 0,
                offsetY: 2
              })

              Column() {
                Text('换算结果')
                  .fontSize(14)
                  .fontColor('#6B7280')
                  .width('100%')
                  .margin({ bottom: 8 })
                
                Row() {
                  Text(this.getResult())
                    .fontSize(36)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#0A59F7')
                    .layoutWeight(1)
                  Text(this.units[this.currentCategory][this.toUnit])
                    .fontSize(18)
                    .fontColor('#6B7280')
                    .margin({ top: 12 })
                }
                .width('100%')
                .alignItems(VerticalAlign.Bottom)
              }
              .width('100%')
              .padding(20)
              .backgroundColor(Color.White)
              .borderRadius(16)
              .shadow({
                radius: 8,
                color: '#08000000',
                offsetX: 0,
                offsetY: 2
              })
            }
            .width('100%')
            .height('100%')
          }
          .tabBar(this.categories[index])
        }, (c: string) => c)
      }
      .barMode(BarMode.Fixed)
      .barWidth('100%')
      .barHeight(50)
      .animationDuration(300)
      .onChange((index: number) => {
        this.onCategoryChange(index)
      })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#F5F7FA')
  }
}

八、代码实现说明

1. 单位换算系统

使用基准单位法进行换算:先将源单位换算到基准单位,再换算到目标单位:

let baseValue = value * this.toBase[category][from]
return baseValue / this.toBase[category][to]

温度使用专门的换算公式,因为温度不是线性比例换算。

2. 标签页切换

使用Tabs组件实现分类切换,切换分类时重置单位选择:

private onCategoryChange(index: number): void {
  this.currentCategory = index
  this.fromUnit = 0
  this.toUnit = 1
}
3. 实时换算

输入值变化时自动重新计算结果,不需要点击按钮:

private getResult(): string {
  let num = parseFloat(this.inputValue)
  if (isNaN(num)) return '0'
  let result = this.convert(num, this.fromUnit, this.toUnit, this.currentCategory)
  return result.toFixed(4).replace(/\.?0+$/, '')
}

结果自动去除末尾多余的零,显示更美观。

4. 单位选择器

使用Select组件实现单位下拉选择,同时提供常用单位的快捷按钮,方便快速选择。

5. 交换单位

一键交换源单位和目标单位,方便反向换算:

private swapUnits(): void {
  let temp = this.fromUnit
  this.fromUnit = this.toUnit
  this.toUnit = temp
}

九、运行项目

代码编写完成后,在DevEco Studio中运行项目。测试不同分类的单位换算,检查结果是否准确,测试交换单位功能,确保实时计算正常工作。

十、开发中遇到的问题

  • 温度换算特殊处理,不能使用简单的乘法系数
  • 结果格式化,去除末尾多余的零
  • 分类切换时重置单位选择,避免索引越界
  • 数字输入限制,只允许输入数字和小数点

十一、总结

本文基于HarmonyOS和ArkTS实现了一个全能单位换算应用。项目通过@State管理换算状态,使用Tabs组件实现分类切换,实现了多种单位的实时换算、单位选择和交换功能。这个项目展示了ArkTS中表单输入、实时计算和组件使用的基本方法,适合作为入门练习项目。

Logo

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

更多推荐