☰
Android Studio 计算器工程化实战:从需求文档到单元测试
2026/10/10 2:30:28 网站建设 项目流程

简介:这是一份面向Android初学者与移动开发入门者的计算器小程序源码文档,围绕Android Studio环境下的计算器实现展开,适合正在学习Android UI布局、事件监听与基础计算逻辑的开发者参考。资源包内共1个docx文件,大小约34KB,以文档形式集中呈现XML布局与Java代码片段,便于对照阅读与整理笔记。内容涉及GridLayout网格布局、TextView表达式与输入输出显示、Button按钮事件绑定,以及MC、MR、MS、M+、M-等内存操作按钮的界面定义,同时包含onClick、DialogInterface与onOptionsItemSelected等事件处理代码,可帮助读者理解计算器从界面搭建到交互响应的完整思路。目前已有1517人学习浏览,适合作为课程作业、练手项目或Android入门阶段的参考资料,便于快速把握计算器应用的核心知识点与代码组织方式。

1. 从一份 docx 需求文档到能跑的计算器:Android Studio 里最容易被低估的工程化训练

很多人第一次在 Android Studio 里做计算器,都是把它当成“练手作业”:拖几个按钮、写个onClick、用Double.parseDouble一算,能出结果就算完。但真正做过一轮完整交付的人会告诉你,计算器是移动端里少见的“麻雀虽小五脏俱全”项目——它同时踩到了 UI 布局、状态管理、输入法交互、表达式解析、异常处理和配置变更恢复这几条线。标题里那份 docx 需求文档,本质上不是让你交一个能按的界面,而是让你把一份自然语言需求翻译成可维护的 Android 工程。这篇文章面向的是刚上手 Android Studio、准备把计算器做成一个能写进简历的完整 Demo 的开发者,也适合已经会写但总在旋转屏幕、连续运算、除零崩溃上翻车的熟手。接下来我会按“需求怎么拆 → 界面怎么搭 → 逻辑怎么写 → 坑在哪 → 怎么验证”的顺序,把这条路径讲透。

2. 需求文档拆解与工程骨架:先定边界再动手

拿到一份计算器需求文档,最忌讳的就是打开 Android Studio 直接拖控件。我一般会先把文档里的功能点拆成三类:必须实现的四则运算、需要明确的边界行为、以及容易被漏掉的非功能需求。这三类决定了你后面代码结构长什么样,也决定了你会在哪一步返工。

2.1 把 docx 里的自然语言翻译成功能清单

一份典型的计算器需求文档,文字往往很模糊,比如“支持基本运算”“结果要准确”“界面简洁”。这些词不能直接写代码,必须转成可判定的条目。我的做法是列一张对照表,左边抄原文,右边写验收标准。

文档原文可验收的功能点对应技术点
支持基本运算加减乘除、连续运算、优先级正确表达式解析或状态机
结果要准确小数精度可控,不出现 0.30000000000000004BigDecimal 或格式化输出
界面简洁4 列网格,按钮等宽等高GridLayout / ConstraintLayout
操作流畅连续点击不卡顿,无重复响应事件绑定与防抖
异常处理除零、溢出、非法输入有提示try-catch 与状态回退

这张表的价值在于,它把“我觉得做完了”变成“我能证明做完了”。比如“结果要准确”这一条,如果你用double直接算0.1 + 0.2,输出就是0.30000000000000004,这在验收时是硬伤。常见做法是显示层用BigDecimal做舍入,或者用DecimalFormat控制小数位。这一步不做,后面测试阶段一定被退回。

2.2 用最小工程结构承载计算器逻辑

Android Studio 新建项目后,我建议不要把所有代码堆在MainActivity里。计算器的核心是“表达式求值”,它和 Android 框架无关,应该单独抽成一个类。这样你既能用单元测试验证,也能在换 UI 时不动逻辑。

