在这里插入图片描述

开发环境: DevEco Studio / HarmonyOS NEXT 6.1.1(API 24)
开发语言: ArkTS(基于 TypeScript 的鸿蒙原生语言)
工程模型: Stage 模型(单 HAP 架构)
项目地址: 本文配套项目完整源码


一、引言

鸿蒙操作系统(HarmonyOS)从诞生之初就致力于打造一个面向全场景的分布式操作系统。随着 HarmonyOS NEXT 的发布,系统彻底剥离了 AOSP 代码,进入"纯血鸿蒙"时代。对于应用开发者而言,这意味着全新的开发范式:从语言选型、UI 框架到工程构建,都需要重新学习和掌握。

ArkTS 是鸿蒙原生的声明式 UI 编程语言,基于 TypeScript 语法做了深度定制和扩展。它继承了 TypeScript 的类型安全特性,同时引入了类似 SwiftUI / Jetpack Compose 的声明式 UI 范式。本文将以一个完整的计算器应用为案例,从零开始讲解如何使用 ArkTS 在 HarmonyOS NEXT 上构建一个功能完备、交互流畅、视觉精致的计算器应用。

通过本文,你将学到:

  • HarmonyOS NEXT 项目的工程结构和配置
  • ArkTS 声明式 UI 的核心概念(@Component、@Builder、@State)
  • 使用 Column / Row / ForEach 等布局组件构建复杂界面
  • 计算器核心逻辑的完整实现
  • 响应式状态管理的实际应用
  • 布局优化与自适应设计策略
  • 项目的构建、调试和验证流程

二、HarmonyOS NEXT 项目工程结构解析

在开始编写代码之前,理解 HarmonyOS NEXT 项目的工程结构至关重要。一个标准的 Stage 模型应用包含以下层次:

2.1 根目录文件

apptools/
├── AppScope/                  # 应用全局配置
│   ├── app.json5              # 应用级配置(应用名称、版本、图标等)
│   └── resources/             # 全局资源文件
├── entry/                     # 模块目录(HAP 包)
│   ├── src/
│   │   ├── main/
│   │   │   ├── ets/           # ArkTS 源码目录
│   │   │   ├── resources/     # 模块级资源
│   │   │   └── module.json5   # 模块配置
│   │   ├── mock/              # 模拟数据(可选)
│   │   ├── ohosTest/          # 测试代码
│   │   └── test/              # 单元测试
│   ├── build-profile.json5    # 模块级构建配置
│   ├── hvigorfile.ts          # 模块级构建脚本
│   └── oh-package.json5       # OHPM 包依赖
├── hvigor/                    # 构建工具配置
│   └── hvigor-config.json5
├── build-profile.json5        # 应用级构建配置
├── hvigorfile.ts              # 应用级构建脚本
├── oh-package.json5           # 全局 OHPM 配置
└── local.properties           # 本地 SDK 路径配置
关键配置文件详解

build-profile.json5(应用级):

{
  "app": {
    "signingConfigs": [],
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "targetSdkVersion": "6.1.0(23)",
        "compatibleSdkVersion": "6.1.0(23)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      }
    ],
    "buildModeSet": [
      { "name": "debug" },
      { "name": "release" }
    ]
  },
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        { "name": "default", "applyToProducts": [ "default" ] }
      ]
    }
  ]
}

这里定义了应用的 SDK 版本兼容性。targetSdkVersion: "6.1.0(23)" 对应 API 24(HarmonyOS NEXT),compatibleSdkVersion 指定向下兼容的最低版本。strictMode 中的 caseSensitiveCheck 在构建时对文件名和资源引用做大小写敏感检查,这是 HarmonyOS NEXT 的要求,也是避免在 Linux / macOS 构建时出现问题的重要手段。

module.json5(模块级):

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone"],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          }
        ]
      }
    ],
    "extensionAbilities": [
      {
        "name": "EntryBackupAbility",
        "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
        "type": "backup",
        "exported": false,
        "metadata": [
          {
            "name": "ohos.extension.backup",
            "resource": "$profile:backup_config"
          }
        ]
      }
    ]
  }
}

pages 字段指向 $profile:main_pages,这个资源文件定义了应用中所有页面的路由列表。每新增一个页面,都需要在这里注册。

main_pages.json(页面路由配置):

{
  "src": [
    "pages/Index",
    "pages/index1"
  ]
}

每个条目对应 src/main/ets/pages/ 目录下的一个 .ets 文件。pages/Index 是默认启动页,pages/index1 是我们新增的计算器页面。

2.2 入口能力(Ability)

在 HarmonyOS Stage 模型中,Ability 是应用的基本组成单元。EntryAbility 负责应用的初始化和生命周期管理:

import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
      }
    });
  }

  onForeground(): void { /* 应用进入前台 */ }
  onBackground(): void { /* 应用进入后台 */ }
  onDestroy(): void { /* 应用销毁 */ }
}

loadContent 加载的 'pages/Index' 对应 main_pages 中的第一个页面。开发者可以通过修改此参数或将其他页面放在路由列表首位来改变应用的默认启动页。


三、ArkTS 声明式 UI 核心概念

在深入计算器代码之前,有必要先理解 ArkTS 声明式 UI 的几个核心概念。这是理解后续所有代码的基础。

3.1 组件(@Component)与结构体(struct)

ArkTS 中,一个 UI 组件由 @Component 装饰器修饰的 struct 定义:

