一、前言:为什么选择计算器作为入门项目?

在上一篇文章中,我们已经完成了开发环境的配置。环境搭好了,接下来最重要的就是找一个合适的练手项目

为什么我强烈推荐计算器作为鸿蒙开发的第一个实战项目?

1.1 计算器项目的独特价值

计算器虽小,但五脏俱全。它涵盖了移动应用开发的核心知识点:

UI布局:Column、Row、ForEach 等 ArkUI 核心组件
事件处理:按钮点击事件、状态管理
逻辑处理:运算逻辑、错误处理、边界情况
数据绑定:@State 装饰器的实战应用
样式设计:颜色、字体、圆角、间距等视觉要素

更重要的是,计算器的需求明确、逻辑清晰、容易验证。你不需要纠结"我要做什么",只需要专注于"怎么用 ArkUI 实现它"。

1.2 学习目标

通过本项目的开发,你将掌握:

  1. ArkUI 声明式开发范式:如何用代码描述 UI 界面
  2. 状态管理:@State 装饰器的正确使用场景
  3. 事件处理:按钮点击、文本输入等交互逻辑
  4. 列表渲染:ForEach 在网格布局中的应用
  5. 条件渲染:如何根据状态动态显示/隐藏 UI 元素

二、项目准备与创建

2.1 开发环境检查

在开始之前,请确保你的环境已经正确配置:

检查项 要求 检查方法
DevEco Studio 6.0 或更高版本 Help → About DevEco Studio
HarmonyOS SDK API 23 Tools → SDK Manager
模拟器或真机 已配置并可运行 Device Manager 中查看
系统环境 Windows 10/11 或 macOS 无特殊要求

如果环境还没搭好,请参考我的上一篇文章《HarmonyOS 原生应用开发环境搭建指南》。

2.2 创建新项目

打开 DevEco Studio,按照以下步步骤创建项目:

步骤1:选择项目模板
  1. 启动 DevEco Studio
  2. 点击 File → New → Create Project
  3. 在模板选择界面,选择 Empty Ability
  4. 点击 Next

在这里插入图片描述

步骤2:配置项目信息

在项目配置页面,填写以下信息:

配置项 填写值 说明
Project Name Calculator 项目名称,全英文
Bundle Name com.example.calculator 包名,反向域名格式
Save Location E:\Reasonix\Calculator 项目保存路径,不要包含中文或空格
Compile SDK 23 根据已安装的 SDK 选择
Model Stage 推荐使用 Stage 模型
Language ArkTS 鸿蒙原生开发语言
Device Type Phone 选择手机设备

填写完成后,点击 Finish 完成项目创建。

步骤3:等待项目同步

项目创建后,DevEco Studio 会自动开始Gradle 同步依赖下载。这个过程可能需要几分钟时间,取决于你的网络速度。

你可以在 IDE 右下角的 Build 窗口中查看同步进度。

同步完成后,你会看到完整的项目结构。

在这里插入图片描述

三、项目结构深度解析

在我们开始编写代码之前,必须深入理解鸿蒙应用的项目结构。很多初学者直接跳过这一步,后面遇到问题时就懵了。

3.1 整体目录结构

让我们先看一下 Reasonix 计算器的完整目录结构:

Calculator/
├── .idea/                          # IDE 配置文件
├── AppScope/                       # 应用级配置
│   └── app.json5                   # 应用全局配置(包名、版本、设备等)
├── entry/                          # 主模块(应用入口)
│   ├── src/main/
│   │   ├── ets/                   # ArkTS 源代码目录
│   │   │   ├── entryability/      # Ability 生命周期管理
│   │   │   │   └── EntryAbility.ts
│   │   │   └── pages/            # 页面文件
│   │   │       └── Index.ets     # 主页面(我们主要修改的文件)
│   │   ├── module.json5           # 模块配置(Ability、权限等)
│   │   └── resources/             # 资源文件
│   │       ├── base/              # 基础资源
│   │       │   ├── element/       # 字符串、颜色等元素
│   │       │   └── profile/       # 页面路由配置
│   │       └── rawfile/           # 原始文件(如音频、视频)
│   └── oh-package.json5           # 模块依赖配置
├── build-profile.json5             # 构建配置(SDK 版本、签名等)
├── hvigorfile.ts                   # Hvigor 构建脚本
└── oh-package.json5               # 项目依赖配置

3.2 核心配置文件详解

