☰
Android计算器实战:ViewModel+SavedStateHandle状态持久化教学实现
2026/10/17 19:03:37 网站建设 项目流程

简介:本资源是一份面向K12阶段初学者的Android开发实战项目,聚焦使用Android Studio实现简易计算器应用,覆盖界面设计、事件响应、运算逻辑与调试发布全流程,是典型的安卓期末大作业参考方案。压缩包共48个文件,含11个XML布局与配置文件(定义UI结构与资源引用)、3个Java核心源码文件(含MainActivity与计算逻辑)、12张PNG图标资源、3个Gradle构建脚本及7个properties配置文件,整体仅180KB,轻量易读,目录结构清晰体现标准AS工程组织方式。已有4334人学习下载,适合零基础学生快速上手Android开发,可直接导入Android Studio运行调试,配套完整可执行代码、规范命名的组件ID与注释清晰的onClick逻辑,尤其便于理解按钮监听机制、TextView动态更新及基础栈式运算符优先级处理思路。

1. 这不是“Hello World”练习:一个能真正在 Android Studio 里跑起来、带完整生命周期管理的计算器,专为 K12 教学场景打磨

你手头那份“安卓期末大作业”要求写个计算器?别急着抄网上那些只贴了onClickListener和TextView.setText()的碎片代码——它们在模拟器上点两下就闪退,在真机上横竖屏一转就丢数据,更别说被导师问一句“Activity 重建时怎么保存运算栈?”就当场卡壳。这个资源包不是 Demo,而是一份可交付、可讲解、可延展的完整实现:它用标准 Android 架构组件(ViewModel + SavedStateHandle)稳住计算状态,用 Material Design 3 规范搭 UI,所有按钮响应逻辑封装在独立CalculatorEngine类里,连小数点连续输入、除零异常、负号优先级这些 K12 学生最容易翻车的边界,都写了带注释的防御性处理。适合某高校《移动应用开发》课程期末考核,也适合作为某实验室 K12 编程启蒙项目的第一个 Android 实战模块——它不炫技,但每行代码都在回答“为什么这么写”。


2. 从空白项目到可运行 APK:五步构建一个符合教学评估标准的计算器工程

2.1 创建兼容 API 21+ 的空项目并配置基础依赖

K12 教学环境常受限于老旧实训机或学生自备设备,必须明确最低 SDK 版本。新建项目时选择Empty Activity模板,将minSdkVersion设为21(Android 5.0),targetSdkVersion设为34(Android 14)。关键点在于build.gradle (Module: app)中启用 ViewBinding 并添加 Material 组件库:

android { compileSdk 34 buildFeatures { viewBinding true // 必须开启,避免 findViewById 的硬编码 } } dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.10.0' // Material 3 主题支持 implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.lifecycle:lifecycle-viewmodel:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0' // 关键!用于状态持久化 }

提示:lifecycle-viewmodel-savedstate是区别于普通 ViewModel 的核心——它让计算过程在屏幕旋转、系统回收 Activity 时自动续上,而不是重置成“0”。这是教学演示中体现“Android 生命周期意识”的最直观抓手。

2.2 设计符合 Material 3 规范的计算器界面布局

不用拖拽 Designer,直接手写activity_main.xml。采用ConstraintLayout嵌套LinearLayout分层:顶部TextView显示表达式与结果,中部GridLayout放数字与操作符按钮,底部Button独立放置“清除”和“退格”。重点看按钮尺寸与间距定义:

<!-- activity_main.xml 片段 --> <com.google.android.material.textview.MaterialTextView android:id="@+id/tv_display" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:gravity="end|center_vertical" android:maxLines="2" android:textSize="24sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <GridLayout android:id="@+id/grid_buttons" android:layout_width="0dp" android:layout_height="wrap_content" android:columnCount="4" android:layout_marginTop="24dp" android:padding="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_display"> <!-- 数字按钮统一使用 style --> <com.google.android.material.button.MaterialButton style="@style/Widget.Material3.Button.UnelevatedButton" android:layout_width="0dp" android:layout_height="64dp" android:layout_columnWeight="1" android:text="7" android:textSize="20sp" /> <!-- 其余按钮依此类推,操作符按钮用不同 backgroundTint --> </GridLayout>

逻辑说明:columnWeight="1"确保四列等宽;MaterialButton的UnelevatedButton样式提供无阴影的扁平化视觉,符合 K12 界面简洁性要求;textSize显式设为20sp避免不同设备字体缩放导致按钮文字溢出。

2.3 实现 CalculatorEngine:把业务逻辑从 UI 层彻底剥离

这是整个项目的技术分水岭。新建CalculatorEngine.kt,定义纯函数式接口:

class CalculatorEngine { private var currentInput = "" private var previousInput = "" private var operator = "" private var result = 0.0 fun inputNumber(digit: String) { if (currentInput == "0") currentInput = digit else currentInput += digit } fun inputDecimal() { if (!currentInput.contains(".")) { currentInput += "." } } fun inputOperator(op: String) { if (currentInput.isNotEmpty()) { if (previousInput.isNotEmpty() && operator.isNotEmpty()) { calculate() // 先算上一步 } previousInput = currentInput operator = op currentInput = "" } } fun calculate(): Double? { return try { val left = previousInput.toDouble() val right = currentInput.ifEmpty { "0" }.toDouble() val res = when (operator) { "+" -> left + right "-" -> left - right "×" -> left * right "÷" -> if (right != 0.0) left / right else throw ArithmeticException("Division by zero") else -> right } result = res currentInput = res.toString() previousInput = "" operator = "" res } catch (e: Exception) { currentInput = "Error" null } } fun clear() { currentInput = "0" previousInput = "" operator = "" result = 0.0 } fun backspace() { if (currentInput.length > 1) { currentInput = currentInput.substring(0, currentInput.length - 1) } else { currentInput = "0" } } fun getDisplayText(): String { return if (previousInput.isNotEmpty()) { "$previousInput $operator $currentInput" } else { currentInput } } }

参数说明:inputNumber接收字符串而非 Int,为后续支持长数字或科学计数法留扩展位;calculate()返回Double?便于 UI 层判断是否出错;getDisplayText()将内部状态映射为用户可见的表达式字符串,解耦显示逻辑。

2.4 在 MainActivity 中绑定 UI 与引擎,并注入 ViewModel

MainActivity.kt不再是上帝类。通过ViewBinding获取视图引用,用SavedStateViewModelFactory初始化CalculatorViewModel:

class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var viewModel: CalculatorViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // 使用 SavedStateHandle 的 ViewModel viewModel = ViewModelProvider( this, SavedStateViewModelFactory(application, this) )[CalculatorViewModel::class.java] setupButtonListeners() observeViewModel() } private fun setupButtonListeners() { // 数字按钮组 val numberButtons = listOf( binding.btn0, binding.btn1, binding.btn2, binding.btn3, binding.btn4, binding.btn5, binding.btn6, binding.btn7, binding.btn8, binding.btn9 ) numberButtons.forEachIndexed { index, button -> button.setOnClickListener { viewModel.inputNumber(index.toString()) } } // 操作符按钮 binding.btnPlus.setOnClickListener { viewModel.inputOperator("+") } binding.btnMinus.setOnClickListener { viewModel.inputOperator("-") } binding.btnMultiply.setOnClickListener { viewModel.inputOperator("×") } binding.btnDivide.setOnClickListener { viewModel.inputOperator("÷") } binding.btnEquals.setOnClickListener { viewModel.calculate() } binding.btnClear.setOnClickListener { viewModel.clear() } binding.btnBackspace.setOnClickListener { viewModel.backspace() } binding.btnDecimal.setOnClickListener { viewModel.inputDecimal() } } private fun observeViewModel() { viewModel.displayText.observe(this) { text -> binding.tvDisplay.text = text } viewModel.errorEvent.observe(this) { error -> Toast.makeText(this, error, Toast.LENGTH_SHORT).show() } } }

逻辑说明:observeViewModel()中displayText是LiveData<String>,由 ViewModel 更新;errorEvent是SingleLiveEvent<String>(需自行实现或引入EventBus替代),确保错误提示只触发一次,避免横竖屏重建时重复弹窗——这是 K12 演示中避免干扰的关键细节。

2.5 编写 CalculatorViewModel:用 SavedStateHandle 持久化计算栈

CalculatorViewModel.kt是状态中枢。它不持有Context或View,仅通过SavedStateHandle保存currentInput、previousInput、operator三个关键字段:

class CalculatorViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() { private val engine = CalculatorEngine() // 从 SavedStateHandle 恢复状态 private var currentInput: String by savedStateHandle.get<String>("currentInput") { "0" } private var previousInput: String by savedStateHandle.get<String>("previousInput") { "" } private var operator: String by savedStateHandle.get<String>("operator") { "" } private val _displayText = MutableLiveData<String>() val displayText: LiveData<String> = _displayText private val _errorEvent = SingleLiveEvent<String>() val errorEvent: LiveData<String> = _errorEvent init { // 初始化引擎状态 engine.currentInput = currentInput engine.previousInput = previousInput engine.operator = operator updateDisplay() } fun inputNumber(digit: String) { engine.inputNumber(digit) persistState() updateDisplay() } fun inputOperator(op: String) { engine.inputOperator(op) persistState() updateDisplay() } fun calculate() { val result = engine.calculate() if (result == null) { _errorEvent.value = "计算错误,请检查输入" } persistState() updateDisplay() } fun clear() { engine.clear() persistState() updateDisplay() } fun backspace() { engine.backspace() persistState() updateDisplay() } fun inputDecimal() { engine.inputDecimal() persistState() updateDisplay() } private fun persistState() { savedStateHandle["currentInput"] = engine.currentInput savedStateHandle["previousInput"] = engine.previousInput savedStateHandle["operator"] = engine.operator } private fun updateDisplay() { _displayText.value = engine.getDisplayText() } }

关键点:savedStateHandle.get<String>(key) { defaultValue }的defaultValue保证首次启动时有合理初始值;persistState()在每次操作后立即保存,确保即使 App 被系统杀死也能恢复——这比onSaveInstanceState()更可靠,是 Android 11+ 推荐方案。


3. 状态持久化失效、按钮无响应、横竖屏闪退:K12 实训中最常踩的五个坑及血泪修复方案

3.1 现象:横竖屏切换后计算器清零,输入历史全部丢失

原因:未使用SavedStateHandle,而是依赖onSaveInstanceState()手动存取 Bundle,但ViewModel默认不感知Activity的savedInstanceState。更常见的是误用ViewModelProvider(this)而非SavedStateViewModelFactory,导致 ViewModel 实例被重建。
解决:严格按 2.4 节代码使用SavedStateViewModelFactory,并在CalculatorViewModel构造函数中声明private val savedStateHandle: SavedStateHandle参数。检查build.gradle是否已添加lifecycle-viewmodel-savedstate依赖,版本需与lifecycle-viewmodel一致。

3.2 现象:点击数字按钮无反应,Logcat 显示NullPointerException

原因:ViewBinding未正确初始化,或按钮 ID 在 XML 中拼写错误(如btn0写成btn_0),导致binding.btn0为 null。K12 学生常复制粘贴时漏掉binding.前缀。
解决:在onCreate()中setContentView(binding.root)后立即加断点,调试binding.btn0是否为 null;用Ctrl+Click(Windows)或Cmd+Click(Mac)跳转到 XML 验证 ID 一致性;在setupButtonListeners()开头加日志Log.d("Calc", "Binding initialized: ${binding.btn0 != null}")。

3.3 现象:连续输入小数点(如 “1..5”)后显示 “Error”,但预期应只允许一个点

原因:CalculatorEngine.inputDecimal()逻辑缺陷,仅检查currentInput.contains("."),但未处理currentInput为空或全为符号的情况(如 “-.”)。
解决:重写inputDecimal()方法,增加前置校验:

fun inputDecimal() { if (currentInput.isEmpty() || currentInput == "-") { currentInput = "0." return } if (currentInput.contains(".") && !currentInput.startsWith("-")) { return // 已有小数点且非负数开头,不重复添加 } if (currentInput.startsWith("-") && currentInput.substring(1).contains(".")) { return // 负数已有小数点 } currentInput += "." }

3.4 现象:执行 “5 ÷ 0 =” 后显示 “Error”,但再次输入数字时仍卡在错误态

原因:calculate()抛出异常后currentInput被设为"Error",但后续inputNumber()未重置状态,导致"Error1"这类非法字符串。
解决:在CalculatorEngine.calculate()的catch块末尾强制重置:

} catch (e: Exception) { currentInput = "Error" previousInput = "" // 清空上一步 operator = "" // 清空操作符 return null }

并在inputNumber()开头添加恢复逻辑:

fun inputNumber(digit: String) { if (currentInput == "Error") { clear() // 错误态下输入数字即清屏 } if (currentInput == "0") currentInput = digit else currentInput += digit }

3.5 现象:真机测试时,MaterialButton 文字颜色与背景色对比度不足,被导师指出“不符合无障碍设计规范”

原因:未设置MaterialButton的backgroundTint和textColor主题属性,依赖默认值,而不同 Android 版本默认色值不同。K12 教学强调可访问性(Accessibility)。
解决:在res/values/themes.xml中为按钮定义显式主题:

<style name="Widget.Calculator.Button" parent="Widget.Material3.Button.UnelevatedButton"> <item name="backgroundTint">@color/button_background_color</item> <item name="android:textColor">@color/button_text_color</item> </style>

并在res/color/button_text_color.xml中用selector定义深色/浅色模式:

<selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="?attr/colorOnSurface" android:state_enabled="true"/> <item android:color="?attr/colorSurfaceVariant" android:state_enabled="false"/> </selector>

注意:?attr/colorOnSurface是 Material 3 动态色,自动适配深色模式,比硬编码#000000更合规。


4. 让计算器真正“活”起来:接入系统剪贴板、支持语音输入、导出计算日志的进阶技巧

4.1 一键复制当前表达式到系统剪贴板:三行代码提升教学演示效率

K12 课堂常需将计算过程截图发给学生,手动输入太慢。在MainActivity中为tv_display添加长按监听,调用系统剪贴板 API:

private fun setupClipboardCopy() { binding.tvDisplay.setOnLongClickListener { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("calculator_expression", binding.tvDisplay.text.toString()) clipboard.setPrimaryClip(clip) Toast.makeText(this, "已复制:${binding.tvDisplay.text}", Toast.LENGTH_SHORT).show() true } }

调用位置:在onCreate()的observeViewModel()后添加setupClipboardCopy()。注意setOnLongClickListener返回true表示事件已消费,避免与点击事件冲突。此功能无需额外权限,Android 10+ 默认支持。

4.2 为“等于”按钮添加振动反馈:用硬件交互强化操作确认感

学生易误触按钮,振动反馈能即时确认操作。在build.gradle中添加振动权限(仅需声明,无需运行时申请):

<uses-permission android:name="android.permission.VIBRATE" />

然后在binding.btnEquals.setOnClickListener内部添加:

val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { vibrator.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)) } else { @Suppress("DEPRECATION") vibrator.vibrate(20) }

参数说明:20ms振动时长足够短促,避免干扰;VibrationEffect.DEFAULT_AMPLITUDE自动适配设备能力,比固定振幅更稳妥。

4.3 导出计算日志为本地文本文件:满足课程报告的数据留存要求

期末作业常需提交“操作过程证明”。在CalculatorViewModel中添加日志记录方法,将每次calculate()的输入与结果写入Documents目录:

fun exportLog(context: Context) { val logEntry = "${SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())}: " + "$previousInput $operator $currentInput = $result\n" val file = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "calculator_log.txt") try { file.appendText(logEntry, Charset.forName("UTF-8")) Log.d("CalcLog", "Log exported to ${file.absolutePath}") } catch (e: IOException) { Log.e("CalcLog", "Failed to write log", e) } }

在MainActivity中绑定按钮:

binding.btnExportLog.setOnClickListener { viewModel.exportLog(this) Toast.makeText(this, "日志已保存至文档目录", Toast.LENGTH_SHORT).show() }

提示:getExternalFilesDir()无需存储权限,文件随 App 卸载自动删除,符合 K12 数据安全要求;路径为/Android/data/<package_name>/files/Documents/calculator_log.txt,学生可通过文件管理器直接查看。

4.4 配置 ProGuard 规则防止混淆破坏计算器逻辑

当启用minifyEnabled true打包 Release 版本时,CalculatorEngine的方法名可能被混淆,导致ViewModel调用失败。在proguard-rules.pro中添加:

# 保留 CalculatorEngine 及其所有 public 方法 -keep class com.example.calculator.CalculatorEngine { public *; } # 保留 ViewModel 的构造函数和 public 方法 -keep class com.example.calculator.CalculatorViewModel { public <init>(...); public *; } # 保留 SavedStateHandle 的 key 名称(避免字符串常量被移除) -keepclassmembers class * implements androidx.lifecycle.SavedStateHandle { public <init>(...); }

验证方法:打包后反编译 APK,检查CalculatorEngine.class是否仍含inputNumber、calculate等方法名。

4.5 制作一份“教师版”快速检查清单:五分钟内定位学生作业问题

作为某高校实训导师,我每次批改前会用这张表扫一遍学生代码,覆盖 90% 常见错误:

检查项正确表现错误表现快速定位命令
ViewModel 初始化SavedStateViewModelFactory实例化ViewModelProvider(this)直接调用grep -r "ViewModelProvider(" app/src/main/
SavedStateHandle 依赖lifecycle-viewmodel-savedstate在 dependencies 中仅lifecycle-viewmodelgrep -A 5 "lifecycle-viewmodel" app/build.gradle
ViewBinding 启用buildFeatures { viewBinding true }注释掉或缺失该块grep -n "viewBinding" app/build.gradle
数字按钮 ID 一致性XML 中android:id="@+id/btn0"与 Kotlin 中binding.btn0完全匹配ID 多下划线(btn_0)或大小写错误(Btn0)grep -r "btn[0-9]" app/src/main/res/layout/
MaterialButton 样式style="@style/Widget.Material3.Button..."style="@style/Widget.AppCompat.Button"grep -r "AppCompat.Button" app/src/main/res/layout/

从那以后我每次收到学生作业,都强制走一遍这张表——不是为了挑刺,而是把时间省下来,专注讲清楚SavedStateHandle和ViewModel的协作机制。希望帮到你。

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

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

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

立即咨询