@Component
struct MyComponent {
  build() {
    // UI 描述
  }
}
  • @Component 标记该 struct 是一个 ArkUI 组件
  • build() 方法是必须实现的,它返回组件的 UI 描述
  • 组件不支持多继承,但可以通过组合方式复用

3.2 入口组件(@Entry)

@Entry 装饰器标记该组件是页面的入口:

@Entry
@Component
struct Calculator {
  // ...
}

一个页面(.ets 文件)中只能有一个 @Entry 组件。当页面被加载时,@Entry 组件会自动创建并显示。

3.3 @State 装饰器与响应式状态

@State 是 ArkTS 中最重要的装饰器之一。被 @State 修饰的变量会成为响应式状态——当它的值发生变化时,框架会自动重新渲染依赖该状态的 UI 部分。

@Component
struct Calculator {
  @State displayText: string = '0';
  @State expressionText: string = '';
  @State isResultShown: boolean = false;
  // ...
}

状态变量的变化是 UI 更新的唯一来源。在计算器中,当用户点击数字按钮时,displayText 被修改,显示区域自动更新为最新的文本。

3.4 @Builder 装饰器

@Builder 用于定义可复用的 UI 片段:

@Builder
createButton(btn: CalcButtonInfo) {
  Button(btn.label)
    .layoutWeight(this.getButtonWeight(btn))
    .height('100%')
    .borderRadius(40)
    // ...
}

与常规函数不同,@Builder 函数内只能编写 UI 描述语句,不能包含复杂的业务逻辑。它常用于抽象重复的 UI 结构,提高代码复用性。

3.5 布局组件

ArkUI 提供了丰富的布局组件,最基础的是:

  • Column:垂直布局,子组件从上到下排列
  • Row:水平布局,子组件从左到右排列
  • RelativeContainer:相对定位布局,子组件可基于容器或兄弟组件定位
  • Stack:层叠布局,子组件重叠排列
  • Flex:弹性布局,灵活控制子组件的排列和对齐

在我们的计算器中,主要使用 ColumnRow 的嵌套组合来实现按钮网格布局。


四、计算器界面设计

4.1 整体布局结构

计算器的界面分为两大部分:上方的显示区域和下方的按钮区域。

┌─────────────────────────┐
│                         │
│   表达式行 (灰色小字)     │  ← 显示区域(layoutWeight: 1)
│   当前数值 (白色大字)     │
│─────────────────────────│  ← 分隔线 (Divider)
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐   │
│ │C │ │± │ │% │ │÷ │   │
│ └──┘ └──┘ └──┘ └──┘   │
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐   │
│ │7 │ │8 │ │9 │ │× │   │
│ └──┘ └──┘ └──┘ └──┘   │  ← 按钮区域(layoutWeight: 2)
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐   │
│ │4 │ │5 │ │6 │ │- │   │
│ └──┘ └──┘ └──┘ └──┘   │
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐   │
│ │1 │ │2 │ │3 │ │+ │   │
│ └──┘ └──┘ └──┘ └──┘   │
│ ┌────┐ ┌──┐ ┌──┐ ┌──┐ │
│ │ 0  │ │. │ │⌫│ │= │ │
│ └────┘ └──┘ └──┘ └──┘ │
└─────────────────────────┘

在代码层面,这个结构通过 ColumnColumn(显示区)和 ColumnRow(按钮区)实现。

4.2 颜色主题设计

计算器采用了深色主题,类似 iOS 计算器的视觉风格:

元素 色值 用途
背景 #1C1C1E 应用整体背景
数字键 #333333 0-9 数字和.按钮
运算符键(高亮) #FF9500 +、-、×、÷、=
功能键 #A5A5A5 C、±、%、⌫
功能键文字 #1C1C1E 功能键上的文字颜色
数字/运算符文字 #FFFFFF 数字键和运算符键的文字颜色
分隔线 #38383A 显示区与按钮区的分割
表达式文字 #8E8E93 灰色运算过程提示文字

颜色方案的选择不仅影响视觉效果,也影响用户体验。深色背景减少了强光下的眩光,高对比度的橙色运算符使操作目标一目了然。

4.3 按钮数据模型

按钮的数据通过一个二维数组和接口定义来组织:

interface CalcButtonInfo {
  label: string;
  type: string;
}

private buttons: CalcButtonInfo[][] = [
  [
    { label: 'C',  type: 'clear' },
    { label: '±',  type: 'toggle' },
    { label: '%',  type: 'percent' },
    { label: '÷',  type: 'op' }
  ],
  [
    { label: '7', type: 'num' },
    { label: '8', type: 'num' },
    { label: '9', type: 'num' },
    { label: '×', type: 'op' }
  ],
  [
    { label: '4', type: 'num' },
    { label: '5', type: 'num' },
    { label: '6', type: 'num' },
    { label: '-', type: 'op' }
  ],
  [
    { label: '1', type: 'num' },
    { label: '2', type: 'num' },
    { label: '3', type: 'num' },
    { label: '+', type: 'op' }
  ],
  [
    { label: '0', type: 'num' },
    { label: '.', type: 'dot' },
    { label: '⌫', type: 'back' },
    { label: '=', type: 'eq' }
  ]
];

每个按钮包含两个属性:

  • label:按钮上显示的文本
  • type:按钮类型,决定点击后的行为和样式

类型系统设计:

类型 含义 代表按钮 行为
num 数字 0-9 纯数字输入
dot 小数点 . 小数点输入
op 运算符 + - × ÷ 设置运算操作
eq 等于 = 计算结果
clear 清空 C 重置所有状态
back 退格 删除最后一位数字
toggle 正负切换 ± 切换当前数字符号
percent 百分比 % 除以 100

五、显示区域的实现

显示区域是计算器的"脸面",它需要清晰地向用户展示当前输入和运算过程。

5.1 双行显示设计

不同于简单的单行显示计算器,我们采用了双行显示方案:

// 显示区域
Column() {
  // 表达式行:显示 "12 + " 或空白
  Text(this.expressionText)
    .fontSize(22)
    .fontColor('#8E8E93')
    .textAlign(TextAlign.End)
    .width('100%')
    .maxLines(1)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
    .padding({ right: 6 })

  // 当前输入 / 结果行
  Text(this.displayText)
    .fontSize(56)
    .fontWeight(FontWeight.Regular)
    .fontColor(Color.White)
    .textAlign(TextAlign.End)
    .width('100%')
    .maxLines(1)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.End)
.padding({ left: 20, right: 20, bottom: 12 })
设计细节
  1. 表达式行(expressionText:以灰色(#8E8E93)、22px 字体显示完整的运算表达式,如 “12 + 3 =”。当没有运算符时显示空白。

  2. 结果行(displayText:以白色(Color.White)、56px 大字显示当前输入数值或计算结果。

  3. 文本对齐:使用 textAlign(TextAlign.End) 右对齐,模拟真实计算器的显示方式。

  4. 溢出处理maxLines(1) 限制单行显示,textOverflow({ overflow: TextOverflow.Ellipsis }) 在数字过长时显示省略号。

  5. 弹性高度:通过 layoutWeight(1) 让显示区在垂直方向上占据 1 份权重。配合按钮区的 layoutWeight(2),实现 1:2 的显示比。

5.2 layoutWeight 弹性布局原理

layoutWeight 是 ArkUI 中实现自适应布局的核心属性。它根据子组件的权重值按比例分配父容器的剩余空间。

Column() {
  // 显示区 — weight: 1
  Column() { /* ... */ }
    .layoutWeight(1)

  // 分隔线
  Divider()
    .height(0.5)

  // 按钮区 — weight: 2
  Column() { /* ... */ }
    .layoutWeight(2)
}
.width('100%')
.height('100%')

在总高度中,显示区占 1/3,按钮区占 2/3。这种方式比固定百分比更灵活——当屏幕尺寸变化时,比例自动保持,无需手动调整。


六、按钮区域的实现

按钮区域是计算器最复杂的 UI 部分,涉及网格布局、按钮创建、样式分发等关键逻辑。

6.1 嵌套迭代的网格生成

使用 ForEach 指令实现二维数组的迭代渲染:

// 按钮区域(外层 Column 遍历行)
Column() {
  ForEach(this.buttons, (row: CalcButtonInfo[]) => {
    // 每一行是一个 Row
    Row() {
      // 内层 ForEach 遍历行中的每个按钮
      ForEach(row, (btn: CalcButtonInfo) => {
        this.createButton(btn)
      }, (btn: CalcButtonInfo) => btn.label + btn.type)
    }
    .width('100%')
    .layoutWeight(1)
    .padding({ left: 8, right: 8, top: 4, bottom: 4 })
  }, (row: CalcButtonInfo[]) => row[0].label + row[0].type)
}
.width('100%')
.layoutWeight(2)

这里的关键点是:

  • 外层 ForEach:遍历 buttons 数组(5 行),每行生成一个 Row 组件
  • 内层 ForEach:遍历每行中的 4 个按钮,使用 @Builder 方法 createButton 创建按钮 UI
  • 键值生成器:第二个参数 (item) => item[0].label + item[0].type 为每个行/按钮生成唯一键,帮助框架高效进行差异更新

6.2 @Builder 按钮工厂

@Builder 装饰的方法专门用于生成 UI 片段:

@Builder
createButton(btn: CalcButtonInfo) {
  Button(btn.label)
    .layoutWeight(this.getButtonWeight(btn))
    .height('100%')
    .borderRadius(40)
    .fontSize(28)
    .fontWeight(FontWeight.Regular)
    .backgroundColor(this.getButtonColor(btn))
    .fontColor(this.getFontColor(btn))
    .margin({ left: 4, right: 4 })
    .onClick(() => {
      this.onButtonClick(btn);
    })
}
弹性宽度分配函数
getButtonWeight(btn: CalcButtonInfo): number {
  return btn.label === '0' ? 2 : 1;
}

0 键占据两格宽度(权重 2),其他按钮各占一格(权重 1)。在最后一行的 4 个按钮中,0 键的权重是 2,其余三个(.、⌫、=)的权重各为 1,总权重为 2 + 1 + 1 + 1 = 5,因此 0 键占 2/5(约 40%)的宽度,其余各占 1/5(约 20%)。这比使用固定百分比更健壮。

颜色分发函数
getButtonColor(btn: CalcButtonInfo): ResourceColor {
  switch (btn.type) {
    case 'num':
    case 'dot':
      return '#333333';     // 深灰:数字键
    case 'op':
    case 'eq':
      return '#FF9500';     // 橙色:运算符 / 等号
    case 'clear':
    case 'toggle':
    case 'percent':
    case 'back':
      return '#A5A5A5';     // 浅灰:功能键
    default:
      return '#333333';
  }
}

getFontColor(btn: CalcButtonInfo): ResourceColor {
  switch (btn.type) {
    case 'clear':
    case 'toggle':
    case 'percent':
    case 'back':
      return '#1C1C1E';     // 深色文字(与浅灰背景对比)
    default:
      return Color.White;   // 白色文字(与深色背景对比)
  }
}

这种基于按钮类型分发颜色的设计,使得新增或调整按钮时只需在数据数组中修改 type 字段,样式自动适配,实现了数据与 UI 样式的解耦。


七、计算器核心逻辑实现

计算器的业务逻辑是整个应用的核心。我们需要处理数字输入、运算符管理、结果计算、边界条件等多个方面。

7.1 状态变量设计

@Component
struct Calculator {
  @State displayText: string = '0';       // 显示文本
  @State expressionText: string = '';      // 表达式文本(显示运算过程)
  @State isResultShown: boolean = false;   // 当前是否显示计算结果

  private firstOperand: number = 0;        // 第一个操作数
  private operator: string = '';           // 当前运算符
  private waitingForSecond: boolean = false; // 是否等待第二个操作数
}

状态变量 vs 普通成员变量:

  • @State 变量(displayTextexpressionTextisResultShown):它们的变更会触发 UI 重新渲染
  • 普通成员变量(firstOperandoperatorwaitingForSecond):它们只在逻辑层使用,变更不会触发 UI 更新

这种分离设计是有意为之的:运算的内部状态(操作数、运算符等)不需要直接映射到 UI,而显示内容和显示模式才需要驱动 UI 更新。

7.2 事件分发机制

所有按钮点击通过统一的入口分发:

onButtonClick(btn: CalcButtonInfo) {
  switch (btn.type) {
    case 'num':
      this.onNumber(btn.label);
      break;
    case 'dot':
      this.onDot();
      break;
    case 'op':
      this.onOperator(btn.label);
      break;
    case 'eq':
      this.onEquals();
      break;
    case 'clear':
      this.onClear();
      break;
    case 'back':
      this.onBackspace();
      break;
    case 'toggle':
      this.onToggleSign();
      break;
    case 'percent':
      this.onPercent();
      break;
  }
}

这种分发模式(Command Pattern)将 UI 事件与业务逻辑解耦。每个按钮类型对应一个独立的处理方法,代码清晰、可维护。

7.3 数字输入处理

数字输入需要处理多种场景:

onNumber(num: string) {
  // 场景1:刚显示完结果,输入新数字 → 清空重新开始
  if (this.isResultShown) {
    this.displayText = '';
    this.expressionText = '';
    this.isResultShown = false;
    this.operator = '';
  }
  // 场景2:等待第二个操作数 → 开始新的数字输入
  if (this.waitingForSecond) {
    this.displayText = '';
    this.waitingForSecond = false;
  }
  // 场景3:当前为 "0" → 替换为输入的数字
  // 场景4:正常追加数字
  let current = this.displayText;
  if (current === '0') {
    this.displayText = num;
  } else {
    this.displayText = current + num;
  }
}

场景分析:

场景 示例操作 期望行为
刚算出结果,按数字 12+3=7 清空显示,从 7 开始新计算
刚按了运算符,按数字 12 +3 清空默认 0,显示 3
屏幕显示 0,按数字 初始状态 → 5 0 被替换为 5
已有数字,继续按 456 追加为 456

7.4 小数点处理

小数点需要防止重复输入:

onDot() {
  // 场景1:等待第二个操作数
  if (this.waitingForSecond) {
    this.displayText = '0.';
    this.waitingForSecond = false;
    this.isResultShown = false;
    return;
  }
  // 场景2:刚显示完结果
  if (this.isResultShown) {
    this.displayText = '0.';
    this.expressionText = '';
    this.isResultShown = false;
    this.operator = '';
    return;
  }
  // 场景3:已有小数点则忽略
  if (!this.displayText.includes('.')) {
    this.displayText += '.';
  }
}

关键约束:displayText.includes('.') 检查当前是否已存在小数点。计算机在处理浮点数时,一个数字中只能有一个小数点,这是数学上的基本规则。

7.5 运算符处理

运算符是计算器中最复杂的逻辑之一,需要处理连续运算(链式计算):

onOperator(op: string) {
  let current = parseFloat(this.displayText);

  if (this.operator !== '' && !this.waitingForSecond) {
    // 连续运算:已有运算符且不是刚按完运算符
    // 例如:12 + 3 × → 先计算 12+3=15,再等 × 的第二操作数
    this.firstOperand = this.compute(this.firstOperand, current, this.operator);
    this.displayText = this.formatNumber(this.firstOperand);
    this.expressionText = this.formatNumber(this.firstOperand) + ' ' + op;
  } else {
    // 首次输入运算符
    this.firstOperand = current;
    this.expressionText = this.displayText + ' ' + op;
  }

  this.operator = op;
  this.waitingForSecond = true;
  this.isResultShown = false;
}

链式运算示例:

用户输入 12 + 3 × 4 = 时:

  1. 12 → displayText = “12”
  2. + → firstOperand = 12, operator = “+”, expressionText = “12 +”, waitingForSecond = true
  3. 3 → displayText = “3”
  4. × → 先计算 12+3=15,再设 firstOperand = 15, operator = “×”, expressionText = “15 ×”
  5. 4 → displayText = “4”
  6. = → 计算 15×4=60, 显示结果 60

这和真实科学计算器的行为一致——遇到新运算符时先计算前一步,而不是等待等号。

7.6 结果计算

等号处理需要细致考虑边缘情况:

onEquals() {
  // 无运算符时,仅显示 " ="
  if (this.operator === '') {
    this.expressionText = this.displayText + ' =';
    return;
  }

  // 连续按 =(在上次结果基础上继续运算)
  if (this.waitingForSecond && this.isResultShown) {
    let current = parseFloat(this.displayText);
    let result = this.compute(this.firstOperand, current, this.operator);
    this.expressionText = this.formatNumber(this.firstOperand)
      + ' ' + this.operator + ' ' + this.formatNumber(current) + ' =';
    this.displayText = this.formatNumber(result);
    this.firstOperand = result;
    this.isResultShown = true;
    return;
  }

  // 正常计算
  let current = parseFloat(this.displayText);
  let result = this.compute(this.firstOperand, current, this.operator);
  this.expressionText = this.formatNumber(this.firstOperand)
    + ' ' + this.operator + ' ' + this.formatNumber(current) + ' =';
  this.displayText = this.formatNumber(result);
  this.firstOperand = result;
  this.isResultShown = true;
  this.waitingForSecond = true;
}

连续按 = 的特性:

这是一个有趣且有用的交互细节。以 5 + 3 = 为例:

  • 第一次按 =:计算 5+3=8,显示 8
  • 第二次按 =:再次计算 8+3=11(在上次结果 8 的基础上继续加 3)
  • 第三次按 =:计算 11+3=14

这种"重复上一次运算"的行为在财务计算等场景中非常实用。

7.7 核心计算引擎

compute 方法封装了四则运算:

compute(a: number, b: number, op: string): number {
  switch (op) {
    case '+':
      return a + b;
    case '-':
      return a - b;
    case '×':
      return a * b;
    case '÷':
      return b !== 0 ? a / b : 0;  // 除零保护
    default:
      return b;
  }
}

这里的除零保护(b !== 0 ? a / b : 0)避免程序崩溃。在实际产品中,更好的做法是显示"除数不能为零"等提示信息,当前简化实现直接返回 0。

7.8 数字格式化

formatNumber 负责将计算结果转换为友好的显示字符串:

formatNumber(n: number): string {
  if (!isFinite(n)) {
    return '错误';
  }
  let str = n.toString();
  // 避免过长的小数显示
  if (str.includes('.') && str.split('.')[1].length > 10) {
    return n.toFixed(10).replace(/\.?0+$/, '');
  }
  return str;
}

格式化策略:

  1. !isFinite(n) 检查:捕获 InfinityNaN 等非有限值,显示"错误"
  2. 小数长度限制:当小数部分超过 10 位时,使用 toFixed(10) 截断,并通过正则 \.?0+$ 去除尾部多余的零

7.9 辅助功能实现

清空(C):

onClear() {
  this.displayText = '0';
  this.expressionText = '';
  this.firstOperand = 0;
  this.operator = '';
  this.waitingForSecond = false;
  this.isResultShown = false;
}

清空操作重置所有状态到初始值,是计算器最基础的功能。

退格(⌫):

onBackspace() {
  if (this.isResultShown) return;   // 结果态不允许退格
  if (this.waitingForSecond) return; // 等待第二操作数时不允许退格

  let current = this.displayText;
  if (current.length === 1 || (current.length === 2 && current.startsWith('-'))) {
    this.displayText = '0';   // 只剩一位(或负号+一位)时归零
  } else {
    this.displayText = current.substring(0, current.length - 1);
  }
}

退格的处理考虑了对负数的影响:-5 退格后应该是 0 而不是 -

正负切换(±):

onToggleSign() {
  if (this.displayText === '0') return;
  if (this.displayText.startsWith('-')) {
    this.displayText = this.displayText.substring(1);
  } else {
    this.displayText = '-' + this.displayText;
  }
}

简单的字符串前缀操作,高效且无精度问题。

百分比(%):

onPercent() {
  let num = parseFloat(this.displayText);
  let result = num / 100;
  this.displayText = this.formatNumber(result);
  this.expressionText = '';
  this.isResultShown = true;
}

百分比操作将当前值除以 100。这是标准的计算器百分比行为。


八、表达式显示:让运算过程可见

8.1 为什么需要表达式显示

传统计算器通常只有一个数字显示行,用户在运算过程中只能看到当前输入的数字,看不到完整的运算表达式。这带来了两个问题:

  1. 认知负担:用户需要记住前面输入了什么数字和运算符
  2. 无法确认:无法确认之前输入的运算符是否准确

我们的计算器通过新增的 expressionText 状态,在独立于结果行的上方显示完整的运算过程,大大提升了用户体验。

8.2 表达式的维护逻辑

表达式文本在三个地方更新:

按下运算符时:

// 首次运算符:显示 "12 +"
this.expressionText = this.displayText + ' ' + op;

// 链式运算:显示 "15 ×"
this.expressionText = this.formatNumber(this.firstOperand) + ' ' + op;

按下等于时:

// 显示 "12 + 3 ="
this.expressionText = this.formatNumber(this.firstOperand)
  + ' ' + this.operator + ' ' + this.formatNumber(current) + ' =';

清空输入时:

this.expressionText = '';  // 清空表达式

8.3 用户交互的完整流程

8 × 3.5 - 2 = 为例,跟踪每一步的状态变化:

操作 displayText expressionText firstOperand operator waitingForSecond
初始 0 (空) 0 (空) false
8 8 (空) 0 (空) false
× 8 “8 ×” 8 × true
3 3 “8 ×” 8 × true → false
. 3. “8 ×” 8 × false
5 3.5 “8 ×” 8 × false
- 28 “28 -” 28 - true
2 2 “28 -” 28 - true → false
= 26 “28 - 2 =” 26 (空) true

每一步的状态转换清晰可追踪。这是声明式 UI 的优势所在——数据和 UI 一一对应,开发者只需维护数据状态,框架负责将其映射到界面。


九、布局优化与自适应设计

9.1 初始布局的问题

在最初的实现中,布局存在以下问题:

  1. 固定百分比溢出:按钮使用 23% × 4 + 48% = 140% 的宽度总和,导致最后一行的按钮超出屏幕
  2. 固定高度不弹性:按钮行固定 80px 高度,无法在不同屏幕尺寸下自适应
  3. 显示/按钮比例固定:30% / 70% 的硬编码比例在不同分辨率的设备上效果差异大
  4. 缺少视觉分隔:显示区和按钮区之间没有视觉边界

9.2 优化后的弹性布局

优化后的布局采用 layoutWeight 实现真正的弹性自适应:

Column() {
  // 显示区 — layoutWeight: 1
  // 占据总可用高度的 1/3(相对于按钮区的 2/3)
  Column() { /* ... */ }
    .layoutWeight(1)

  Divider().height(0.5).color('#38383A').width('92%')

  // 按钮区 — layoutWeight: 2
  // 占据总可用高度的 2/3
  Column() { /* ... */ }
    .layoutWeight(2)
}

为什么 layoutWeight 优于固定百分比?

  • 自适应:无论屏幕尺寸如何,比例自动保持
  • 无溢出风险:不会出现宽度总和超过 100% 的问题
  • 与其他布局约束协同:如 marginpadding 自动参与空间计算

9.3 按钮弹性宽度

getButtonWeight(btn: CalcButtonInfo): number {
  return btn.label === '0' ? 2 : 1;
}

同样采用 layoutWeight 分配按钮宽度。在 Row 布局中,layoutWeightRow 的可用宽度按权重比例分配给子组件:

  • 标准行:4 个按钮 × 权重 1 = 总权重 4,各占 25%
  • 最后一行:0 键权重 2 + 其他 3 个按钮各权重 1 = 总权重 5,0 键占 40%,其余各占 20%

由于每行按钮的 margin({ left: 4, right: 4 }) 也被包含在权重分配中,按钮之间的间距保持均匀。

9.4 分隔线的视觉作用

Divider()
  .height(0.5)
  .color('#38383A')
  .width('92%')

分隔线虽然只占据 0.5px 的高度,但它在视觉上起到了关键的"区域分割"作用:

  • 明确划分"显示区"和"操作区"的边界
  • 深灰色(#38383A)在深色背景中不刺眼
  • width('92%') 保持两侧留白,视觉上更精致

十、构建验证与调试

10.1 使用 hvigorw 构建

HarmonyOS NEXT 使用 hvigor 作为构建工具。通过命令行可以快速完成编译验证:

# 查看版本
hvigorw --version
# 输出:6.23.5

# 构建 HAP 包(跳过签名,用于开发调试)
hvigorw assembleHap --daemon=false --analyze=false

构建过程输出:

UP-TO-DATE :entry:default@PreBuild...
Finished :entry:default@CompileArkTS... after 1 s 814 ms
Finished :entry:default@PackageHap... after 652 ms
Finished :entry:default@PackingCheck... after 9 ms
BUILD SUCCESSFUL in 4 s 671 ms

关键阶段说明:

构建阶段 说明 典型耗时
PreBuild 预构建检查 即时
CompileArkTS ArkTS 源码编译(语法检查、类型检查、优化) 2-5 秒
PackageHap 打包为 .hap 文件 < 1 秒
PackingCheck 包内容合规性检查 < 0.1 秒
SignHap HAP 签名(仅配置了签名时执行) 即时

10.2 常见编译错误处理

类型错误:

ERROR: ArkTS:ERROR File: pages/index1.ets:XX:XX
Type 'string' is not assignable to type 'number'

ArkTS 类型检查较为严格,确保所有类型匹配。

资源引用错误:

ERROR: Resource $media:xxx not found in module 'entry'

资源文件名需与引用完全匹配(大小写敏感)。

包名/路由错误:

ERROR: The page 'pages/index1' declared in main_pages.json is not found

确保文件名、路径、注册名三者一致。

10.3 调试技巧

  1. 使用 hilog 输出日志hilog.info(0x0000, 'testTag', 'value: %{public}s', value);

  2. 弹出 Toast 提示:在 API 24 中可使用 promptAction.showToast({ message: '提示信息' })

  3. 预览器调试:DevEco Studio 内置的 Previewer 支持实时预览,无需模拟器即可验证 UI 布局


十一、完整代码清单

以下是计算器应用的完整代码(entry/src/main/ets/pages/index1.ets),共 334 行:

@Entry
@Component
struct Calculator {
  @State displayText: string = '0';
  @State expressionText: string = '';
  @State isResultShown: boolean = false;

  private firstOperand: number = 0;
  private operator: string = '';
  private waitingForSecond: boolean = false;

  private buttons: CalcButtonInfo[][] = [
    [
      { label: 'C', type: 'clear' },
      { label: '±', type: 'toggle' },
      { label: '%', type: 'percent' },
      { label: '÷', type: 'op' }
    ],
    [
      { label: '7', type: 'num' },
      { label: '8', type: 'num' },
      { label: '9', type: 'num' },
      { label: '×', type: 'op' }
    ],
    [
      { label: '4', type: 'num' },
      { label: '5', type: 'num' },
      { label: '6', type: 'num' },
      { label: '-', type: 'op' }
    ],
    [
      { label: '1', type: 'num' },
      { label: '2', type: 'num' },
      { label: '3', type: 'num' },
      { label: '+', type: 'op' }
    ],
    [
      { label: '0', type: 'num' },
      { label: '.', type: 'dot' },
      { label: '⌫', type: 'back' },
      { label: '=', type: 'eq' }
    ]
  ];

  build() {
    Column() {
      // ======== 显示区域 ========
      Column() {
        // 表达式行:显示 "12 + " 或空白
        Text(this.expressionText)
          .fontSize(22)
          .fontColor('#8E8E93')
          .textAlign(TextAlign.End)
          .width('100%')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .padding({ right: 6 })

        // 当前输入 / 结果行
        Text(this.displayText)
          .fontSize(56)
          .fontWeight(FontWeight.Regular)
          .fontColor(Color.White)
          .textAlign(TextAlign.End)
          .width('100%')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%')
      .layoutWeight(1)
      .justifyContent(FlexAlign.End)
      .padding({ left: 20, right: 20, bottom: 12 })

      // ======== 按钮分隔线 ========
      Divider()
        .height(0.5)
        .color('#38383A')
        .width('92%')

      // ======== 按钮区域 ========
      Column() {
        ForEach(this.buttons, (row: CalcButtonInfo[]) => {
          Row() {
            ForEach(row, (btn: CalcButtonInfo) => {
              this.createButton(btn)
            }, (btn: CalcButtonInfo) => btn.label + btn.type)
          }
          .width('100%')
          .layoutWeight(1)
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        }, (row: CalcButtonInfo[]) => row[0].label + row[0].type)
      }
      .width('100%')
      .layoutWeight(2)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1C1C1E')
  }

  @Builder
  createButton(btn: CalcButtonInfo) {
    Button(btn.label)
      .layoutWeight(this.getButtonWeight(btn))
      .height('100%')
      .borderRadius(40)
      .fontSize(28)
      .fontWeight(FontWeight.Regular)
      .backgroundColor(this.getButtonColor(btn))
      .fontColor(this.getFontColor(btn))
      .margin({ left: 4, right: 4 })
      .onClick(() => {
        this.onButtonClick(btn);
      })
  }

  getButtonWeight(btn: CalcButtonInfo): number {
    return btn.label === '0' ? 2 : 1;
  }

  getButtonColor(btn: CalcButtonInfo): ResourceColor {
    switch (btn.type) {
      case 'num':
      case 'dot':
        return '#333333';
      case 'op':
      case 'eq':
        return '#FF9500';
      case 'clear':
      case 'toggle':
      case 'percent':
      case 'back':
        return '#A5A5A5';
      default:
        return '#333333';
    }
  }

  getFontColor(btn: CalcButtonInfo): ResourceColor {
    switch (btn.type) {
      case 'clear':
      case 'toggle':
      case 'percent':
      case 'back':
        return '#1C1C1E';
      default:
        return Color.White;
    }
  }

  onButtonClick(btn: CalcButtonInfo) {
    switch (btn.type) {
      case 'num':
        this.onNumber(btn.label);
        break;
      case 'dot':
        this.onDot();
        break;
      case 'op':
        this.onOperator(btn.label);
        break;
      case 'eq':
        this.onEquals();
        break;
      case 'clear':
        this.onClear();
        break;
      case 'back':
        this.onBackspace();
        break;
      case 'toggle':
        this.onToggleSign();
        break;
      case 'percent':
        this.onPercent();
        break;
    }
  }

  onNumber(num: string) {
    if (this.isResultShown) {
      this.displayText = '';
      this.expressionText = '';
      this.isResultShown = false;
      this.operator = '';
    }
    if (this.waitingForSecond) {
      this.displayText = '';
      this.waitingForSecond = false;
    }
    let current = this.displayText;
    if (current === '0') {
      this.displayText = num;
    } else {
      this.displayText = current + num;
    }
  }

  onDot() {
    if (this.waitingForSecond) {
      this.displayText = '0.';
      this.waitingForSecond = false;
      this.isResultShown = false;
      return;
    }
    if (this.isResultShown) {
      this.displayText = '0.';
      this.expressionText = '';
      this.isResultShown = false;
      this.operator = '';
      return;
    }
    if (!this.displayText.includes('.')) {
      this.displayText += '.';
    }
  }

  onOperator(op: string) {
    let current = parseFloat(this.displayText);
    if (this.operator !== '' && !this.waitingForSecond) {
      this.firstOperand = this.compute(this.firstOperand, current, this.operator);
      this.displayText = this.formatNumber(this.firstOperand);
      this.expressionText = this.formatNumber(this.firstOperand) + ' ' + op;
    } else {
      this.firstOperand = current;
      this.expressionText = this.displayText + ' ' + op;
    }
    this.operator = op;
    this.waitingForSecond = true;
    this.isResultShown = false;
  }

  onEquals() {
    if (this.operator === '') {
      this.expressionText = this.displayText + ' =';
      return;
    }
    if (this.waitingForSecond && this.isResultShown) {
      let current = parseFloat(this.displayText);
      let result = this.compute(this.firstOperand, current, this.operator);
      this.expressionText = this.formatNumber(this.firstOperand)
        + ' ' + this.operator + ' ' + this.formatNumber(current) + ' =';
      this.displayText = this.formatNumber(result);
      this.firstOperand = result;
      this.isResultShown = true;
      return;
    }
    let current = parseFloat(this.displayText);
    let result = this.compute(this.firstOperand, current, this.operator);
    this.expressionText = this.formatNumber(this.firstOperand)
      + ' ' + this.operator + ' ' + this.formatNumber(current) + ' =';
    this.displayText = this.formatNumber(result);
    this.firstOperand = result;
    this.isResultShown = true;
    this.waitingForSecond = true;
  }

  onClear() {
    this.displayText = '0';
    this.expressionText = '';
    this.firstOperand = 0;
    this.operator = '';
    this.waitingForSecond = false;
    this.isResultShown = false;
  }

  onBackspace() {
    if (this.isResultShown) return;
    if (this.waitingForSecond) return;
    let current = this.displayText;
    if (current.length === 1 || (current.length === 2 && current.startsWith('-'))) {
      this.displayText = '0';
    } else {
      this.displayText = current.substring(0, current.length - 1);
    }
  }

  onToggleSign() {
    if (this.displayText === '0') return;
    if (this.displayText.startsWith('-')) {
      this.displayText = this.displayText.substring(1);
    } else {
      this.displayText = '-' + this.displayText;
    }
  }

  onPercent() {
    let num = parseFloat(this.displayText);
    let result = num / 100;
    this.displayText = this.formatNumber(result);
    this.expressionText = '';
    this.isResultShown = true;
  }

  compute(a: number, b: number, op: string): number {
    switch (op) {
      case '+': return a + b;
      case '-': return a - b;
      case '×': return a * b;
      case '÷': return b !== 0 ? a / b : 0;
      default: return b;
    }
  }

  formatNumber(n: number): string {
    if (!isFinite(n)) return '错误';
    let str = n.toString();
    if (str.includes('.') && str.split('.')[1].length > 10) {
      return n.toFixed(10).replace(/\.?0+$/, '');
    }
    return str;
  }
}

interface CalcButtonInfo {
  label: string;
  type: string;
}

十二、扩展与改进方向

基于当前实现,可以在以下方向做进一步扩展:

12.1 科学计算功能

增加三角函数(sin、cos、tan)、指数、对数、开方等科学运算功能。可以通过增加第二页按钮或通过模式切换(基础 / 科学模式)实现。

12.2 运算历史记录

维护一个历史运算列表,用户可以通过上滑查看之前的所有运算记录。可以使用 @State 数组和历史记录弹窗实现。

12.3 键盘输入支持

当前计算器仅支持屏幕按钮点击。可以扩展为支持外接键盘的数字和运算符输入,提升使用便捷性。

12.4 横竖屏适配

module.json5abilities 配置中设置 orientation,并针对横屏模式提供不同的布局方案(如增加更多科学计算按钮)。

12.5 深色/浅色主题切换

利用系统 ConfigurationConstant.ColorMode 监听系统主题变化,提供深浅两套颜色方案。

12.6 更精确的浮点运算

JavaScript / TypeScript 的浮点运算存在精度问题(如 0.1 + 0.2 !== 0.3)。可以引入十进制运算库或实现定点数运算来解决。


十三、总结

本文通过一个完整的计算器应用案例,系统地介绍了 HarmonyOS NEXT 上使用 ArkTS 进行应用开发的全流程。从项目结构、声明式 UI 概念、布局实现、状态管理、业务逻辑到构建验证,涵盖了 ArkTS 应用开发的各个关键环节。

通过这个案例,我们可以看到 ArkTS 的几个核心优势:

  1. 声明式 UI 简洁直观:UI 结构即代码结构,build() 方法中的布局嵌套直接反映界面视觉层次
  2. 响应式状态管理高效@State 装饰器让 UI 与数据自动同步,开发者只需关注业务逻辑
  3. 类型安全减少 bug:ArkTS 基于 TypeScript 的类型系统在编译阶段就能发现大量潜在错误
  4. 工具链完善:从代码编写、实时预览到构建打包,DevEco Studio 和 hvigor 提供了接近原生开发体验的工具支持

HarmonyOS NEXT 作为新一代国产操作系统,其应用生态正在蓬勃发展。掌握 ArkTS 开发技能,意味着能够为这个快速增长的平台贡献应用,也能在这个新赛道中占得先机。

无论是初学者还是跨平台开发者,ArkTS 的低学习曲线和完善的开发工具都使得入门的门槛相对较低。计算器应用虽然简单,但它涵盖了声明式 UI 开发的核心概念——组件化、状态管理、事件处理、布局系统——这些知识和经验可以无缝迁移到更复杂的应用开发中。

希望本文能为你在 HarmonyOS NEXT 应用开发之路上提供一份有价值的参考。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