简介:面向K12阶段学习者的Android Studio简易计算器期末大作业完整工程,以可直接运行的项目形式展示安卓开发从零到发布的规范流程。压缩包共48个文件,约180KB,包含XML布局文件(activity_main.xml等界面定义)、Java源代码(MainActivity及封装计算逻辑的CalculatorEngine类)、PNG图片素材,以及Gradle构建脚本、properties属性文件、gradle wrapper配置等工程支撑文件,目录结构清晰,便于初学者逐项拆解。项目覆盖界面搭建、按钮事件监听、基于栈的运算符优先级处理、结果回显与错误排查等关键环节,可深入理解Android应用的活动、布局与逻辑层如何协作。已有4335人浏览学习,适合作为期末作业参考或入门实践模板。对照源码与布局文件,还能掌握在Android Studio中创建、调试并打包APK的完整方法。
1. 安卓期末大作业最容易翻车的点,不是界面,是运算规则
安卓期末大作业里最容易被扣分的点,不是界面不够花,而是运算规则经不起问。拆一个用 Android Studio 实现的简易计算器:双层 LinearLayout 排布按键区、完整表达式解析、中缀转后缀再求值,全程 Java + XML、没有第三方依赖,源码大约 300 行,能直接编译出 APK 交作业。
这个项目对两类人最有用:第一次用 Android Studio 做期末大作业、不想让代码停在“两个数相加”水平的学生;以及 K12 阶段刚接触编程、想用一个小而完整的手感项目入门 Android 开发的同学。
拆完整个工程你会看到,真正值得抄的不是按钮布局,而是那条“输入表达式 → 转后缀 → 栈求值”的链路——它像一个迷你编译器前端,写在期末作业里既不越界,又足够拿得出手。
2. 技术选型与工程结构:Java + XML,不整花活
2.1 为什么选 Java 而不是 Kotlin
现在 Android Studio 新建项目默认给 Kotlin,但这个计算器刻意选了 Java。原因很现实:期末大作业的验收场景里,老师习惯翻开代码一行行问,Java 的语法对学生来说更好口头解释——每个方法、每个变量类型直白,不需要额外讲val、var、空安全这些 Kotlin 概念。Kotlin 当然更现代,但大作业求的是“代码能被读懂、被讲清楚”。
另一个取舍是不用 Jetpack Compose 而是用传统 XML 布局。Compose 写起来更短,但调试思路和老师课件里的findViewById、setOnClickListener对不上。传统 View 体系虽然啰嗦,却是 Android 课程里最通用的知识,换到任何老项目都能用。
第三方库方面,这个项目一个都不引。计算器本身只有界面和运算逻辑两层,androidx.appcompat已经够用。不引库的好处是工程不依赖网络仓库里的未知版本,Gradle Sync 失败的概率大幅降低,这对交作业前最后一晚赶工来说很关键。
2.2 从 app/src 看起的目录结构
app/ ├── src/main/ │ ├── java/com/example/calculator/ │ │ ├── MainActivity.java // 界面、按钮事件、表达式状态管理 │ │ └── CalculatorEngine.java // 计算引擎:中缀转后缀 + 栈求值 │ ├── res/layout/activity_main.xml // 显示区 + 按键区布局 │ ├── res/values/ │ │ ├── strings.xml // 应用名等字符串资源 │ │ └── colors.xml // 主题颜色(模板默认即可) │ └── AndroidManifest.xml // 入口 Activity 声明 ├── build.gradle // 项目级构建脚本 └── settings.gradle // 仓库与模块声明关键文件只有三个:MainActivity.java、CalculatorEngine.java、activity_main.xml。这是故意为之——大作业的工程结构越简单,答辩时越容易把每条代码的职责说清楚。
CalculatorEngine单独抽成一个类,是这套代码里最值得保留的设计。Activity 只负责“用户按了什么键、界面上显示什么”,至于“1+2×3 为什么等于 7 而不是 9”,全在引擎里算。这样拆开以后,即使界面崩了也不会影响计算逻辑,反过来引擎写错了也不会牵连布局。
2.3 从输入到输出的一次完整数据流
整个 App 的运行状态可以用三个词概括:expression(表达式字符串)、justEvaluated(是否刚按过等号)、etDisplay(显示区)。
用户每次按键,对应的 tag 值(比如“7”“+”“=”)被拼进expression字符串,然后显示区同步刷新。按下等号时,expression被交给CalculatorEngine.calculate(),计算完的结果字符串再写回expression和显示区。这个“字符串进、字符串出”的设计让 Activity 完全不需要知道数字是怎么算出来的。
表达式采用字符串缓存而不是“按一个键算一次”,是因为计算过程需要能回退、能修改。比如用户输入“123”后发现多按了一个 3,退格键只需要substring截掉末尾字符即可。如果每次按键都立刻算结果,这种编辑能力就没了,还得额外维护一堆中间状态变量。
3. 界面层实现:LinearLayout 双层嵌套,权重控尺寸
3.1 显示区与按键区的 XML 布局
布局文件不长,核心思路是“外层纵向排、每行横向排”。最外层是一个纵向的LinearLayout,里面先放一个EditText做显示区,下面再放五个横向LinearLayout,每行四个按钮。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="8dp"> <!-- 显示区:只读、右对齐、不显示光标 --> <EditText android:id="@+id/etDisplay" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginBottom="8dp" android:background="@android:drawable/editbox_background" android:cursorVisible="false" android:focusable="false" android:gravity="end|center_vertical" android:hint="0" android:textSize="28sp" /> <!-- 第一行功能键:C、DEL、左括号、右括号 --> <LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal"> <Button android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:tag="C" android:text="C" /> <Button android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:tag="DEL" android:text="DEL" /> <Button android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:tag="(" android:text="(" /> <Button android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:tag=")" android:text=")" /> </LinearLayout> <!-- 其余四行同理,键位见下表 --> </LinearLayout>这里有两处值得留意。按钮宽度写0dp,配合layout_weight="1"让四个按钮均分一行宽度,这是 LinearLayout 里最稳的等分写法。EditText设置了focusable="false"和cursorVisible="false",把它变成一块纯展示区域——如果不关掉焦点,点一下屏幕就会弹出输入法,把计算器布局顶上去。
整个按键区共五行四列,键位排布如下:
| 行 | 按键 |
|---|---|
| 1 | C |
| 2 | 7 |
| 3 | 4 |
| 4 | 1 |
| 5 | 0 |
第二到第五行的 XML 结构完全一样,把tag和text替换成对应字符即可。之所以没用GridLayout,是因为新版里控制子控件权重需要layout_columnWeight这类属性,老设备兼容性不如 LinearLayout 稳。对大作业来说,布局控件的核心诉求是“不翻车”,不是“最少的代码行数”。
3.2 用 tag 做按钮分发:一个 onClick 管所有键
布局文件里所有按钮都没有写onClick属性,而是在 Activity 里用一个专门的方法做分发。这个方法通过view.getTag()拿到每个按钮在 XML 里预设的标识,再用 switch 语句区分处理。
public class MainActivity extends AppCompatActivity { private EditText etDisplay; private String expression = ""; // 当前完整表达式 private boolean justEvaluated = false; // 上次按的是不是等号 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); etDisplay = findViewById(R.id.etDisplay); } // 布局里所有按钮都绑定到这个方法 public void onClick(View view) { String tag = (String) view.getTag(); switch (tag) { case "C": expression = ""; justEvaluated = false; break; case "DEL": if (expression.length() > 0) { expression = expression.substring(0, expression.length() - 1); } justEvaluated = false; break; case "=": calculateAndShow(); return; // 等号按下后不把 "=" 拼进表达式 default: // 数字、小数点、运算符统一按字符串拼接 if (justEvaluated) { boolean isDigitKey = "0123456789.".contains(tag); justEvaluated = false; if (isDigitKey) { expression = ""; // 等号后按数字,开启新的计算 } } expression += tag; break; } etDisplay.setText(expression); } private void calculateAndShow() { if (expression.isEmpty()) { etDisplay.setText("0"); return; } // 去掉末尾残留的运算符,比如 "5+3-" while (expression.length() > 0 && isEndWithOperator(expression)) { expression = expression.substring(0, expression.length() - 1); } try { expression = CalculatorEngine.calculate(expression); etDisplay.setText(expression); justEvaluated = true; } catch (ArithmeticException e) { etDisplay.setText("错误"); } } private boolean isEndWithOperator(String s) { char c = s.charAt(s.length() - 1); return c == '+' || c == '-' || c == '*' || c == '/'; } }switch的 default 分支里,数字、小数点、四则运算符、括号全部走字符串拼接,这是整套界面逻辑最简单的部分。真正的小细节是justEvaluated标志:用户在等号算出结果后再按数字,应该开启一个新算式而不是把数字接到旧结果后面;如果接着按的是运算符,则要允许在结果基础上继续运算。这个交互细节很多计算器 Demo 都不做,写上去之后体验会明显更接近真实计算器。
这段代码里有一个常见的踩坑点:XML 里给按钮写android:onClick="onClick"时,方法签名必须是public void onClick(View view),少一个参数或者不是 public,运行时点击就会直接闪退报IllegalStateException。
3.3 接口边界:Activity 里不出现任何计算逻辑
注意MainActivity从头到尾没有出现“把两个数字加起来”之类的代码。它做的事只有三件:接收按键、维护expression字符串、把字符串发给引擎并显示结果。calculateAndShow里唯一的逻辑是去掉末尾多余的运算符,这也属于输入校正而不是计算。
这样的边界划分有个很实际的收益:答辩时老师问“你这计算逻辑怎么测的”,你可以直接打开CalculatorEngine类,在 main 方法里写一行System.out.println(calculate("3+5*2"))验证输出,完全不需要启动模拟器。换句话说,计算引擎被设计成了不依赖 Android 环境的纯 Java 类,这为后续做单元测试和二次开发都留好了路。
4. 核心计算模块:中缀转后缀、栈求值,一个不少
4.1 为什么不做“边输入边算”的状态机
最常见的简易计算器写法是“状态机”:维护当前值、上一个运算符、是否刚按过等号这三个变量,每按一个运算符就立刻把前面的数算掉。这种写法代码更短,但遇到3+5×2会算出 16——因为加法在乘法之前被提前执行了。
要正确处理优先级,就得拿到完整的表达式后统一处理。这个项目选的是“中缀转后缀(逆波兰表达式)+ 栈求值”的方案:先把3+5×2改写成3 5 2 × +,然后从左到右扫描后缀序列,遇数字压栈、遇运算符弹出两个数计算再压回。整个过程只需要一个优先级表和一个栈,不涉及递归下降解析,代码量可控,逻辑也容易跟老师讲清楚。
这里刻意不实现一元负号的解析,比如输入5*-3会直接提示“错误”。不是写不出来,而是大作业场景里“支持负号”不是刚需,硬加反而会把优先级判断搞复杂。边界条件明确写在文档里,比藏着半生不熟的功能更稳妥。
4.2 中缀转后缀:优先级表的现场表演
package com.example.calculator; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.List; /** * 计算引擎:中缀表达式转后缀表达式,再通过栈求值。 * 纯 Java 实现,可以脱离 Android 环境单独测试。 */ public class CalculatorEngine { private static boolean isOperator(char c) { return c == '+' || c == '-' || c == '*' || c == '/'; } private static int priority(char op) { switch (op) { case '+': case '-': return 1; case '*': case '/': return 2; default: throw new IllegalArgumentException("未知运算符: " + op); } } /** 中缀转后缀:输入 "3+5*2",输出 ["3","5","2","*","+"] */ public static List<String> infixToPostfix(String expr) { List<String> output = new ArrayList<>(); Deque<Character> ops = new ArrayDeque<>(); StringBuilder num = new StringBuilder(); for (int i = 0; i < expr.length(); i++) { char c = expr.charAt(i); if (Character.isDigit(c) || c == '.') { num.append(c); // 数字和小数点先攒起来 } else if (c == '(') { ops.push(c); } else if (c == ')') { flushNumber(output, num); while (!ops.isEmpty() && ops.peek() != '(') { output.add(String.valueOf(ops.pop())); } ops.pop(); // 弹出左括号 } else if (isOperator(c)) { flushNumber(output, num); // 栈顶运算符优先级不低于当前时,先弹出 while (!ops.isEmpty() && ops.peek() != '(' && priority(ops.peek()) >= priority(c)) { output.add(String.valueOf(ops.pop())); } ops.push(c); } } flushNumber(output, num); while (!ops.isEmpty()) { output.add(String.valueOf(ops.pop())); } return output; } private static void flushNumber(List<String> output, StringBuilder num) { if (num.length() > 0) { output.add(num.toString()); num.setLength(0); } } }核心规则只有一句:当新运算符要入栈时,把栈里所有“优先级不低于它”的运算符先弹出去。比如+遇到栈顶的*,因为*优先级更高,先弹出*再压入+,这样后缀表达式里乘法的位置就会比加法靠前,求值时先算。括号的优先级被特殊处理成“遇到右括号才弹出”,保证括号内的运算符不会提前出栈。
StringBuilder num的作用是拼接多位数和小数点。扫到数字时先攒着,遇到运算符或右括号才作为一个完整的 token 输出,这样100 + 20不会变成1、0、0三个独立数字。
4.3 后缀求值:栈的现场表演
/** 后缀表达式求值:支持四则运算,除零抛 ArithmeticException */ public static double evaluate(List<String> postfix) { Deque<Double> stack = new ArrayDeque<>(); for (String token : postfix) { char c = token.charAt(0); if (isOperator(c) && token.length() == 1) { if (stack.size() < 2) { throw new ArithmeticException("表达式不完整"); } double b = stack.pop(); double a = stack.pop(); switch (c) { case '+': stack.push(a + b); break; case '-': stack.push(a - b); break; case '*': stack.push(a * b); break; case '/': if (b == 0) { throw new ArithmeticException("除数不能为0"); } stack.push(a / b); break; } } else { // 数字 token 直接入栈 stack.push(Double.parseDouble(token)); } } if (stack.size() != 1) { throw new ArithmeticException("表达式语法错误"); } return stack.pop(); }求值过程就是“从左到右扫,数字进栈,运算符弹两个”。这里要特别强调两个细节:第一是b = stack.pop(); a = stack.pop();的顺序,减法是用后弹出的数减先弹出的数,如果写反了,3-1会被算成1-3。第二是stack.size() < 2的空栈保护,否则用户输入5*这种残缺表达式时,pop会抛出NoSuchElementException,而这个异常不会被MainActivity里捕获的ArithmeticException接住,直接导致崩溃。
除零判断放在b == 0时抛出ArithmeticException,这样“除以 0”会走 Activity 里的 catch 分支,显示“错误”而不是崩溃。
4.4 对外封装:一个入口,统一格式化结果
/** 对外统一入口:传字符串进来,返回字符串结果 */ public static String calculate(String expression) { List<String> postfix = infixToPostfix(expression); double result = evaluate(postfix); return formatResult(result); } /** 整数结果不显示小数点,避免 4.0 这种多余格式 */ public static String formatResult(double value) { if (value == Math.floor(value) && !Double.isInfinite(value)) { long longValue = (long) value; if (Math.abs(value) <= Long.MAX_VALUE) { return String.valueOf(longValue); } } return String.valueOf(value); } }calculate是引擎对外唯一的方法,Activity 不需要知道中缀、后缀、栈这些概念。formatResult处理的是显示层的小情绪:8/2的结果是4.0,但计算器应该显示4;当结果超出long范围时退回科学计数法,避免(long) 1e20这类溢出变成负数。formatResult用public修饰,是为了方便后面二次开发时在 Activity 里直接复用格式化逻辑。
5. 运行调试与避坑:从 Gradle 同步到 APK 打包
5.1 三步把项目跑到真机或模拟器
拿到工程代码后,运行路径很直接。第一步用 Android Studio 的 Open 功能选中项目根目录(含settings.gradle的那层),IDE 会自动识别出 Gradle 工程;第二步等待 Gradle Sync 完成,右下角进度条消失、Event Log 里没有红色报错就算通过;第三步点工具栏的 Run,选择一个已连接的模拟器或真机。
真机和模拟器的选择有个经验:模拟器用 Android Studio 自带的*.avd即可,但冷启动时间很长,建议先开模拟器再打开项目,让系统预热;真机调试需要提前在开发者选项里打开 USB 调试,部分设备还要额外选择“文件传输”模式才能被识别。对交作业的场景来说,模拟器能跑通就足够录屏演示了。
5.2 避坑记录:五条血泪经验
第一条,Gradle Sync 一直转圈或报错Could not resolve。现象是项目刚打开就卡在依赖下载,或者明明照着模板建的工程却连编译都过不去。原因大多是默认仓库地址在当前网络环境访问不稳定。解决方法是把settings.gradle里的仓库地址换成阿里云镜像仓库,在repositories块里添加maven { url 'https://maven.aliyun.com/repository/google' }和maven { url 'https://maven.aliyun.com/repository/public' },然后重新 Sync。
第二条,点击按钮直接闪退,Logcat 报IllegalStateException: Could not find method onClick(View) in the Activity class。现象是布局里写了android:onClick="onClick",但运行一点按钮就崩。原因一般是方法签名不对——必须是public void onClick(View view),写成了带返回值或包成 private 都会触发这个异常。解决方法是检查MainActivity里的方法签名,尤其别漏掉参数类型是android.view.View。
第三条,3+5×2算出 16。现象是输入合法表达式但结果完全错误。原因是采用了“边输入边算”的状态机,加减乘除在按下时立刻执行,优先级被忽略了。解决方法是改为本章介绍的中缀转后缀方案,或者在状态机里额外维护优先级判断逻辑,但后者更容易写乱。
第四条,算完2+3=5之后再按 4,显示变成54。现象是上一次的结果和新的输入粘连在了一起。原因是expression没有被清空,新数字直接被拼接到了旧结果后面。解决方法就是MainActivity里的justEvaluated标志,等号之后按数字先重置表达式。
第五条,连续输入两个运算符或括号不匹配导致崩溃。现象是输入5++3或者(5+3后按等号,程序闪退。原因是evaluate里栈弹出到空栈时抛出的是NoSuchElementException,没被 catch 住。解决方法是引擎里加上stack.size() < 2判断并抛出ArithmeticException,同时 Activity 里统一 catch 这个异常。这套兜底逻辑改完之后,“错误”的提示虽然朴素,但至少应用不会在答辩现场崩溃。
5.3 打出可提交的 debug APK
运行调试通过之后,还需要产出一个能脱离 Android Studio 独立安装的 APK。操作路径是菜单栏 Build → Build Bundle(s)/APK(s) → Build APK(s),构建完成后左下角会弹出提示,点 locate 可以直接在文件管理器里定位到app/build/outputs/apk/debug/app-debug.apk。
把 APK 发给同学或老师安装验证时,注意手机需要允许“安装未知来源应用”。另外提交代码前检查一下AndroidManifest.xml里的android:label,它决定桌面上显示的应用名,默认可能是Calculator或你创建工程时填的项目名,改成自己容易识别的名称会显得更完整。
6. 二次开发思路:给你的计算器加一点“作品感”
6.1 三个低成本改造方向
| 改造方向 | 改动位置 | 难度 |
|---|---|---|
| 深色主题 | activity_main.xml背景色 + EditText 与按钮颜色 | 低 |
| 计算历史记录 | Activity 里维护一个List<String>,每次按等号后追加一行 | 低 |
| 加“开平方”单目运算按钮 | XML 新增按钮 + Activity 的 switch 新开 case | 中 |
这里面投入产出比最高的其实是“单目运算按钮”,因为它不需要改动引擎的数据结构,只需在界面层单独处理,很适合作为亲自上手的第一个改造点。
6.2 给 Activity 加一个开平方键
假设在底部布局里加了一个 tag 为√的按钮,MainActivity的 onClick 里对应新增一段:
case "√": try { double value = Double.parseDouble(expression); expression = CalculatorEngine.formatResult(Math.sqrt(value)); etDisplay.setText(expression); justEvaluated = true; } catch (NumberFormatException e) { etDisplay.setText("错误"); } break;注意这个按钮的使用方式是“先输入数字,再按开平方”,跟 Windows 计算器里“先按数字再按根号”的习惯一致。这里用到了CalculatorEngine.formatResult,前提是把引擎里那个方法的访问修饰符从private改成public static,否则 Activity 调不到。
如果你想把√融进完整表达式(比如计算√9 + 1),那就要动CalculatorEngine的解析器了,需要把√当成一元运算符处理,给它的优先级设置成比乘除更高,并在evaluate里单独写弹栈逻辑。这个改动量不小,作为期末项目来说性价比不高,放在扩展说明里交代清楚即可。
6.3 改完之后的自测路径
每改一处功能,我都会强制自己过一遍这几组输入:1+2×3必须等于 7;8÷2必须显示 4 而不是 4.0;5÷0必须提示错误而不是崩溃;2+3=之后再按 5,要开启全新算式而不是变成55;连按C要恢复到初始空白状态。这套用例同时覆盖了优先级、小数格式、异常兜底、状态复位四个维度,能挡住八成回归问题。
从那以后,我每次拿到类似的计算器类需求,都先画一条从按钮到结果的链路再写代码:按键进表达式、表达式进引擎、结果回显示,三个环节各管各的,调试定位快很多。这个项目也是按这条链路拆的,希望帮到你。
本文还有配套的精品资源,点击获取