很多刚接触Android开发的朋友问我的第一个问题,往往是“我该写个什么项目练手”。我一般都会推荐:用AS写一个简单计算器。AS就是Android Studio,做Android开发最常用的IDE。这个项目看着不起眼,但Activity生命周期、布局编写、事件绑定、状态管理这几块基础能力,它全都覆盖到了,而且难度刚好卡在一个晚上能写完、又不至于毫无挑战的位置。适合完全零基础、刚学完Java或者Kotlin语法、想完整走一遍Android工程流程的人。做个能算数的App不算本事,把这个项目里藏着的状态机思路理清楚,才是真正的收获。
1. 动手前先想清楚:语言与工程基础
1.1 用Java还是Kotlin
现在的Android Studio新建项目时,默认推荐的是Kotlin,不少教程也直接拿Kotlin写Demo。但我的建议很直接:如果你已经有明确的主力语言,就用那个;如果还没想好,Kotlin是趋势,但计算器这点业务逻辑用Java写也完全没有障碍。
本文的示例代码我用Java来写,理由很朴素——Java的语法对新手来说更直白,声明变量、调用方法、switch分支,每一步都能跟教科书对上。Kotlin写起来更简洁,但初学阶段“少写几行”并不是最重要的,重要的是每一步都看得懂。等逻辑全部理清楚了,再改成Kotlin其实就是换个语法外壳,花不了半个小时。工程创建时选Empty Views Activity就行,包名随意,minSdk建议选23以上,targetSdk用你本机SDK里最新的稳定版。编译版本这件事不用纠结,AS默认给的配置就够用,不需要手动改Gradle文件里的任何参数。
1.2 界面方案与布局选型
动手写代码之前,先把界面在脑子里过一遍。一个最简单的计算器,上面是一块显示屏,下面是若干排按钮。显示屏用TextView就够了,不涉及输入法,也不用处理焦点问题。按钮区最省事的方案是用LinearLayout嵌套——竖向排五排,每排横向放四五个按钮,每排占一行高度。
为什么不推荐直接用ConstraintLayout?不是它不行,而是对于这种规则网格按钮,LinearLayout配合weight权重分配反而是最直观的解法。每个按钮说好占多少比例,布局文件写完,界面长什么样心里基本有数。ConstraintLayout当然也能做,但约束条件写起来比LinearLayout啰嗦,新手容易漏约束然后出现布局飘来飘去的问题。另外一个思路是用GridLayout,网格布局天然适合计算器,但GridLayout的索引管理对新手不太友好。权衡下来,LinearLayout嵌套是我带过这么多项目后觉得最稳的入门方案。
2. 搭界面:一个XML搞定所有按钮
2.1 LinearLayout和weight的配合
先解释一个很多新手栽过跟头的概念:weight权重。LinearLayout里的按钮,如果设置了layout_width="0dp"和layout_weight="1",就表示它参与“剩余空间”的按比例分配。比如一行里有四个按钮,每个权重都是1,总权重是4,那么每个按钮拿到1/4的宽度。关键在于layout_width必须写0dp,如果写成wrap_content,按钮会先按内容占一块地方,剩下的空间才按权重分,最后比例完全不是你想的那样。
下面是一行按钮的完整写法,以数字1、2、3和加号这一行为例:
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal"> <Button android:id="@+id/btn1" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_margin="4dp" android:text="1" /> <Button android:id="@+id/btn2" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_margin="4dp" android:text="2" /> <Button android:id="@+id/btn3" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_margin="4dp" android:text="3" /> <Button android:id="@+id/btnAdd" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_margin="4dp" android:text="+" /> </LinearLayout>其他几行照抄这个结构,改一下id和text就行:清空、删除、百分比、除号一行,7、8、9、乘号一行,4、5、6、减号一行,最后0、小数点、等号一行。注意最外层LinearLayout的方向是vertical,每一行的layout_height都设为0dp并且权重为1,这样五排按钮会均分竖直方向的空间,不管什么屏幕尺寸都不会挤压重叠。
2.2 显示区为什么要用TextView
很多人习惯性地把显示结果的控件选成EditText,理由是“看着像输入框”,但这会给自己挖坑。EditText默认可以聚焦、弹输入法、支持粘贴文本,你辛辛苦苦写完运算逻辑,用户长按粘贴一串乱七八糟的字符串进来,程序直接就崩了。TextView没有这些问题,它天生就是用来展示内容的。
显示区的布局我建议这样写:
<TextView android:id="@+id/tvResult" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="0.3" android:gravity="end|center_vertical" android:maxLines="1" android:text="0" android:textSize="36sp" />几个细节说明一下。gravity设为end,表示文字靠右显示,这是计算器的通用习惯。maxLines设为1,防止数字太长时把按钮挤得走样。textSize用36sp起步,算出来的结果很长时会显得有点挤,但不影响功能。最外层LinearLayout的高度分配上,给显示区块一个0.3的权重,五排按钮共占1个权重,整体比例就是上面留一小块、下面按钮区占大头,视觉比例比较接近真机上的计算器。
3. 写逻辑:状态机才是计算器的灵魂
3.1 五个状态变量,理清所有输入路径
界面只是壳,计算器真正的核心是“状态”。很多新手写计算器是先拿一个字符串存所有输入,等按等号时再用表达式求值器去算,这个方向一上来就复杂了。正确做法是维护几个状态变量,让每次按键都走明确的分支。
我建议用下面这几个变量:
private TextView tvResult; // 当前屏幕上显示的数字,也就是用户正在输入的内容 private StringBuilder currentInput = new StringBuilder("0"); // 参与运算的第一个操作数,比如按下“2+3”里的2 private double firstOperand = 0; // 当前挂起的运算符,比如“+”或者“×” private String pendingOperator = ""; // 用户刚按过运算符,下一个数字键应该重新开始输入 private boolean isOperatorPressed = false; // 已经按过等号并显示了结果,下一个数字键应该清空重来 private boolean isResultShown = false;这五个变量各管一摊事。currentInput管的是屏幕显示,firstOperand管的是暂时寄存的数字,pendingOperator管的是下一步要做的运算,两个布尔变量管的是“用户输入节奏”。为什么要单独用一个布尔变量记录是否刚按过运算符?因为用户按完“2 +”,再按“3”的时候,程序需要知道这个3是新的输入,而不是接着2后面变成23。同理,按完等号后屏幕显示8,这时再按数字5,应该从5重新开始,而不是在8后面补一位变成85。
拿“1 + 2 =”这个操作序列走一遍:按1,currentInput变成“1”。按加号,此时firstOperand记下1,pendingOperator记下“+”,isOperatorPressed置为true。按2,因为isOperatorPressed是true,currentInput先清空再append“2”。按等号,程序取出firstOperand和currentInput里的2做加法,结果写回currentInput。每一步都有明确的归宿,不会出现歧义。这就是状态机设计的意义——用几个标志位把用户所有可能的操作路径都约束住。
3.2 数字键和小数点键的逻辑
数字键的点击事件其实只有几行代码,但分支条件特别容易写漏。我建议把所有数字键还有小数点键都路由到同一个方法里,用按钮的tag区分具体是哪个字符:
private void onNumberPressed(String tag) { // 刚算完结果,再按数字就重新开始 if (isResultShown) { currentInput.setLength(0); isResultShown = false; } if (tag.equals(".")) { // 小数点不能出现两次 if (currentInput.indexOf(".") >= 0) { return; } // 直接按小数点,补一个0在前面,显示“0.” if (currentInput.length() == 0) { currentInput.append("0"); } } else { // 开头是0时,直接按数字要把0替换掉 if (currentInput.toString().equals("0")) { currentInput.setLength(0); } // 限制最长输入15位,防止溢出和显示溢出 if (currentInput.length() >= 15) { return; } } currentInput.append(tag); updateDisplay(); }这里面有两个容易被忽略的边界。一是“0开头”的处理,用户先按了个0,接着按5,屏幕应该显示5而不是05,所以要先清空再append。二是小数点只允许出现一次,用户按“1.2.3”这种输入没有意义,必须拦下来。另外,输入长度限制十五位这个判断,很多入门教程里都没有,但不加的话,用户连续按几十位数字,double类型解析时就会出问题,显示上也会变成科学计数法。
updateDisplay方法也很简单,统一出口,所有修改currentInput的地方最后都调它刷新屏幕:
private void updateDisplay() { tvResult.setText(currentInput.toString()); }3.3 运算符、等号和清空键的逻辑
运算符键的处理是计算器状态机的精华部分,也是新手写起来最容易乱的地方。核心逻辑是:如果当前已经有一个挂起的运算符,而且用户没有刚按过运算符,那说明输入格式是“数字 运算符 数字 运算符”,可以先把前面的运算做掉,把结果作为新的firstOperand;如果用户连续按了两个运算符,比如“5 + ×”,那直接用新的运算符覆盖旧的,不进行运算。
private void onOperatorPressed(String op) { // 有挂起的运算符,且不是刚按完运算符的状态,就先把之前的运算执行掉 if (pendingOperator.length() > 0 && !isOperatorPressed) { performCalculation(); } else { // 没有挂起运算符,或者用户刚按过运算符,直接取当前输入作为第一个操作数 firstOperand = parseInput(); } pendingOperator = op; isOperatorPressed = true; isResultShown = false; } private void onEqualsPressed() { if (pendingOperator.length() == 0) { return; } performCalculation(); pendingOperator = ""; isOperatorPressed = false; isResultShown = true; } private void onClear() { currentInput.setLength(0); currentInput.append("0"); firstOperand = 0; pendingOperator = ""; isOperatorPressed = false; isResultShown = false; updateDisplay(); }performCalculation是真正做加减乘除的地方:
private void performCalculation() { double secondOperand = parseInput(); switch (pendingOperator) { case "+": firstOperand = firstOperand + secondOperand; break; case "-": firstOperand = firstOperand - secondOperand; break; case "*": firstOperand = firstOperand * secondOperand; break; case "/": if (secondOperand == 0) { showError(); return; } firstOperand = firstOperand / secondOperand; break; } currentInput.setLength(0); currentInput.append(formatResult(firstOperand)); updateDisplay(); } private double parseInput() { if (currentInput.length() == 0) { return 0; } return Double.parseDouble(currentInput.toString()); }这里有个取舍要说明:系统计算器支持“连按等号”继续运算,比如“5 + = =”会得到25。我们这个入门版本故意不做这个功能,因为要实现它需要额外保存“上一次的运算符”和“上一次的操作数”,状态变量会从五个变成七个八个,对新手来说认知负担一下子大很多。先把基本运算链路跑通,连按等号可以在后续扩展阶段再加。
3.4 浮点精度和结果显示
写计算器绕不开浮点精度问题。0.1加0.2,数学上等于0.3,但double计算出来的结果很可能是0.30000000000000004。这不是Android的问题,是二进制浮点表示的天生缺陷。如果不做任何处理,用户会在屏幕上看到一长串莫名其妙的小数尾巴,体验很差。
解决办法是在显示层面做格式化。我的formatResult方法是这样写的:
private String formatResult(double value) { if (Double.isNaN(value) || Double.isInfinite(value)) { return "错误"; } // 结果是整数时,去掉小数点,直接显示整数 if (value == Math.floor(value) && Math.abs(value) < 1e15) { return String.valueOf((long) value); } // 用字符串转换,避免多余尾零 return String.valueOf(value); }整数直接显示整数,这个判断很重要。否则按“2 + 2”,结果可能显示成“4.0”或者“4.000000000000001”,很不好看。非整数情况直接用String.valueOf转换,虽然某些极端场景下还是会带一长串小数,但对一个入门计算器来说够用了。真想彻底解决精度问题,可以引入BigDecimal做运算,但除法除不尽时还需要设计舍入规则,复杂度上来了。我对初学者的建议是:运算先用double跑通,显示层做好格式化,等以后做更复杂的项目再上BigDecimal,没必要一开始就被精度问题绊住。
4. 绑事件:批量绑定与交互细节
4.1 用数组和tag批量绑定,别写二十遍findViewById
计算器有将近二十个按钮,如果每个按钮都写一遍findViewById和setOnClickListener,代码又长又容易写错。我习惯的做法是把按钮id放进数组,循环绑定,再用tag区分具体按键。
数字键和小数点键的绑定:
private void bindNumberKeys() { int[] ids = { R.id.btn0, R.id.btn1, R.id.btn2, R.id.btn3, R.id.btn4, R.id.btn5, R.id.btn6, R.id.btn7, R.id.btn8, R.id.btn9, R.id.btnDot }; View.OnClickListener listener = v -> { String tag = (String) v.getTag(); onNumberPressed(tag); }; for (int id : ids) { Button btn = findViewById(id); btn.setOnClickListener(listener); btn.setTag(btn.getText().toString()); } }注意我在绑定监听器时顺手把按钮显示的文字设置成了tag。这样数字键的tag就是“0”“1”这些字符,小数点键的tag是“.”,之后在onNumberPressed里直接用tag判断,不用再根据id去switch。运算符按钮和功能键按钮同理,把所有运算符按钮放进一个数组,统一绑定到onOperatorPressed,tag设成“+”“-”“*”“/”。
代码干净了,后面加按钮也方便。以后想加个“平方”按钮,只需要在布局里加一个控件,id加进数组,tag设置成对应符号,逻辑方法里加一个分支,不需要动其他任何代码。
4.2 清空、删除和按键防抖
删掉最后一个输入字符,这个功能看着简单,但有几个边界要想清楚。当前输入只有一位数字时,删除之后应该显示0而不是显示空字符串;当前处于“刚算完结果”的状态时,按删除键我建议直接清空整个算式,因为用户这时候大概率是想开启新的一次计算,而不是删结果里的某一位数字。
private void onDelete() { if (isResultShown) { onClear(); return; } if (currentInput.length() > 1) { currentInput.deleteCharAt(currentInput.length() - 1); } else { currentInput.setLength(0); currentInput.append("0"); } updateDisplay(); }关于“防抖”,很多新手一上来就给按钮加各种防抖逻辑。其实计算器场景里,按钮点击事件的处理是幂等的——不管用户多快连按多少次,每次点击的逻辑都独立完整,不会出现累加错误的状态。真正需要防的,反而是某些教程里写的“点击后清零再赋值”这种写法,快速点击时会互相覆盖出问题。所以我的建议是:不要画蛇添足加防抖,把每个点击分支写清楚,比什么都管用。
5. 抗风险:状态保存与异常兜底
5.1 旋转屏幕为什么丢结果
新手最容易遇到的诡异问题:算到一半,旋转一下手机,屏幕上的结果变成0了。原因很简单——旋转屏幕会让Activity销毁并重新创建,所有内存里的变量全部重置。firstOperand、pendingOperator、currentInput全都回到初始值,你的计算结果自然就消失了。
解决办法是实现状态保存和恢复。Activity在即将被销毁时会调用onSaveInstanceState,我们可以把关键状态存进去,重建后再读出来:
@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString("currentInput", currentInput.toString()); outState.putDouble("firstOperand", firstOperand); outState.putString("pendingOperator", pendingOperator); outState.putBoolean("isOperatorPressed", isOperatorPressed); outState.putBoolean("isResultShown", isResultShown); } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); currentInput = new StringBuilder(savedInstanceState.getString("currentInput", "0")); firstOperand = savedInstanceState.getDouble("firstOperand", 0); pendingOperator = savedInstanceState.getString("pendingOperator", ""); isOperatorPressed = savedInstanceState.getBoolean("isOperatorPressed", false); isResultShown = savedInstanceState.getBoolean("isResultShown", false); updateDisplay(); }有人会说,那干脆在AndroidManifest.xml里给Activity加一行android:screenOrientation="portrait",把屏幕锁死成竖屏,不就一了百了了吗?这个做法能用,但不推荐作为默认方案。锁死方向等于把问题藏起来了,并没有真正理解Activity生命周期。而且以后做平板适配、做横屏布局时,锁方向的方案会反咬你一口。既然计算器项目已经帮你遇到了状态保存这个问题,就花十分钟把它解决掉,这笔账怎么算都划算。
5.2 除零、溢出与输入保护
除零是所有计算器都必须处理的情况。在我的performCalculation里,已经加了secondOperand == 0的判断,会调用showError方法:
private void showError() { currentInput.setLength(0); currentInput.append("错误"); isResultShown = true; updateDisplay(); }把“错误”直接显示在屏幕上,同时把isResultShown置为true。这样用户按任意数字键时,会命中onNumberPressed里isResultShown的判断分支,自动清空错误提示,开启新的输入。不需要用户手动先按一次清空键,体验会顺手很多。
溢出判断也别忘了。double类型在数值过大的时候会变成Infinity,这种情况下继续运算没有任何意义。我在formatResult里已经用Double.isInfinite和Double.isNaN做了拦截,任何NaN或者Infinity的结果都会统一显示成“错误”。配合前面说的十五位输入长度限制,实际使用中基本不会出现溢出场景,但程序里留着这层保护,心里踏实。
6. 常见问题速查与避坑记录
6.1 问题与原因对照表
下面这个表格里的问题,是我这些年看别人做计算器项目时反复出现的,整理出来可以做自查清单:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 按钮挤成一团,比例不对 | layout_width没设0dp,weight没生效 | 按钮宽度统一设0dp,用weight分配 |
| 旋转屏幕后计算结果丢失 | Activity重建,内存变量重置 | 实现onSaveInstanceState和onRestoreInstanceState |
| 0.1+0.2显示一长串小数 | double浮点精度误差 | 显示层做格式化,整数直接显示long |
| 连续按等号没反应 | 没有保存上一次运算符 | 入门版本可接受,后续扩展时加lastOperator |
| 输入超长变成科学计数法 | 没有限制输入长度 | 数字输入长度限制15位以内 |
| 点击删除后屏幕空了 | 删除逻辑没处理最后一位 | 删除到只剩一位时显示0 |
| 按完等号再按数字变成拼接 | isResultShown状态没处理 | 结果状态后按数字先清空currentInput |
6.2 我建议新手养成的好习惯
代码写完之后,我强烈建议你在真机上完整跑一遍这些操作序列:连续输入多位数字、连续按多个运算符、小数和0开头的输入、快速连续按等号、旋转屏幕后再运算、除以0、输入超长数字。这几个动作覆盖了计算器绝大部分边界情况,任何一个出问题,都能对照上面的表格快速定位。
另外一个习惯是命名要表意。我见过有人把状态变量命名为a、b、c,结果代码写出来第二周自己都看不懂。firstOperand、pendingOperator这种冗长一点的名字,比a、b、c值钱得多。再有就是每个方法只做一件事,onNumberPressed就只管数字输入,performCalculation就只管运算,别把清空逻辑写进数字处理里,代码会越写越乱。
最后再分享一点我的体会:计算器虽然小,但它是少有的能把Android基础能力完整串起来的项目。做完它,你不只是得到一个能算数的App,更重要的是建立了“写代码前先把状态想清楚”的习惯。后面做表单、做播放器、做购物车,核心还是这套状态管理的思路。想进阶的话,可以试着加运算符优先级、括号、历史记录,或者用Kotlin重新写一遍,都会很有收获。