ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从零实现一个Android简单计算器:布局、逻辑与状态管理详解

从零实现一个Android简单计算器:布局、逻辑与状态管理详解

不少刚接触Android开发的朋友,想找个能顺手练完整个基础链路的项目,第一反应多半是“做个计算器”。它没有复杂的网络请求,也不需要设计数据库表,但要真正把一个能用的计算器做出来,你需要把界面布局、按钮事件、状态切换、异常处理这几件事全部串在一起,这里面的坑并不比做一个列表页少。我当初用AS写这个简单计算器时,前后重构过两版代码,第一次只想着把功能跑通,结果连续运算和退格逻辑乱成一团;第二次才把状态机理清楚,写出来的东西不管自己多久之后回看,都能一眼明白。

这篇文章不打算给你一个“复制粘贴就完事”的成品,而是把从新建项目到调试上真的完整走一遍,把每个设计选择的理由也讲清楚。适合两类人看:一是刚学完基础语法、想找个项目练手的新手,二是会写点界面但总在计算逻辑上卡壳的朋友。看完你应该能独立写出一个界面干净、逻辑可靠的四则运算计算器,并且知道怎么扩展成科学计算器。

1. 项目启动与整体设计思路

1.1 创建工程和基础选型

在AS里新建项目时,选择“Empty Views Activity”或者“Empty Activity”都可以,关键是语言建议直接选Kotlin。现在Kotlin已经是Android开发的默认选择,用它写计算器代码更紧凑,空指针问题也少很多。项目名可以叫“SimpleCalculator”或者你喜欢的任何名字,包名保持默认即可。

最低支持的Android版本(minSdk)我习惯设为24,也就是Android 7.0。这个版本覆盖了绝大多数还在流通的设备,同时又能让你放心使用较新的API和第三方库,不用为了兼容老版本写一堆判断。如果你手头的设备或模拟器系统版本比较老,调到21也能运行,只是布局属性上稍微注意一点就行。

新建完项目,不要急着写代码。先打开模块的build.gradle文件看一眼,确认一下依赖里有没有自动带上appcompat。这个库提供了向后兼容的AppCompatActivity以及各种Material风格控件,是写界面很基础的东西。如果没有,在dependencies里补一行:

implementation("androidx.appcompat:appcompat:1.6.1")

1.2 功能边界:到底做到哪一步才算“简单计算器”

很多人一上来就想做一个和系统计算器一模一样的应用,有历史记录、有百分比、有正负号切换、有连续表达式。我建议第一次做时收着点,把功能边界划清楚,否则会被各种细节拖垮。

我这次定的目标是:

  • 支持数字 0-9 的输入
  • 支持小数点输入
  • 支持加、减、乘、除四种运算
  • 支持等号计算
  • 支持清空(C)
  • 支持退格(删除最后一位)
  • 支持连续运算,比如输入5 + 3 + 2 =能得到 10

不做的功能包括:括号、科学函数、历史记录、按键音效。等核心逻辑稳定之后,这些都可以当作扩展项再加。这样做的好处是,你可以把精力集中在“一个表达式如何被拆解和计算”这件事上,而不是被各种花哨按钮干扰。

1.3 两种实现路线的取舍

计算器的核心逻辑一般有两种实现方式:

第一种是“表达式解析”。把所有输入拼成一个字符串,比如"5+3*2",然后调用表达式求值引擎,解析并计算结果。这种方式功能上限很高,支持括号、优先级都很方便,但引入的依赖和算法复杂度也会提高。

第二种是“逐步计算”。用户每次输入一个数字,再按运算符时,就把当前显示值和上一个值做一次运算,保存中间结果。这种方式不依赖第三方库,代码写在明处,每一步的变量变化都能看清,特别适合新手理解状态管理。

我这次选的是第二种,原因很实际:简单计算器的需求足够简单,逐步计算的代码量更少,逻辑也更符合“人按计算器的习惯”。如果你以后想做带括号的复杂表达式,再回归到表达式解析也不迟,那是另一个话题了。

