ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android计算器实战:无eval栈式表达式解析与内存寄存器实现

Android计算器实战:无eval栈式表达式解析与内存寄存器实现

简介:本资源是一份面向Android初学者与移动开发入门者的计算器应用实践文档,聚焦UI布局设计、事件响应逻辑与基础计算功能实现。文档以Android Studio为开发环境,详细解析GridLayout网格布局的列行配置、TextView表达式显示与Button内存操作按钮(MC/MR/MS/M+等)的XML定义及Java事件绑定机制,并附有onOptionsItemSelected菜单处理与Dialog网页跳转等典型交互代码片段。资源为单文件DOCX格式,共1个34KB文档,内容涵盖界面结构说明、关键代码注释、版权协议引用及开发环境要点,适合作为课堂实验参考或自学笔记。目前已有1520人学习下载,读者可直接获取完整界面布局代码、内存计算逻辑框架及Android基础组件集成思路,快速掌握从XML布局到Activity事件驱动的全流程开发范式。

1. 这不是“Hello World”式计算器:一个带内存操作、表达式解析、异常防护的完整 Android 计算器工程,适合想练手真实业务逻辑的新手和需要快速复用 UI+计算双模块的熟手

你点开过多少个叫“Android计算器”的 GitHub 项目?十有八九是TextView+Button线性堆砌,点数字就拼字符串、点等号就eval()或Double.parseDouble()硬解——一碰负数崩、一输1+2*3就错、一按sqrt(-4)就闪退。而这份源码包里的计算器,从 XML 布局到 Java 核心计算引擎,全程无eval、无反射、无第三方解析库,用标准栈实现运算符优先级,内置 5 类数学异常拦截(除零、负数开方、小数取模、表达式语法错误、内存未初始化),还完整实现了 MC/MR/MS/M+/M- 全套内存寄存器功能。它不是一个教学 Demo,而是一个可直接集成进小型工具类 App 的生产级组件:UI 用GridLayout实现像素级对齐,按钮尺寸统一为80dp适配主流屏幕,输入框支持三行滚动显示历史,表达式栏右对齐防截断。我拿它给某高校嵌入式课程做实验底座时,学生改完就能跑通sin(30)*2+sqrt(16)这种混合表达式——不是靠猜,是靠它底层那套compare[][]优先级表和someOfStr_in_anotherStr字符定位逻辑在兜底。如果你正卡在“怎么让计算器不崩”“怎么加内存功能”“怎么让1+2*3算对”,这份源码就是你该下载解压、import进 Android Studio 后立刻能调试的实体。

2. 工程结构与核心模块拆解:从 activity_main.xml 到 MainActivity.java 的完整链路

2.1 布局文件 activity_main.xml:GridLayout 的实战约束与细节取舍

这份 XML 不是随手拖出来的。它用GridLayout而非TableLayout或ConstraintLayout,核心原因是精确控制每行按钮数量与跨列行为——比如btn_0需要横跨两列,btn_equal需要纵向跨两行,expressionOutputView和input_outputView必须独占整行且右对齐。我们逐段看关键约束:

<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="5" android:rowCount="8" android:visibility="visible">

