一、引言计算器是每个操作系统必带的应用。从 iOS 的计算器到 Android 的 Google Calculator从 Windows 的计算器到 macOS 的 Spotlight 计算功能计算器的设计语言在过去二十年几乎没变过——一个显示区和一组按钮。这种稳定性不是保守而是因为 4×5 的按钮网格已经达到了输入效率和认知负担的最优平衡点。从技术角度看计算器是一个经典的状态机问题。它涉及四种状态——初始态显示 0、输入态构建操作数、待运算态按下运算符后等待第二个操作数、结果态按下等号后显示结果。输入处理需要区分追加数字和覆盖数字小数点不能重复输入运算需要处理除零和连续运算的场景。本文将用 ArkUI 从零构建一个标准计算器。功能包括四则运算加减乘除、百分比、小数、退格删除、全部清除、连续运算、除零/无穷大保护。按钮网格采用嵌套ForEach动态渲染运算核心是一个compute()纯函数。阅读完本文你将能够设计计算器状态机四种状态 状态转换规则使用嵌套ForEach构建按钮网格布局实现输入处理逻辑数字追加、小数点保护、退格处理除零和无穷大等边界情况用toPrecision()控制浮点数显示精度二、状态机设计2.1 四个核心状态计算器的状态不是用一个枚举变量表示的而是由四个变量组合描述Statedisplay:string0;// 显示屏上当前可见的内容privateprevValue:string;// 运算符左侧的操作数待运算值privatependingOp:string;// 当前等待执行的运算符privatenewInput:booleantrue;// 下一次输入是否覆盖显示屏这四个变量组合出四种计算器状态状态displayprevValuependingOpnewInput用户看到的初始态0true显示 0等待输入输入态123false正在输入第一个数待运算态123123true已按 等待第二个数结果态246true显示计算结果初始态是用户打开计算器时的状态——显示屏显示 0没有之前的运算历史准备好接收第一次输入。输入态发生在用户开始按数字键时。newInput翻转为false后续数字追加到display末尾而非覆盖。待运算态在用户按下运算符、−、×、÷后进入。display的当前值被复制到prevValuependingOp记录运算符newInput重置为true——因为下一个数字应该覆盖显示屏而非追加。结果态在用户按下等号后进入。compute()执行计算结果写入displayprevValue和pendingOp清空newInput重置为true。这四个状态覆盖了计算器的所有交互路径输入 → 运算 → 再输入 → 结果以及 AC 清除到任何位置的重置。2.2 状态转换图初始态 ──(按数字)──→ 输入态 输入态 ──(按数字)──→ 输入态追加 输入态 ──(按运算符)─→ 待运算态 待运算态 ──(按数字)─→ 输入态新操作数 待运算态 ──(按运算符)─→ 待运算态更换运算符 待运算态 ──(按)─→ 结果态 结果态 ──(按数字)──→ 输入态开始新运算 结果态 ──(按运算符)─→ 待运算态继续运算 任意态 ──(按AC)──→ 初始态两个值得注意的转换待运算态按运算符 → 待运算态用户可能在输入第二个操作数之前改变主意。例如先按 “” 再决定按 “×”新的运算符替换旧的。这不需要计算只需更新pendingOp。结果态按运算符 → 待运算态用户可能在看到一个计算结果后想继续运算。例如 “6×742”然后按 “547”。此时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、功能灰#E0E0E8activeColor按下时的背景色。比正常色稍暗提供触觉反馈的视觉替代fontColor文字颜色。数字深色#1a1a2e、运算符白色#FFFFFF、功能深色span列跨度。1 为标准宽度25%2 为双倍宽度50%。只有 “0” 按钮使用 span23.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.span2?50%:25%;}span2的按钮占据 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 提供了GridGridItem组件用于网格布局但这里选择了嵌套Column Row的方案。原因是跨列需求“0” 按钮需要 span2占两列。用 Grid 实现需要设置GridItem的columnSpan但语法更冗长。行高一致性每行 72vp 高度 3vp 间距在 Row 中更容易控制。Grid 的行高需要设置rowsTemplate对 5 行固定高度的场景没有优势。代码可读性五行 Row每行四个按钮——这个布局用 Row 嵌套更接近设计师的思维模型。四、输入处理逻辑4.1 数字输入数字输入是最高频的操作它的处理逻辑直接影响用户体验if(this.newInput){this.displaylabel;// 覆盖当前显示this.newInputfalse;}else{this.displaythis.display0?label:this.displaylabel;// 追加或替换前置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.display0.;// 新输入从小数点开始 → 前缀0this.newInputfalse;return;}this.displaythis.display.;return;}关键设计当newInput为 true 时直接输入小数点显示屏显示 “0.” 而非 “.”。这是因为 “.5” 在某些文化中可能造成混淆而 “0.5” 是通用的数字表示。4.3 退格删除⌫ 按钮删除最后一个字符但有两个边界条件if(label⌫){if(!this.newInputthis.display.length1){this.displaythis.display.substring(0,this.display.length-1);}elseif(!this.newInputthis.display.length1){this.display0;this.newInputtrue;}return;}newInput true什么都不做。此时显示屏刚被重置如按了运算符没有可删除的输入。display.length 1正常删除最后一个字符。例如 “123” → “12”。display.length 1最后一个字符删除后显示屏回到 “0” newInput 状态。例如 “5” → “0”。4.4 全部清除AC 按钮把所有状态重置为初始值if(labelAC){this.display0;this.displayExpr;this.prevValue;this.pendingOp;this.newInputtrue;return;}每一次对display、prevValue、pendingOp、newInput的重置都是在重建初始态。AC 是万能逃生按钮——无论计算器处于什么状态按一下就能回到起点。五、运算逻辑5.1 compute 纯函数运算的核心是一个纯函数接收两个操作数和一个运算符返回结果compute(a:number,op:string,b:number):number{if(op)returnab;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 时返回NaNNot a Number而不是让 JavaScript 返回Infinity。NaN在formatResult()中被转换为 “错误” 提示比Infinity对普通用户更友好。百分比a / 100。例如输入 50 然后按 % → 0.5。这是一个简化的实现——真实计算器中的百分比行为更复杂例如 “20010%” 应等于 220但简化版本已经能覆盖百分比的基本用例。5.2 运算符按下当用户按下 、−、×、÷ 时if(label||label−||label×||label÷){constcurparseFloat(this.display);if(isNaN(cur))return;if(this.pendingOp!!this.newInput){// 链式运算已经有一个待执行的运算先算出结果constprevparseFloat(this.prevValue);constresultthis.compute(prev,this.pendingOp,cur);this.displaythis.formatResult(result);this.prevValuethis.formatResult(result);}else{this.prevValuethis.display;}this.pendingOplabel;this.displayExprthis.prevValue label;this.newInputtrue;return;}两种路径无待执行运算pendingOp为空或刚输入了新数字直接将当前显示值复制到prevValue记录运算符设置newInput true。有链式运算pendingOp不为空且不是刚重置的输入先执行前一个运算将结果显示在屏幕上再记录新运算符。例如用户输入 “6×7” → 42然后按 “5” → 47。链式运算让用户可以在一次交互中连续做多步计算不需要每次都按 “” 再继续。displayExpr用于在显示屏上方显示计算过程帮助用户跟踪当前的运算上下文。例如用户按 123 上方显示 “123 ”下方的display准备接收第二个数。5.3 等号计算按下等号执行当前运算并显示结果if(label){if(this.pendingOp!){constcurparseFloat(this.display);constprevparseFloat(this.prevValue);constresultthis.compute(prev,this.pendingOp,cur);this.displayExprthis.prevValue this.pendingOp this.display ;this.displaythis.formatResult(result);this.prevValue;this.pendingOp;this.newInputtrue;}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∞;constsn.toString();returns.length12?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没有列表筛选只有四种状态、十几种按钮、一个计算纯函数。核心要点回顾四变量状态机display屏幕内容、prevValue左操作数、pendingOp待执行运算符、newInput是否覆盖输入。这四个变量组合出初始态、输入态、待运算态、结果态四种计算器状态覆盖了所有交互路径。按钮网格用嵌套 ForEach 渲染5 行 × 4 列的二维数组驱动 UI。span2让 “0” 按钮占据双倍宽度。三种颜色白/蓝/灰构建了清晰的视觉层级—数字是基础运算符引导操作功能键是辅助。输入处理的三个分支数字追加/覆盖/替换前置0、小数点防重复 自动前缀 0、退格空时保护 单字符回退到初始态。每一个分支都考虑了边界情况。运算逻辑的核心是 compute() 纯函数无副作用、可独立测试。除零返回NaN而非让框架崩溃。链式运算pendingOp不为空时按新运算符先执行前一个运算让用户能连续做多步计算。精度处理formatResult()处理 NaN→错误、Infinity→∞、超长浮点数→toPrecision(10)。浮点精度是每个计算器都要面对的现实问题用toPrecision是实用且有效的解决方案。