2. 界面布局与控件规划

2.1 用线性布局搭出计算器骨架

计算器界面无非是一个“显示屏”加若干行按钮。Android里实现这种规则排列的方式有很多,比如GridLayout、TableLayout,或者嵌套多个LinearLayout。我习惯用嵌套线性布局,因为代码直观,权重分配也好控制。

整体结构是这样的:

  • 最外层是一个垂直LinearLayout,设置一定的内边距
  • 顶部放一个TextView,用来显示输入的内容和结果
  • 中间放3行数字和运算符按钮,每行使用一个水平LinearLayout
  • 每个按钮设置layout_width="0dp",layout_weight="1",让它们等宽排列

这样写出来的布局不会因为屏幕变宽而乱掉,按钮会自动拉伸填满一行。初次做布局的新手最容易犯的错,是把按钮宽高直接写死成200dp,换个屏幕就挤成一团甚至看不见,权重方案能彻底避免这个问题。

2.2 完整的布局代码示例

下面是我用的activity_calculator.xml主体结构,为了篇幅,省略了部分重复按钮,但写法是完全一致的:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp"> <TextView android:id="@+id/tv_display" android:layout_width="match_parent" android:layout_height="100dp" android:gravity="bottom|end" android:text="0" android:textSize="36sp" android:background="#F5F5F5" android:padding="12dp" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:layout_marginTop="12dp"> <Button android:id="@+id/btn_7" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="7" /> <Button android:id="@+id/btn_8" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="8" /> <Button android:id="@+id/btn_9" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="9" /> <Button android:id="@+id/btn_add" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="+" /> </LinearLayout> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <Button android:id="@+id/btn_4" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="4" /> <Button android:id="@+id/btn_5" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="5" /> <Button android:id="@+id/btn_6" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="6" /> <Button android:id="@+id/btn_subtract" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="-" /> </LinearLayout> <!-- 数字1、2、3和乘法按钮,写法同上 --> <!-- 数字0、小数点、清空、除法和退格按钮,写法同上 --> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <Button android:id="@+id/btn_clear" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="C" /> <Button android:id="@+id/btn_backspace" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="1" android:text="⌫" /> <Button android:id="@+id/btn_equals" android:layout_width="0dp" android:layout_height="60dp" android:layout_weight="2" android:text="=" /> </LinearLayout> </LinearLayout>

注意最后一行的等号按钮,我给它的layout_weight设成了 2,让它在视觉上占两个按钮的宽度,这样界面看起来更接近常见计算器布局,也和前面的行对齐。权重总和为1+1+2=4,等号自然就是一半宽度。

2.3 控件命名规范和后续维护

按钮的id要见名知义,我习惯把数字按钮命名为btn_0、btn_1,运算符按钮命名为btn_add、btn_subtract、btn_multiply、btn_divide,功能按钮命名为btn_clear、btn_backspace、btn_equals。这样在代码里查找和绑定按钮时,几乎不用注释就知道它是干什么的。之前见过不少人把按钮命名为button1、button2,写到后面自己都不记得哪个是哪个,调试时只能一个个试。

如果你觉得纯白按钮太单调,可以给运算符和功能键加背景色。用一个drawable资源定义圆角和背景颜色,或者直接用MaterialButton的app:backgroundTint属性,方便又美观。颜色选择上,数字键用灰色调,运算符用深蓝或橙色,功能键用浅灰,用户一眼就能区分按键类型。

3. 运算逻辑与核心代码实现

3.1 状态管理:三个关键变量

逐步计算的核心是维护好三个状态:上一个数字、挂起的运算符、当前输入的内容。我定义了下面这些成员变量:

private var firstNumber: Double? = null private var pendingOperator: String? = null private var isNewInput = true

