简介:面向零基础初学者的Android Studio计算器项目源码,适合Android课程设计、大作业参考,也适合刚跑通Hello World的开发者作为第二个练手实例。项目基于Java实现加减乘除、小数点与清零等基本运算,重点演示界面布局、按钮事件响应、控件绑定与基础逻辑组织,配套博客讲解可帮助逐步消化。压缩包共409个文件,整体约13.37MB,包含flat编译资源、xml布局与配置、java源代码、gradle构建脚本、jar依赖、apk安装包以及png图标等,类型覆盖Android工程从源码、资源到构建产物的主要环节;解压后可按Android Studio工程结构直接导入运行,并通过源码与资源文件对照理解项目构建流程。目前已有3755人学习浏览。通过查看源码、布局和构建配置,读者可快速掌握Android项目组织方式与按钮点击事件写法,为后续独立开发打好基础。
1. 为什么计算器是小白上手安卓app最划算的一个项目
很多人学 Android Studio 开发的第一个念头是写 Hello World,但 Hello World 点一下按钮弹一行字,证明不了你会写界面,也证明不了你会处理逻辑。计算器这个入门级安卓 app 恰恰把两大块都占了:上面是按钮和显示区的 UI 布局,底下是表达式解析和运算逻辑。从零开始,用 Android Studio 把加减乘除、小数点和括号做到能真机算题,全部代码两百行上下,比看十篇教程都来劲。它能一次性串起 Activity、布局文件、控件绑定、事件监听、异常处理这些后面天天用的概念,做完你就有底气往应用市场投第一个包了。这篇笔记直接用一套能复现的写法带你走完:建工程、画界面、写逻辑、打 APK,以及五个我实际踩过的坑。
2. 搭建可运行的安卓工程:新建项目、GridLayout 布局和虚拟机三件事
2.1 新建项目时,三处不能跳过的手工配置
新建工程用 Android Studio 的 New Project 选 Empty Views Activity,包名建议用com.example.calculator,语言选 Java 或 Kotlin 都行,这篇用 Java。等你点了 Finish,先别急着写代码,有三处配置不提前改,后面会翻车。
第一处:模板工程默认用 ConstraintLayout,这个布局做计算器键盘并不顺手,因为你要的是整齐的按钮网格。后面我直接把布局文件换成 GridLayout,你需要在app/build.gradle的 dependencies 里删掉或保留implementation 'androidx.constraintlayout:constraintlayout:2.x.x'。保留也无所谓,但为了避免编译时多拉一个依赖,我一般会删掉:
dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' // implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.android.material:material:1.9.0' }删掉 ConstraintLayout 后,Activity 里的setContentView不受影响,因为你引用的是自己的R.layout.activity_main。真正要改的是布局根节点,这个在下一节。
第二处:Gradle 的 SDK 版本。新建项目默认compileSdk 34、targetSdk 34,如果你手里的 Android Studio 是较老版本,可能没有下载 API 34 的 SDK。这时候去 SDK Manager 里装 34,或者直接把这行改成compileSdk 33,同时把targetSdk也改成 33。改完记得 Sync Now。
第三处:把 Theme 改成不带 ActionBar 的,否则计算器顶部会顶一条系统标题栏,显示应用名。在values/themes.xml里,把parent="Theme.Material3.DayNight"改成parent="Theme.Material3.DayNight.NoActionBar"。这是一个细节,很多人做完才发现标题栏占掉了显示区的高度。
这三处改完,工程才算真正“干净”。模板自带的MainActivity里有一段FloatingActionButton相关的代码,没有的话跳过,有的话顺手删掉,因为计算器用不到悬浮按钮。
2.2 用 GridLayout 排计算器键盘:行数列数、按钮拉伸与字号
先明确结构:上方是一块很大的显示区,下方是 4 行 4 列的按钮键盘。显示区用 TextView,键盘区用 GridLayout。整体竖排,所以根节点用 LinearLayout,方向为 vertical。
GridLayout 是 Android 官方自带的网格布局控件,不需要额外引库。关键属性是rowCount和columnCount,一旦指定行数列数,子视图按顺序填充。想让按钮均分宽度和高度,核心技巧是给每个按钮设layout_columnWeight="1"和layout_rowWeight="1",这两个属性是 GridLayout 特有,作用是让按钮在网格单元内拉伸填满。
<?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"> <TextView android:id="@+id/tv_display" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:background="#F5F5F5" android:gravity="bottom|end" android:padding="16dp" android:text="0" android:textSize="42sp" /> <GridLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="3" android:rowCount="4" android:columnCount="4"> <Button android:id="@+id/btn_7" android:layout_columnWeight="1" android:layout_rowWeight="1" android:text="7" /> <!-- 其余按钮按顺序排列 --> </GridLayout> </LinearLayout>关键点:layout_height="0dp"配合layout_weight让显示区和键盘区按 1:3 分配高度。如果你改成wrap_content,键盘会顶出去或者显示区挤成一条线。每个按钮都要写layout_columnWeight和layout_rowWeight,少写一个,那一格就不会拉伸,按钮会变成自己内容的大小,非常难看。
Button 默认有边框和背景,在 Material 主题下按钮是圆角的,这没问题。但如果你的按钮文字太小,在themes.xml里全局改会影响其他地方,我一般直接在布局里给每个 Button 写android:textSize="24sp"。偷懒做法是抽一个 style 出来,但入门阶段直接写属性,出问题好排查。
2.3 布局预览与虚拟机:为什么模拟器跑出来和预览不一样
写完 XML,Android Studio 的 Preview 窗口会显示渲染效果,但很多人会遇到一个问题:预览里按钮排列正常,跑到模拟器上却出现按钮被挤到屏幕外,或者行距忽大忽小。最常见的原因就是根节点 LinearLayout 的layout_weight没写,或者 GridLayout 的权重属性漏了。预览窗口在渲染时会宽容处理一些属性,而真机不会,它会严格按布局参数排。
另一个问题是模拟器本身的屏幕密度。如果你创建的虚拟机是Pixel 2 API 33,分辨率 1080×1920,按钮显示正常;但如果你用Nexus One这类小屏老设备,42sp 的显示区字会溢出。建议创建虚拟机时直接选 Pixel 系列,屏幕比例和现代手机一致,不要贪旧机型。
跑虚拟机的步骤:工具栏点 Device Manager,Create Device,选 Pixel 4,下载系统镜像后 Finish。启动一次虚拟机通常需要两分钟,如果是 AMD 处理器,记得在 BIOS 里开 SVM 虚拟化,不开的话模拟器会直接卡死在启动画面,这是新手第一个玄学问题。
3. 计算逻辑用逆波兰表达式:Java 实现与两个边界坑
3.1 为什么中缀计算器最容易写崩
拿到“1+2×3”,你当然知道先乘后加,但计算机从左往右读,按顺序算会得到 9。解决办法有两种:一是维护两个栈实时比较运算符优先级,边读边算;二是先把中缀表达式转成后缀表达式,再一次性求值。
我推荐第二种,也就是逆波兰表达式。原因很简单:转换和求值是两段独立代码,每一段都能单独测试。中缀转后缀时,遇到数字就输出,遇到运算符则根据栈顶优先级决定是否弹栈;后缀求值时,遇到数字入栈,遇到运算符则弹出两个数计算,再把结果压回栈。整个过程没有括号的递归,没有对“当前该不该算”的状态判断。
表达式可以包含十进制小数,所以解析要用Double.parseDouble,但这里藏着两个坑:一是String转double前必须 trim,去掉用户手滑输入的空格;二是连续输入“12.”这种以点结尾的数,parseDouble("12.")在 Java 里居然能过,但会丢掉小数点,显示时就不对。后面小节会专门处理显示层。
3.2 逆波兰实现:转后缀、求值、一次性处理小数的 Java 代码
第一步写一个独立工具类ExpressionEvaluator.java,放在和 MainActivity 同级的包下。代码如下:
import java.util.ArrayList; import java.util.List; import java.util.Stack; public class ExpressionEvaluator { public static double eval(String expr) { List<String> rpn = toRPN(expr); return calculate(rpn); } private static List<String> toRPN(String expr) { List<String> output = new ArrayList<>(); Stack<String> ops = new Stack<>(); String num = ""; for (int i = 0; i < expr.length(); i++) { char c = expr.charAt(i); if (Character.isDigit(c) || c == '.') { num += c; } else { if (!num.isEmpty()) { output.add(num); num = ""; } if (c == '(') { ops.push(String.valueOf(c)); } else if (c == ')') { while (!ops.isEmpty() && !ops.peek().equals("(")) { output.add(ops.pop()); } ops.pop(); } else if ("+-*/".indexOf(c) >= 0) { String cur = String.valueOf(c); while (!ops.isEmpty() && priority(ops.peek()) >= priority(cur)) { output.add(ops.pop()); } ops.push(cur); } } } if (!num.isEmpty()) { output.add(num); } while (!ops.isEmpty()) { output.add(ops.pop()); } return output; } private static int priority(String op) { if ("*/".contains(op)) return 2; if ("+-".contains(op)) return 1; return 0; } private static double calculate(List<String> rpn) { Stack<Double> stack = new Stack<>(); for (String token : rpn) { if ("+-*/".contains(token)) { double b = stack.pop(); double a = stack.pop(); switch (token) { case "+": stack.push(a + b); break; case "-": stack.push(a - b); break; case "*": stack.push(a * b); break; case "/": stack.push(a / b); break; } } else { stack.push(Double.parseDouble(token)); } } return stack.pop(); } }逻辑说明:toRPN里num字符串负责拼接多位小数,遇到运算符时先把手里的数字丢进输出列表,再处理运算符栈。括号处理是重点,遇到右括号时要把左括号之前的所有运算符全部弹到输出里,左括号本身弹掉但不进输出。
calculate里的弹栈顺序也很关键:先弹b再弹a,对应二元运算的左操作数和右操作数。如果你写成a - b的顺序反了,计算结果会差一个符号,这也是新手最容易翻车的位置。除法同理,a是被除数,b是除数,顺序错了 8÷4 会算出 0.5。
这段代码没有处理表达式开头是负号的情况,比如输入“-5+3”,程序会把减号当成二元运算符,最终栈里只剩下一个数字没法配对就抛异常。解决办法:在 MainActivity 里接到输入后,判断字符串第一个字符是不是“-”,如果是,在前面补一个 0 再传给求值器,比如“-5+3”变成“0-5+3”,结果一致,语义也没变。
3.3 精度问题:0.1+0.2 不是 0.3
逆波兰表达式计算出来是double,但double的二进制浮点表示天生无法精确表示比如 0.1,所以 0.1+0.2 的结果不是 0.3,而是 0.30000000000000004。这个在计算器上非常扎眼,用户会觉得你的 app 有 bug。
常见的做法是在显示层格式化,而不是在计算层处理。计算层保持double,显示层用BigDecimal或者DecimalFormat控制输出位数。计算器场景一般保留 8 到 10 位小数就够用:
import java.text.DecimalFormat; public static String formatResult(double value) { DecimalFormat df = new DecimalFormat("0.#########"); return df.format(value); }逻辑说明:0.#########表示最多保留 9 位小数,末尾的 0 不显示。除不尽的循环小数比如 1÷3=0.3333333333,会被截到 9 位,正常使用不会发现精度损失。如果你用df.format(0.30000000000000004),得到的是0.3,用户就看不到那一长串尾巴了。
这里的另一个坑是:DecimalFormat默认会做四舍五入,不是截断。对计算器来说四舍五入是符合直觉的,因为 1÷3 显示成 0.333333333 和显示成 0.333333334 用户都能接受,但如果你做的是进制转换之类需要精确截断的场景,就不能用这个格式。
4. 按钮事件绑定与输入状态机:一个监听器处理全部按键
4.1 布局文件里每个按钮都写 android:onClick 会怎样
有一种偷懒写法是在 XML 里给每个 Button 写android:onClick="onClick",然后在 Activity 里写一个同名方法接收View参数。这样代码量确实少,但缺点也很明显:一旦方法名写错,运行到点击时才崩溃,编译期不报错;而且同一份布局文件如果被多个 Activity 复用,还得保证每个 Activity 都有同名方法。这个方案对新手不友好。
我一般直接在 Java 代码里统一绑定。在MainActivity的onCreate里,把数字按钮和运算符按钮归成两组List<Button>,循环设置监听器:
public class MainActivity extends AppCompatActivity implements View.OnClickListener { private TextView tvDisplay; private String currentInput = ""; private boolean isLastResult = false; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); tvDisplay = findViewById(R.id.tv_display); int[] numberIds = {R.id.btn_0, R.id.btn_1, R.id.btn_2, R.id.btn_3, R.id.btn_4, R.id.btn_5, R.id.btn_6, R.id.btn_7, R.id.btn_8, R.id.btn_9}; for (int id : numberIds) { findViewById(id).setOnClickListener(this); } int[] opIds = {R.id.btn_plus, R.id.btn_minus, R.id.btn_mul, R.id.btn_div, R.id.btn_clear, R.id.btn_dot, R.id.btn_eq, R.id.btn_back}; for (int id : opIds) { findViewById(id).setOnClickListener(this); } } @Override public void onClick(View v) { int id = v.getId(); if (id == R.id.btn_clear) { currentInput = ""; tvDisplay.setText("0"); return; } if (id == R.id.btn_eq) { // 计算结果 } // ... } }这里关键是implements View.OnClickListener配合setOnClickListener(this),所有按钮的点击事件都汇到同一个onClick。好处是只有一个入口,后续加退格、括号之类的新按钮,只需要在onClick里加一个if分支。逻辑说明放在代码之后:findViewById循环里用了两个数组分别装数字和运算符,以后要加一个取反按钮,就往数组里加一个 id。
4.2 连续按小数点、重复运算符、除数为 0 的处理
计算器最容易被用户按出问题的是表达式不合法。比如用户连按两次小数点,得到“1..2”,Double.parseDouble直接崩溃;再比如用户按完“5 +”马上又按“3”,表达式会变成“5 + 3”,这个其实没问题,但用户按完“5 +”再按“×”,表达式会变成“5 + ×”,求值器遇到连续运算符会直接崩。
处理方案不是把逻辑写在求值器里,而是写在按钮输入的入口处。每按一个数字,先判断当前输入串最后一个字符是不是运算符或左括号,如果是且新按的也是运算符,就替换掉最后一个字符,而不是追加。每按一个小数点,先判断当前正在输入的数字里是否已经有点,判断方式是从表达式最后一个运算符位置往后查,如果已含点就忽略本次点击:
if (id == R.id.btn_dot) { int lastOpIndex = Math.max(currentInput.lastIndexOf('+'), Math.max(currentInput.lastIndexOf('-'), Math.max(currentInput.lastIndexOf('*'), currentInput.lastIndexOf('/')))); String tail = currentInput.substring(lastOpIndex + 1); if (tail.contains(".")) { return; } if (tail.isEmpty()) { currentInput += "0."; } else { currentInput += "."; } tvDisplay.setText(currentInput); }这里用lastIndexOf找到最后一个运算符的位置,substring取出最后一个操作数,如果里面已经有点,就忽略点击。如果尾数为空,说明用户刚按完一个运算符就按了点,此时要补一个“0.”,否则表达式变成“5 + .”也是非法的。
除数为 0 的处理放在求值器外面。在onClick里按下等号后,先调ExpressionEvaluator.eval,但用try-catch包住:
if (id == R.id.btn_eq) { if (currentInput.isEmpty()) return; try { double result = ExpressionEvaluator.eval(currentInput); tvDisplay.setText(ExpressionEvaluator.formatResult(result)); } catch (ArithmeticException | NumberFormatException e) { tvDisplay.setText("错误"); } }ArithmeticException在 Java 里不会因为除零而触发(double 除零得到 Infinity),真正会出现的是EmptyStackException和NumberFormatException,所以 catch 里写RuntimeException更稳妥。你可以把 catch 的粒度放大,直接 catch 所有运行时异常,这样任何非法表达式都只显示“错误”,而不是让 app 闪退。
4.3 按“=”之后再按数字,是继续算还是新开一局
这是计算器交互里最影响体验的细节。用户算完“1+2=3”,接着按“4”,期望看到的是新表达式从 4 开始,而不是“3+4”。所以在按下等号并拿到结果后,要设置一个标志位isLastResult,然后在数字按钮分支里判断:
if (id >= R.id.btn_0 && id <= R.id.btn_9) { if (isLastResult) { currentInput = ""; isLastResult = false; } currentInput += "3"; // 实际是从按钮文字里取 tvDisplay.setText(currentInput); }逻辑说明:isLastResult在等号分支里置为 true,在数字点击时如果为 true,说明上一次已经算完,新输入要覆盖而不是追加。运算符点击时不用清空,因为“3 ×”是在结果 3 的基础上继续运算,这是符合用户预期的。但注意,如果用户按完等号后直接按运算符再按数字,表达式会变成“[新结果] × 数字”,此时isLastResult要在运算符按下时重置为 false,否则第二次按数字时会被误判为“新开一局”。
这三个状态位currentInput、isLastResult、tail.contains(".")就构成了计算器的输入状态机。所有入口都在同一个onClick方法里,状态机维护起来不难。如果以后要加括号按钮,只需要在运算符分支里把()也当作合法输入,并检查括号匹配,求值器里的栈已经支持了。
5. 避坑记录:入门计算器也会踩的五个真坑
5.1 模拟器上按键间距忽大忽小,按钮像是被拉伸后又挤回来
现象:Preview 里 4×4 键盘整整齐齐,跑到虚拟机里第一行按钮很宽,第二行又很窄,按钮之间缝隙不对称。
原因:GridLayout 里只写了layout_columnWeight="1"没有写layout_rowWeight="1",或者只写了行权重没写列权重。GridLayout 与 LinearLayout 的 weight 机制不同,行和列要分别指定拉伸方向,少一个维度,那一维度就按内容高度排列,按钮文字大小不一致时就会出现锯齿状排列。
解决:给 GridLayout 下每个 Button 同时写layout_columnWeight="1"和layout_rowWeight="1",并且把 GridLayout 的layout_height设成0dp配合外部 LinearLayout 的layout_weight。如果已经全写了还是乱,检查是不是把layout_columnSpan误写成了layout_gravity="fill_horizontal"之类——这两个属性和按键无冲突,但会改变跨列布局。
5.2 编译通过,但点按钮没反应,连日志都不打
现象:在 Activity 里写了setOnClickListener,运行后模拟器点击按钮,界面没有任何变化,也没有报错。
原因:最常见是布局文件里按钮 id 与findViewById里写的 id 不一致。比如 XML 里写@+id/btn_mul,Java 里写findViewById(R.id.btn_mul),但 XML 里某个按钮的 id 在复制粘贴时改成了btn_multiply,编译时 R 类不会报错,因为两个 id 都存在,只是你绑定错了对象。另一种可能是重复调用了setContentView,后面的布局覆盖了前面的,导致你绑定的是旧布局里的按钮。
解决:在onClick方法开头加一行Log.d("Calculator", "clicked: " + v.getId()),然后到 Logcat 里过滤 Calculator 标签。如果点击没打日志,说明监听器没绑上;如果打出来的 id 和预期不一致,去 XML 里核对每个按钮的@+id命名。强迫自己在 XML 写完按钮后立刻检查 id 列表,是最省事的消灭此类问题的方式。
5.3 0.2+0.7 显示成 0.8999999999999999
现象:表达式“0.2+0.7”计算结果在 TextView 上显示一长串小数。
原因:double的底层浮点表示无法精确存储 0.2 和 0.7,误差在运算中累积。这不是 Android 的问题,是 Java 的问题,C++、Python 同样存在。
解决:显示层统一走formatResult方法,保留 9 位小数四舍五入。注意不要在计算层用BigDecimal,因为BigDecimal的除法需要传精度,处理周期小数很麻烦,对入门项目没必要。如果你确实想精确计算,可以用BigDecimal.valueOf(double).setScale(4, RoundingMode.HALF_UP)替换DecimalFormat,但表达式求值的中间结果也要用 BigDecimal,整个过程代码会膨胀不少。计算器显示 9 位小数已经足够。
5.4 打出来的 APK 装到手机上提示“解析失败”或“未安装应用”
现象:用 Android Studio 的 Build > Build APK 生成 app-debug.apk,传到手机后点击安装,系统弹“解析包出现问题”。
原因:低版本系统上安装 targetSdk 31+ 的 APK 会出现这种情况,尤其是 Android 7 以下的旧手机;还有一种情况是 APK 是用 debug 签名签的,部分国产 ROM 对 debug 签名的应用有安装限制。另外如果你开启了 Instant Run 或者从Build > Build Bundle(s)里生成了 .aab,那本身就不是 APK,传到手机当然装不了。
解决:入门阶段直接用Build > Build APK(s),产出在app/build/outputs/apk/debug/app-debug.apk。用一个支持 Android 10 以上的手机测试,或者干脆用命令行adb install app-debug.apk,adb 安装会输出更具体的错误信息,比如 INSTALL_FAILED_UPDATE_INCOMPATIBLE 表示手机上已有签名不同的旧版本。后者对应的解决方案是卸载旧应用再装。
5.5 Activity 频繁闪退,Logcat 里全是 ClassNotFoundException
现象:启动 app 瞬间闪退,Logcat 提示类找不到,指向你写的工具类。
原因:新写的ExpressionEvaluator.java类没有保存到正确的包目录下。入口类找工具类,靠包名定位,如果你把类文件放在了com.example.calculator之外的目录,比如java/com/example/calculator/utils,而 Activity 里没有加import,就会在运行时找不到类。编译期 AS 有时候不强制报错,运行时才暴露。
解决:检查ExpressionEvaluator.java的包名声明和所在目录结构是否一致。按 Android Studio 提供的项目视图,Java 文件要放在app/src/main/java/com/example/calculator/下,与 MainActivity 同级,这样无需 import。如果放在了子目录,在 MainActivity 顶部加import com.example.calculator.ExpressionEvaluator;。每次新建类文件后,手动看一眼文件头的package行,这是很多人忽略的血泪经验。
6. 把 APK 装到手机上的验证方法与一个值得改的小细节
6.1 用命令行构建并安装:三步验证你的 app 真能跑
Android Studio 界面点一遍 Build 很多人都会,但命令行构建能让你对产物路径有明确认知。打开项目根目录,执行:
./gradlew assembleDebug构建产物在app/build/outputs/apk/debug/app-debug.apk。第二步连上开启 USB 调试的手机,执行adb install -r app/build/outputs/apk/debug/app-debug.apk,-r表示覆盖安装。第三步启动应用:adb shell am start -n com.example.calculator/.MainActivity。如果手机屏幕上弹出计算器界面,说明从源码到安装的整条链路都通了。这条链路里最容易失败的环节是 adb 没识别到设备,先执行adb devices看一眼是否有 unauthorized 提示,有就去手机上点允许调试弹窗。
6.2 把等号后的结果接着参与运算:一行代码做初值交换
在onClick等号分支里,计算完结果后不要直接清空输入串,而是把结果字符串暂存起来,当用户再按运算符时,判断当前输入串是否等于暂存结果,如果是,清空输入串后只保留运算符后面新输入的数字。这样 3+3=6,再按“×”再按 2 再按“=”,得到的是 12 而不是 6×2 的错乱结果。新手往往忽略这一点,但做计算器到第二版时一定会被用户挑战。
6.3 从计算器到下一个项目:把你的键盘布局改成汇率换算
做完计算器,下一步最顺手的练手项目是汇率换算器:同一套 GridLayout 键盘,把运算逻辑换成查表换算,再引入Spinner选择货币,就把 Activity、布局、事件、数据解析全部复习了一遍。我第一次完整跑通这两个项目之后,再去看 RecyclerView 列表和网络请求,心里就完全不慌了,因为知道一个 app 的代码再怎么复杂,入口还是那套按钮到回调的链路。这个教训后来帮我省了大量在网上搜“为什么我的 app 一打开就闪退”的时间,希望你也能从这篇笔记里获得同样的底气,希望帮到你。
本文还有配套的精品资源,点击获取