// CalculatorEngine.kt // 职责:只负责表达式求值,不持有任何 Android 上下文 class CalculatorEngine { // 用 BigDecimal 避免浮点误差,scale 控制小数位 fun evaluate(expression: String): String { return try { val result = ExpressionParser(expression).parse() // 去掉末尾多余的 0,例如 3.50 -> 3.5 result.stripTrailingZeros().toPlainString() } catch (e: ArithmeticException) { "错误" // 除零等算术异常 } catch (e: Exception) { "错误" // 非法表达式 } } }

这段代码的关键点是:evaluate接收字符串、返回字符串,中间不碰任何 View。参数expression是用户按键拼出来的原始表达式,比如"12+3×4"。返回"错误"是一种兜底策略,实际项目中你可以返回密封类来区分“除零”和“格式错误”。把逻辑抽离后,MainActivity只负责收集按键、拼接表达式、调用evaluate、刷新显示。这样旋转屏幕时,你只需要保存表达式字符串,而不是保存一堆中间状态。

2.3 依赖与 SDK 版本的选择理由

计算器不需要复杂依赖,但有两个选择会影响后续踩坑概率。第一是minSdk,如果你设到 21 以下,一些新的布局属性用不了;设到 24 以上,覆盖设备又变少。我一般设minSdk 24,兼顾覆盖率和 API 可用性。第二是语言选择,Kotlin 现在是 Android Studio 默认,空安全特性对计算器这种频繁判空场景很友好。如果你还在用 Java,TextUtils.isEmpty会写到手酸。在build.gradle里确认这两项即可,不需要引入第三方计算库——计算器逻辑本身足够简单,引入库反而增加排查成本。

3. 界面搭建与输入状态管理:让按钮真正听话

界面部分是计算器最直观的地方,也是最容易埋下“玄学 bug”的地方。很多人按钮摆得漂亮,但一点就崩,或者连续点两个运算符就出现"++"这种非法表达式。这一章讲怎么用布局约束保证整齐,以及怎么用状态机管住输入序列。

3.1 用 GridLayout 搭出等宽按钮矩阵

计算器按钮通常是 4 列 5 行。用GridLayout最省事,但要注意layout_columnWeight和layout_rowWeight必须成对设置,否则按钮宽度会参差不齐。下面是一个按钮的典型写法:

<!-- activity_main.xml 片段 --> <GridLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:columnCount="4" android:rowCount="5"> <Button android:id="@+id/btn_7" android:text="7" android:layout_width="0dp" android:layout_height="0dp" android:layout_columnWeight="1" android:layout_rowWeight="1" android:layout_margin="2dp" /> <!-- 其余按钮同理,id 按语义命名 --> </GridLayout>

这里layout_width="0dp"配合columnWeight="1"才能让四列平分宽度。layout_height="0dp"配合rowWeight="1"让五行平分高度。margin给 2dp 是为了按钮之间有视觉缝隙,但不要用padding代替,否则文字会贴边。如果你发现某个按钮特别宽,检查是不是漏了columnWeight。这个布局在横屏下会拉伸变形,所以后面要配合ScrollView或限制最大宽度。

3.2 输入状态机:防止出现非法表达式

用户按键是随机的,可能连点两个加号,也可能在除号后直接点等号。如果只是简单拼接字符串,显示区就会出现"12++3"这种内容,求值必然失败。我的做法是在MainActivity里维护一个简单的状态标记:

// MainActivity.kt 片段 private var lastInputType = InputType.NUMBER // 上一次输入类型 private val expressionBuilder = StringBuilder() private fun onDigitPressed(digit: String) { expressionBuilder.append(digit) lastInputType = InputType.NUMBER updateDisplay() } private fun onOperatorPressed(op: String) { // 如果上一次是运算符,替换而不是追加 if (lastInputType == InputType.OPERATOR) { expressionBuilder.setLength(expressionBuilder.length - 1) } expressionBuilder.append(op) lastInputType = InputType.OPERATOR updateDisplay() } private fun onEqualsPressed() { if (lastInputType == InputType.OPERATOR) return // 运算符结尾不计算 val result = engine.evaluate(expressionBuilder.toString()) expressionBuilder.clear() expressionBuilder.append(result) lastInputType = InputType.NUMBER updateDisplay() }

