ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Android Studio简易计算器实战:Java双栈求值与状态保存

Android Studio简易计算器实战:Java双栈求值与状态保存

简介:面向K12阶段学习者与Android入门者的期末大作业实践包,主题为Android Studio简易计算器,涵盖布局设计、按钮事件监听、计算逻辑封装、运算符优先级处理、结果显示与APK打包发布等基础环节,可用于课程设计、课堂演示或个人练手。压缩包共48个文件,约180KB,含12张png界面与图标素材、11个xml布局及配置、3个java源码、3个gradle构建脚本,并配有properties、jar、bat等工程支撑文件,较完整地保留了Android项目结构。目前已有4343人学习,说明其作为入门案例具有一定参考度。读者可从中获得可运行的工程骨架、计算器界面与事件处理思路、基于栈的运算符优先级实现线索,以及项目构建和调试的排错参照,便于按模块理解并二次修改。

1. 从“能算对”到“能交上去”:一个简易计算器的真实分量

带过几届课程设计后我发现,计算器这个题目看着最不起眼,却最能暴露一个人有没有真正跑通 android 的工程链路。界面拖控件三分钟搞定,卡人的全是那些不起眼的细节:点按钮没反应、屏幕转一下就清零、除个零直接闪退、结果后面挂着0.30000000000000004。这份 Android Studio 简易计算器大作业要解决的就是这些:用 GridLayout 排按钮矩阵,用 Activity 接管事件分发,用字符串表达式做求值,再加上状态保存,拼出一个能跑、能改、能交的完整 App。它不冲科学计算器的全功能,胜在结构薄、层次清,每一块都能单独换掉重写。刚接触 android 开发、K12 阶段需要一个照着敲的入门样本的同学,拿它最合适。

2. 工程骨架:Android Studio 建出来的项目长什么样

2.1 用 Empty Views Activity 模板起项目

新建工程时选 Empty Views Activity,不要选 Empty Activity——后者默认生成 Compose 代码,对拿 Java 写大作业的人来说全是没见过的注解。语言选 Java,包名随手起一个com.example.calculator,这样生成的工程里自带一个MainActivity、一份activity_main.xml、一个AndroidManifest.xml,从这三样东西开始改就够了。

最低 SDK 常见做法是设 21 或 24,21 能覆盖更老的机型,24 省掉不少兼容分支,大作业层面区别不大。我一般选 24,模拟器上跑起来问题少。Gradle 配置大部分保持模板原样,只需要确认两处:namespace和applicationId一致,compileOptions里编译版本是 Java 8 及以上。前者决定R类的包路径,改了要同步改 Java 文件顶部的 import;后者不设的话,后面的 lambda 和 switch 会报语法错误。

// app/build.gradle android { namespace 'com.example.calculator' // 与 Java 源码包路径保持一致 defaultConfig { applicationId "com.example.calculator" minSdk 24 // 低于 21 会遇到不少老 API 兼容问题 targetSdk 34 versionCode 1 versionName "1.0" } compileOptions { // 不写这两行,lambda、try-with-resources 会在低版本编译目标上报错 sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }

把namespace和applicationId分开是有原因的:前者是代码里的包名,后者是应用在设备上的唯一标识。大作业阶段两者写一样最省事,但要知道它们不是同一个概念,将来做白标或多渠道包时才不会懵。minSdk决定了你能否直接用String.join、Objects.equals这类 API,改小了编译期会红一片,别以为是代码写错。

依赖上不需要额外引入任何库。appcompat和material是模板自带的,AppCompatActivity和Button就来自这里。有人会想到引入一个表达式解析库来处理运算,这个后面第 4 章会讲为什么不推荐。

2.2 工程目录:三个 Java 文件承担全部逻辑

这套计算器的代码量很小,小到可以把职责切得非常干净。我在java/com/example/calculator/下放三个类,各自只干一件事:

app/src/main/ ├── java/com/example/calculator/ │ ├── MainActivity.java // 界面、事件、状态,约 150 行 │ ├── Calculator.java // 表达式求值,纯 Java,不碰 Android API │ └── FormatUtil.java // 结果格式化与输入规整 ├── res/ │ ├── layout/activity_main.xml │ ├── values/colors.xml │ ├── values/strings.xml │ └── drawable/ // 按钮背景,可用 shape 代替图片 └── AndroidManifest.xml