三个变量的含义分别是:

  • firstNumber:保存运算符左边的数值。它为空时,说明当前还没有进行任何运算。
  • pendingOperator:保存用户刚按下的运算符,比如+、-、×、÷。
  • isNewInput:标记下一次按下数字时,是应该清空当前显示、还是直接追加在现有数字后面。

isNewInput是整个逻辑里最容易理解错的地方。举个例子:用户输入5,然后按下+,接下来再按3。如果此时没有这个标记,显示框里的5就会变成53,那肯定不对。按下+之后,我们需要把“下一次输入当作新的运算数处理”,所以isNewInput被设为true,数字按钮点击时先清空显示内容,再写入新数字。这个标记是保证连续输入能正确衔接的关键。

3.2 数字按钮的点击处理

数字按钮的实现非常简单,但要注意两个边界条件:一是当前是否为新输入的开始,二是显示内容是否已经有了小数点。

fun onDigit(view: View) { val digit = (view as Button).text.toString() val currentText = binding.tvDisplay.text.toString() if (isNewInput) { binding.tvDisplay.text = if (digit == ".") "0." else digit isNewInput = false } else { if (digit == "." && currentText.contains(".")) { return } if (currentText == "0" && digit != ".") { binding.tvDisplay.text = digit } else { binding.tvDisplay.text = currentText + digit } } }

这里的binding.tvDisplay是视图绑定生成的控件引用,用了官方推荐的ViewBinding方式。如果你没用ViewBinding,换成findViewById也是一样的效果。

有个细节值得专门提一下:当显示框里只有单个0,而用户按下的是数字键5时,我们不应该把显示变成05,而是直接替换成5。所以我在代码里做了if (currentText == "0" && digit != ".")这个判断。这个细节很容易被忽略,但体验差异很大。

3.3 运算符按钮和等号的处理

运算符按钮的职责可以分成两种场景:第一次按运算符,或者连续按第二个运算符。

先说最简单的场景:用户还没有输入过运算符,那么按下+时,我们把当前显示值存进firstNumber,把+存进pendingOperator,然后把isNewInput设为true,后续输入的数字会作为第二个操作数。