InputType是一个枚举,标记上一次按的是数字还是运算符。onOperatorPressed里如果发现上一次也是运算符,就先删掉最后一个字符再追加,这样"12+"再按"×"会变成"12×"而不是"12+×"。onEqualsPressed里判断结尾是运算符就直接返回,避免把"12+"丢给解析器。这个状态机很土,但能挡住 90% 的非法输入。参数expressionBuilder是唯一的表达式来源,显示区只读它,不要反过来从 TextView 取文本,否则光标和格式问题会让你怀疑人生。

3.3 配置变更时保存表达式而不是结果

旋转屏幕是计算器最经典的翻车场景。如果你把表达式存在Activity的成员变量里,旋转后Activity重建,表达式就丢了。正确做法是用onSaveInstanceState保存表达式字符串:

override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString("expression", expressionBuilder.toString()) outState.putString("lastInputType", lastInputType.name) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) savedInstanceState?.let { expressionBuilder.append(it.getString("expression", "")) lastInputType = InputType.valueOf(it.getString("lastInputType", "NUMBER")) updateDisplay() } }

注意保存的是表达式和状态类型,不是计算结果。因为用户可能旋转后还想继续编辑,只恢复结果会丢失上下文。lastInputType用name存字符串,恢复时用valueOf转回枚举,这样比存 int 更可读。如果你用 ViewModel 也可以,但计算器这种单页面场景,onSaveInstanceState足够且更轻。

4. 表达式求值与精度处理:别让 0.1+0.2 毁掉验收

计算器的核心难点不在界面,而在“怎么把一串字符算对”。这一章讲两种求值方案的选择理由,以及 BigDecimal 在 Android 上的实际用法和性能边界。

4.1 双栈法求值:中缀表达式转后缀再计算

常见做法是调度场算法(Shunting-yard):一个栈存数字,一个栈存运算符,遇到运算符时比较优先级决定是否弹出。下面是一个简化实现:

// ExpressionParser.kt class ExpressionParser(private val input: String) { fun parse(): BigDecimal { val numbers = ArrayDeque<BigDecimal>() val operators = ArrayDeque<Char>() var i = 0 while (i < input.length) { val c = input[i] when { c.isDigit() || c == '.' -> { val sb = StringBuilder() while (i < input.length && (input[i].isDigit() || input[i] == '.')) { sb.append(input[i]); i++ } numbers.push(BigDecimal(sb.toString())) continue } c in "+-×÷" -> { while (operators.isNotEmpty() && priority(operators.first()) >= priority(c)) { numbers.push(applyOp(operators.removeFirst(), numbers.pop(), numbers.pop())) } operators.push(c) } } i++ } while (operators.isNotEmpty()) { numbers.push(applyOp(operators.removeFirst(), numbers.pop(), numbers.pop())) } return numbers.pop() } private fun priority(op: Char) = when (op) { '+', '-' -> 1 '×', '÷' -> 2 else -> 0 } private fun applyOp(op: Char, b: BigDecimal, a: BigDecimal): BigDecimal = when (op) { '+' -> a.add(b) '-' -> a.subtract(b) '×' -> a.multiply(b) '÷' -> a.divide(b, 10, RoundingMode.HALF_UP) // 保留 10 位,四舍五入 else -> throw IllegalArgumentException() } }

numbers栈存 BigDecimal,operators栈存字符。priority函数定义乘除优先级高于加减。applyOp里除法必须指定scale和RoundingMode,否则BigDecimal遇到除不尽会抛ArithmeticException。这里保留 10 位小数,最后显示时再stripTrailingZeros。注意numbers.pop()的顺序:先弹出的是右操作数,后弹出的是左操作数,所以applyOp的参数顺序是(op, b, a),写反了减法会得到负数。