Calculator不依赖任何 Android 类,这一点很重要。它意味着你可以直接把它拖到 JVM 单元测试里跑,不需要模拟器、不需要 Robolectric,验证一百条表达式两秒钟出结果。很多人写大作业把所有逻辑塞进 Activity,最后想测一下只能靠手点,点错一个还得重新编译安装。把纯计算逻辑剥出来,是这套代码里最值得抄的一个习惯。

FormatUtil单独抽出来,是因为结果格式化在计算器里出现的频率比想象中高:按下等号要格式化一次,连续运算要格式化一次,报错文案也要统一。散在各处最后必然出现“主界面显示 3,历史记录显示 3.0”这种不一致。

AndroidManifest.xml基本不用动,保持模板里那一个<activity android:name=".MainActivity" android:exported="true">加 LAUNCHER intent-filter 就行。exported在 Android 12 之后是必填的,不写会编译不过,这不是玄学,是平台强制。

2.3 布局文件:一个 TextView 加一个 GridLayout

activity_main.xml的骨架用垂直 LinearLayout 分两段:上半部分显示区,下半部分键区。显示区里放两个 TextView,一个显示当前输入的表达式(小字、灰色、右对齐),一个显示结果(大字、深色)。键区用 GridLayout,四列五行,把 0-9、小数点、四则运算符、等号、清除、退格一共 20 个按钮铺满。

<!-- res/layout/activity_main.xml(节选) --> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp"> <!-- 表达式行:显示用户正在输入的内容 --> <TextView android:id="@+id/tv_expression" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="end" android:maxLines="2" android:textSize="20sp" android:textColor="#888888" /> <!-- 结果行:按下等号后显示计算结果 --> <TextView android:id="@+id/tv_result" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="end" android:maxLines="1" android:textSize="40sp" android:textStyle="bold" /> <GridLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:layout_marginTop="16dp" android:columnCount="4" android:rowCount="5" android:useDefaultMargins="false"> <!-- 每个按钮统一用 layout_columnWeight / layout_rowWeight 均分 --> <Button android:id="@+id/btn_7" android:layout_width="0dp" android:layout_height="0dp" android:layout_columnWeight="1" android:layout_rowWeight="1" android:text="7" /> <!-- 其余按钮结构一致,只改 id 和 text --> </GridLayout> </LinearLayout>

layout_height="0dp"配layout_weight="1"是让键区吃掉剩余高度,屏幕大小不同的机器上按钮会自动伸缩。GridLayout 里每个按钮的宽高都写0dp再加layout_columnWeight/layout_rowWeight,四个方向均分,这比写死 dp 值稳得多——写死的话在平板上按钮挤成一坨,在窄屏上又会超出边界。

按钮文字不要放中文,7、×、÷、C、⌫就够了。中文按钮在部分机型的默认字体下会掉字,而且×和÷用 Unicode 字符而不是*/,是为了和用户认知对齐,内部再映射成运算符,这个映射放在第 4 章讲。

提示:strings.xml里把这些按钮文案集中定义,布局中用@string/btn_7引用。虽然现在看着麻烦,但将来要加英文版或者改主题文案,只需要动一个文件,不用在 XML 里逐个翻。

3. 事件绑定:一个 OnClickListener 接管二十个按钮

3.1 找到按钮并统一挂监听

二十个按钮,如果每个都写findViewById(...).setOnClickListener(new View.OnClickListener(){...}),光是匿名内部类就能堆出一百多行,而且每个里面的逻辑高度重复。常见做法是让 Activity 实现View.OnClickListener,然后遍历按钮 id 数组统一挂载。

