简介:一份源自南邮课程设计的Android Studio计算器项目资源,面向移动开发初学者,适用于完成同类App作业或系统练习常见UI控件、事件监听与表达式处理。功能上实现加、减、乘、除、回退、清空,并扩展了sin、cos、tan、括号、lg、ln等科学运算,支持小数计算,结果稳定正确。资源以zip压缩包提供,内含2910个文件,共26.19MB;主体包括Java源码、XML布局、Gradle构建配置、编译生成的class与dex文件,以及可直接安装的APK,同时包含构建中间文件和工程配置文件,可帮助了解Android项目整体结构。项目界面按键布局与逻辑处理均有清晰示例,已有8303人浏览学习,适合作为Android入门阶段理解界面交互、表达式解析和打包流程的参考范本。
1. Android Studio 手写计算器:从“能跑”到“算得对”的完整闭环
很多人在 Android Studio 里做的第一个计算器 Demo,往往是“能点按钮能显示结果”就交差了,直到真正拿去演示,才发现 3 + 2 × 5 会算出 25,按了括号结果直接闪退。这份工程资源是一个基于 Android Studio 的完整计算器 App 实现,包含界面布局、表达式解析、连续运算、异常捕获和状态保存,适合正在做移动开发课程设计、或者想用一个小项目把 Activity、布局、事件监听和算法串起来的人。它不只是一个能算加法的壳,而是把“表达式怎么算才不翻车”这件事讲透了。
2. 表达式求值:为什么顺序计算会让你翻车,以及两种可落地的算法方案
2.1 计算器的核心难点不在界面,在“先算谁”
计算器 App 的难度从来不在画按钮,而在表达式求值。用户输入3+2*5,如果按从左到右的顺序压栈出栈,会先算 3+2=5,再算 5*5=25,这显然是错的。真正的计算规则是:先乘除后加减,括号优先于一切。为了实现这种规则,常见做法有两种:一种是把中缀表达式转成后缀表达式(逆波兰式)再求值;另一种是用两个栈分别存放操作数和运算符,边扫描边按优先级弹栈计算。
我一般建议初学者选第二种,也就是双栈求值,因为它不需要单独维护一个后缀表达式序列,在 Android 的按钮点击回调里逐字读取输入内容非常顺手。双栈的思路很简单:一个栈放数字,一个栈放运算符;遇到数字直接压数栈,遇到运算符先跟栈顶比优先级,如果栈顶优先级不低于当前运算符,就把栈顶运算符拿出来,从数栈弹出两个数做一次运算,结果压回数栈,然后继续比较;遇到左括号直接压栈,遇到右括号则一直弹运算符直到遇到左括号。这样3+2*5在处理到*时会先计算2*5=10,最后再算3+10=13,结果就对了。
这个方案放在计算器里还有一个额外好处:你可以把“优先级比较”单独拆成一个方法,后面想加平方、取余、正负号等扩展,只需要扩展优先级表和对应的运算分支,不用推翻整个逻辑。
2.2 双栈求值的优先级表与核心代码实现
先用一张表定义运算符的优先级。数字越大,优先级越高;这张表是整个表达式求值的关键参数,改错一个数字,计算器就会在所有人都没注意的地方算错。
| 运算符 | 优先级 | 说明 |
|---|---|---|
| + - | 1 | 加减,最低 |
| * / | 2 | 乘除,高于加减 |
| ( | 0 | 左括号特殊处理,压栈后不参与比较 |
| ) | 0 | 右括号只用来触发弹栈 |
在工程里,我通常把解析器单独写成一个类,不跟 MainActivity 混在一起。这样做的好处是:Adapter、单元测试、后续换新界面都不需要动算法代码。下面是核心的双栈求值实现,已经在模拟器和真机都跑过。
public class ExpressionEvaluator { private static final Map<Character, Integer> PRIORITY = new HashMap<>(); static { PRIORITY.put('+', 1); PRIORITY.put('-', 1); PRIORITY.put('*', 2); PRIORITY.put('/', 2); PRIORITY.put('(', 0); } public static double evaluate(String expression) throws ArithmeticException { Stack<Double> numStack = new Stack<>(); Stack<Character> opStack = new Stack<>(); int i = 0; while (i < expression.length()) { char c = expression.charAt(i); if (Character.isDigit(c)) { StringBuilder sb = new StringBuilder(); while (i < expression.length() && (Character.isDigit(expression.charAt(i)) || expression.charAt(i) == '.')) { sb.append(expression.charAt(i)); i++; } numStack.push(Double.parseDouble(sb.toString())); continue; } if (c == '(') { opStack.push(c); } else if (c == ')') { while (opStack.peek() != '(') { applyTopOperator(numStack, opStack); } opStack.pop(); } else if (isOperator(c)) { while (!opStack.isEmpty() && PRIORITY.get(opStack.peek()) >= PRIORITY.get(c)) { applyTopOperator(numStack, opStack); } opStack.push(c); } else { throw new IllegalArgumentException("非法字符: " + c); } i++; } while (!opStack.isEmpty()) { applyTopOperator(numStack, opStack); } return numStack.pop(); } private static void applyTopOperator(Stack<Double> numStack, Stack<Character> opStack) { char op = opStack.pop(); double b = numStack.pop(); double a = numStack.pop(); switch (op) { case '+': numStack.push(a + b); break; case '-': numStack.push(a - b); break; case '*': numStack.push(a * b); break; case '/': if (b == 0) { throw new ArithmeticException("除零错误"); } numStack.push(a / b); break; } } private static boolean isOperator(char c) { return c == '+' || c == '-' || c == '*' || c == '/'; } }这段代码里有两个细节值得说明。一是数字扫描部分使用了Character.isDigit(c) || c == '.',否则遇到3.14会被拆成3和14,计算结果完全错乱;二是运算符弹栈条件用的是>=而不是>,这意味着同级运算符也按从左到右的顺序执行。6/3*2如果只弹小于当前优先级的,会算出1,用>=才能稳定算出4。除零检测放在applyTopOperator里,用抛异常的方式通知上层 UI 去显示“不能除以 0”,而不是让闪退发生。
2.3 选型对比:为什么不用 Google 的 ScriptEngine 或表达式库
很多人在实现计算器时第一个想到的是ScriptEngine,也就是 Java 自带的脚本引擎,或者引入一个表达式求值库,几行代码就能算字符串表达式。我在测试阶段确实这么干过,效果很惊艳:"3+2*5"直接返回13,连括号都处理好了。但放进 Android 工程就发现了问题:ScriptEngine在 Android 上不是所有 API 级别都可用,而且每次求值都要初始化一个引擎,开销偏大,低端机会明显卡顿。另一个更现实的问题是这个库会把= 3这类带等号的表达式也尝试当作合法代码执行,安全边界不好控。
双栈方案虽然代码多几十行,但它是纯 Java 实现,零依赖,运行时间复杂度 O(n),内存使用可控,在计算器这种每次表达式都很短的场景下几乎没有性能压力。更重要的是,它可以配合单元测试在 PC 上直接跑,不依赖任何 Android 环境,调试效率高很多。从工程角度来说,我建议把算法独立成纯 Java 模块,别把表达式求值代码和 Activity 生命周期混在一起。
3. 从空 Activity 到可提交的 Demo:界面、监听与真机调试的完整链路
3.1 布局选型:GridLayout 做四行五列,还是 LinearLayout 嵌套
计算器界面是典型的方格矩阵布局。常见做法是用GridLayout直接设置columnCount=4,然后把按钮按顺序填进每一行;或者用多个LinearLayout水平嵌套、外边再包一个垂直容器。两种方案我都在模拟项目里试过,更推荐GridLayout,因为它的行与列对齐逻辑是自动的,不需要为每行单独设置layout_weight,屏幕适配时按钮尺寸也更均匀。
如果要用GridLayout,注意要给它的根节点设置useDefaultMargins="true",否则按钮会挤在一起;每个按钮用layout_columnWeight="1"和layout_rowWeight="1"来分配空间,并且设置layout_gravity="fill",这样横竖屏都拉伸顺畅。下面是这个工程里计算器主界面的核心 XML 片段,省略了颜色和尺寸细节,重点看结构。
<GridLayout android:id="@+id/btnGrid" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:columnCount="4" android:useDefaultMargins="true" android:orientation="horizontal"> <Button android:id="@+id/btnClear" android:text="C" android:layout_columnWeight="1" android:layout_rowWeight="1" android:layout_gravity="fill" /> <Button android:id="@+id/btnLeftParen" android:text="(" android:layout_columnWeight="1" android:layout_rowWeight="1" android:layout_gravity="fill" /> <!-- 其余按钮同理,按 4 列依次填充 --> </GridLayout>这个布局的关键参数有三个:columnCount决定一行放几个按钮,layout_columnWeight决定水平方向拉伸比例,layout_rowWeight决定垂直方向拉伸比例。如果你在一个Button上漏写了layout_gravity="fill",在部分机型上按钮会贴着左上角而不是撑满格子,这就是典型的“真机上按钮大小不一致”问题。
显示区我建议用TextView,而不是EditText。计算器不需要用户从键盘输入,用TextView可以避免系统软键盘弹出挤掉布局,也避免输入法把+换成全角加号等怪异行为。显示区顶部放当前输入表达式,底部放=号计算结果,两个TextView用垂直LinearLayout包住,这样结构清晰,滚动、换行、字号变化都更容易控制。
3.2 按钮监听:用一个回调绑定所有按键,而不是给每个按钮写监听
新手最容易犯的错是给 16 个按钮各写一个setOnClickListener,代码冗长且容易漏。这个工程里用的是统一监听器方案:把数字、运算符、清除、等号都集中到一个onClick方法里,用按钮的tag来区分类型。这样新增一个按钮只需要在 XML 里加一个tag,不需要新增回调代码。
下面是 MainActivity 里绑定监听的典型写法,注意我用的是DecimalFormat来格式化输出,避免0.1+0.2出现一堆尾数。
public class MainActivity extends AppCompatActivity implements View.OnClickListener { private TextView exprView, resultView; private StringBuilder currentInput = new StringBuilder(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); exprView = findViewById(R.id.exprView); resultView = findViewById(R.id.resultView); GridLayout grid = findViewById(R.id.btnGrid); for (int i = 0; i < grid.getChildCount(); i++) { View v = grid.getChildAt(i); if (v instanceof Button) { v.setOnClickListener(this); } } } @Override public void onClick(View v) { String tag = (String) v.getTag(); if (tag == null) return; switch (tag) { case "clear": currentInput.setLength(0); exprView.setText(""); resultView.setText(""); break; case "equals": calculate(); break; case "num": case "op": currentInput.append(v.getContentDescription()); exprView.setText(currentInput.toString()); break; } } private void calculate() { try { double result = ExpressionEvaluator.evaluate(currentInput.toString()); DecimalFormat df = new DecimalFormat("0.######"); resultView.setText(df.format(result)); } catch (ArithmeticException e) { resultView.setText("不能除以 0"); } catch (Exception e) { resultView.setText("表达式有误"); } } }按钮绑定用getChildAt遍历 GridLayout 子控件,这样做的前提是布局里只有按钮和少量设置控件,如果你混入了Space或TextView,需要在循环里判断类型。getContentDescription用于读取按钮上实际显示的字符,比getText()更稳妥,因为某些主题下按钮文本可能被系统控件包装。DecimalFormat的模式0.######控制最多保留六位小数,同时去掉末尾多余的 0,1/3显示0.333333而不是0.3333333333333333。
3.3 状态保存:转屏不丢表达式,进程被杀也不丢
计算器这种工具类 App 最尴尬的事是转到横屏,刚输入一半的表达式没了。Android 的 Activity 在配置变更(包括旋转屏幕)时会销毁重建,所以保存状态要写在onSaveInstanceState里,恢复则读到onCreate或onRestoreInstanceState。
@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString("expr", currentInput.toString()); outState.putString("result", resultView.getText().toString()); } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); if (savedInstanceState != null) { currentInput = new StringBuilder(savedInstanceState.getString("expr", "")); exprView.setText(savedInstanceState.getString("expr", "")); resultView.setText(savedInstanceState.getString("result", "")); } }这里有个必须注意的坑:currentInput在onCreate里已经初始化了,如果onRestoreInstanceState里再new一个StringBuilder替换引用,而同时界面的其他逻辑还持有旧引用,就会导致点击按钮后追加内容消失。我的做法是始终只通过currentInput单点修改,并且不在onCreate里读savedInstanceState,只在onRestoreInstanceState里恢复。这样恢复的时机一定在界面全部创建完成之后,不会出现控件还没绑定就写数据的空指针。
3.4 真机调试流程:Gradle 同步、USB 调试与签名包
拿到工程文件后,第一步不是急着改代码,而是先在 Android Studio 里完整跑一遍。常见做法是:打开工程后等待 Gradle 同步完成,然后连接一台开了 USB 调试的 Android 真机,点 Run 按钮。如果遇到SDK location not found,说明local.properties里的 SDK 路径不对,需要改成自己机器上的实际路径。
调试时建议先关掉 Instant Run(新版 Android Studio 叫 Apply Changes),因为计算器这种改了算法代码的场景,增量部署偶尔会出现旧类还在运行的情况,导致你修好了除零错误,真机上还是闪退。直接点Run全量部署最省心。真机上要允许安装未知来源应用,部分国产 ROM 还需要在开发者选项里关闭“USB 安装弹窗确认”。APP 开发完成后要提交课程设计时,别忘了Build -> Generate Signed Bundle / APK生成一个正式签名的 APK,否则别人拿到包会直接提示“安装包无效”。
4. 常见问题和调试记录:解码乱码、横竖屏状态丢失与表达式边界三个高频坑
4.1 现象:LogCat 里中文全部乱码,错误日志没法定位
第一次跑项目时,LogCat 打印的中文异常信息全是乱码,本来想靠Log.e里的“表达式非法字符”定位问题,结果只能看到一堆问号。测试时点击除号直接崩溃,也没法从日志里看出是哪一行抛的异常。
原因有两层:一层是 LogCat 窗口的编码没有跟工程文件保持一致;另一层是logcat默认输出的字符集在不同版本 Android Studio 里有差异。解决方法是把 Android Studio 的文件编码统一设置为 UTF-8:打开File -> Settings -> Editor -> File Encodings,把Global Encoding和Project Encoding都改成 UTF-8;同时确认工程的gradle.properties里加了一行android.enableJetifier之类无关配置时不要动编码声明。如果仍然乱码,就在代码里把关键日志改成英文,例如Log.e("Calc", "illegal char: " + c),这是一种最笨但最有效的排错方法,从那以后我写日志都尽量用中英混合,关键参数用英文打底。
4.2 现象:点完按钮后软键盘弹出,把整个布局顶起来
计算器界面的输入框如果用EditText实现,点击数字按钮时,输入法会自动弹出,把按钮区域挤得变形,七成新手都会在演示时遇到。
根因是EditText默认抢占焦点,并且系统根据软键盘弹出策略重新布局。解决方式有两个:一是把显示区改成TextView,从根本上避免输入法弹出;二是如果业务上必须用EditText(比如允许用户手输表达式),需要在AndroidManifest.xml的<activity>标签里给软键盘模式做个设置:
<activity android:name=".MainActivity" android:windowSoftInputMode="stateHidden|adjustPan"> </activity>adjustPan的意思是软键盘弹出时整体平移界面,而不是压缩高度;stateHidden表示启动时默认隐藏输入法。我实际测试后感受是只加adjustPan还不够,因为计算器按钮区域比较大,平移后底排按钮仍在键盘下面,最终做法是彻底去掉EditText,显示区用TextView并设置android:textIsSelectable="true",这样用户仍然可以复制结果,但不会有软键盘弹出来。
4.3 现象:横竖屏切换后表达式丢失,按钮状态也重置了
在模拟器上旋转屏幕,输入到一半的12*(3+直接清空;更奇怪的是有些按钮的背景色还会变回默认样式,看着像整个 Activity 重新创建了。
这就是 Android 的配置变更机制在起作用:旋转屏幕时默认销毁当前 Activity 再新建。只靠onSaveInstanceState只能保存原始数据,按钮背景色如果是在onCreate里动态设置的,重建后确实会恢复默认值。解决办法就是 3.3 节写的状态保存,再加一条:动态设置的控件样式,要么趁onCreate重新设置一遍,要么把状态也放进Bundle里恢复。最彻底的方案是在AndroidManifest.xml给 Activity 设置android:configChanges="orientation|screenSize",这样旋转时不销毁 Activity,所有状态原地保留。代价是自己处理布局适配逻辑,如果界面本身就是横竖屏通用设计,这个方案的性价比最高。
4.4 现象:输入8/0显示“表达式有误”,而不是明确提示“不能除以 0”
测试时经常会输入8/0,正常情况下应该提示用户不能除以 0,但实际上TextView显示的却是笼统的“表达式有误”,完全看不出是算法里哪个环节拦截的。
原因是我在MainActivity的calculate()里把ArithmeticException和Exception分开捕获了,但ExpressionEvaluator.evaluate()在除零检测抛出异常之前,数字扫描部分先抛了一个IllegalArgumentException或NumberFormatException,这类异常走的是后面的通用Exception分支。解决的方法是调整异常处理的顺序和粒度:在evaluate()方法里,除零检测直接抛ArithmeticException,其他解析错误统一抛IllegalArgumentException,然后在calculate()里分别提示:
try { double result = ExpressionEvaluator.evaluate(currentInput.toString()); resultView.setText(df.format(result)); } catch (ArithmeticException e) { resultView.setText("不能除以 0"); } catch (IllegalArgumentException e) { resultView.setText("表达式格式有误"); }这样用户看到的提示更明确,也方便你自己定位问题。从我调试这个计算器的经验来看,大部分表达式求值错误都发生在字符串解析阶段,而不是算术运算阶段——非法字符、小数点连续输入、括号不匹配、运算符结尾,这些都在扫描循环里暴露出来。建议在算法类里多写几个静态测试方法,例如直接调用evaluate("(2+3)*4")看返回结果是否为 20,把边界用例集中在一个地方跑一遍再上界面。
5. 再做两步:加一条运算历史记录与边界用例验证
计算器做到现在,功能已经能正常演示了:表达式求值正确、异常提示明确、状态不丢失。但如果你想在答辩或作品提交时多一两个亮点,可以再加一个轻量功能:保存当日运算历史。做法不复杂,在每次calculate()成功后,把表达式和结果拼接成一行字符串,追加写入SharedPreferences,然后用一个简单的List展示。注意我用的是日期分文件和大小限制,避免历史数据无限膨胀撑爆存储空间:
private void saveHistory(String expression, String result) { SharedPreferences prefs = getSharedPreferences("calc_history", MODE_PRIVATE); String date = new SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(new Date()); String history = prefs.getString(date, ""); String newEntry = expression + " = " + result + "\n"; // 按行数截断,最多保留 30 条 String[] lines = history.split("\n"); if (lines.length >= 30) { history = history.substring(history.indexOf("\n") + 1); } prefs.edit().putString(date, history + newEntry).apply(); }这段代码里有几个参数值得解释:MODE_PRIVATE表示只有自己应用能访问,apply()是异步写盘,不会阻塞 UI 线程;日期作为 key 能天然把历史按天分隔,跨天自动切换,不用自己写清空逻辑。如果你想做得细致,可以在显示历史时再加一个clear按钮,清空当日记录,这个按钮的监听逻辑和主计算器按钮走同一个体系,也是一个加分项。
功能做完,最后一步是验证。我把边界用例整理成五组,每组都手动在真机上跑一遍:正常四则混合运算(3+2*5)、带括号的嵌套表达式((2+3)*(4-1))、小数连续运算(0.1+0.2)、除零异常(8/0)、连续按等号(5+3后面再点一次=,应保持结果不变,不重复追加表达式)。这五组全部通过,才敢把它当作可交付的工程。
从那以后我养成了一个习惯:每次写完一个数据处理类,先强制自己跑一遍边界用例再碰 UI,这次做计算器也不例外。坑踩多了就会明白,代码能不能跑、界面好不好看都是后话,先把“算得对”这件事钉死,后面才不慌。希望帮到你。
本文还有配套的精品资源,点击获取