3.2.1 app.json5 - 应用全局配置
{
  app: {
    bundleName: 'com.example.calculator',
    vendor: 'example',
    versionCode: 1000000,
    versionName: '1.0.0',
    icon: '$media:app_icon',
    label: '$string:app_name',
    minAPIVersion: 23,
    targetAPIVersion: 23,
    apiReleaseType: 'Release',
    debug: true,
    // 允许在手机和平板上运行
    supportedDevices: [
      'phone',
      'tablet'
    ]
  }
}

关键配置项说明

  • bundleName:应用的唯一标识符,必须全局唯一
  • minAPIVersion:最低支持的 API 版本
  • targetAPIVersion:目标 API 版本
  • supportedDevices:支持的设备类型
3.2.2 build-profile.json5 - 构建配置
{
  app: {
    signingConfigs: [],
    compileSdkVersion: 23,
    compatibleSdkVersion: 23,
    products: [
      {
        name: 'default',
        signingConfig: 'default',
      }
    ]
  },
  modules: [
    {
      name: 'entry',
      srcPath: './entry',
    }
  ]
}

关键配置项说明

  • compileSdkVersion:编译时使用的 SDK 版本
  • compatibleSdkVersion:兼容的最低 SDK 版本
  • products:产品配置,用于多设备适配
3.2.3 module.json5 - 模块配置
{
  module: {
    name: 'entry',
    type: 'entry',
    description: '$string:module_desc',
    mainElement: 'EntryAbility',
    deviceTypes: [
      'phone',
      'tablet'
    ],
    deliveryWithInstall: true,
    installationFree: false,
    pages: '$profile:main_pages',
    abilities: [
      {
        name: 'EntryAbility',
        srcEntry: './ets/entryability/EntryAbility.ts',
        description: '$string:EntryAbility_desc',
        icon: '$media:icon',
        label: '$string:app_name',
        startWindowIcon: '$media:icon',
        startWindowBackground: '#000000',
        visible: true,
        skills: [
          {
            entities: [
              'entity.system.home'
            ],
            actions: [
              'action.system.home'
            ]
          }
        ]
      }
    ]
  }
}

关键配置项说明

  • type: 'entry':表示这是一个入口模块(用户可启动的应用)
  • mainElement:指定应用的主 Ability
  • abilities:声明应用的 Ability(类似 Android 的 Activity)

3.3 为什么只修改 Index.ets?

很多初学者会问:“这么多文件,我需要修改哪些?”

答案:对于计算器这种简单应用,我们只需要修改一个文件——entry/src/main/ets/pages/Index.ets

原因如下:

  1. ArkUI 的声明式开发范式:UI 和逻辑都写在一个文件中
  2. 单页面应用:计算器只有一个页面,不需要多页面路由
  3. 无需额外权限:计算器不需要网络、存储等敏感权限
  4. 无需额外配置:默认的 Ability 配置已经足够

这也是为什么我推荐使用 Empty Ability 模板——它提供了一个最干净的起点,没有任何多余的代码和配置。


四、核心代码实现——从零开始编写计算器

现在,让我们开始编写计算器的核心代码。我会逐段解释每一部分的作用和设计思路

4.1 创建基础 UI 结构

打开 Index.ets删除所有默认代码,从一个空白文件开始。

首先,我们需要创建一个最基本的 ArkUI 页面结构:

@Entry
@Component
struct Index {
  build() {
    Column() {
      Text('Hello HarmonyOS')
        .fontSize(30)
        .fontWeight(FontWeight.Bold)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

代码解释

  • @Entry:标记这是一个页面入口组件
  • @Component:标记这是一个自定义组件
  • struct Index:定义组件的结构体
  • build():UI 描述函数,返回 UI 组件树
  • Column():纵向布局容器
  • Text():文本组件
  • .width('100%'):链式调用设置属性

点击 Run 按钮,你应该能在模拟器或真机上看到 “Hello HarmonyOS” 的文本。

📸 截图提示:此处应插入"首次运行成功"的截图

4.2 设计计算器 UI 布局

计算器的 UI 可以分为两个主要区域:

  1. 显示区域:显示当前输入和计算结果
  2. 按钮区域:数字键、运算符键、功能键
4.2.1 显示区域
@Entry
@Component
struct Index {
  @State displayText: string = '0'  // 显示文本(响应式状态)

  build() {
    Column() {
      // ===== 显示区域 =====
      Column() {
        Text(this.displayText)
          .fontSize(48)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .textAlign(TextAlign.End)
          .width('100%')
          .padding({ right: 20 })
          .maxLines(1)
      }
      .width('100%')
      .height(160)
      .backgroundColor('#1C1C1E')
      .justifyContent(FlexAlign.End)
      .padding({ bottom: 20 })

      // ===== 按钮区域(暂时留空)=====
      Column() {
        Text('按钮区域')
          .fontColor(Color.White)
      }
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#000000')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000000')
  }
}

关键点解释

  1. @State 装饰器

    • @State displayText: string = '0'
    • 作用:当 displayText 的值发生变化时,UI 会自动重新渲染
    • 这是 ArkUI 响应式编程的核心机制
  2. 布局权重

    • .height(160):显示区域固定高度 160px
    • .layoutWeight(1):按钮区域占据剩余所有空间
  3. 文本对齐

    • .textAlign(TextAlign.End):文本右对齐(符合计算器显示习惯)
    • .maxLines(1):限制单行显示
4.2.2 按钮区域——定义按钮数据

计算器有 20 个按钮,我们需要用数据驱动的方式去渲染它们。

private readonly buttons: string[][] = [
  ['C', '±', '%', '÷'],
  ['7', '8', '9', '×'],
  ['4', '5', '6', '-'],
  ['1', '2', '3', '+'],
  ['0', '.', '', '=']
]

设计思路

  • 使用二维数组表示按钮的行列布局
  • 每一行是一个数组,包含 4 个按钮
  • 第五行的第三个元素是空字符串,用作占位符(因为 “0” 按钮占了两个位置)

4.3 渲染按钮网格

使用 ForEach 循环渲染按钮:

// ===== 按钮区域 =====
Column() {
  ForEach(this.buttons, (row: string[]) => {
    Row() {
      ForEach(row, (label: string) => {
        if (label === '') {
          // 占位符:透明不可见的按钮
          Button()
            .width(75)
            .height(75)
            .opacity(0)
        } else {
          Button(label)
            .width(this.getButtonWidth(label))
            .height(75)
            .borderRadius(37.5)
            .fontSize(this.getFontSize(label))
            .fontWeight(FontWeight.Bold)
            .backgroundColor(this.getButtonColor(label))
            .fontColor(this.getTextColor(label))
            .margin(5)
            .onClick(() => {
              this.onButtonClick(label)
            })
        }
      })
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .padding({ left: 10, right: 10 })
  })
}

关键点解释

  1. 嵌套 ForEach

    • 外层 ForEach:遍历每一行
    • 内层 ForEach:遍历每一行中的按钮
  2. 条件渲染

    • if (label === ''):如果是占位符,渲染一个透明的按钮
    • else:渲染正常的按钮
  3. 按钮属性

    • .width(this.getButtonWidth(label)):动态计算按钮宽度(“0” 按钮是其他按钮的 2 倍宽)
    • .borderRadius(37.5):圆角半径 = 高度的一半,实现圆形按钮
    • .onClick(() => { ... }):绑定点击事件

4.4 辅助方法——动态计算按钮样式

private getButtonWidth(label: string): number {
  return label === '0' ? 160 : 75  // "0" 按钮宽 160px,其他按钮宽 75px
}

private getFontSize(label: string): number {
  if (['C', '±', '%'].includes(label)) {
    return 22  // 功能键字体小一点
  }
  if (['÷', '×', '-', '+', '='].includes(label)) {
    return 30  // 运算符字体大一点
  }
  return 26  // 数字键字体适中
}

private getButtonColor(label: string): ResourceColor {
  if (label === 'C' || label === '±' || label === '%') {
    return '#A5A5A5'  // 功能键:浅灰色
  }
  if (['÷', '×', '-', '+', '='].includes(label)) {
    return '#FF9F0A'  // 运算符:橙色
  }
  return '#333333'  // 数字键:深灰色
}

private getTextColor(label: string): ResourceColor {
  if (label === 'C' || label === '±' || label === '%') {
    return '#000000'  // 功能键:黑色文字
  }
  return '#FFFFFF'  // 其他按键:白色文字
}

设计思路

  • 单一职责原则:每个方法只负责计算一个属性
  • 可读性:使用 includes() 方法判断按钮类型
  • 一致性:保持 iOS 计算器的经典配色方案

五、实现计算器逻辑——事件处理与状态管理

UI 完成了,接下来需要实现计算器的核心逻辑

5.1 状态变量设计

@Entry
@Component
struct Index {
  // ==================== 状态变量 ====================
  @State displayText: string = '0'        // 显示文本
  private currentInput: string = ''        // 当前输入
  private previousInput: string = ''       // 上一个输入
  private currentOperator: string = ''      // 当前运算符
  private isNewInput: boolean = true       // 是否是新输入
  private hasError: boolean = false        // 是否有错误
  private readonly MAX_DIGITS: number = 15 // 最大输入位数
  
  // ... 其他代码 ...
}

状态变量分类

  1. @State 变量(会触发 UI 刷新):

    • displayText:显示文本
  2. 普通变量(不会触发 UI 刷新):

    • currentInputpreviousInput:中间计算状态
    • currentOperator:当前运算符
    • isNewInput:输入状态标记
    • hasError:错误状态标记

为什么这样设计?

  • 只有 displayText 需要驱动 UI 刷新,所以它用 @State 装饰
  • 其他变量只是内部逻辑状态,变化时不需要重新渲染 UI
  • 在计算出结果后,我们统一更新 displayText,从而触发 UI 刷新

这是 ArkUI 状态管理的最佳实践

5.2 事件分发机制

所有按钮的点击事件都统一分发到 onButtonClick() 方法:

private onButtonClick(label: string): void {
  if (this.hasError && label !== 'C') {
    return  // 错误状态下,只允许按 C 键清除
  }

  switch (label) {
    case 'C':
      this.clearAll()
      break
    case '±':
      this.toggleSign()
      break
    case '%':
      this.percent()
      break
    case '÷':
    case '×':
    case '-':
    case '+':
      this.handleOperator(label)
      break
    case '=':
      this.calculate()
      break
    case '.':
      this.appendDot()
      break
    default:
      this.appendNumber(label)
  }
}

设计优点

  1. 统一入口:所有按钮事件都经过这个方法,便于统一处理(如错误状态拦截)
  2. 清晰的分发:使用 switch-case 语句,逻辑清晰
  3. 易于扩展:新增按钮类型只需要增加一个 case

5.3 数字输入处理

private appendNumber(num: string): void {
  if (this.isNewInput) {
    // 新输入:替换当前显示
    this.currentInput = num
    this.isNewInput = false
  } else {
    // 连续输入:拼接数字
    if (this.currentInput.length >= this.MAX_DIGITS) {
      return  // 超过最大位数,忽略输入
    }
    this.currentInput += num
  }
  this.displayText = this.currentInput
}

关键点

  1. 新输入 vs 连续输入

    • 新输入:替换当前显示(如按了运算符后,再按数字)
    • 连续输入:拼接数字(如依次按 “1”、“2”、“3”)
  2. 长度限制

    • MAX_DIGITS = 15:防止输入过长导致显示溢出

5.4 小数点处理

private appendDot(): void {
  if (this.isNewInput) {
    // 新输入状态下按小数点,自动补 "0"
    this.currentInput = '0.'
    this.isNewInput = false
  } else {
    // 检查当前输入是否已有小数点
    if (this.currentInput.includes('.')) {
      return  // 已有小数点,忽略输入
    }
    if (this.currentInput.length >= this.MAX_DIGITS) {
      return  // 超过最大位数,忽略输入
    }
    this.currentInput += '.'
  }
  this.displayText = this.currentInput
}

关键点

  • 自动补零:在新输入状态下按小数点,显示 “0.”(符合用户习惯)
  • 重复检查:如果当前输入已有小数点,忽略本次输入

5.5 运算符处理

private handleOperator(op: string): void {
  if (this.currentOperator !== '' && !this.isNewInput) {
    // 链式运算:先完成之前的运算
    this.calculate()
  }
  this.previousInput = this.currentInput || '0'
  this.currentOperator = op
  this.isNewInput = true
}

链式运算的实现原理

假设用户输入:5 + 3 + 2 =

  1. 5currentInput = "5"
  2. +previousInput = "5", currentOperator = "+", isNewInput = true
  3. 3currentInput = "3"(新输入,替换)
  4. +:检测到 currentOperator 不为空,且 isNewInput = false先计算 5 + 3 = 8,然后 previousInput = "8", currentOperator = "+", isNewInput = true
  5. 2currentInput = "2"(新输入,替换)
  6. =:计算 8 + 2 = 10,显示结果

5.6 计算逻辑

private calculate(): void {
  if (this.currentOperator === '' || this.isNewInput) {
    return  // 没有运算符,或还没有输入第二个数
  }

  const prev = parseFloat(this.previousInput)
  const curr = parseFloat(this.currentInput)
  let result: number = 0

  switch (this.currentOperator) {
    case '+':
      result = prev + curr
      break
    case '-':
      result = prev - curr
      break
    case '×':
      result = prev * curr
      break
    case '÷':
      if (curr === 0) {
        this.showError('Error')
        return
      }
      result = prev / curr
      break
    default:
      return
  }

  // 溢出检查
  if (!isFinite(result)) {
    this.showError('Error')
    return
  }

  // 格式化结果
  this.currentInput = this.formatNumber(result)
  this.displayText = this.currentInput
  this.currentOperator = ''
  this.previousInput = ''
  this.isNewInput = true
}

关键点

  1. 除零保护

    if (curr === 0) {
      this.showError('Error')
      return
    }
    
  2. 溢出检查

    if (!isFinite(result)) {
      this.showError('Error')
      return
    }
    
    • isFinite() 函数检查数值是否是有穷的(不是 InfinityNaN
  3. 结果格式化

    this.currentInput = this.formatNumber(result)
    

5.7 数字格式化

private formatNumber(num: number): string {
  // 处理极小/极大数字
  if (Math.abs(num) > 1e15 || (Math.abs(num) < 1e-10 && num !== 0)) {
    return num.toExponential(6)  // 转换为科学计数法
  }
  
  // 去掉多余的小数位
  const str = num.toPrecision(12)
  let result = String(parseFloat(str))
  
  // 截断过长的数字
  if (result.length > this.MAX_DIGITS) {
    result = result.substring(0, this.MAX_DIGITS)
  }
  
  return result
}

关键点

  1. 科学计数法

    • 极大数字(如 1e20)或极小数字(如 1e-20),转换为科学计数法显示
  2. 浮点数精度处理

    const str = num.toPrecision(12)
    let result = String(parseFloat(str))
    
    • 问题:0.1 + 0.2 = 0.30000000000000004(浮点数精度问题)
    • 解决:parseFloat(num.toPrecision(12)) 去掉多余的小数位
  3. 长度截断

    • 如果格式化后的数字仍然过长,截断到 MAX_DIGITS

5.8 清除功能

private clearAll(): void {
  this.displayText = '0'
  this.currentInput = ''
  this.previousInput = ''
  this.currentOperator = ''
  this.isNewInput = true
  this.hasError = false
}

关键点:将所有状态变量重置为初始值。

5.9 错误处理

private showError(msg: string): void {
  this.displayText = msg
  this.hasError = true
  this.currentInput = ''
  this.currentOperator = ''
  this.previousInput = ''
  this.isNewInput = true
}

关键点

  • 显示错误信息(如 “Error”)
  • 设置 hasError = true锁定所有按钮(除了 “C” 键)
  • 重置其他状态变量

六、踩坑记录——真实开发中的问题与解决方案

在开发这个计算器的过程中,我遇到了很多。有些坑在官方文档中找不到答案,只能靠自己摸索。

坑点1:@State 装饰器不是万能的!

问题描述

刚开始写代码时,我给所有变量都加上了 @State 装饰器:

@State currentInput: string = ''   // ❌ 没必要
@State previousInput: string = ''  // ❌ 没必要
@State displayText: string = '0'   // ✅ 只有这个需要

结果编辑器疯狂报黄线警告,虽然能运行,但性能很差。

原因分析

  • @State 装饰的变量发生变化时,会触发 UI 自动重新渲染
  • currentInputpreviousInput 只是内部逻辑状态,它们变化时不需要刷新界面
  • 只有 displayText 需要直接驱动 UI,所以只有它需要 @State

正确做法

@State displayText: string = '0'  // 只有需要驱动 UI 的变量才加 @State
private currentInput: string = ''   // 纯逻辑变量不加装饰器
private previousInput: string = ''  // 纯逻辑变量不加装饰器

经验教训

⚠️ 不要把 @State 当成"全局变量声明"!它是有性能成本的。只有需要驱动 UI 刷新的状态才加 @State


坑点2:ForEach 渲染时,空字符串会报错

问题描述

我想实现计算器的按钮布局,第五行的 “0” 键占了两个按钮的宽度,所以需要一个占位符。最初写的是这样:

private readonly buttons: string[][] = [
  ['7', '8', '9', '×'],
  ['4', '5', '6', '-'],
  ['1', '2', '3', '+'],
  ['0', '.', '=', '']  // 最后一个空字符串想作为占位符
]

结果运行时直接闪退,日志显示:

Error: Button text cannot be empty

原因分析

  • ArkUI 的 Button 组件,text 参数不能为空字符串
  • 如果传入空字符串,运行时会抛出异常

解决方法

在渲染时判断,如果是空字符串就渲染一个透明不可见的 Button

ForEach(row, (label: string) => {
  if (label === '') {
    Button()  // ✅ 不传入 text 参数,创建空按钮
      .width(75)
      .height(75)
      .opacity(0)  // 透明度设为 0,完全不可见
  } else {
    Button(label)
      .width(label === '0' ? 160 : 75)
      .onClick(() => { this.onButtonClick(label) })
  }
})

经验教训

⚠️ ArkUI 的 Button 组件 text 参数不能为空字符串!需要占位时用 opacity(0) 隐藏掉。


坑点3:浮点数精度问题差点让我怀疑人生

问题描述

计算 0.1 + 0.2 的时候,结果显示 0.30000000000000004

我在想:这是 ArkTS 的 Bug 吗?

原因分析

这是计算机科学的老大难问题——浮点数精度问题。

在计算机中,浮点数是用二进制表示的,而很多十进制小数无法用二进制精确表示。

例如:

  • 0.1 的二进制表示是一个无限循环小数
  • 0.2 同理
  • 所以它们相加的结果会有微小的误差

解决方法

parseFloat(num.toPrecision(12)) 去掉多余小数位:

private formatNumber(num: number): string {
  if (!isFinite(num)) return 'Error'
  
  // 用 toPrecision(12) 限制精度,再用 parseFloat 去掉多余的小数位
  return parseFloat(num.toPrecision(12)).toString()
}

测试

  • 0.1 + 0.20.30000000000000004parseFloat((0.1 + 0.2).toPrecision(12))0.3

经验教训

⚠️ 浮点数精度问题是计算机科学的老大难,不是 ArkTS 的 Bug。涉及小数计算时一定要做格式化处理!


坑点4:按钮圆角设置无效?

问题描述

想让计算器按钮变成圆形,设置了 .borderRadius(37.5)(按钮高度是 75px,37.5 就是一半),结果按钮还是方的…

原因分析

忘记同时设置 .backgroundColor(),按钮默认是透明背景,所以看不到圆角效果。

解决方法

Button(label)
  .width(75)
  .height(75)
  .borderRadius(37.5)
  .backgroundColor('#333333')  // ✅ 必须设置背景色才能看到圆角
  .fontColor(Color.White)

经验教训

⚠️ ArkUI 的 borderRadius 需要配合 backgroundColor 才能看到效果。如果按钮是透明背景,圆角"存在但看不见"。


坑点5:布局不对齐?检查 layoutWeight 的使用

问题描述

计算器按钮区域想占满剩余屏幕空间,最初写的是:

Column() {
  // 按钮区域
}
.width('100%')
.height('100%')  // ❌ 这样会把屏幕撑爆

结果底部被屏幕剪切掉了…

原因分析

  • .height('100%') 是相对于父容器的高度
  • 如果父容器已经有固定高度的内容(如显示区域 160px),再设置 .height('100%') 会导致内容溢出

解决方法

.layoutWeight(1) 让按钮区域自动占满剩余空间

Column() {
  // 按钮区域
}
.width('100%')
.layoutWeight(1)  // ✅ 自动计算剩余空间并填充

经验教训

⚠️ 在 ArkUI 的 Column 纵向布局中,固定高度的元素(如顶部状态栏)用 .height(),剩余空间用 .layoutWeight(1) 填充。不要写死高度!


七、测试与调试

代码写完了,接下来需要全面测试计算器的功能。
在这里插入图片描述

7.1 功能测试用例

我整理了以下测试用例,确保计算器的每个功能都正常工作:

测试操作 预期结果 实际结果 是否通过
12 + 34 = 显示 46 通过
100 - 23 = 显示 77 通过
7 × 8 = 显示 56 通过
100 ÷ 4 = 显示 25 通过
5 ÷ 0 = 显示 Error 通过
C 显示 0 通过
100 ± 显示 -100 通过
200 % 显示 2 通过
5 + 3 + 2 = 显示 10 通过
0.1 + 0.2 = 显示 0.3 通过
9 连续按 20 次 最多显示 15 位 通过

7.2 真机调试

模拟器上测试通过后,还需要在真机上测试。

步骤

  1. 用 USB 线连接手机和电脑
  2. 在手机上开启开发者模式(设置 → 关于手机 → 连续点击版本号 7 次)
  3. 开启 USB 调试
  4. 在 DevEco Studio 的设备列表中,选择你的手机
  5. 点击 Run 按钮

八、项目完整代码

以下是 Reasonix 计算器的完整代码(Index.ets):

@Entry
@Component
struct Index {
  // ==================== 状态变量 ====================
  @State displayText: string = '0'
  private currentInput: string = ''
  private previousInput: string = ''
  private currentOperator: string = ''
  private isNewInput: boolean = true
  private hasError: boolean = false
  private readonly MAX_DIGITS: number = 15

  // ==================== 按钮文字数组 ====================
  private readonly buttons: string[][] = [
    ['C', '±', '%', '÷'],
    ['7', '8', '9', '×'],
    ['4', '5', '6', '-'],
    ['1', '2', '3', '+'],
    ['0', '.', '', '=']
  ]

  // ==================== 输入处理 ====================
  private onButtonClick(label: string): void {
    if (this.hasError && label !== 'C') {
      return
    }

    switch (label) {
      case 'C':
        this.clearAll()
        break
      case '±':
        this.toggleSign()
        break
      case '%':
        this.percent()
        break
      case '÷':
      case '×':
      case '-':
      case '+':
        this.handleOperator(label)
        break
      case '=':
        this.calculate()
        break
      case '.':
        this.appendDot()
        break
      default:
        this.appendNumber(label)
    }
  }

  // ==================== 数字输入 ====================
  private appendNumber(num: string): void {
    if (this.isNewInput) {
      this.currentInput = num
      this.isNewInput = false
    } else {
      if (this.currentInput.length >= this.MAX_DIGITS) return
      this.currentInput += num
    }
    this.displayText = this.currentInput
  }

  // ==================== 小数点 ====================
  private appendDot(): void {
    if (this.isNewInput) {
      this.currentInput = '0.'
      this.isNewInput = false
    } else {
      if (this.currentInput.includes('.')) return
      if (this.currentInput.length >= this.MAX_DIGITS) return
      this.currentInput += '.'
    }
    this.displayText = this.currentInput
  }

  // ==================== 取反 ====================
  private toggleSign(): void {
    if (this.currentInput === '0' || this.currentInput === '') return
    if (this.currentInput.startsWith('-')) {
      this.currentInput = this.currentInput.substring(1)
    } else {
      this.currentInput = '-' + this.currentInput
    }
    this.displayText = this.currentInput
  }

  // ==================== 百分号 ====================
  private percent(): void {
    if (this.currentInput === '' || this.currentInput === '0') return
    let num = parseFloat(this.currentInput)
    num = num / 100
    this.currentInput = this.formatNumber(num)
    this.displayText = this.currentInput
  }

  // ==================== 运算符处理 ====================
  private handleOperator(op: string): void {
    if (this.currentOperator !== '' && !this.isNewInput) {
      this.calculate()
    }
    this.previousInput = this.currentInput || '0'
    this.currentOperator = op
    this.isNewInput = true
  }

  // ==================== 计算 ====================
  private calculate(): void {
    if (this.currentOperator === '' || this.isNewInput) return
    const prev = parseFloat(this.previousInput)
    const curr = parseFloat(this.currentInput)
    let result: number = 0

    switch (this.currentOperator) {
      case '+':
        result = prev + curr
        break
      case '-':
        result = prev - curr
        break
      case '×':
        result = prev * curr
        break
      case '÷':
        if (curr === 0) {
          this.showError('Error')
          return
        }
        result = prev / curr
        break
      default:
        return
    }

    // 溢出检查
    if (!isFinite(result)) {
      this.showError('Error')
      return
    }

    this.currentInput = this.formatNumber(result)
    this.displayText = this.currentInput
    this.currentOperator = ''
    this.previousInput = ''
    this.isNewInput = true
  }

  // ==================== 清空 ====================
  private clearAll(): void {
    this.displayText = '0'
    this.currentInput = ''
    this.previousInput = ''
    this.currentOperator = ''
    this.isNewInput = true
    this.hasError = false
  }

  // ==================== 错误显示 ====================
  private showError(msg: string): void {
    this.displayText = msg
    this.hasError = true
    this.currentInput = ''
    this.currentOperator = ''
    this.previousInput = ''
    this.isNewInput = true
  }

  // ==================== 数字格式化 ====================
  private formatNumber(num: number): string {
    // 处理极小/极大数字
    if (Math.abs(num) > 1e15 || (Math.abs(num) < 1e-10 && num !== 0)) {
      return num.toExponential(6)
    }
    // 去掉多余的小数位
    const str = num.toPrecision(12)
    let result = String(parseFloat(str))
    if (result.length > this.MAX_DIGITS) {
      result = result.substring(0, this.MAX_DIGITS)
    }
    return result
  }

  // ==================== UI 构建 ====================
  build() {
    Column() {
      // ===== 显示区域 =====
      Column() {
        Text(this.displayText)
          .fontSize(48)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .textAlign(TextAlign.End)
          .width('100%')
          .padding({ right: 20 })
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Clip })
      }
      .width('100%')
      .height(160)
      .backgroundColor('#1C1C1E')
      .justifyContent(FlexAlign.End)
      .padding({ bottom: 20 })

      // ===== 按钮区域 =====
      Column() {
        ForEach(this.buttons, (row: string[]) => {
          Row() {
            ForEach(row, (label: string) => {
              if (label === '') {
                // 占位空按钮(第5行第3列)
                Button()
                  .width(75)
                  .height(75)
                  .opacity(0)
              } else {
                Button(label)
                  .width(this.getButtonWidth(label))
                  .height(75)
                  .borderRadius(37.5)
                  .fontSize(this.getFontSize(label))
                  .fontWeight(FontWeight.Bold)
                  .backgroundColor(this.getButtonColor(label))
                  .fontColor(this.getTextColor(label))
                  .margin(5)
                  .onClick(() => {
                    this.onButtonClick(label)
                  })
              }
            })
          }
          .width('100%')
          .justifyContent(FlexAlign.Center)
          .padding({ left: 10, right: 10 })
        })
      }
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#000000')
      .padding({ bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000000')
  }

  // ==================== UI 辅助方法 ====================

  private getButtonWidth(label: string): number {
    // 0 键宽一些
    return label === '0' ? 160 : 75
  }

  private getFontSize(label: string): number {
    // 功能键小一点
    if (['C', '±', '%'].includes(label)) {
      return 22
    }
    if (['÷', '×', '-', '+', '='].includes(label)) {
      return 30
    }
    return 26
  }

  private getButtonColor(label: string): ResourceColor {
    if (label === 'C' || label === '±' || label === '%') {
      return '#A5A5A5'   // 浅灰功能键
    }
    if (['÷', '×', '-', '+', '='].includes(label)) {
      return '#FF9F0A'   // 橙色运算符
    }
    return '#333333'     // 深灰数字键
  }

  private getTextColor(label: string): ResourceColor {
    if (label === 'C' || label === '±' || label === '%') {
      return '#000000'
    }
    return '#FFFFFF'
  }
}

九、总结与展望

9.1 项目总结

通过本文的完整实战记录,我们成功开发了一个功能完善的鸿蒙原生计算器应用。在这个过程中,我们学习了:

ArkUI 声明式开发范式
状态管理(@State 装饰器的正确使用)
事件处理(按钮点击、事件分发)
列表渲染(ForEach 在网格布局中的应用)
条件渲染(根据状态动态显示/隐藏 UI)
数字格式化(浮点数精度处理、科学计数法)
错误处理(除零保护、溢出检查)

9.2 踩坑经验总结

回顾整个开发过程,我踩的坑主要集中在以下几个方面:

坑的类型 典型例子 解决思路
语法理解类 @State 乱用、浮点数精度 理解框架设计理念,不要照搬其他语言经验
UI 调试类 圆角无效、布局不对齐 多试试不同的属性组合,查看官方 API 文档
逻辑处理类 链式运算、边界情况 写单元测试,覆盖所有边界情况

给新手的建议

  1. 不要急于求成:先把官方文档的 “快速入门” 章节看完,再动手写代码
  2. 多打印日志:遇到奇怪问题时,用 console.info() 打印变量值,帮助你定位问题
  3. 善用搜索引擎:你踩的坑,大概率别人也踩过。CSDN、华为开发者论坛都有很多答案
  4. 真机调试尽早配置:模拟器只是辅助,真机调试才能发现真实问题

9.3 下一步学习计划

计算器项目完成后,你已经掌握了 ArkUI 开发的核心知识点。接下来,我推荐你尝试以下项目:

  1. 天气查询应用:学习网络请求、JSON 解析、列表渲染
  2. 本地音乐播放器:学习音频播放、文件读取、后台任务
  3. 健康数据可视化:学习图表绘制、数据持久化、分享功能

十、相关资源

  • 华为开发者官网:https://developer.harmonyos.com
  • ArkUI 官方文档:https://developer.harmonyos.com/cn/docs/documentation/doc-guides/arkui
  • 本项目完整代码:https://gitee.com/yourusername/reasonix-calculator(示例链接)

附录:完整项目文件清单

Reasonix/
├── Calculator/
│   ├── AppScope/
│   │   └── app.json5
│   ├── entry/
│   │   ├── src/main/
│   │   │   ├── ets/
│   │   │   │   ├── entryability/
│   │   │   │   │   └── EntryAbility.ts
│   │   │   │   └── pages/
│   │   │   │       └── Index.ets    ← 核心代码文件
│   │   │   ├── module.json5
│   │   │   └── resources/
│   │   └── oh-package.json5
│   ├── build-profile.json5
│   ├── hvigorfile.ts
│   └── oh-package.json5
└── README.md

希望这篇文章能帮助你顺利入门鸿蒙开发!如果觉得有用,请不吝点赞 👍

Logo

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

更多推荐