public class MainActivity extends AppCompatActivity implements View.OnClickListener { // 需要参与输入的所有按钮,集中在一个数组里,加按钮只改这里 private static final int[] INPUT_BTN_IDS = { 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, R.id.btn_dot, R.id.btn_plus, R.id.btn_minus, R.id.btn_mul, R.id.btn_div }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); for (int id : INPUT_BTN_IDS) { findViewById(id).setOnClickListener(this); // this 就是 OnClickListener } findViewById(R.id.btn_equal).setOnClickListener(this); findViewById(R.id.btn_clear).setOnClickListener(this); findViewById(R.id.btn_delete).setOnClickListener(this); } }

这里把按钮分成两组:一组是产生字符的(数字、小数点、运算符),一组是触发动作的(等号、清除、退格)。分开是为了后面加长按、双击这类交互时不会互相干扰。findViewById返回View,直接复用 Activity 自身作为监听器,省掉了每个按钮一个匿名类的开销。

需要留意的点是findViewById的返回值可能为null,如果布局里 id 拼错或者控件被ViewStub延迟加载,这里会直接 NPE。写完布局立刻跑一次,比后面盯着崩溃日志猜快得多。

3.2 输入状态机:三个字段管住全部行为

计算器的交互本质上是个小状态机,用三个字段就能描述清楚:input存用户敲进去的字符串,lastResult存上一次的结果(用于连续运算),justCalculated标记上一次操作是不是按了等号。

private final StringBuilder input = new StringBuilder(); private String lastResult = ""; private boolean justCalculated = false; @Override public void onClick(View v) { int id = v.getId(); // 1. 动作类按钮优先处理 if (id == R.id.btn_clear) { input.setLength(0); lastResult = ""; justCalculated = false; render(); return; } if (id == R.id.btn_delete) { if (input.length() > 0) { input.deleteCharAt(input.length() - 1); } justCalculated = false; render(); return; } if (id == R.id.btn_equal) { calculate(); return; } // 2. 字符类按钮:先决定是接着输入还是重开 String label = ((Button) v).getText().toString(); if (justCalculated) { if (isOperator(label)) { // 刚算完按运算符,拿结果继续算 input.setLength(0); input.append(lastResult); } else { // 刚算完按数字,重新开始 input.setLength(0); } justCalculated = false; } // 3. 小数点去重:同一个数字里不允许出现第二个点 if (".".equals(label)) { int start = lastNumberStart(input); if (input.indexOf(".", start) >= 0) { return; } if (input.length() == 0 || isOperator(String.valueOf(input.charAt(input.length() - 1)))) { input.append("0"); // ".5" 补成 "0.5" } } input.append(label); render(); }

justCalculated这个标志是整个交互里最容易漏掉的一环。没有它会出现两种情况:算完1+2显示 3,再按4,变成了34;或者算完按+,变成3+但用户以为是从 3 开始,实际是1+2+,再按等号结果不对。加上这个判断后,两种路径都符合直觉。

小数点去重用的是lastNumberStart,从末尾往前扫,遇到运算符或左括号就停,得到当前正在输入的这个数字的起点,再在这个区间里找有没有点。

private int lastNumberStart(StringBuilder sb) { for (int i = sb.length() - 1; i >= 0; i--) { char c = sb.charAt(i); if (c == '+' || c == '-' || c == '×' || c == '÷' || c == '(') { return i + 1; } } return 0; } private boolean isOperator(String s) { return "+".equals(s) || "-".equals(s) || "×".equals(s) || "÷".equals(s); }

参数上lastNumberStart接收的是StringBuilder而不是String,避免每次调用都生成新字符串;如果在1.5+2.这种场景下再按小数点,起点会定位到2,区间内没有点,允许追加,变成1.5+2.,符合预期。运算符判断要连×÷一起判,用*/判会漏。

3.3 render:把状态同步到界面

输入变了之后需要刷新界面,这一步单独抽成方法,所有分支都调它,避免某条路径忘了刷新导致“点了没反应”。

private void render() { String text = input.length() == 0 ? "0" : input.toString(); TextView tvExpression = findViewById(R.id.tv_expression); tvExpression.setText(text); // 空输入时结果区清空,避免上一次的结果残留 if (input.length() == 0) { ((TextView) findViewById(R.id.tv_result)).setText(""); } }

这里每次渲染都调了一次findViewById,严格说有性能损耗,正常做法是在onCreate里把两个 TextView 存成成员变量。我保留这种写法是因为对入门者来说,字段越少越不容易出现“Activity 重建后引用了旧 View”的问题;如果真在意性能,改成成员变量并在onCreate里初始化即可,逻辑不变。

4. 运算核心:不用第三方库,写一个双栈求值器

4.1 为什么不用 ScriptEngine

最容易想到的方案是把表达式拼成 JS 字符串丢给ScriptEngineManager执行,几行代码搞定。问题有三个:Nashorn在新版本 Android 上已被移除,minSdk一调低就崩;执行任意脚本本身有安全风险,虽然计算器场景无所谓,但老师看到会扣分;报错信息完全不可控,用户敲个非法表达式,抛出来的是脚本引擎的异常栈。所以正经做还是自己写求值。

另一个常见思路是“只支持两个数一个运算符”,按等号时用split切开前两个数字。这个方案在1+2+3面前直接废掉,而且用户一定会按出这种输入。既然要做,就做能处理多运算符和括号的中缀表达式求值。

我采用的是双栈法:一个数字栈存操作数,一个符号栈存运算符。遇到数字压栈,遇到运算符时先把栈顶优先级不低于它的运算符全部结算掉,再把自己压进去。遇到右括号就一路结算到左括号。这个算法代码量在八十行左右,逻辑线性,出问题容易定位。

4.2 Calculator 类的完整实现

public final class Calculator { private Calculator() {} /** * 对中缀表达式求值,支持 + - × ÷、括号、小数、一元正负号 * @param expression 用户输入的表达式,例如 "1+2×3" 或 "(-2.5)÷4" * @return 计算结果 * @throws ArithmeticException 除数为 0 * @throws IllegalArgumentException 表达式非法(括号不匹配、末尾缺操作数等) */ public static double eval(String expression) { Deque<Double> values = new ArrayDeque<>(); Deque<Character> ops = new ArrayDeque<>(); boolean expectOperand = true; // 当前是否在等待一个操作数 int i = 0, n = expression.length(); while (i < n) { char c = expression.charAt(i); if (c == ' ') { i++; continue; } if (c == '(') { ops.push(c); expectOperand = true; i++; continue; } if (c == ')') { while (!ops.isEmpty() && ops.peek() != '(') { apply(values, ops); } if (ops.isEmpty()) { throw new IllegalArgumentException("括号不匹配"); } ops.pop(); // 弹出 '(' expectOperand = false; i++; continue; } // 一元正负号:出现在表达式开头、运算符后或左括号后 if (expectOperand && (c == '-' || c == '+')) { int start = i++; while (i < n && (Character.isDigit(expression.charAt(i)) || expression.charAt(i) == '.')) { i++; } if (start + 1 == i) { throw new IllegalArgumentException("运算符后缺少数字"); } values.push(Double.parseDouble(expression.substring(start, i))); expectOperand = false; continue; } if (Character.isDigit(c) || c == '.') { int start = i; while (i < n && (Character.isDigit(expression.charAt(i)) || expression.charAt(i) == '.')) { i++; } values.push(Double.parseDouble(expression.substring(start, i))); expectOperand = false; continue; } // 二元运算符:先把优先级高的结算掉 while (!ops.isEmpty() && ops.peek() != '(' && prec(ops.peek()) >= prec(c)) { apply(values, ops); } ops.push(c); expectOperand = true; i++; } while (!ops.isEmpty()) { if (ops.peek() == '(') { throw new IllegalArgumentException("括号不匹配"); } apply(values, ops); } if (values.size() != 1) { throw new IllegalArgumentException("表达式不完整"); } return values.pop(); } /** 优先级:× ÷ 高于 + - */ private static int prec(char op) { if (op == '×' || op == '÷') return 2; if (op == '+' || op == '-') return 1; throw new IllegalArgumentException("未知运算符: " + op); } /** 弹出一个运算符和两个操作数,算完压回数字栈 */ private static void apply(Deque<Double> values, Deque<Character> ops) { char op = ops.pop(); if (values.size() < 2) { throw new IllegalArgumentException("表达式不完整"); } double b = values.pop(); // 注意先弹出的是右操作数 double a = values.pop(); switch (op) { case '+': values.push(a + b); break; case '-': values.push(a - b); break; case '×': values.push(a * b); break; case '÷': if (b == 0) throw new ArithmeticException("除数不能为 0"); values.push(a / b); break; default: throw new IllegalArgumentException("未知运算符: " + op); } } }

expectOperand这个布尔量是整个解析的关键。它标记“下一个字符应该是一个操作数的开始”,只有在这种状态下出现的-才是一元负号,其余情况都是减号。没有它的话3-2里的减号会被当成负号,-3里的负号会被当成减号,两种情况必有一种算错。

apply里先弹b再弹a这个顺序不能反。数字栈是后进先出,先出来的必然是右边的操作数,5-3如果按先出的当被减数就变成了3-5,结果符号全反,这是双栈法最经典的翻车点。

eval里有三处抛异常:括号不匹配、运算符后缺数字、表达式不完整。这三处都是用户真能按出来的输入,比如连按两个+、只按一个(就按等号。把这些情况在求值阶段拦住,上层只需要catch两个异常类型,比在 UI 层做一堆if判断干净。

4.3 结果格式化:别让用户看到 0.30000000000000004

double的精度问题在计算器里一定会遇到,0.1 + 0.2直接String.valueOf出来就是一长串。格式化这一步必须做,而且要做得让用户看着正常。

public final class FormatUtil { private FormatUtil() {} /** 把 double 转成适合显示的字符串:整数不带小数点,小数去掉末尾的 0 */ public static String display(double value) { if (Double.isNaN(value) || Double.isInfinite(value)) { return "错误"; } // 整数直接转 long,避免出现 "3.0" if (value == Math.rint(value) && Math.abs(value) < 1e15) { return String.valueOf((long) value); } // 用 BigDecimal 截断到 10 位有效数字,再去掉末尾的 0 BigDecimal bd = BigDecimal.valueOf(value) .setScale(10, RoundingMode.HALF_UP) .stripTrailingZeros(); return bd.toPlainString(); } }

Math.rint判断整数比value % 1 == 0稳,后者对负数和大数会出现误判。setScale(10, ...)限制小数位,是因为有些除法结果会无限循环,不截断的话 TextView 会显示好几十位把布局撑爆。stripTrailingZeros之后必须调toPlainString,否则 BigDecimal 会输出3E+2这种科学计数法,用户看不懂。

1e15这个阈值是long能安全表示的整数范围上限附近,超过它再用(long)转换会溢出成别的数,所以大数走 BigDecimal 分支。

在MainActivity里按下等号时的调用就是:

private void calculate() { if (input.length() == 0) return; try { double result = Calculator.eval(input.toString()); String text = FormatUtil.display(result); lastResult = text; ((TextView) findViewById(R.id.tv_result)).setText(text); justCalculated = true; } catch (ArithmeticException e) { ((TextView) findViewById(R.id.tv_result)).setText("除数不能为 0"); justCalculated = false; } catch (IllegalArgumentException e) { ((TextView) findViewById(R.id.tv_result)).setText("表达式有误"); justCalculated = false; } }

两个catch分开写而不是合成一个Exception,是因为除零和表达式非法给用户的提示完全不同,合在一起只能显示“出错了”,用户不知道改哪里。异常处理完把justCalculated置回false,让用户可以接着在当前表达式上修改,而不是被强制清空。

5. 避坑与排查:跑不通的时候先看这几处

5.1 崩溃类问题:闪退不是玄学

现象一:点击任意按钮,应用直接闪退,Logcat 里是NullPointerException。原因基本是findViewById返回了null。要么是 XML 里控件的android:id拼写和 Java 里的R.id.xxx对不上,要么是setContentView调用的布局文件不是你正在改的那份。还有一种隐蔽情况:在onCreate之前(比如字段初始化时)就调了findViewById,此时视图树还没建好。 解决方法是把findViewById全部挪到setContentView之后,然后逐行核对 id。Android Studio 里按住 Ctrl 点R.id.xxx能跳到布局文件,跳不过去就说明这个 id 在布局里不存在。

现象二:输入5÷0按等号,应用闪退。原因是double除以 0 得到的是Infinity而不是抛异常,但如果你在求值代码里没做检查,直接把Infinity传给BigDecimal.valueOf,会抛NumberFormatException。或者是用了整数除法,int / 0直接抛ArithmeticException。 解决办法是在apply里显式判断b == 0并抛出自定义异常,在calculate里catch住转成提示文案。同时FormatUtil.display开头要判断isNaN和isInfinite,双层兜底。

现象三:连续按两个小数点,或者按出1++2,按等号闪退。原因是解析器对非法输入没有防护,Double.parseDouble拿到空串或者++直接抛NumberFormatException,而这个异常没被catch,直接冒到主线程。 解决办法是在eval的一元符号分支里检查start + 1 == i的情况(只有符号没有数字),在 UI 层对运算符做去重:如果input末尾已经是运算符,再按运算符就替换而不是追加。这个判断放在onClick的字符追加之前。

5.2 逻辑与体验类问题:不闪退但算错

现象四:屏幕旋转一下,输入的数字全没了,回到 0。原因是 Activity 在配置变更时会被销毁重建,onCreate重新执行,成员变量input是新建的空对象。很多人以为StringBuilder是成员变量就能保住,其实整个 Activity 实例都换了。 解决办法是重写onSaveInstanceState把输入内容存进 Bundle,在onCreate里取出来。或者在AndroidManifest.xml里给 Activity 加android:configChanges="orientation|screenSize"阻止重建,但这是权宜之计,多窗口、字体缩放照样会触发重建。正解还是存 Bundle:

@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString("key_input", input.toString()); outState.putString("key_last_result", lastResult); } // onCreate 中 setContentView 之后 if (savedInstanceState != null) { input.append(savedInstanceState.getString("key_input", "")); lastResult = savedInstanceState.getString("key_last_result", ""); render(); }

现象五:算完1+2显示 3,再按4,表达式变成34,结果区还挂着 3。原因是缺少justCalculated状态判断,所有输入都被无条件追加。同时render里只在输入为空时清结果区,输入非空但已经算完的情况下结果区没更新。 解决办法是第 3 章那套状态机:justCalculated为真时,按数字就清空重来,按运算符就拿lastResult接着算;render里在表达式变化时同步清掉结果区,只保留上一次的最终结果在lastResult中。

现象六:1.1+2.2显示3.3000000000000003。这是浮点数的固有精度问题,不是代码写错。十进制小数在二进制里绝大多数无法精确表示,double相加后误差累积。 解决办法就是第 4 章的FormatUtil.display:截断到 10 位有效数字再去尾零。注意截断位置不能太狠,10 位对计算器场景够用,写 2 位会把1÷3算成0.33,用户一眼看出不对。

注意:排查时优先看 Logcat 里第一条Caused by,而不是最后一行。异常栈越往下越接近根因,但最上面那条往往是你自己代码的行号,定位最快。

6. 二次开发:把 demo 改成能拿得出手的作品

交完作业想再往前走一步,最省力的改造方向是加科学计算功能。Calculator的两个栈结构已经支持任意二元运算符,加sin、log这类一元函数只需要在eval里识别函数名并压栈,apply里增加对应分支。改动点集中在一个类,界面只需要在 GridLayout 后面再铺一页,用ViewFlipper或第二个 Activity 切换。

扩展方向主要改动点需要注意的风险
科学计算(三角、对数)Calculator.eval增加函数识别分支角度制与弧度制要明确,sin(30)用户预期是 0.5
历史记录新增 Room 表,calculate成功后插入记录要存原始表达式和结果两份,只存结果没法复现
主题切换values-night/colors.xml+AppCompatDelegate深色模式下按钮文字颜色要单独定义,否则看不清
横屏布局layout-land/activity_main.xml横屏下按钮要重新排布,不能简单复用竖屏 GridLayout

无论加哪个功能,我建议先补单元测试再动手。Calculator是纯 Java 类,测试成本极低:

public class CalculatorTest { @Test public void precedenceIsRespected() { // 先乘除后加减 assertEquals(7, Calculator.eval("1+2×3"), 1e-9); } @Test public void unaryMinusWorks() { // 一元负号与二元减号要能区分 assertEquals(-6, Calculator.eval("3×-2"), 1e-9); assertEquals(5, Calculator.eval("3--2"), 1e-9); } @Test public void decimalPrecisionIsHandled() { // 浮点误差交给 FormatUtil 收敛 assertEquals("3.3", FormatUtil.display(Calculator.eval("1.1+2.2"))); } @Test(expected = ArithmeticException.class) public void divideByZeroThrows() { Calculator.eval("5÷0"); } }

这套测试跑一遍不到一秒,覆盖了优先级、一元负号、精度、除零四个最容易翻车的点。每加一个新运算符,先往这里补一条断言,再改实现,改完跑一次,比装到模拟器上手动点十遍可靠得多。

有个细节我踩过:测试里写assertEquals(7, result)不指定 delta,double比较会因为精度直接失败,必须带第三个参数。另外FormatUtil.display返回字符串,断言要用字符串比较,别转回double再比,那样等于把格式化又绕回去了。

从那以后我每次给这类小项目加功能,都强制先把测试跑绿再动 UI,界面层的问题靠肉眼,逻辑层的问题靠断言,两边不混在一起查。希望帮到你。

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

RELATED READING

延伸阅读

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