4.2 BigDecimal 的 scale 与舍入模式怎么选

BigDecimal的divide方法如果不传scale,遇到1÷3会直接抛异常。传了scale又要选RoundingMode。常见选项有HALF_UP(四舍五入)、DOWN(截断)、HALF_EVEN(银行家舍入)。计算器场景我一般用HALF_UP,因为用户预期是“四舍五入”。scale设 10 是折中:太小会丢精度,太大显示会很长。显示层再用DecimalFormat("#.##########")格式化,去掉末尾零。如果你做的是财务类计算器,scale要设 2,RoundingMode用HALF_EVEN更符合规范。参数没有绝对对错,但必须在需求文档里写清楚,否则测试会拿1÷3的结果跟你扯皮。

4.3 连续运算与等号重复按的行为定义

连续运算有两种流派:一种是按=后结果继续参与下一次运算,另一种是按运算符时立即计算前一步。我倾向于前者,因为用户心智模型是“先输入完整表达式再求值”。但等号重复按要有定义:很多计算器重复按=会重复上一次运算,比如2+3=得 5,再按=得 8。这个行为要在需求文档里明确,否则实现时容易漏。我的做法是记录上一次的运算符和操作数,重复按等号时复用。如果不想要这个行为,就在onEqualsPressed里加一个标记,第二次按直接忽略。两种都能接受,关键是别让用户按了没反应又不知道为什么。

5. 避坑与排查:计算器开发中最常见的五类翻车

这一章是我自己踩过和帮别人排查过的真实问题,按“现象 → 原因 → 解决”写。每一条都对应前面章节的某个决策,如果你正在翻车,可以直接对号入座。

5.1 现象:旋转屏幕后表达式清空

原因:表达式存在Activity成员变量里,配置变更导致Activity重建,变量重新初始化。解决:用onSaveInstanceState保存表达式字符串和输入状态,在onCreate里恢复。注意不要保存View引用,否则会内存泄漏。如果用了 ViewModel,确认 ViewModel 的创建方式正确,不要每次new一个。

5.2 现象:连续点两个运算符出现"12++"

原因:输入状态机缺失或判断条件写错,比如只判断了lastInputType但没有在追加前删除旧运算符。解决:在onOperatorPressed里先检查lastInputType == OPERATOR,如果是就setLength(length - 1)再追加。同时确保onEqualsPressed里判断结尾是运算符时直接返回,不要求值。

5.3 现象:0.1 + 0.2显示0.30000000000000004

原因:用Double或Float做运算,二进制浮点无法精确表示十进制小数。解决:运算层用BigDecimal,显示层用DecimalFormat或stripTrailingZeros。注意BigDecimal构造要用字符串,不要用BigDecimal(0.1),否则会把double的误差带进来。正确写法是BigDecimal("0.1")。

5.4 现象:除零时应用直接崩溃

原因:BigDecimal.divide遇到除数为零会抛ArithmeticException,如果没有 try-catch,异常会一路抛到主线程导致崩溃。解决:在evaluate方法里捕获ArithmeticException,返回“错误”或弹出 Toast。同时检查applyOp里除法前是否判断了除数是否为零,双重保险。不要用Double.isInfinite去判断,因为 BigDecimal 不走那条路。

5.5 现象:按钮点击无响应或响应两次

原因:一是按钮的onClick绑定在XML里又用setOnClickListener绑了一次,导致重复触发;二是按钮被其他透明 View 遮挡,点击事件被拦截。解决:统一用一种绑定方式,我一般用setOnClickListener在代码里绑,方便调试。检查布局层级,用 Android Studio 的 Layout Inspector 看有没有 View 覆盖在按钮上。如果是快速连点导致重复计算,可以在onEqualsPressed里加一个时间戳判断,500ms 内忽略第二次。

