从零开发鸿蒙原生计算器——完整实战记录与踩坑指南
一、前言:为什么选择计算器作为入门项目?
在上一篇文章中,我们已经完成了开发环境的配置。环境搭好了,接下来最重要的就是找一个合适的练手项目。
为什么我强烈推荐计算器作为鸿蒙开发的第一个实战项目?
1.1 计算器项目的独特价值
计算器虽小,但五脏俱全。它涵盖了移动应用开发的核心知识点:
✅ UI布局:Column、Row、ForEach 等 ArkUI 核心组件
✅ 事件处理:按钮点击事件、状态管理
✅ 逻辑处理:运算逻辑、错误处理、边界情况
✅ 数据绑定:@State 装饰器的实战应用
✅ 样式设计:颜色、字体、圆角、间距等视觉要素
更重要的是,计算器的需求明确、逻辑清晰、容易验证。你不需要纠结"我要做什么",只需要专注于"怎么用 ArkUI 实现它"。
1.2 学习目标
通过本项目的开发,你将掌握:
- ArkUI 声明式开发范式:如何用代码描述 UI 界面
- 状态管理:@State 装饰器的正确使用场景
- 事件处理:按钮点击、文本输入等交互逻辑
- 列表渲染:ForEach 在网格布局中的应用
- 条件渲染:如何根据状态动态显示/隐藏 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:选择项目模板
- 启动 DevEco Studio
- 点击 File → New → Create Project
- 在模板选择界面,选择 Empty Ability
- 点击 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:指定应用的主 Abilityabilities:声明应用的 Ability(类似 Android 的 Activity)
3.3 为什么只修改 Index.ets?
很多初学者会问:“这么多文件,我需要修改哪些?”
答案:对于计算器这种简单应用,我们只需要修改一个文件——entry/src/main/ets/pages/Index.ets。
原因如下:
- ArkUI 的声明式开发范式:UI 和逻辑都写在一个文件中
- 单页面应用:计算器只有一个页面,不需要多页面路由
- 无需额外权限:计算器不需要网络、存储等敏感权限
- 无需额外配置:默认的 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 可以分为两个主要区域:
- 显示区域:显示当前输入和计算结果
- 按钮区域:数字键、运算符键、功能键
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')
}
}
关键点解释:
-
@State 装饰器:
@State displayText: string = '0'- 作用:当
displayText的值发生变化时,UI 会自动重新渲染 - 这是 ArkUI 响应式编程的核心机制
-
布局权重:
.height(160):显示区域固定高度 160px.layoutWeight(1):按钮区域占据剩余所有空间
-
文本对齐:
.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 })
})
}
关键点解释:
-
嵌套 ForEach:
- 外层 ForEach:遍历每一行
- 内层 ForEach:遍历每一行中的按钮
-
条件渲染:
if (label === ''):如果是占位符,渲染一个透明的按钮else:渲染正常的按钮
-
按钮属性:
.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 // 最大输入位数
// ... 其他代码 ...
}
状态变量分类:
-
@State 变量(会触发 UI 刷新):
displayText:显示文本
-
普通变量(不会触发 UI 刷新):
currentInput、previousInput:中间计算状态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)
}
}
设计优点:
- 统一入口:所有按钮事件都经过这个方法,便于统一处理(如错误状态拦截)
- 清晰的分发:使用
switch-case语句,逻辑清晰 - 易于扩展:新增按钮类型只需要增加一个
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
}
关键点:
-
新输入 vs 连续输入:
- 新输入:替换当前显示(如按了运算符后,再按数字)
- 连续输入:拼接数字(如依次按 “1”、“2”、“3”)
-
长度限制:
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 =
- 按
5:currentInput = "5" - 按
+:previousInput = "5",currentOperator = "+",isNewInput = true - 按
3:currentInput = "3"(新输入,替换) - 按
+:检测到currentOperator不为空,且isNewInput = false,先计算5 + 3 = 8,然后previousInput = "8",currentOperator = "+",isNewInput = true - 按
2:currentInput = "2"(新输入,替换) - 按
=:计算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
}
关键点:
-
除零保护:
if (curr === 0) { this.showError('Error') return } -
溢出检查:
if (!isFinite(result)) { this.showError('Error') return }isFinite()函数检查数值是否是有穷的(不是Infinity或NaN)
-
结果格式化:
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
}
关键点:
-
科学计数法:
- 极大数字(如
1e20)或极小数字(如1e-20),转换为科学计数法显示
- 极大数字(如
-
浮点数精度处理:
const str = num.toPrecision(12) let result = String(parseFloat(str))- 问题:
0.1 + 0.2 = 0.30000000000000004(浮点数精度问题) - 解决:
parseFloat(num.toPrecision(12))去掉多余的小数位
- 问题:
-
长度截断:
- 如果格式化后的数字仍然过长,截断到
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 自动重新渲染currentInput和previousInput只是内部逻辑状态,它们变化时不需要刷新界面- 只有
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.2→0.30000000000000004→parseFloat((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 真机调试
模拟器上测试通过后,还需要在真机上测试。
步骤:
- 用 USB 线连接手机和电脑
- 在手机上开启开发者模式(设置 → 关于手机 → 连续点击版本号 7 次)
- 开启 USB 调试
- 在 DevEco Studio 的设备列表中,选择你的手机
- 点击 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 文档 |
| 逻辑处理类 | 链式运算、边界情况 | 写单元测试,覆盖所有边界情况 |
给新手的建议:
- 不要急于求成:先把官方文档的 “快速入门” 章节看完,再动手写代码
- 多打印日志:遇到奇怪问题时,用
console.info()打印变量值,帮助你定位问题 - 善用搜索引擎:你踩的坑,大概率别人也踩过。CSDN、华为开发者论坛都有很多答案
- 真机调试尽早配置:模拟器只是辅助,真机调试才能发现真实问题
9.3 下一步学习计划
计算器项目完成后,你已经掌握了 ArkUI 开发的核心知识点。接下来,我推荐你尝试以下项目:
- 天气查询应用:学习网络请求、JSON 解析、列表渲染
- 本地音乐播放器:学习音频播放、文件读取、后台任务
- 健康数据可视化:学习图表绘制、数据持久化、分享功能
十、相关资源
- 华为开发者官网: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
希望这篇文章能帮助你顺利入门鸿蒙开发!如果觉得有用,请不吝点赞 👍
更多推荐

所有评论(0)