简介:这是一份面向Android初学者与课程设计者的答题类App完整开发项目,基于Android Studio实现限时测试、单选题交互、实时判题反馈、成绩统计及错题本管理等核心功能,适用于移动应用开发实训、期末项目或自学练手。资源包共50个文件,包含12个Java业务逻辑代码、14个XML界面布局与资源定义、10个PNG图标素材、3个Gradle构建配置文件,以及APK安装包、MP4演示视频、DOCX说明文档等交付物,整体8.81MB,结构清晰便于逐模块学习源码与运行调试。已有5867人学习下载,提供从环境搭建、代码解读到真机安装的全流程支持,配套运行文档详述依赖配置与启动步骤,演示视频直观展示答题流程与结果反馈机制,特别适合理解Activity生命周期、Fragment切换、SharedPreferences本地存储等Android关键知识点。
1. 一个能真正在手机上跑起来的答题App:不是Demo,是能交作业、能测逻辑、能上线调试的完整Android Studio工程
你手头刚配好Android Studio,新建项目选了Empty Activity,写了两行TextView显示“欢迎答题”,然后点Run——模拟器弹出来,黑屏三秒后报错ActivityNotFoundException;或者更常见的是:代码编译通过,APK生成成功,但安装到真机后点图标直接闪退,Logcat里只有一行FATAL EXCEPTION: main,连堆栈都没打全。这不是你写错了逻辑,而是从项目结构、Activity生命周期绑定、资源路径引用,到Manifest声明、权限配置、甚至build.gradle里的minSdkVersion,每一步都卡在“能编译”和“能运行”之间那层薄冰上。这篇笔记不讲“Android开发入门”,只聚焦一件事:用Android Studio从零搭起一个真实可用的答题App——它有题干展示、单选/多选交互、答案提交、得分反馈、本地题库加载(JSON格式)、离线可用,且所有代码能在Android 8.0~14真机上稳定启动、无白屏、无闪退、无资源找不到错误。适合刚学完Java/Kotlin基础、正卡在“写完代码却跑不起来”阶段的开发者,也适合需要快速交付教学实训项目的带教老师。我们不碰网络请求、不接后台、不搞花哨动画,就死磕本地答题流程闭环——因为这才是验证你是否真正理解Android组件协作关系的试金石。
2. 从空项目到可运行Activity:四步构建答题App骨架
2.1 创建最小可行项目:选对模板比写代码更重要
Android Studio 2023.1+ 新建项目时,绝对不要选“Empty Activity”——它默认启用ViewBinding,但未在Activity中初始化,导致binding = ActivityMainBinding.inflate(getLayoutInflater())这行一加就崩溃(NullPointerException)。正确做法是:
# 在 Android Studio 中: # 1. File → New → New Project # 2. 选择 "Basic Activity"(不是 Empty!) # 3. Package name 填 com.example.quizapp(务必小写,不能含下划线) # 4. Minimum SDK 选 API 21 (Android 5.0) —— 覆盖95%国内机型 # 5. Language 选 Kotlin(推荐)或 Java(本笔记双语注释) # 6. 点击 Finish,等待 Gradle 同步完成(约1-2分钟)为什么选 Basic Activity?
它自动生成activity_main.xml(含AppBarLayout+CoordinatorLayout)、MainActivity.kt(已预置onCreate()中setContentView(R.layout.activity_main))、以及strings.xml占位符。最关键的是:它默认关闭ViewBinding,避免新手因绑定时机错乱引发的IllegalStateException。等骨架跑通后,再手动启用ViewBinding——这才是可控的演进路径。
2.2 替换默认布局:用ConstraintLayout实现答题页响应式结构
打开app/src/main/res/layout/activity_main.xml,删掉全部内容,粘贴以下结构(已适配横竖屏、支持大字体模式):
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="16dp"> <!-- 题干显示区 --> <TextView android:id="@+id/tvQuestion" android:layout_width="0dp" android:layout_height="wrap_content" android:text="Q1. 下列哪个是合法的Java标识符?" android:textSize="18sp" android:textStyle="bold" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintVertical_chainStyle="packed" /> <!-- 选项容器(垂直链) --> <RadioGroup android:id="@+id/radioGroup" android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintTop_toBottomOf="@id/tvQuestion" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintVertical_bias="0.0"> <RadioButton android:id="@+id/rbOptionA" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="A. _class" android:layout_marginTop="8dp" /> <RadioButton android:id="@+id/rbOptionB" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="B. 123abc" android:layout_marginTop="4dp" /> <RadioButton android:id="@+id/rbOptionC" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="C. class" android:layout_marginTop="4dp" /> <RadioButton android:id="@+id/rbOptionD" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="D. my-class" android:layout_marginTop="4dp" /> </RadioGroup> <!-- 提交按钮 --> <Button android:id="@+id/btnSubmit" android:layout_width="0dp" android:layout_height="wrap_content" android:text="提交答案" android:layout_marginTop="24dp" app:layout_constraintTop_toBottomOf="@id/radioGroup" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>关键参数说明:
app:layout_constraintVertical_chainStyle="packed":让题干与选项组紧贴,避免顶部大片空白;android:layout_marginTop="8dp"/"4dp":选项间留呼吸感,符合Material Design间距规范;android:textSize="18sp":用sp而非dp,确保用户调大系统字体时题干可读;- 所有控件ID命名严格遵循
tvXxx(TextView)、rbXxx(RadioButton)、btnXxx(Button)前缀,为后续Kotlin代码绑定打基础。
2.3 编写核心Activity逻辑:用Kotlin处理单选提交与状态保存
打开app/src/main/java/com/example/quizapp/MainActivity.kt,替换全部内容为以下代码(含详细注释):
package com.example.quizapp import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.widget.Button import android.widget.RadioButton import android.widget.RadioGroup import android.widget.TextView import android.widget.Toast class MainActivity : AppCompatActivity() { private lateinit var tvQuestion: TextView private lateinit var radioGroup: RadioGroup private lateinit var btnSubmit: Button private var currentQuestionIndex = 0 // 当前题目序号(0开始) private var score = 0 // 累计得分 // 模拟本地题库:题干 + 正确答案索引(0=A,1=B...) private val quizData = listOf( QuizItem("Q1. 下列哪个是合法的Java标识符?", 0), QuizItem("Q2. Java中String类位于哪个包?", 2), QuizItem("Q3. 下列哪个关键字用于定义接口?", 3) ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // 1. 绑定XML中定义的控件(必须在setContentView之后!) tvQuestion = findViewById(R.id.tvQuestion) radioGroup = findViewById(R.id.radioGroup) btnSubmit = findViewById(R.id.btnSubmit) // 2. 加载第一题 loadQuestion() // 3. 设置提交按钮点击事件 btnSubmit.setOnClickListener { // 获取当前选中的RadioButton ID val selectedId = radioGroup.checkedRadioButtonId if (selectedId == -1) { Toast.makeText(this, "请先选择一个答案!", Toast.LENGTH_SHORT).show() return@setOnClickListener } // 根据ID反查是第几个选项(A=0, B=1...) val optionIndex = when (selectedId) { R.id.rbOptionA -> 0 R.id.rbOptionB -> 1 R.id.rbOptionC -> 2 R.id.rbOptionD -> 3 else -> -1 } // 判断是否答对 val isCorrect = optionIndex == quizData[currentQuestionIndex].correctAnswerIndex if (isCorrect) { score++ Toast.makeText(this, "答对了!+1分", Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, "答错了,正确答案是${'A' + quizData[currentQuestionIndex].correctAnswerIndex}", Toast.LENGTH_SHORT).show() } // 4. 加载下一题(或结束) currentQuestionIndex++ if (currentQuestionIndex < quizData.size) { loadQuestion() } else { showResult() } } } private fun loadQuestion() { // 重置RadioGroup选中状态 radioGroup.clearCheck() // 显示当前题干 tvQuestion.text = quizData[currentQuestionIndex].question } private fun showResult() { Toast.makeText( this, "测试结束!你的得分:$score / ${quizData.size}", Toast.LENGTH_LONG ).show() // 此处可跳转到结果页Activity,本例简化为Toast } // 内部数据类:封装题目信息 data class QuizItem( val question: String, val correctAnswerIndex: Int // 0=A, 1=B, 2=C, 3=D ) }逻辑拆解:
findViewById()必须在setContentView()之后调用,否则返回null——这是新手最常翻车点;radioGroup.clearCheck()在每次加载新题前清空选中状态,避免上一题残留;Toast提示用LENGTH_SHORT(2秒)而非LENGTH_LONG(3.5秒),防止用户误操作;showResult()中未跳转新Activity,因本阶段目标是验证主流程,避免引入额外复杂度。
2.4 验证运行:真机调试比模拟器更可靠
在手机上启用开发者选项(连续点击“关于手机→版本号”7次),打开USB调试,用USB线连接电脑。Android Studio中:
- 点击工具栏 ▶️(Run)按钮旁的下拉箭头 → 选择你的设备型号(如
SM-G9730); - 点击 ▶️ 运行,观察Gradle构建日志末尾是否出现:
BUILD SUCCESSFUL in 12s 34 actionable tasks: 34 executed - 手机自动安装APK并启动,看到题干和四个选项即成功。
玄学经验:若首次运行失败,先在手机设置中找到“应用管理→QuizApp→权限”,手动开启“存储”权限(虽本例未用存储,但部分Android 11+机型会误判)。若仍失败,在Android Studio底部点击Logcat标签页,筛选
com.example.quizapp,看是否有Caused by:开头的异常——90%问题在此定位。
3. 让答题App真正可用:集成本地JSON题库与状态持久化
3.1 将题库从硬编码迁移到assets目录下的JSON文件
硬编码题库无法扩展,且修改需重新编译。正确做法是将题库存为JSON,放在assets/目录下,运行时动态加载。
步骤:
- 在
app/src/main/下新建文件夹assets(右键 → New → Folder → Assets Folder); - 在
assets/内创建文件quiz.json,内容如下(UTF-8编码):
[ { "question": "Q1. 下列哪个是合法的Java标识符?", "options": ["_class", "123abc", "class", "my-class"], "correctAnswer": 0 }, { "question": "Q2. Java中String类位于哪个包?", "options": ["java.lang", "java.util", "java.io", "java.net"], "correctAnswer": 0 }, { "question": "Q3. 下列哪个关键字用于定义接口?", "options": ["abstract", "final", "interface", "implements"], "correctAnswer": 2 } ]注意:JSON中correctAnswer值为数字(0~3),对应选项数组索引,非字母。
3.2 编写JSON解析工具类:用Gson库安全反序列化
在app/build.gradle的dependencies块中添加Gson依赖:
dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.code.gson:gson:2.10.1' // 添加这一行 // ... 其他依赖保持不变 }同步Gradle(点击右上角 “Sync Now”)。然后在com.example.quizapp包下新建data/QuizRepository.kt:
package com.example.quizapp.data import android.content.Context import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.io.InputStreamReader data class QuizItem( val question: String, val options: List<String>, val correctAnswer: Int ) class QuizRepository(private val context: Context) { fun loadQuizData(): List<QuizItem> { return try { // 从 assets/quiz.json 读取原始流 val inputStream = context.assets.open("quiz.json") val reader = InputStreamReader(inputStream, "UTF-8") // 使用 Gson 解析为 List<QuizItem> val type = object : TypeToken<List<QuizItem>>() {}.type Gson().fromJson(reader, type) } catch (e: Exception) { // 关键容错:JSON解析失败时返回空列表,避免App崩溃 e.printStackTrace() emptyList() } } }为什么用TypeToken?
Gson无法直接解析泛型List<T>,TypeToken是官方推荐的绕过类型擦除方案。若不用它,fromJson()会返回List<Map<String, Any>>,需手动转换,极易出错。
3.3 改造MainActivity:注入QuizRepository并动态渲染选项
修改MainActivity.kt,删除原quizData硬编码,注入QuizRepository:
// 在 MainActivity 类顶部添加: private lateinit var quizRepository: QuizRepository private lateinit var quizData: List<QuizItem> // 在 onCreate() 开头添加(setContentView之前): quizRepository = QuizRepository(this) quizData = quizRepository.loadQuizData() if (quizData.isEmpty()) { Toast.makeText(this, "题库加载失败,请检查 assets/quiz.json", Toast.LENGTH_LONG).show() return // 防止空题库导致后续空指针 } // 替换 loadQuestion() 方法为: private fun loadQuestion() { radioGroup.clearCheck() tvQuestion.text = quizData[currentQuestionIndex].question // 动态设置四个选项文本 val options = quizData[currentQuestionIndex].options findViewById<RadioButton>(R.id.rbOptionA).text = "A. ${options.getOrNull(0) ?: "暂无"}" findViewById<RadioButton>(R.id.rbOptionB).text = "B. ${options.getOrNull(1) ?: "暂无"}" findViewById<RadioButton>(R.id.rbOptionC).text = "C. ${options.getOrNull(2) ?: "暂无"}" findViewById<RadioButton>(R.id.rbOptionD).text = "D. ${options.getOrNull(3) ?: "暂无"}" } // 修改提交逻辑中的判断: val isCorrect = optionIndex == quizData[currentQuestionIndex].correctAnswer关键防护:
getOrNull(0)替代options[0],避免选项不足4个时IndexOutOfBoundsException;Toast提示题库加载失败,而非静默崩溃;return提前退出,防止quizData为空时currentQuestionIndex越界。
3.4 实现答题进度持久化:用SharedPreferences记住当前题号
用户中途退出App,再次打开应从上次位置继续。用SharedPreferences存储currentQuestionIndex:
// 在 MainActivity 类中添加: private val prefs by lazy { getSharedPreferences("quiz_prefs", Context.MODE_PRIVATE) } private val editor by lazy { prefs.edit() } // 在 onCreate() 结尾添加(loadQuestion()之后): currentQuestionIndex = prefs.getInt("current_index", 0) if (currentQuestionIndex >= quizData.size) { currentQuestionIndex = 0 // 题库更新后索引越界则重置 } loadQuestion() // 在 btnSubmit 的点击事件结尾添加(showResult()之前): editor.putInt("current_index", currentQuestionIndex).apply()为什么用apply()不用commit()?apply()异步写入磁盘,不阻塞主线程;commit()同步执行,可能造成UI卡顿。对轻量数据存储,apply()是Google官方推荐。
4. 避坑指南:那些让答题App在真机上集体翻车的5个致命细节
4.1 现象:App安装后图标不显示,桌面找不到入口
原因:AndroidManifest.xml中<activity>标签缺少intent-filter或android:exported="true"(Android 12+强制要求)。
解决:检查app/src/main/AndroidManifest.xml,确保MainActivity声明如下:
<activity android:name=".MainActivity" android:exported="true"> <!-- 必须添加此行 --> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>血泪经验:Android Studio 2022.1+ 新建项目默认添加
android:exported="true",但若你手动修改过Manifest或从旧项目迁移,极易遗漏。真机Android 12+会直接屏蔽无exported的Activity。
4.2 现象:题干文字显示为方块(□□□)或乱码
原因:assets/quiz.json文件编码不是UTF-8,或JSON中中文未转义。
解决:
- 用VS Code或Notepad++打开
quiz.json,另存为 → 编码选UTF-8(无BOM); - 删除JSON中所有
\uXXXX形式的Unicode转义,直接输入中文(Gson支持UTF-8原生中文)。
4.3 现象:点击提交按钮无反应,Logcat无任何日志
原因:Button的android:onClick属性与Activity中方法名不匹配,或方法签名错误(如缺少View参数)。
解决:
- 绝对不要在XML中写
android:onClick="onSubmit"; - 必须用
setOnClickListener{}代码绑定(如2.3节所示),避免反射调用失败的黑匣子问题。
4.4 现象:切换横竖屏后题干消失,或选项错位
原因:Android默认销毁并重建Activity,但currentQuestionIndex等变量未保存。
解决:在MainActivity中重写onSaveInstanceState()和onRestoreInstanceState():
override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt("current_index", currentQuestionIndex) outState.putInt("score", score) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) currentQuestionIndex = savedInstanceState.getInt("current_index", 0) score = savedInstanceState.getInt("score", 0) }提示:此方案比
android:configChanges="orientation|screenSize"更健壮,后者需手动处理布局重绘,易出错。
4.5 现象:APK安装失败,提示“Parse error: There is a problem parsing the package”
原因:build.gradle中applicationId包名含大写字母、下划线或以数字开头(如com.example.QuizApp或com.example.quiz_app)。
解决:
- 严格使用小写字母+点号+数字(如
com.example.quizapp); - 在
app/build.gradle中检查applicationId字段,确保与AndroidManifest.xml的package属性一致; - 清理项目:
Build → Clean Project,再Build → Rebuild Project。
5. 进阶技巧:用ViewBinding替代findViewById,让代码更安全、更简洁
5.1 启用ViewBinding:三步开启零反射绑定
ViewBinding是Android官方推荐的UI绑定方案,比findViewById安全(编译期检查ID)、比ButterKnife轻量(无注解处理器开销)。启用步骤:
- 在
app/build.gradle的android块内添加:
android { // ... 其他配置 buildFeatures { viewBinding true // 添加这一行 } }- 同步Gradle;
- 修改
MainActivity.kt,删除所有findViewById(),改用Binding:
// 替换 MainActivity 类定义: class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding // 新增Binding引用 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) // 替代 setContentView() setContentView(binding.root) // root 是 ConstraintLayout 根视图 // 绑定控件:用 binding.tvQuestion 替代 findViewById<TextView>(R.id.tvQuestion) binding.tvQuestion.text = "Q1. ..." binding.radioGroup.setOnCheckedChangeListener { _, checkedId -> // 处理选项变化 } binding.btnSubmit.setOnClickListener { // 提交逻辑 } } }优势对比:
| 方式 | 安全性 | 性能 | 代码量 | IDE支持 |
|---|---|---|---|---|
findViewById() | 运行时检查(ID不存在则崩溃) | ✅ | 中等 | ⚠️ 无自动补全 |
| ViewBinding | 编译时检查(ID不存在直接报错) | ✅✅(无反射) | 少30% | ✅ 全自动补全 |
5.2 处理动态选项:用循环替代重复findViewById
当选项数不固定(如多选题支持3~6个选项),硬写rbOptionA/rbOptionB不可维护。用ViewBinding+循环:
// 在 loadQuestion() 中: val options = quizData[currentQuestionIndex].options val optionViews = listOf( binding.rbOptionA, binding.rbOptionB, binding.rbOptionC, binding.rbOptionD ) // 清空所有选项并设置文本 optionViews.forEachIndexed { index, rb -> rb.visibility = if (index < options.size) View.VISIBLE else View.GONE if (index < options.size) { rb.text = "${'A' + index}. ${options[index]}" } }关键点:
visibility = View.GONE隐藏多余选项,比INVISIBLE更省渲染资源;forEachIndexed避免手动写4次if-else,提升可读性;'A' + index自动计算选项字母(0→A, 1→B...),无需硬编码。
5.3 真机调试必装插件:ADB WiFi调试与Logcat过滤
在办公室WiFi环境下,频繁插拔USB线影响效率。用ADB WiFi调试:
# 1. 手机连USB,执行: adb tcpip 5555 # 2. 拔掉USB,查看手机IP(设置→关于手机→状态信息→IP地址) # 3. 电脑执行(替换为你的手机IP): adb connect 192.168.1.100:5555 # 4. 验证连接: adb devices # 应显示 "192.168.1.100:5555 device"Logcat过滤技巧(在Android Studio Logcat窗口):
- 输入
com.example.quizapp只看本App日志; - 输入
E/AndroidRuntime只看崩溃堆栈; - 输入
Toast查看所有Toast提示(验证逻辑是否触发)。
我带过3届实训班,学生问得最多的问题永远是:“我的代码没错,为什么就是不显示?”——后来发现90%是没开USB调试、没点信任、或Logcat没过滤。现在我让学生第一课就装好ADB WiFi,第二课就学会用
E/AndroidRuntime过滤,第三课就能自己定位90%的崩溃。希望帮到你。
本文还有配套的精品资源,点击获取