fun onOperator(view: View) { val operator = (view as Button).text.toString() val currentValue = binding.tvDisplay.text.toString().toDoubleOrNull() ?: return if (firstNumber == null) { firstNumber = currentValue pendingOperator = operator } else { // 已经有上一次操作数,先计算结果 val result = calculate( firstNumber!!, currentValue, pendingOperator!! ) binding.tvDisplay.text = formatResult(result) firstNumber = result pendingOperator = operator } isNewInput = true }

这里做了一个很关键的处理:当用户连续输入5 + 3 +时,第二个+按下时,我们不能直接把它存为新的运算符,而是先把5 + 3的结果算出来,将结果作为下一次运算的第一个操作数,再把新的+存起来。这就是“逐步计算”的本质——表达式永远只保留“第一个数 + 运算符”,第二个数由后续按键提供。

等号按钮的逻辑更简单:

fun onEquals(view: View) { val currentValue = binding.tvDisplay.text.toString().toDoubleOrNull() ?: return val first = firstNumber ?: return val op = pendingOperator ?: return val result = calculate(first, currentValue, op) binding.tvDisplay.text = formatResult(result) firstNumber = null pendingOperator = null isNewInput = true }

等号算完之后,要记得把状态清空,否则用户接着按运算符时,可能会拿到一个过期的firstNumber,导致计算错误。

3.4 浮点误差处理和显示格式化

计算逻辑虽然直接返回Double,但如果你天真地把结果直接显示出来,一定会遇到类似0.1 + 0.2 = 0.30000000000000004的尴尬。这并不是Android的问题,而是所有采用二进制浮点数的编程语言都会遇到的精度问题。简单的计算器项目不需要引入高精度BigDecimal,但显示格式化是必须做的。

calculate方法里有一个formatResult函数来收尾:

private fun calculate(a: Double, b: Double, op: String): Double { return when (op) { "+" -> a + b "-" -> a - b "×" -> a * b "÷" -> { if (b == 0.0) { return Double.NaN } a / b } else -> 0.0 } } private fun formatResult(value: Double): String { if (value.isNaN()) { return "错误" } return if (value == value.toLong().toDouble()) { value.toLong().toString() } else { String.format("%.8f", value).trimEnd('0').trimEnd('.') } }

格式化时先判断结果是否为整数。如果是整数,就按整数显示,避免出现8.00000000这种难看的结果。如果不是整数,则保留8位小数,并去掉末尾多余的0。这样一个计算器显示效果就接近系统计算器了。

除数为零时,我返回了Double.NaN,并在格式化时把它转成“错误”提示。用户看到这个提示,至少知道是自己操作问题,而不是应用崩溃了。

3.5 清空、退格和基础状态复位

清空按钮是最没有技术含量的,但它必须把所有状态全部复位,一个都不能漏:

fun onClear(view: View) { binding.tvDisplay.text = "0" firstNumber = null pendingOperator = null isNewInput = true }

退格按钮稍微讲究一点。它要做的是去掉当前显示内容的最后一个字符,但如果显示的是0,或者正准备输入新数字,那就什么都不做。退格还有一个容易被忽略的情况:如果删除后数字变成空字符串,要把它恢复成0,不能留着空框。

fun onBackspace(view: View) { if (isNewInput) return var text = binding.tvDisplay.text.toString() if (text.length > 1) { text = text.dropLast(1) } else { text = "0" } binding.tvDisplay.text = text }

我还想在处理退格时加上一个“防止-5的负号被误删”的细节。因为我没有实现正负号按钮,所以在简单版本里这个情况不会出现。如果你后面加了正负号切换,再写退格逻辑时就要判断删除的是负号还是数字。

3.6 连续运算的完整流程演示

我们把上面的逻辑串起来,跑一遍12 + 5 × 3 =这种连续输入,看看状态是怎么流转的。

用户输入 12,显示 12。 按下+:firstNumber=12,pendingOperator="+",isNewInput=true。 输入 5,显示 5。 按下×:因为firstNumber不为空,先计算12 + 5 = 17,把结果显示为 17,然后firstNumber=17,pendingOperator="×",isNewInput=true。 输入 3,显示 3。 按下=:计算17 × 3 = 51,显示 51,清空firstNumber和pendingOperator。

注意,这个流程其实不遵守数学里的“先乘除后加减”,12 + 5 × 3的结果会按顺序算成 51,而不是标准数学的 27。很多新手拿到这个程序后会困惑,为什么跟学校学的不一样。原因是“逐步计算”本身就没有做优先级处理,它忠实还原的是普通四则计算器的“从左到右”操作习惯。如果你希望支持运算优先级,就得引入表达式解析,或者把×和÷的优先级单独处理。这类扩展我在后面会讲到。

4. 调试记录与常见故障排查

4.1 按钮点击没反应

这是我见过新手最容易遇到的问题。界面上按钮清清楚楚,点击之后却毫无动静。原因通常有三个:一是按钮的onClick方法没写好,二是布局里id跟代码里的不一致,三是ViewBinding没有绑定到当前布局。

我的排查顺序是:先打开MainActivity,看setContentView和binding是否对应同一份布局文件。再检查按钮的id是否在XML里唯一,有没有复制粘贴后忘了改。最后看fun onDigit(view: View)这种方法的签名是不是(View)类型,漏掉参数的话,按钮点击事件压根调不到这个方法。

如果你不想在XML里写android:onClick="onDigit",也可以给每个按钮设置setOnClickListener。但我推荐使用XML标签方式,代码里方法名清晰,查找方便,也不会因为动态改名导致绑定失效。

4.2 计算结果总是差一位或变成零

结果不对是计算器开发里最常见的逻辑问题。比如输入3 + 5,结果却显示5;或者2 × 3,结果显示成0。

出现这种情况,十有八九是firstNumber的赋值时机错了。你可以回顾一下:按下运算符时,你拿到的是显示框的文字,还是上次计算后残留的某个值?调试时我习惯在关键节点加上一行日志:

Log.d("CalculatorDebug", "firstNumber=$firstNumber, op=$pendingOperator, display=${binding.tvDisplay.text}")

然后在模拟器里按几个数字,在Logcat里看状态变化。这个方法比猜代码快得多。我自己的经验是:把点击运算符时的状态、点击等号时的状态都打出来,几乎一眼就能找到是谁没更新、谁没归零。

还要注意一点:TextView的text是CharSequence类型,直接转String时要用toString(),否则可能拿到包含其他内容的显示对象,导致toDouble解析异常。

4.3 布局在不同尺寸下错位

模拟器上好好的界面,换到真机就按钮挤成一团或间距过大。原因基本都和布局宽度有关。优先检查是不是用了match_parent让按钮填满整行,然后没给其他按钮留出空间。或者是直接给按钮设置了固定宽度,比如100dp,在窄屏上自然放不下5列。

解决方案其实在上面已经提过:所有一行里的按钮都设为layout_width="0dp",并且用layout_weight分配比例。这样无论屏幕多宽,按钮都会均分。如果出现按钮文字被截断,把按钮的layout_width从0dp改成wrap_content但保留权重,也是一种思路,但不建议。

还有一种隐蔽情况:某个按钮的layout_height是wrap_content,其他按钮是60dp,结果它们不对齐。最简单的办法是同一行所有按钮的高度写死成同一值,比如60dp,保持一致。

4.4 真机调试设置备忘

使用模拟器调试时可以直接运行,但真机调试偶尔会遇到识别不了设备的情况。我遇到过的原因主要是:手机没打开开发者选项,或者USB连接模式被选成了“仅充电”。解决办法是打开设置里的“开发者选项”,开启“USB调试”,然后把USB连接方式改成“传输文件”或“PTP”模式。

如果插上电脑还是没有识别,可以试试更换数据线。USB口的充电线经常只走电不传数据,这类问题不是代码问题,却会卡掉你一下午。

4.5 常见问题速查表

现象可能原因排查和解决
按钮点了没反应id不一致 / onClick方法签名不对 / 绑定布局错误检查XML和Kotlin代码里的id,确认方法带View参数
计算结果错乱firstNumber或operator状态残留在等号后清空两个状态,补Log查看流转
加法结果为小数后一堆0没有做格式化用formatResult统一处理整数和小数显示
除数为0崩溃没有判断除数在calculate里对0处理,返回特殊值
连续运算不生效按运算符时没有先算上一次结果按运算符时若已有firstNumber,先计算并覆盖
布局错位固定宽度 / 权重丢失统一用0dp+weight,高度设为固定dp

5. 扩展方向与进阶优化建议

5.1 加入乘除优先级

如果你做完基础版觉得不过瘾,最值得做的扩展就是让计算器能按数学规则先算乘除,再算加减。这个改造说难不难,思路有几种,我推荐写一个简单的表达式解析器。

思路可以这样:把输入拆成数字和运算符,先扫描一遍所有×和÷,对相邻数字先做计算,再把结果替换回原表达式。例如3 + 5 × 2,先算5 × 2 = 10,表达式变成3 + 10,最后从左到右相加得到 13。这种算法叫“调度场算法”的简化版,代码量大约三四十行,却能大幅提升计算器的实用性。

5.2 历史记录功能

历史记录是很多人喜欢的扩展方向。实现方式也很直接:用一个RecyclerView显示之前计算过的表达式和结果,每次点击等号时,把“表达式=结果”字符串存到一个ArrayList里,再刷新列表。如果需要持久化,可以选Room数据库或者简单的SharedPreferences。我建议第一版先用SharedPreferences存JSON字符串,功能稳定后再考虑数据库。

5.3 简单科学计算器

给计算器增加科学函数也没有想象中复杂,因为系统自带的数学库已经提供了几乎所有函数。比如平方根用Math.sqrt,三角函数用Math.sin、Math.cos,对数用Math.log。关键是界面上要增加一列函数按钮,同时要让计算逻辑支持“函数优先”的处理方式。一个常见的做法是:按函数按钮时,把当前显示值直接传入函数,结果覆盖当前显示,不需要改变firstNumber和pendingOperator的状态。

5.4 发布前可以做的自检清单

项目做完了,想装到手机上或者发到应用市场,建议做一轮基础自检。我这里列一个自己固定的检查项:

  • 布局在不同尺寸的模拟器和真机上都跑一遍,确认没有重叠或按钮消失
  • 测试用例跑一遍:0+0、1÷0、0.1+0.2、999999999+1、连续按多个运算符
  • 检查退格是否能把显示归零
  • 检查横竖屏切换时状态是否保留,如果发现状态丢了,用onSaveInstanceState保存第一操作数和运算符
  • 最后看一眼打包体积,正式包要打开混淆和资源压缩,这样APK体积能小不少

5.5 精简依赖和资源细节

最后再分享一个很多人不注意的小点:新建项目默认会带一些用不上的资源,比如导航菜单、主题里多余的样式。你这个计算器项目用不到这些,可以删掉无关的XML文件和drawable,减少无意义的代码量。同时建议把主题从继承默认主题改成继承Theme.MaterialComponents.DayNight.DarkActionBar或Theme.AppCompat.DayNight,这样应用能自适应深色模式,用户手机开了深色主题,计算器不会一亮一暗地闪眼。

6. 实际体验中的几个提醒

代码写完只是第一步,真正把它用起来,你才会发现一系列和“逻辑是否对”无关,但同样影响体验的细节。

按键触控反馈是我比较早发现的一个问题。默认的按钮点击时没有震动,也没有明显的按下状态,用户容易怀疑自己没点中。解决方式有两种:一是给按钮设置stateListAnimator,让它在按下时改变透明度或背景色;二是在点击事件里调用view.performHapticFeedback触发轻微震动。我用的是第二种方式,代价是需要在真机上看一下震动强度,别太生硬就行。

还有一个细节是显示区域的TextView长度。当用户输入一个很大的数字,显示框可能放不下。简单做法是动态调整字体大小,比如文本超过12个字符时把字号降到24sp,超过20个字符时降到18sp。我在原版代码里没有做这个,实际测试输入12345678901234567890时,显示直接溢出边框,观感很差。后来加了一个adjustTextSize的小方法,只在.和数字输入时调用,成本很低,效果明显。

再就是关于代码组织。我见过很多人把一个Activity里塞几百行逻辑,计算器项目小,看起来问题不大,但一旦你要加历史记录、科学函数,这个文件就会膨胀到难以维护。建议把“计算逻辑”单独抽一个CalculatorEngine类,界面只负责接收点击和刷新显示。这样以后加单元测试也方便,直接对CalculatorEngine写测试,不用在模拟器里手动点按钮验证。

我个人实际写这个项目最大的体会是:计算器看起来简单,但它几乎是“状态管理”的最佳教学案例。三个变量的状态流转、运算符和数字的交互顺序、连续运算的边界情况,覆盖了Android开发里大部分基础而又核心的概念。如果你正在学AS,认真把一个简单计算器做透,比急着堆砌五六个半成品Demo要有用得多。下一步可以试着用同样的状态机思路去做一个BMI计算器或者购物车结算页面,你会发现很多概念都是相通的。

把状态变量命名清楚,把每次点击时的变化捋顺,把异常情况兜住,这个计算器就不只是“能算”,而是真正“好用”了。

RELATED READING

延伸阅读

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