6. 用单元测试锁住计算逻辑:一个能复用的验证习惯

计算器做完后,怎么证明它是对的?靠手点按钮只能覆盖几条路径,而且每次改代码都要重新点一遍。我的习惯是给CalculatorEngine写单元测试,把边界用例固定下来。这样以后换 UI、换语言、换解析算法,只要测试还绿,逻辑就没退化。

6.1 用 JUnit 覆盖四则运算与边界

在test目录下新建CalculatorEngineTest.kt,用 JUnit 4 写断言。注意evaluate返回字符串,断言时直接比字符串,避免浮点比较。

// CalculatorEngineTest.kt class CalculatorEngineTest { private val engine = CalculatorEngine() @Test fun `addition with decimals`() { assertEquals("0.3", engine.evaluate("0.1+0.2")) } @Test fun `division by zero returns error`() { assertEquals("错误", engine.evaluate("5÷0")) } @Test fun `operator priority`() { assertEquals("14", engine.evaluate("2+3×4")) } @Test fun `continuous operation`() { assertEquals("20", engine.evaluate("10+10")) } }

assertEquals的第一个参数是期望值,第二个是实际值。测试方法名用反引号包起来,可读性更好。division by zero这条用例专门验证异常处理路径,确保不会抛到测试框架。operator priority验证乘除优先于加减。如果你改了scale或RoundingMode,addition with decimals这条会最先告诉你结果变了。

6.2 用参数化测试批量验证表达式

JUnit 4 支持@RunWith(Parameterized::class),可以把多组输入输出放在一个测试里。这样加用例只需要加一行数据,不用复制方法。

@RunWith(Parameterized::class) class ExpressionParameterizedTest( private val expression: String, private val expected: String ) { companion object { @JvmStatic @Parameterized.Parameters fun data(): Collection<Array<String>> = listOf( arrayOf("1+1", "2"), arrayOf("9-3", "6"), arrayOf("6×7", "42"), arrayOf("8÷2", "4"), arrayOf("1÷3", "0.3333333333") ) } @Test fun `evaluate expression`() { assertEquals(expected, CalculatorEngine().evaluate(expression)) } }

data()返回一个集合,每个元素是一组参数。1÷3的期望值取决于你scale设了多少位,这里设 10 位所以是0.3333333333。如果哪天你把scale改成 5,这条用例会失败,提醒你同步更新期望值。参数化测试的好处是边界用例一目了然,评审时别人能看到你覆盖了哪些情况。

6.3 把测试跑在 CI 里的最小配置

如果你把计算器项目放到代码托管平台,可以加一个简单的 CI 配置,每次提交自动跑测试。以 GitHub Actions 为例,在.github/workflows/test.yml里写:

name: Android Unit Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Run unit tests run: ./gradlew testDebugUnitTest

testDebugUnitTest是 Gradle 任务名,只跑单元测试,不跑仪器测试,所以不需要模拟器,速度快。JDK 17是当前 Android Gradle Plugin 的常见要求,具体版本看你项目里的compileOptions。这个配置的价值在于,你改完计算逻辑推上去,几分钟内就知道有没有破坏已有用例。我自己的习惯是本地先跑一遍./gradlew test,推上去再让 CI 复核,双保险。

6.4 一个我坚持了很久的验证习惯

每次改完CalculatorEngine,我不会直接打开模拟器点按钮,而是先跑单元测试。测试绿了,再去模拟器上看 UI 表现。这个顺序帮我省了大量“点了半天发现是逻辑错”的时间。计算器的 UI 问题通常好定位,逻辑问题才隐蔽。把逻辑锁在测试里,UI 层就只剩布局和事件绑定,排查范围小很多。另外,我会把需求文档里的每条验收标准对应到至少一个测试用例,文档改了就改测试,测试改了就改代码。这个循环跑顺了,计算器这种小项目基本不会翻车。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询