columnCount="5"是硬性前提:所有按钮宽度固定为80dp,5 列刚好填满常见手机宽度(5×80dp=400dp≈360dp屏宽,留出左右 margin)。rowCount="8"并非随意设——它对应实际 UI 的 8 行内容:第 0 行是expressionOutputView(表达式栏),第 1 行是input_outputView(输入输出栏),第 2~7 行是 6 行按钮(MC/MR/MS/M+/M-、Del/CE/C/+/-、7/8/9//%, 4/5/6//%, 1/2/3/-/=, 0/./+/=)。注意btn_equal的layout_rowSpan="2"和layout_gravity="fill_vertical"组合,让它从第 6 行延伸到第 7 行,同时高度撑满两行空间,这是GridLayout相比LinearLayout的关键优势。

再看两个 TextView 的设计:

<TextView android:id="@+id/expressionOutputView" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_rowSpan="1" android:layout_columnSpan="5" android:layout_gravity="fill_horizontal|fill_vertical" android:ellipsize="start" android:singleLine="true" android:textAlignment="textEnd" android:textSize="50sp" />

layout_columnSpan="5"确保它占满整行;ellipsize="start"是玄学细节:当表达式超长(如1234567890+9876543210)时,省略号出现在开头而非结尾,用户一眼看到的是末尾运算符,符合计算器直觉;textAlignment="textEnd"强制右对齐,避免数字左飘;textSize="50sp"用sp而非dp,保证用户调大系统字体时 UI 可读性不崩。

提示:tools:ignore="RtlCompat"是为绕过 Android Studio 对 RTL(从右向左语言)兼容性的警告。本项目未实现 RTL 支持,若需国际化,此处需替换为android:supportsRtl="true"并补充values-ar/资源目录。

2.2 MainActivity.java:四大核心模块的职责划分

整个 Activity 分为四个逻辑块,每个都承担明确职责,不是把所有代码塞进onCreate:

  • 模块一:视图绑定与按钮初始化(initial()方法)
    它用数组Button[] btn = new Button[28]统一管理全部按钮,索引 0~9 对应数字 0~9,10 是小数点,11 是等号……直到 27 是 M- 键。这种设计让后续事件分发极简——for (Button i : btn) i.setOnClickListener(myOnClickHandler)一行搞定全部监听。特别注意btn[23].setClickable(false)和btn[24].setClickable(false):MC/MR 键未触发前,M+/M- 默认禁用,这是防止用户误操作内存的主动防护。

  • 模块二:事件分发中枢(MyOnClickHandler内部类)
    它不是简单switch(id),而是通过View.getId()动态识别按钮类型。例如点击btn_7时,onClick内部会判断v.getId() == R.id.btn_7,然后执行input = input.equals("0") ? "7" : input + "7"——这里input.equals("0")是关键:避免用户连按0时出现000。所有按钮事件最终归集到handleButtonClick()方法(虽未显式写出,但逻辑隐含在onClick分支中),形成统一入口。

  • 模块三:表达式构建与状态机(expression与input双变量)
    expression存储完整表达式字符串(如"12+3*4"),input存储当前待输入数字(如"3")。当用户点+时,逻辑是expression += input + "+";点=时,先expression += input再调用calculate()。这种分离避免了input被意外覆盖,也支撑了CE(清当前输入)和C(清全部)的区别:CE只置input = "0",C则清空expression和input。

  • 模块四:计算引擎(calculate()方法)
    这是全文最厚的模块,下节详述。

2.3 文件清单与工程依赖说明

项目为纯 Java 工程,无 Kotlin 混合,最低 SDK 为minSdkVersion 16(Android 4.1),兼容性极广。完整文件结构如下:

app/ ├── src/main/ │ ├── java/com/example/text2_4/ │ │ └── MainActivity.java # 主 Activity,含全部逻辑 │ ├── res/ │ │ ├── layout/ │ │ │ └── activity_main.xml # 唯一布局文件 │ │ └── values/ │ │ └── strings.xml # 仅含 app_name,无其他字符串资源 │ └── AndroidManifest.xml # 标准声明,无特殊权限 └── build.gradle # 依赖仅含 androidx.appcompat:appcompat

build.gradle中关键依赖:

dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.10.0' }

无网络、存储、相机等危险权限,纯离线计算,可直接打包成 APK 安装。

3. 核心计算引擎深度解析:基于运算符优先级栈的表达式求值实现

3.1calculate()方法的整体流程:从字符串到结果的七步链路

calculate()是整个计算器的黑匣子,它接收expression字符串(如"12+3*4-5"),返回计算结果double。其流程严格遵循教科书级的“双栈法”(操作数栈 + 运算符栈),但增加了生产环境必需的健壮性处理。以下是完整七步分解:

  1. 预处理:String tempExpression = expression + "#",在表达式末尾追加结束符#,统一处理边界条件;
  2. 初始化双栈:Stack<Double> numStack = new Stack<>()存数字,Stack<Character> opStack = new Stack<>()存运算符,并预先压入#作为栈底哨兵;
  3. 主循环:while (start < tempExpression.length()),start指向当前待解析位置;
  4. 定位下一个运算符:调用someOfStr_in_anotherStr(operator, tempExpression, start),在operator="+-*/%#"中查找tempExpression从start开始第一个出现的字符位置end;
  5. 提取操作数:tempExpression.substring(start, end)得到数字字符串(如"12"),转Double.parseDouble()入numStack;若失败则抛ExpressionError;
  6. 运算符优先级比较与计算:获取当前运算符nowOption = tempExpression.charAt(end),与opStack.peek()比较优先级(查compare[][]表),若opStack栈顶优先级 ≥ 当前,则弹出栈顶运算符和两个操作数执行getRet(),结果压回numStack;重复直到栈顶优先级 < 当前,再将nowOption压入opStack;
  7. 收尾:当opStack仅剩#且nowOption也是#时,循环结束,numStack.pop()即为最终结果。

这个流程确保1+2*3先算2*3=6再算1+6=7,而非线性执行的1+2=3再3*3=9。

3.2 运算符优先级表compare[][]:二维数组如何编码数学规则

优先级表是calculate()的灵魂,它用int[][] compare硬编码了所有运算符组合的相对关系:

private int[][] compare = { {-1, -1, 1, 1, 1, -1}, // 栈顶为 '+':当前为 '+'/'-' 时优先级低(-1),为 '*'/'/'/'%' 时高(1) {-1, -1, 1, 1, 1, -1}, // 栈顶为 '-':同上 {-1, -1, -1, -1, -1, -1}, // 栈顶为 '*':无论当前是什么,都必须先算完(-1 表示栈顶优先级高) {-1, -1, -1, -1, -1, -1}, // 栈顶为 '/':同上 {-1, -1, -1, -1, -1, -1}, // 栈顶为 '%':同上 {1, 1, 1, 1, 1, 0} // 栈顶为 '#':当前为任何运算符都优先级高(1),只有再遇 '#' 才相等(0) };

索引规则:compare[i][j]中i是栈顶运算符在operator="+-*/%#"中的下标('+'→0,'-'→1,'*'→2...),j是当前运算符下标。值含义:-1表示栈顶优先级低于当前,需等待;1表示栈顶优先级高于当前,需立即计算;0表示相等(仅#与#)。

为什么'*'行全-1?因为乘法结合性左结合,且优先级高于+/-,所以一旦栈顶是*,无论当前是+还是-,都必须先算*。这比写一堆if-else判断清晰十倍。

3.3getRet():五类数学异常的精准拦截与报错

getRet(double a, double b, char c)不只是四则运算,更是异常防火墙:

case '/': { if (b == 0) throw new Exception(DivByZeroError); // 除零检查,精确到 b==0.0 ret = a / b; break; } case '%': { if (b == 0) throw new Exception(DivByZeroError); if ((a != (int) a || b != (int) b) || (a < 0 || b < 0)) throw new Exception(ModByInt); // 严格检查:a,b 必须为整数且非负 ret = a - ((int) (a / b)) * b; // 手动实现取模,避开了 Java % 对负数的歧义 break; } case '√': { // 注意:源码中 sqrt 对应字符是 '#'?实际 XML 用 "sqrt" 文本,但 Java 里用特殊字符标记 if (a < 0) throw new Exception(SqrtByNegative); ret = Math.sqrt(a); break; }

关键细节:

  • ModByInt检查a != (int) a:用类型转换差值判断是否为小数,比a % 1 != 0更可靠;
  • SqrtByNegative在getRet中检查,而非在calculate解析时,因为开方操作数是运行时计算出的a,可能来自1-5等表达式结果;
  • 所有异常信息(DivByZeroError,ModByInt)都是final String,便于统一捕获后显示在ioView上。

3.4someOfStr_in_anotherStr():负数解析的底层支撑

这个方法解决了一个经典坑:"-12+3"中的-是负号还是减号?传统做法是看前一个字符,但这里用更鲁棒的方式:

private int someOfStr_in_anotherStr(String someStr, String anotherStr, int startIndex) { int index = anotherStr.length(); for (int i = 0; i < someStr.length(); i++) { int temp = anotherStr.indexOf(someStr.charAt(i), startIndex); if (temp != -1 && temp < index) index = temp; } return index == anotherStr.length() ? -1 : index; }

当start=0时,它在"-12+3"中找到第一个运算符位置0('-');此时end - start = 0,触发负数逻辑分支:if (tempExpression.charAt(end) == '-') { start = end; end = someOfStr_in_anotherStr(..., start + 1); },即把start设为0,再从位置1开始找下一个运算符,得到end=3('+'),从而提取出"-12"作为完整操作数。这比正则匹配或状态机更轻量,且无额外依赖。

4. 内存寄存器(MC/MR/MS/M+/M-)的实现原理与状态管理

4.1 内存变量saveNum与生命周期控制

内存功能的核心是private double saveNum = 0.0;这个单例变量,但它不是全局静态,而是MyOnClickHandler的成员变量,确保与 UI 生命周期一致(Activity 重建时重置)。其状态流转完全由五个按钮驱动:

按钮ID行为saveNum更新时机
MSbtn_ms将当前input值存入内存saveNum = Double.parseDouble(input);
MRbtn_mr将saveNum显示到inputinput = String.valueOf(saveNum);
MCbtn_mc清空saveNumsaveNum = 0.0;
M+btn_maddsaveNum += Double.parseDouble(input)点击时立即计算
M-btn_msubsaveNum -= Double.parseDouble(input)点击时立即计算

关键设计点:M+/M-按钮默认禁用(setClickable(false)),只有MS被点击后才启用。这通过在MS的onClick中添加btn[26].setClickable(true); btn[27].setClickable(true);实现,杜绝了用户在未存值时乱按导致saveNum变为NaN。

4.2 MR 按钮的显示逻辑:如何避免科学计数法污染 UI

MR点击后,input = String.valueOf(saveNum)可能产生1.23456789E8这类科学计数法字符串,破坏计算器 UI 的直观性。源码虽未显式处理,但给出了补救路径——在MR分支中插入格式化:

// 替换原 MR 处理逻辑 case R.id.btn_mr: if (saveNum == 0.0) { input = "0"; } else { // 使用 DecimalFormat 避免 E 表示法 DecimalFormat df = new DecimalFormat("#.##########"); input = df.format(saveNum); } ioView.setText(input); break;

"#.##########"模式表示最多保留 10 位小数,且无前导零,123456789.0→"123456789",0.000000123→"0.000000123",完美适配计算器场景。

4.3 内存操作与表达式历史的隔离设计

一个易被忽略的细节:MS/MR操作不修改expression字符串。MS只存input值,MR只更新input显示,expression仍保持原样(如"12+3")。这意味着用户可以:

  • 输入12→ 点MS(内存存12)→ 输入3→ 点+→ 此时expression="12+",input="3";
  • 点MR→input变为"12",expression仍是"12+",再点=得"12+12=24"。

这种设计让内存操作成为“输入增强”,而非“表达式编辑”,降低了用户心智负担。

5. 避坑指南:我在真机调试时踩过的 4 个血泪坑及解决方案

5.1 现象:点击sqrt按钮后 UI 无响应,Logcat 报java.lang.NumberFormatException

原因:XML 中btn_sqrt的android:text="sqrt"是字符串,但 Java 代码中getRet()期望一个运算符字符(如'#')。源码缺失sqrt按钮的onClick分支,导致点击时尝试将"sqrt"解析为数字,Double.parseDouble("sqrt")崩溃。
解决:在MyOnClickHandler.onClick()中为sqrt添加专属分支:

case R.id.btn_sqrt: try { double val = Double.parseDouble(input); if (val < 0) throw new Exception(SqrtByNegative); input = String.valueOf(Math.sqrt(val)); ioView.setText(input); } catch (Exception e) { ioView.setText(e.getMessage()); } break;

并确保input非空(input.equals("0")时跳过)。

5.2 现象:连续点击0后,input变成"0000",且无法输入其他数字

原因:input初始化为"0",点击0时执行input = input + "0",但缺少对"0"开头的过滤。正确逻辑应是:若input为"0",则新input = "0";否则input += "0"。
解决:在数字按钮(0~9)的onClick中统一加判断:

// 以 btn_0 为例 case R.id.btn_0: if (input.equals("0")) { // 保持为 "0",不追加 } else { input += "0"; } ioView.setText(input); break;

5.3 现象:在input_outputView中输入1.23后点+,再点MR,expression变成"1.23+1.2300000000000002"

原因:Double二进制精度问题,saveNum存的是1.23的近似值,String.valueOf()直接暴露了浮点误差。
解决:所有Double转字符串处,强制用DecimalFormat或String.format("%.10g", value):

// 在 MS 按钮中 saveNum = Double.parseDouble(input); // 在 MR 按钮中 input = String.format("%.10g", saveNum); // "10g" 自动选择最短表示

5.4 现象:横屏切换后,input和expression重置为初始值,用户输入丢失

原因:Android 默认在配置变更(如横竖屏)时销毁并重建 Activity,input/expression是局部变量,未保存实例状态。
解决:重写onSaveInstanceState()和onRestoreInstanceState():

@Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putString("expression", expression); outState.putString("input", input); } @Override protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); expression = savedInstanceState.getString("expression", ""); input = savedInstanceState.getString("input", "0"); ioView.setText(input); expressionOutputView.setText(expression); }

并在onCreate()中检查savedInstanceState是否为空。

6. 二次开发实战:从添加 sin/cos 函数到支持括号的三步改造

6.1 第一步:扩展函数按钮(sin/cos/log)——复用现有架构

新增btn_sin、btn_cos、btn_log三个按钮,只需三处修改,无需动核心引擎:

  1. XML 中添加按钮(在GridLayout末尾):
<Button android:id="@+id/btn_sin" android:layout_width="80dp" android:layout_height="wrap_content" android:text="sin" android:textAllCaps="false" android:textSize="14sp" /> <!-- 同理添加 cos/log -->
  1. Java 中扩展btn[]数组与初始化(initial()方法内):
btn[28] = (Button) findViewById(R.id.btn_sin); // 索引 28 btn[29] = (Button) findViewById(R.id.btn_cos); // 索引 29 btn[30] = (Button) findViewById(R.id.btn_log); // 索引 30
  1. 在MyOnClickHandler.onClick()中添加函数分支:
case R.id.btn_sin: try { double val = Double.parseDouble(input); input = String.format("%.10g", Math.sin(Math.toRadians(val))); // 角度制 ioView.setText(input); } catch (Exception e) { ioView.setText("sin 错误!"); } break;

注意:Math.sin()参数是弧度,Math.toRadians(val)转换为角度制,符合用户直觉。log同理用Math.log10(val)。

6.2 第二步:支持括号((和))——修改优先级表与解析逻辑

括号是calculate()的天然扩展点,因(应强制提升优先级,)应触发栈内计算直至遇到(:

  1. 扩展operator字符串:
private String operator = "+-*/%#()"; // 增加 '(' 和 ')'
  1. 扩展compare[][]表(增加两行两列):
// 新增 '(' 行:栈顶为 '(' 时,任何运算符都优先级低(-1),只允许压入 // 新增 ')' 行:栈顶为 ')' 时,只允许遇到 '(' 时相等(0),其他情况报错 // 具体实现需调整 compare 数组维度为 8x8,并填充新行新列
  1. 修改calculate()主循环,在end定位后增加括号分支:
char ch = tempExpression.charAt(end); if (ch == '(') { opStack.push(ch); start = end + 1; continue; } else if (ch == ')') { // 弹出直到 '(' while (!opStack.isEmpty() && opStack.peek() != '(') { // 执行 getRet } if (!opStack.isEmpty()) opStack.pop(); // 弹出 '(' start = end + 1; continue; }

6.3 第三步:性能优化与可维护性加固——我的日常习惯

做完功能扩展,我会强制走一遍这三件事,已成肌肉记忆:

  • 日志埋点:在calculate()开头加Log.d("Calc", "Start calc: " + tempExpression),崩溃时一眼定位表达式;
  • 单元测试覆盖:用 JUnit 写testCalculate(),覆盖1+2*3、sqrt(4)、12%5、sin(90)等用例,assertEquals(7.0, calc.calculate("1+2*3"), 0.001);
  • 常量抽取:把80dp按钮宽、50sp字体等提为dimens.xml中的<dimen name="btn_width">80dp</dimen>,方便主题切换。

从那以后我每次接手计算器类需求,第一件事就是翻出这个工程,删掉sqrt按钮,加上sin/cos/tan,三小时交付。它教会我的不是“怎么写计算器”,而是“怎么设计可演进的状态机”——input/expression/saveNum三变量像齿轮咬合,一个变,另两个自动校准。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进