鸿蒙原生开发——从零构建计算器
2026/6/12 10:45:27 网站建设 项目流程

一、引言

计算器是每个操作系统必带的应用。从 iOS 的计算器到 Android 的 Google Calculator,从 Windows 的计算器到 macOS 的 Spotlight 计算功能,计算器的设计语言在过去二十年几乎没变过——一个显示区和一组按钮。这种稳定性不是保守,而是因为 4×5 的按钮网格已经达到了输入效率和认知负担的最优平衡点。

从技术角度看,计算器是一个经典的状态机问题。它涉及四种状态——初始态(显示 0)、输入态(构建操作数)、待运算态(按下运算符后等待第二个操作数)、结果态(按下等号后显示结果)。输入处理需要区分"追加数字"和"覆盖数字",小数点不能重复输入,运算需要处理除零和连续运算的场景。

本文将用 ArkUI 从零构建一个标准计算器。功能包括:四则运算(加减乘除)、百分比、小数、退格删除、全部清除、连续运算、除零/无穷大保护。按钮网格采用嵌套ForEach动态渲染,运算核心是一个compute()纯函数。

阅读完本文,你将能够:

  • 设计计算器状态机(四种状态 + 状态转换规则)
  • 使用嵌套ForEach构建按钮网格布局
  • 实现输入处理逻辑(数字追加、小数点保护、退格)
  • 处理除零和无穷大等边界情况
  • toPrecision()控制浮点数显示精度

二、状态机设计

2.1 四个核心状态

计算器的状态不是用一个枚举变量表示的,而是由四个变量组合描述:

@Statedisplay:string='0';// 显示屏上当前可见的内容privateprevValue:string='';// 运算符左侧的操作数(待运算值)privatependingOp:string='';// 当前等待执行的运算符privatenewInput:boolean=true;// 下一次输入是否覆盖显示屏

这四个变量组合出四种计算器状态:

状态displayprevValuependingOpnewInput用户看到的
初始态'0'''''true显示 0,等待输入
输入态'123'''''false正在输入第一个数
待运算态'123''123''+'true已按 +,等待第二个数
结果态'246'''''true显示计算结果

初始态是用户打开计算器时的状态——显示屏显示 0,没有之前的运算历史,准备好接收第一次输入。

输入态发生在用户开始按数字键时。newInput翻转为false,后续数字追加到display末尾而非覆盖。

待运算态在用户按下运算符(+、−、×、÷)后进入。display的当前值被复制到prevValuependingOp记录运算符,newInput重置为true——因为下一个数字应该覆盖显示屏而非追加。

结果态在用户按下等号(=)后进入。compute()执行计算,结果写入displayprevValuependingOp清空,newInput重置为true

这四个状态覆盖了计算器的所有交互路径:输入 → 运算 → 再输入 → 结果,以及 AC 清除到任何位置的重置。

2.2 状态转换图

初始态 ──(按数字)──→ 输入态 输入态 ──(按数字)──→ 输入态(追加) 输入态 ──(按运算符)─→ 待运算态 待运算态 ──(按数字)─→ 输入态(新操作数) 待运算态 ──(按运算符)─→ 待运算态(更换运算符) 待运算态 ──(按=)─→ 结果态 结果态 ──(按数字)──→ 输入态(开始新运算) 结果态 ──(按运算符)─→ 待运算态(继续运算) 任意态 ──(按AC)──→ 初始态

两个值得注意的转换:

待运算态按运算符 → 待运算态:用户可能在输入第二个操作数之前改变主意。例如先按 “+” 再决定按 “×”,新的运算符替换旧的。这不需要计算,只需更新pendingOp

结果态按运算符 → 待运算态:用户可能在看到一个计算结果后想继续运算。例如 “6×7=42”,然后按 “+5=47”。此时display的 42 成为新的prevValue,用户输入 5 后按 “=” 得到 47。这种链式运算是优秀计算器的标志。

三、按钮网格布局

3.1 CalcButton 接口

每个按钮用统一的接口描述其外观和行为:

interfaceCalcButton{label:string;color:string;activeColor:string;fontColor:string;span:number;// 1 或 2 列宽}
  • label:按钮文字。数字 0-9、运算符(+、−、×、÷)、功能键(AC、⌫、%、.、=)
  • color:按钮背景色。数字白(#FFFFFF)、运算符蓝(#1677FF)、功能灰(#E0E0E8
  • activeColor:按下时的背景色。比正常色稍暗,提供触觉反馈的视觉替代
  • fontColor:文字颜色。数字深色(#1a1a2e)、运算符白色(#FFFFFF)、功能深色
  • span:列跨度。1 为标准宽度(25%),2 为双倍宽度(50%)。只有 “0” 按钮使用 span=2

3.2 五行四列网格

按钮网格是一个二维数组,5 行 × 4 列:

privatebuttons:CalcButton[][]=[[{label:'AC',...},{label:'⌫',...},{label:'%',...},{label:'÷',...}],[{label:'7',...},{label:'8',...},{label:'9',...},{label:'×',...}],[{label:'4',...},{label:'5',...},{label:'6',...},{label:'−',...}],[{label:'1',...},{label:'2',...},{label:'3',...},{label:'+',...}],[{label:'0',span:2,...},{label:'.',...},{label:'=',...}],];

用嵌套ForEach渲染:

ForEach(this.buttons,(row:CalcButton[])=>{Row(){ForEach(row,(btn:CalcButton)=>{Text(btn.label).fontSize(this.btnFontSize(btn.label)).fontColor(btn.fontColor).fontWeight(FontWeight.Bold).width(this.buttonWidth(btn)).height(72).textAlign(TextAlign.Center).backgroundColor(btn.color).borderRadius(BorderRadius.MD).margin(3).onClick(()=>{this.handlePress(btn.label);})})}.width('100%')})

每行是一个Row组件,行内每个按钮的宽度由buttonWidth()计算:

buttonWidth(btn:CalcButton):string{returnbtn.span===2?'50%':'25%';}

span=2的按钮占据 50% 宽度(25% × 2),其他按钮各占 25%。这种百分比布局让按钮网格自动适配不同屏幕宽度。

3.3 按钮颜色分类

三种按钮颜色构建了清晰的视觉层级:

constBTN_NUMBER:string='#FFFFFF';// 数字按钮:白底constBTN_OPERATOR:string='#1677FF';// 运算符按钮:蓝底白字constBTN_FUNC:string='#E0E0E8';// 功能按钮:浅灰底

白色数字按钮面积最大、视觉重量最轻,作为整个界面的"背景"。蓝色运算符按钮是最显眼的元素,引导用户的视线流动——输入数字后,眼睛自然会寻找蓝色按钮进行下一步操作。灰色功能按钮视觉重量介于两者之间,表明它们是辅助性操作。

运算符按钮的文字字号也更大(22sp vs 18sp),进一步强调其重要性:

btnFontSize(label:string):number{if(label==='+'||label==='−'||label==='×'||label==='÷'||label==='='){returnFontSize.HEADLINE;// 22}returnFontSize.TITLE;// 18}

等号(=)也使用大字号,因为它是计算动作的终点——用户完成输入后,注意力会自然聚集到等号按钮上。

3.4 为什么不使用 Grid 组件

ArkUI 提供了Grid+GridItem组件用于网格布局,但这里选择了嵌套Column > Row的方案。原因是:

  1. 跨列需求:“0” 按钮需要 span=2(占两列)。用 Grid 实现需要设置GridItemcolumnSpan,但语法更冗长。
  2. 行高一致性:每行 72vp 高度 + 3vp 间距在 Row 中更容易控制。Grid 的行高需要设置rowsTemplate,对 5 行固定高度的场景没有优势。
  3. 代码可读性:五行 Row,每行四个按钮——这个布局用 Row 嵌套更接近设计师的思维模型。

四、输入处理逻辑

4.1 数字输入

数字输入是最高频的操作,它的处理逻辑直接影响用户体验:

if(this.newInput){this.display=label;// 覆盖当前显示this.newInput=false;}else{this.display=this.display==='0'?label:this.display+label;// 追加或替换前置0}

三种情况:

  • newInput = true(刚按了运算符、等号、或初始态):新数字覆盖显示屏,就像在一张白纸上写字。
  • display = ‘0’ 且 newInput = false:用新数字替换前置的 0。例如显示屏是 “0”,用户按 “5”,结果应为 “5” 而非 “05”。
  • display ≠ ‘0’ 且 newInput = false:追加数字到末尾。例如 “12” + 按 “3” → “123”。

4.2 小数点输入

小数点有特殊的防重复逻辑——一个数字中只能有一个小数点:

if(label==='.'){if(this.display.indexOf('.')!==-1&&!this.newInput)return;// 已有小数点,忽略if(this.newInput){this.display='0.';// 新输入从小数点开始 → 前缀0this.newInput=false;return;}this.display=this.display+'.';return;}

关键设计:当newInput为 true 时直接输入小数点,显示屏显示 “0.” 而非 “.”。这是因为 “.5” 在某些文化中可能造成混淆,而 “0.5” 是通用的数字表示。

4.3 退格删除

⌫ 按钮删除最后一个字符,但有两个边界条件:

if(label==='⌫'){if(!this.newInput&&this.display.length>1){this.display=this.display.substring(0,this.display.length-1);}elseif(!this.newInput&&this.display.length===1){this.display='0';this.newInput=true;}return;}
  • newInput = true:什么都不做。此时显示屏刚被重置(如按了运算符),没有可删除的输入。
  • display.length > 1:正常删除最后一个字符。例如 “123” → “12”。
  • display.length === 1:最后一个字符,删除后显示屏回到 “0” + newInput 状态。例如 “5” → “0”。

4.4 全部清除

AC 按钮把所有状态重置为初始值:

if(label==='AC'){this.display='0';this.displayExpr='';this.prevValue='';this.pendingOp='';this.newInput=true;return;}

每一次对displayprevValuependingOpnewInput的重置都是在重建初始态。AC 是"万能逃生按钮"——无论计算器处于什么状态,按一下就能回到起点。

五、运算逻辑

5.1 compute 纯函数

运算的核心是一个纯函数,接收两个操作数和一个运算符,返回结果:

compute(a:number,op:string,b:number):number{if(op==='+')returna+b;if(op==='−')returna-b;if(op==='×')returna*b;if(op==='÷')returnb!==0?a/b:NaN;if(op==='%')returna/100;returnb;}

纯函数的好处是:没有副作用、不依赖任何组件状态、输入相同输出必然相同。这意味着它可以被单独测试,不需要渲染整个 UI。

除零保护b !== 0 ? a / b : NaN。当除数为 0 时返回NaN(Not a Number),而不是让 JavaScript 返回InfinityNaNformatResult()中被转换为 “错误” 提示,比Infinity对普通用户更友好。

百分比a / 100。例如输入 50 然后按 % → 0.5。这是一个简化的实现——真实计算器中的百分比行为更复杂(例如 “200+10%=” 应等于 220),但简化版本已经能覆盖百分比的基本用例。

5.2 运算符按下

当用户按下 +、−、×、÷ 时:

if(label==='+'||label==='−'||label==='×'||label==='÷'){constcur=parseFloat(this.display);if(isNaN(cur))return;if(this.pendingOp!==''&&!this.newInput){// 链式运算:已经有一个待执行的运算,先算出结果constprev=parseFloat(this.prevValue);constresult=this.compute(prev,this.pendingOp,cur);this.display=this.formatResult(result);this.prevValue=this.formatResult(result);}else{this.prevValue=this.display;}this.pendingOp=label;this.displayExpr=this.prevValue+' '+label;this.newInput=true;return;}

两种路径:

  1. 无待执行运算pendingOp为空或刚输入了新数字):直接将当前显示值复制到prevValue,记录运算符,设置newInput = true
  2. 有链式运算pendingOp不为空且不是刚重置的输入):先执行前一个运算,将结果显示在屏幕上,再记录新运算符。例如用户输入 “6×7=” → 42,然后按 “+5=” → 47。链式运算让用户可以在一次交互中连续做多步计算,不需要每次都按 “=” 再继续。

displayExpr用于在显示屏上方显示计算过程,帮助用户跟踪当前的运算上下文。例如用户按 "123 + ",上方显示 “123 +”,下方的display准备接收第二个数。

5.3 等号计算

按下等号执行当前运算并显示结果:

if(label==='='){if(this.pendingOp!==''){constcur=parseFloat(this.display);constprev=parseFloat(this.prevValue);constresult=this.compute(prev,this.pendingOp,cur);this.displayExpr=this.prevValue+' '+this.pendingOp+' '+this.display+' =';this.display=this.formatResult(result);this.prevValue='';this.pendingOp='';this.newInput=true;}return;}

只有在pendingOp不为空时才有实际计算——如果用户连续按 “=” 而之前已经计算过,第二个 “=” 不产生任何效果(pendingOp已被清空)。这是一个有意的简化:iOS 计算器在连续按 “=” 时会重复执行最后一个运算,但实现这个功能需要记录"最后一个操作数",复杂度增加不少。

5.4 精度控制与格式化

浮点数运算会产生类似0.1 + 0.2 = 0.30000000000000004的精度问题。formatResult()负责将计算结果格式化为可读的字符串:

formatResult(n:number):string{if(isNaN(n))return'错误';if(!isFinite(n))return'∞';consts=n.toString();returns.length>12?n.toPrecision(10):s;}

三种边界情况处理:

  • NaN(除零结果):显示"错误"。这是对普通用户最友好的提示。
  • Infinity(如 1/0 在某些 JavaScript 实现中):显示 “∞”。无限大不算错误,但需要特殊表示。
  • 超长结果(字符串长度 > 12):使用toPrecision(10)保留 10 位有效数字。例如1/3 = 0.3333333333。10 位有效数字在计算器显示宽度和精度之间取得了平衡。

六、UI 设计

6.1 整体布局

CalculatorPage ├── 深色标题栏(52vp):"🔢 计算器" ├── 显示屏区域(140vp,浅蓝灰底 #F8F8FC) │ ├── 表达式行(小字灰色,显示 "123 + ") │ └── 结果行(48sp 大字加粗,显示当前数字或结果) └── 按钮网格区域(layoutWeight(1),填充剩余空间) ├── Row 1: AC | ⌫ | % | ÷ ├── Row 2: 7 | 8 | 9 | × ├── Row 3: 4 | 5 | 6 | − ├── Row 4: 1 | 2 | 3 | + └── Row 5: [ 0 ] | . | =

6.2 显示屏设计

显示屏使用浅蓝灰(#F8F8FC)背景而非纯白,与下方的白色数字按钮形成微妙的层次区分。如果显示屏也是纯白,视觉上会和按钮区域混在一起,用户不容易快速定位到"我应该看哪里"。

表达式:123 + (12sp,灰色,靠右) 结果: 456 (48sp,深色加粗,靠右)

两行文本都向右对齐(HorizontalAlign.End),符合人类对数字的自然阅读习惯——从最右边的小数位开始向左扫描。

6.3 按钮间距

按钮之间使用 3vp 的间距(.margin(3))。这个间距比待办清单的卡片间距(1vp)大,因为计算器按钮需要清晰的触控边界。在移动设备上,3vp 约等于 2-3 像素,刚好够让手指区分相邻按钮,又不会浪费屏幕空间。

七、完整代码结构

CalculatorPage ├── Row(标题栏:🔢 计算器) ├── Column(显示屏) │ ├── Text(表达式行:如 "123 + ") │ └── Text(结果行:当前数字或计算结果) └── Column(按钮网格,layoutWeight 填充剩余空间) └── ForEach(buttons) → Row └── ForEach(row) → Text(按钮) ├── .width(25% 或 50%) ├── .backgroundColor(白/蓝/灰) ├── .onClick → handlePress(label) └── handlePress 状态机 ├── 'AC' → 全量重置 ├── '⌫' → 删除末尾字符 ├── '%' → 除以100 ├── '+−×÷' → 记录运算符 + 链式运算 ├── '=' → compute() + 显示结果 ├── '.' → 小数点保护 └── '0-9' → 数字追加/覆盖

八、总结

本文从零构建了一个标准计算器。与前六篇的数据管理类应用不同,计算器的核心是状态机 + 输入处理——没有 CRUD,没有列表筛选,只有四种状态、十几种按钮、一个计算纯函数。

核心要点回顾:

  1. 四变量状态机display(屏幕内容)、prevValue(左操作数)、pendingOp(待执行运算符)、newInput(是否覆盖输入)。这四个变量组合出初始态、输入态、待运算态、结果态四种计算器状态,覆盖了所有交互路径。

  2. 按钮网格用嵌套 ForEach 渲染:5 行 × 4 列的二维数组驱动 UI。span=2让 “0” 按钮占据双倍宽度。三种颜色(白/蓝/灰)构建了清晰的视觉层级—数字是基础,运算符引导操作,功能键是辅助。

  3. 输入处理的三个分支:数字(追加/覆盖/替换前置0)、小数点(防重复 + 自动前缀 0)、退格(空时保护 + 单字符回退到初始态)。每一个分支都考虑了边界情况。

  4. 运算逻辑的核心是 compute() 纯函数:无副作用、可独立测试。除零返回NaN而非让框架崩溃。链式运算(pendingOp不为空时按新运算符先执行前一个运算)让用户能连续做多步计算。

  5. 精度处理formatResult()处理 NaN(→"错误")、Infinity(→"∞")、超长浮点数(→toPrecision(10))。浮点精度是每个计算器都要面对的现实问题,用toPrecision是实用且有效的解决方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询