
简介这是一份面向Android开发初学者的PDF教程目标是教读者用Android Studio从零实现一个简单计算器App。内容先讲解如何在新建项目后通过GridLayout搭建6行4列的计算器界面包括文本框、清除按钮与数字/运算符按钮的id及摆放方式。随后给出MainActivity中的实现思路用findViewById获取组件为按钮设置点击监听器借助操作数变量和运算符编号在按下等于号时用switch判断并执行加减乘除运算最后把结果回显到文本框。整个文档以XML布局和Java代码示例贯穿省去理论铺垫无需额外工程文件即可对照实践适合已经了解Android基础、想动手练习界面与事件处理的读者参考。资源压缩包含一个PDF文档大小109KB整体内容精炼已有3708人学习下载PDF格式便于随时查阅可作课程设计或自学练手的配套材料。1. 为什么你在 Android Studio 里写计算器写出来的却总是个玩具在 Android Studio 里实现简单计算器功能这个标题看起来比任何入门项目都要平淡但真正动手写的时候你会发现网上能找到的很多示例最后做出来的东西只能“把一串表达式拼好再用某个库整体求值”。它们既不能支撑连续按等号也扛不住旋转屏幕按钮之间还经常出现大小不一、按了没反应的怪问题。这篇笔记要解决的正是这些藏在“简单”背后的硬骨头输入状态怎么管理、运算符怎么连按、浮点误差怎么处理、旋转屏状态怎么保住。这个项目适合已经学完 Activity、布局和基本事件监听的人。你不需要掌握 Compose也不需要引入任何第三方依赖。我会把界面、逻辑、绑定、排错四段拆开讲每一个步骤都给出能直接复制的 XML 和 Kotlin 代码并且告诉你参数为什么这么设。看完这篇你不仅有一个能用的计算器还能把它当做一个可测试、可扩展的小工具继续加功能。我的核心观点很简单计算器这类工具难点从来不是加减乘除的数学而是用户乱按的时候程序不会崩、不会算错、不会清零。所以别急着写代码先想想输入会经过哪几个状态再动手。2. 搭界面用 GridLayout 排按钮用 style 统一按钮姿势2.1 创建项目选 Empty Views Activity 并锁竖屏打开 Android Studio 新建项目模板选择 Empty Views Activity而不是 Empty Compose Activity。原因很直接这个系列的文章要讲 XML 布局和控件权重Compose 的写法会和后面所有代码脱节。语言选择 Kotlin它对空指针的控制更严格写事件监听器时不容易在回调里踩空。创建完之后第一个动作不是改布局而是打开 AndroidManifest.xml给 MainActivity 加上android:screenOrientationportrait。为什么要先锁竖屏因为默认情况下旋转屏幕会导致 Activity 被销毁重建你辛辛苦苦输入的数字和运算符会全部清零。我们先用锁定竖屏把“状态保存”这个复杂的点撇开专心把计算逻辑跑通到第 5 章讲完 ViewModel 之后再把这个限制去掉你自然就明白旋转时发生了什么。activity android:name.MainActivity android:exportedtrue android:screenOrientationportrait intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity这段 manifest 片段里android:screenOrientationportrait是唯一的重点。它告诉系统这个 Activity 只使用竖屏模式系统不会因为用户转手机而重建它。等你想研究横竖屏适配时删掉这一行就行但删掉之前请确保你的计算器状态已经能被 ViewModel 或保存实例状态机制恢复否则就是给自己找麻烦。2.2 用 GridLayout 实现 4 列按钮并抽一个 Button 样式界面结构非常简单上面一个 TextView 用来显示输入和结果下面一个 GridLayout 摆放数字和运算符按钮。TextView 不用 EditText是因为计算器的输入来自按钮而不是键盘EditText 弹出的软键盘反而会遮挡按钮。GridLayout 相比多层 LinearLayout 嵌套的优势是它天然支持按权重平均分配行列代码层级少性能也好。在 GridLayout 里让每个按钮占一格你需要给每个按钮设置layout_width0dp、layout_height0dp、layout_columnWeight1和layout_rowWeight1同时layout_gravityfill让按钮填满自己的格子。如果真的给二十个按钮都写这么一串八成会有人复制到一半漏掉某个属性。所以我习惯先把这些公共属性抽成一个 style放在res/values/styles.xmlstyle nameCalcButton item nameandroid:layout_width0dp/item item nameandroid:layout_height0dp/item item nameandroid:layout_columnWeight1/item item nameandroid:layout_rowWeight1/item item nameandroid:layout_gravityfill/item item nameandroid:textSize26sp/item item nameandroid:background?attr/selectableItemBackground/item /stylelayout_columnWeight和layout_rowWeight是 GridLayout 里的权重属性和 LinearLayout 的layout_weight类似控制每个单元格在行和列上的占比。这里全部设为 1表示每行四个按钮等宽每列五行按钮等高。?attr/selectableItemBackground是系统自带的选择器背景按下按钮时会出现水波纹反馈不需要自己写 drawable。然后布局文件activity_main.xml就有了干净的表达。我写一个完整版本你可以直接复制到你的工程里?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical TextView android:idid/tv_display android:layout_widthmatch_parent android:layout_height120dp android:background#F5F5F5 android:gravitycenter_vertical|end android:maxLines2 android:padding16dp android:scrollbarsvertical android:text0 android:textSize40sp / GridLayout android:idid/grid_buttons android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:columnCount4 android:rowCount5 !-- 第一行 -- Button android:idid/btn_clear stylestyle/CalcButton android:tagC android:textC / Button android:idid/btn_plus stylestyle/CalcButton android:tag android:text / Button android:idid/btn_minus stylestyle/CalcButton android:tag- android:text- / Button android:idid/btn_multiply stylestyle/CalcButton android:tag× android:text× / !-- 第二行 -- Button android:idid/btn_7 stylestyle/CalcButton android:tag7 android:text7 / Button android:idid/btn_8 stylestyle/CalcButton android:tag8 android:text8 / Button android:idid/btn_9 stylestyle/CalcButton android:tag9 android:text9 / Button android:idid/btn_divide stylestyle/CalcButton android:tag÷ android:text÷ / !-- 第三行 -- Button android:idid/btn_4 stylestyle/CalcButton android:tag4 android:text4 / Button android:idid/btn_5 stylestyle/CalcButton android:tag5 android:text5 / Button android:idid/btn_6 stylestyle/CalcButton android:tag6 android:text6 / Button android:idid/btn_equals stylestyle/CalcButton android:tag android:text / !-- 第四行 -- Button android:idid/btn_1 stylestyle/CalcButton android:tag1 android:text1 / Button android:idid/btn_2 stylestyle/CalcButton android:tag2 android:text2 / Button android:idid/btn_3 stylestyle/CalcButton android:tag3 android:text3 / Button android:idid/btn_dot stylestyle/CalcButton android:tag. android:text. / !-- 第五行 -- Button android:idid/btn_0 stylestyle/CalcButton android:tag0 android:text0 / Button android:idid/btn_backspace stylestyle/CalcButton android:tagDEL android:textDEL / View stylestyle/CalcButton android:visibilityinvisible / View stylestyle/CalcButton android:visibilityinvisible / /GridLayout /LinearLayout注意最后两个单元格是View不是Button并且设成了invisible。它们的任务是占住格子让前两行按钮不会因为缺位置而错位。如果你不想让界面上出现一片空白也可以把其中一个 View 换成倍率、括号等功能但当前我们的简单计算器不需要就先留白。android:tag属性很关键。你会在第 4 章看到我用 tag 而不是用文本内容来区分按钮因为文本内容以后可能被换掉比如把 “DEL” 换成 “⌫”但 tag 作为程序标识保持稳定。每个按钮的 tag 都要和计算逻辑里期望的字符一致尤其是乘号 “×” 和除号 “÷”不要写成键盘上的 “*” 和 “/”否则第 3 章的状态机匹配不到。3. 写核心逻辑用状态机让 123 连续运算不出错3.1 为什么不用表达式字符串求值很多教程会引导你这么做用一个 StringBuffer 用户每按一次按钮就往里追加字符按等号时直接把整个字符串交给某个表达式解析器算结果。这种做法的确能让1234*5这种式子算出来但它有几个致命问题。第一Android 主线程上不允许使用某些 Java 脚本引擎你需要额外引入大体积依赖为了一个计算器完全不值得。第二表达式解析器是一个黑匣子用户按错一个符号你根本查不到是解析器内部报错还是输入状态出问题。第三它完全没办法处理“连续按等号”、“刚算完结果再按运算符”这类交互因为这些交互需要的是“上一步的结果”这个状态而不是一串文本。正确的做法是维护一个计算器状态机。状态机并不复杂你只需要记住四件事上一个操作数、当前输入的数字字符串、正在等待的运算符、以及“是否刚刚按下等号”。有了这四个状态连续运算、连续运算符替换、等号后继续计算全都变成清晰的逻辑分支。3.2 状态机与运算逻辑的 Kotlin 实现我把核心逻辑单独写成一个 Calculator 类不依赖 Android 的 Activity这样它天生可以被单元测试也可以在横竖屏切换时被 ViewModel 保存。类的完整实现如下你新建一个文件Calculator.kt把代码拷贝进去就行。import java.math.BigDecimal class Calculator { private var previousValue: Double? null private var currentInput private var pendingOperator: String? null private var justEvaluated false val display: String get() if (currentInput.isEmpty()) 0 else currentInput fun inputDigit(digit: String) { if (justEvaluated) { previousValue null pendingOperator null justEvaluated false } currentInput digit } fun inputDot() { if (justEvaluated) { previousValue null pendingOperator null justEvaluated false } if (currentInput.contains(.)) return currentInput if (currentInput.isEmpty()) 0. else currentInput . } fun inputOperator(op: String) { if (justEvaluated) { pendingOperator op currentInput justEvaluated false return } if (currentInput.isEmpty()) { pendingOperator op return } if (previousValue null) { previousValue currentInput.toDouble() } else if (pendingOperator ! null) { performOperation() } pendingOperator op currentInput } fun inputEquals() { if (currentInput.isEmpty() || pendingOperator null) return performOperation() pendingOperator null justEvaluated true } fun clear() { previousValue null currentInput pendingOperator null justEvaluated false } fun backspace() { if (currentInput.isNotEmpty()) { currentInput currentInput.dropLast(dropLast) } } private fun performOperation() { val left previousValue ?: 0.0 val right currentInput.toDouble() val result when (pendingOperator) { - left right - - left - right × - left * right ÷ - if (right 0.0) Double.NaN else left / right else - return } previousValue result currentInput formatResult(result) justEvaluated true } private fun formatResult(value: Double): String { if (value.isNaN() || value.isInfinite()) return 错误 return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString() } }第一次看这段代码可能会被justEvaluated绕晕我换个角度解释。justEvaluated是一个“一次性开关”它只在按下等号之后的一瞬间为 true用来决定下一次按键该继续沿用上一次的结果还是开启一个全新的计算。比如你算完538此时显示器上是 “8”previousValue也是 8。如果接着按 “”inputOperator看到justEvaluated true知道用户是想在 8 的基础上继续加所以只设置pendingOperator 清空当前输入等待新的数字。如果接着按数字 “2”inputDigit看到justEvaluated true则判断用户想重新开始于是把上次结果和运算符全部清掉从 “2” 开始输入。这是移动计算器最符合肌肉记忆的行为。performOperation里的pendingOperator是真正执行运算的入口。它从previousValue取左操作数从currentInput.toDouble()取右操作数。如果用户点的是除号且右操作数是 0我们没有抛出异常而是返回Double.NaN再在formatResult里统一显示为 “错误”。这样可以避免 Activity 崩溃也让错误状态能被后续输入自然清除。3.3 逐步追踪一个表达式123为了让你确认这个状态机确实能处理连续运算我们走一遍123的完整过程。我把四个状态字段和显示值的变化整理成一张表你可以对照代码看操作previousValuecurrentInputpendingOperatorjustEvaluated显示按 1null1nullfalse1按 1.0false1按 21.02false2按 3.0false3按 33.03false3按 6.06nulltrue6第二次按 “” 时代码会进入else if (pendingOperator ! null)分支先执行一次performOperation()把左操作数 1 和当前输入 2 加起来得到 3再把 3 存入previousValue然后才把新的运算符 “” 挂上。所以你在界面上看到的是按完第一个加号后显示 1输入 2 后显示 2再按加号时瞬间变成 3这个跳变就是一次隐藏的中间运算。熟悉这种状态机之后你可以继续扩展出“退格删除最后一位”“长按清空”甚至“百分比”。但现阶段请先把这三段代码跑通再进入下一章的事件绑定。4. 绑事件用 tag 统一分发避免二十个监听器4.1 为什么用 tag 而不用二十个 setOnClickListener界面上有 20 个可点击控件如果你老老实实写btn_1.setOnClickListener { ... }、btn_2.setOnClickListener { ... }一个 Activity 里会出现一大段重复代码而且每个监听器里都要判断自己代表哪个数字或运算符。更麻烦的是以后你改了某个按钮的 id漏改监听器的概率很大。我在上一章布局里给每个 Button 都设置了android:tag比如数字 7 的 tag 就是7加号的 tag 是。这样我只需要一个监听器在回调里读取View.getTag()就知道用户按的是哪个按钮。这个方案在按钮特别多的界面里非常好用算是我写工具类界面的固定套路。4.2 MainActivity 代码初始化、绑定、分发回到 MainActivity完整代码可以像我下面这样写。你复制到MainActivity.kt里删掉模板自带的内容直接替换即可。package com.example.simplecalculator import android.os.Bundle import android.widget.Button import android.widget.TextView import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { private lateinit var display: TextView private val calculator Calculator() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) display findViewById(R.id.tv_display) bindButtons() display.text calculator.display } private fun bindButtons() { val buttonIds listOf( 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_equals, R.id.btn_plus, R.id.btn_minus, R.id.btn_multiply, R.id.btn_divide, R.id.btn_clear, R.id.btn_backspace ) buttonIds.forEach { id - findViewByIdButton(id).setOnClickListener { val tag it.tag?.toString() ?: returnsetOnClickListener onButtonPressed(tag) } } } private fun onButtonPressed(tag: String) { when (tag) { C - calculator.clear() DEL - calculator.backspace() - calculator.inputEquals() , -, ×, ÷ - calculator.inputOperator(tag) . - calculator.inputDot() else - calculator.inputDigit(tag) } display.text calculator.display } }bindButtons用一个 List 保存所有按钮 id然后统一设置点击监听器。这里有一个容易被忽略的细节布局 XML 里那两个不可见的 View 不在buttonIds列表里所以不会被绑定监听器这当然是正确的。onButtonPressed根据 tag 分流到 Calculator 的不同方法最后更新显示。如果你在清单文件中锁定了竖屏那么现在你的计算器已经可以在模拟器或真机上跑起来了。试着输入123观察显示上的变化。如果点击某个按钮没反应先检查它的 tag 是不是真的是而不是前后带了空格。我在整理布局时特别容易多敲一个空格这种问题不会报错只会让程序找不到匹配分支最终表现就是按钮静默。4.3 给交互加上细节长按退格就清空一个不错的小技巧是给退格按钮加上长按事件长按退格直接清空全部状态等效于按 C。这样手指不用移动到左上角去点 C单手操作更顺手。在bindButtons函数里加几行findViewByIdButton(R.id.btn_backspace).setOnLongClickListener { calculator.clear() display.text calculator.display true }setOnLongClickListener的返回值必须为 true表示这个长按事件已经被消费掉不需要再触发点击事件。如果你返回 false长按结束后还会触发一次普通点击于是清空完又被退格删掉一个字符看起来像卡了一下。这是 Android 事件分发里很容易踩到的坑记住长按要返回 true。5. 常见坑排查从闪退、浮点误差到旋转丢状态5.1 旋转屏幕后清零这是 Activity 重建不是 bug现象手机上 12 输到一半不小心横过来显示立刻变回 0。原因默认情况下屏幕旋转会导致 Activity 被销毁并重建所有成员变量都回到初始值。calculator是 Activity 里的一个普通对象自然也被清空。解决最稳妥的做法是用 ViewModel。ViewModel 的生命周期和 Activity 实例解耦屏幕旋转时不会销毁。把calculator从 MainActivity 挪到 ViewModel 里Activity 每次重建后拿到的是同一个 ViewModel 实例计算器状态也就保住了。import androidx.lifecycle.ViewModel class CalculatorViewModel : ViewModel() { val calculator Calculator() }然后在 MainActivity 里这样用private val viewModel: CalculatorViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) display findViewById(R.id.tv_display) val calculator viewModel.calculator // 后续所有对 calculator 的引用都从 viewModel.calculator 获取 }如果你不想引入 Lifecycle 组件也可以在onSaveInstanceState里保存四个状态字段在onCreate里恢复。但那样代码量更大而且要小心Bundle的键名冲突。ViewModel 是现在的主流做法Android Studio 新建工程默认会带上 Lifecycle 的依赖所以直接用。5.2 除以零没有闪退但显示 Infinity 或错误现象输入8 ÷ 0结果不是零而是出现一个像 “∞” 或者 “Infinity” 的字符串继续按其他按钮还会把 Infinity 带入后续计算。原因在 Kotlin 和 Java 里Double除法遇到除数为零时会返回Infinity不会抛异常。整数除法才会抛ArithmeticException。你如果用Int接收结果才会崩溃用Double则只是得到一个特殊浮点值。解决计算逻辑里主动处理。我给出的 Calculator 类中除号分支使用了if (right 0.0) Double.NaN else left / right。Double.NaN不是一个有效数字但它会破坏除法和比较。formatResult里用value.isNaN() || value.isInfinite()判断并直接返回 “错误”。这样显示器上显示的不是凌乱的 Infinity而是能让人看懂的错误提示。用户按任何数字或 C 键错误文本就会被正常覆盖。5.3 0.10.2 显示 0.30000000000000004现象点 0.1、、0.2、结果不是 0.3而是一长串鬼画符。原因这是二进制浮点数的经典误差。0.1和0.2在二进制世界里都是无限循环小数IEEE 754 标准用有限位数保存它们再相加就出现了一个非常接近 0.3 但不是 0.3 的值。解决不要试图用else开关来修正浮点应该统一在格式化输出时处理。我这里用的是BigDecimal.valueOf(value).stripTrailingZeros().toPlainString()。它先把 double 转成十进制字符串再去除末尾多余的零最终输出就是干净的数字。为什么要用BigDecimal而不用String.format(%.10f)因为后者会固定保留 10 位小数像 2 会被格式化成 2.0000000000你还得再写一段逻辑去掉末尾零。stripTrailingZeros一步到位。但注意不要在中间计算中用BigDecimal代替Double因为小数除法的舍入模式需要额外处理对简单计算器来说属于过度设计。5.4 按钮点击没反应也没有报错现象布局和代码都照着写了运行起来点击按钮毫无反馈Logcat 里也没有任何异常。原因最常见的三种情况。一是buttonIds列表漏掉了某个按钮的 id导致这个按钮根本没有绑定监听器二是布局 XML 里按钮的android:tag写错比如多了一个空格导致onButtonPressed落入else分支后被当成普通数字追加三是bindButtons()里findViewByIdButton(id)返回 null但这种情况会立刻崩溃不会“没反应”。解决先在bindButtons()的最后加一句Log.d(Calculator, button count: ${buttonIds.size})确认绑定循环执行了。然后在onButtonPressed的开头加Log.d(Calculator, tag: $tag)这样每点一个按钮都能在 Logcat 看到对应的 tag。如果 tag 是空字符串就去检查 XML 里android:tag是不是漏写了。如果 Log 显示 tag 正确但行为不对再回到状态机代码里追问。5.5 连续按运算符 32中间那个加号被吞了现象输入 3、、、2、期望的结果可能是 5 或某种报错但实际得到的却是 0 或者没有任何响应。原因状态机里对连续运算符的处理不统一。一些实现当你当前输入为空时直接忽略新运算符这会导致第二个加号被忽略最终用户实际执行的是 325看起来没问题。但如果你先按 3、×、- 用户本意可能是打算把乘号改成减号结果却因为第二个负号被忽略最后还是算乘法。对用户来说输入被静默篡改比报错更可怕。解决我的inputOperator里有两个关键点。如果currentInput.isEmpty()就把新的运算符直接替换pendingOperator如果justEvaluated true则把运算符挂到一个已经算出的结果上。这两种情况都不会出现“吞输入”的感觉。你可以自己在代码里测试3、×、-、2、应该得到 3-21。这是计算器用户能接受的最小纠错行为。6. 进阶把计算器做成能测试、能保状态的小工具到这步你的简单计算器已经具备基本可用性。如果想让这个项目成为你简历上拿得出手的练手作品我建议你再做三件事把 Calculator 类用 JUnit 测试覆盖、接入 ViewModel 保存状态、补一个长按行为。首先写测试。Calculator 类不依赖 Android SDK所以可以直接创建本地单元测试文件src/test/java/.../CalculatorTest.kt。下面是几个最值得覆盖的用例import org.junit.Assert.assertEquals import org.junit.Test class CalculatorTest { Test fun continuousAddition() { val calc Calculator() calc.inputDigit(1) calc.inputOperator() calc.inputDigit(2) calc.inputOperator() calc.inputDigit(3) calc.inputEquals() assertEquals(6, calc.display) } Test fun divideByZeroShowsError() { val calc Calculator() calc.inputDigit(8) calc.inputOperator(÷) calc.inputDigit(0) calc.inputEquals() assertEquals(错误, calc.display) } Test fun decimalResultIsTrimmed() { val calc Calculator() calc.inputDigit(0) calc.inputDot() calc.inputDigit(1) calc.inputOperator() calc.inputDigit(0) calc.inputDot() calc.inputDigit(2) calc.inputEquals() assertEquals(0.3, calc.display) } Test fun afterEqualsDigitStartsNewExpression() { val calc Calculator() calc.inputDigit(5) calc.inputOperator() calc.inputDigit(3) calc.inputEquals() calc.inputDigit(2) assertEquals(2, calc.display) } }测试用例里最难写的是最后一条等号之后按数字必须开启全新计算。很多计算器在这里实现得不干净结果就是 538再按 2 变成 82这是错的。Calculator 代码里inputDigit开头对justEvaluated的处理已经保证了这一点测试的目的就是防止你以后重构时不小心破坏它。然后接入 ViewModel。第 5 章已经讲过把calculator从 Activity 挪到CalculatorViewModel中。这一步也让第 2 章里锁定的竖屏可以去掉因为你已经不怕旋转导致清空了。现在你可以把activity标签里的android:screenOrientationportrait删掉然后在真实设备上旋转看看计算结果会保留这就是 ViewModel 的价值。最后长按退格清空的功能我在 4.3 提过。你可以顺手再给等号按钮加一个长按事件比如长按等于号就把当前结果复制到剪贴板。但我不建议一开始加太多花活先让核心功能稳定再加这些边缘交互。我写这个计算器的时候第一次用的就是 StringBuffer 拼表达式再交给解析库计算。结果测试机上一切正常发布后发现某些输入会引起解析库内部报错而且因为依赖体积变大差点让包超限。后来老老实实拆成状态机每一个逻辑都能用单元测试验证从此再没有因为计算器这种小功能翻过车。希望这个经验对你也有用。把 Calculator 当成一个独立模块去对待你会收益良多。希望帮到你。本文还有配套的精品资源点击获取