
简介这是一份基于安卓开发工具开发的安卓答题应用完整工程面向安卓入门学习者和需要快速搭建答题类项目的开发者。应用覆盖限时答题、选择题作答、正确性判断与即时反馈、答题结果统计、错题集记录及历史成绩保存等功能链路可直接编译运行。压缩包共五十个文件包含十二个源代码文件、十四个界面布局与配置文件、十个图片资源以及项目构建脚本、安装包、演示视频和运行说明文档整体仅八点八一兆字节。已有五千九百零五人学习下载。资源附带可直接安装的安装包、演示视频和运行环境文档并配有完整源代码方便对照学习项目结构、界面绘制与业务逻辑实现尤其适合课程设计、毕业设计或练习安卓开发使用。1. 基于 Android Studio 开发的安卓的答题app自己搭和下载现成的差在哪这次聊的是基于 Android Studio 开发的安卓的答题app也就是一个能离线刷题、自动判分、记录错题的单机题库工具。考证、驾考、考研刷题的人多手机里往往装了三四个答题 App广告多、要联网、题库旧还总在关键时刻弹更新。自己做这个项目最值钱的部分不在把题塞进去而在把答题过程做成完整闭环题库怎么存、倒计时怎么不飘、错题怎么回看、APK 怎么发出去。这个标题背后其实是三条线——Android Studio 的工程组织、SQLite 数据层、Activity/Fragment 生命周期管理。适合两类人刚学完 Android 基础想练手的学生以及需要给团队或班级做内部刷题工具的从业者。2. 把工程跑起来Android Studio 安装、Gradle 配置与首个答题入口页面2.1 Android Studio 安装与中文设置不折腾环境的半小时清单先说环境。Android Studio 的安装教程网上一搜一大把但真正卡人的不在安装包而在 SDK 组件和模拟器。我一般装完先做三件事。第一确认 SDK Manager 里 Android SDK Platform 和 Build-Tools 装齐了缺哪个编译时马上报错缺了再补也不迟。第二把界面切成中文。Android Studio 原生英文界面让不少初学者劝退其实在 Settings Plugins 里搜索 Chinese 语言包安装后重启就行。这在“android studio 设置中文”的搜索结果里反复出现确实有效。第三检查模拟器能不能联网。安卓虚拟机怎么联网是新手问得最多的问题模拟器内访问宿主机用 10.0.2.2访问公网则要检查模拟器 Wi-Fi 设置把 DNS 换成公共 DNS九成能通。注意别去动宿主机网络配置那只影响模拟器。再补一点国内下载的细节。官网下载安装包可能比较慢常见做法是走镜像站拉安装包装上以后 Gradle 仓库也换成国内镜像加速。这个动作能省掉大量“下载超时然后重来”的时间属于环境搭建里性价比最高的一步。2.2 创建答题工程minSdk、targetSdk 与依赖选型的取舍创建工程时Android Studio 的模板默认生成 Kotlin 项目。答题 app 这种单机工具不需要复杂的组件化一个 application 模块就够了。关键在 app/build.gradle.kts 里的这几项配置plugins { id(com.android.application) id(org.jetbrains.kotlin.android) } android { namespace com.example.quizapp compileSdk 34 defaultConfig { applicationId com.example.quizapp minSdk 24 targetSdk 34 versionCode 1 versionName 1.0.0 } buildTypes { release { isMinifyEnabled false proguardFiles( getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro ) } } } dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.appcompat:appcompat:1.6.1) implementation(com.google.android.material:material:1.11.0) implementation(androidx.recyclerview:recyclerview:1.3.2) implementation(androidx.constraintlayout:constraintlayout:2.1.4) }minSdk 24 意味着 Android 7.0 及以上都能装这个门槛对答题工具完全够用。compileSdk 34 表示用 Android 14 的 API 编译。targetSdk 34 决定新系统上的行为适配不要一有新版本就拉满反而容易踩到新系统的隐私限制后面避坑章节会展开。依赖方面答题页用 RecyclerView 做列表用 Material 组件做按钮和对话框用 ConstraintLayout 做布局。核心依赖就这四个别急着上 Hilt、Room 全家桶单机工具用不上只会拖慢构建。2.3 第一个页面用 RecyclerView 搭题库分类列表入口页面不需要花哨。常见做法是书架式列表左边科目名右边题目数。MainActivity 里加载题库元数据点击分类跳答题页。看 MainActivity.kt 的骨架class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var adapter: CategoryAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) adapter CategoryAdapter(getCategoryList()) { category - val intent Intent(this, QuizActivity::class.java) intent.putExtra(category, category) startActivity(intent) } binding.rvCategory.adapter adapter binding.rvCategory.layoutManager LinearLayoutManager(this) } private fun getCategoryList(): ListString { val db DBHelper(this).readableDatabase val cursor db.rawQuery(SELECT DISTINCT category FROM questions ORDER BY category, null) val list mutableListOfString() while (cursor.moveToNext()) { list.add(cursor.getString(0)) } cursor.close() return list } }这里的关键是 Adapter 把点击位置转发出去点击时携带 category 跳转。题库分类列表从 SQLite 的 category 字段里 distinct 出来而不是硬编码在 strings.xml 里。好处很直接以后加科目不用改代码只要题库 JSON 里新增分类列表自动多一项。CategoryAdapter 的写法是标准 RecyclerView.Adapter注意在 onBindViewHolder 里用 binding.root.setOnClickListener 而不是在 ViewHolder 里 else 处理这样 Adapter 只负责绑定数据点击事件通过 lambda 返回给外部。3. 题库装进 SQLite表结构、批量导入与进度存储3.1 为什么选 SQLite答题数据的离线优先与读写取舍答题 app 的题库是结构化数据题目、选项、答案、解析之间有外键关系。常见做法有三种Room、SQLiteOpenHelper、直接读 JSON 文件进内存。我的建议是直接用 SQLiteOpenHelper。Room 是对 SQLite 的封装适合团队和大型项目但答题场景没那么复杂。直接把 JSON 读进内存只适合几百道题的小题库题量过五千每次冷启动解析 JSON 就会出现明显卡顿而且想按分类、按难度、按错题数做混合查询就非常别扭。SQLite 单文件、离线可用、事务性好进程被杀数据不容易损坏。答题场景里网络差是常态地铁、电梯、考场里能离线刷题体验比什么都重要。用户把题目做了一半关掉 App下次打开进度还在这就是 SQLite 事务带来的价值。3.2 建三张表题目表、选项表、答题记录表用 SQLiteOpenHelper 管理建库和版本升级。看一下 onCreate 里的建表 SQLclass DBHelper(context: Context) : SQLiteOpenHelper(context, quiz.db, null, 1) { override fun onCreate(db: SQLiteDatabase) { db.execSQL( CREATE TABLE questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, content TEXT NOT NULL, option_count INTEGER NOT NULL, answer TEXT NOT NULL, analysis TEXT DEFAULT ) ) db.execSQL( CREATE TABLE options ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, option_tag TEXT NOT NULL, option_content TEXT NOT NULL, FOREIGN KEY(question_id) REFERENCES questions(id) ) ) db.execSQL( CREATE TABLE answer_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, user_answer TEXT, is_correct INTEGER DEFAULT 0, answer_time INTEGER NOT NULL ) ) db.execSQL(CREATE INDEX idx_options_question ON options(question_id)) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { // 升级时先备份旧表再重建不要直接 DROP做题记录会丢 } }题目表里为什么要有 option_count因为乱序出题要把选项洗牌洗牌前先从 options 表按 question_id 查出全部选项count 字段用来校验结果防止选项缺失的情况。answer 字段直接存选项标签比如 A/B/C/D判断题就存“对/错”这样判分逻辑只需要一个 equals 判断。选项单独成表而不是拼在 content 字段里原因有二乱序出题时方便重排解析答案时不用再 split 字符串。答题记录表单独放不能跟题目表混在一起。刷题产生的记录高频写、低频读和数据本身分开以后日积月累的记录不影响题目查询速度。3.3 批量导入assets 里的 JSON 一次灌进数据库题库源文件我放在 assets/questions.json格式按题库导出常见结构题目对象带 options 数组。导入代码必须放在子线程大题库在主线程跑会直接 ANR。核心逻辑fun importQuestionsFromJson(context: Context, db: SQLiteDatabase) { val jsonText context.assets.open(questions.json) .bufferedReader().use { it.readText() } val jsonArray JSONArray(jsonText) db.beginTransaction() try { for (i in 0 until jsonArray.length()) { val obj jsonArray.getJSONObject(i) val questionId db.insertOrThrow(questions, null, ContentValues().apply { put(category, obj.getString(category)) put(content, obj.getString(content)) put(option_count, obj.getJSONArray(options).length()) put(answer, obj.getString(answer)) put(analysis, obj.optString(analysis, )) }) val options obj.getJSONArray(options) for (j in 0 until options.length()) { val opt options.getJSONObject(j) db.insertOrThrow(options, null, ContentValues().apply { put(question_id, questionId) put(option_tag, opt.getString(tag)) put(option_content, opt.getString(content)) }) } } db.setTransactionSuccessful() } catch (e: JSONException) { Log.e(QuizImport, 解析失败: $e) } finally { db.endTransaction() } }事务的作用是保证要么全部导入成功要么一条都不写入。setTransactionSuccessful 必须在循环结束后调用否则 finally 里的 endTransaction 会回滚表现就是题目少了一半。这个坑我在后面避坑章节再细讲。导入前建议先执行一次 DELETE FROM questions 和 DELETE FROM options保证重复导入不会叠加。生产环境里我一般把版本号写进 JSON 文件名比如 questions_v2.json升级题库时靠版本号判断是否重新导入。3.4 进度和记录分开存SharedPreferences 与 SQLite 的边界答题进度和答题记录是两个概念。记录用 SQLite 表存进度用 SharedPreferences 存。进度指“我做到第几题、还剩多少秒、当前选中哪个答案”这些是状态记录指“哪道题做错了、选了哪个答案、时间戳”这些是事实。状态用 SharedPreferences 读写一行代码搞定val sp getSharedPreferences(quiz_progress, Context.MODE_PRIVATE) sp.edit() .putInt(current_index, currentIndex) .putLong(remaining_millis, remainingMillis) .putString(selected_option, selectedOption) .apply()别把当前题号写进数据库否则每切一题就 update 一次白白增加 IO。SharedPreferences 的写入走异步落盘性能开销小得多。进度和记录分开还有一个额外好处用户清掉应用数据重装后进度文件没了但数据库的做题统计还在不会出现“题都白刷了”的错乱感。4. 答题主流程倒计时、自动跳题与状态保存4.1 用 Fragment 组织答题页单 Activity 架构与重建恢复答题页是核心。常见做法是单 Activity 多 Fragment或者单 Activity 单 View 动态切换。我一般用单 Activity 多 Fragment。理由很直接答题页需要保存当前题号、选中项、剩余时间来电、旋转屏幕、切后台再回来时系统会重建 ActivityFragment 里的 onSaveInstanceState 正好处理这个。看一下 QuizFragment 的骨架class QuizFragment : Fragment() { private var currentIndex 0 private var remainingMillis 0L private var selectedOption companion object { fun newInstance(category: String, mode: String) QuizFragment().apply { arguments Bundle().apply { putString(category, category) putString(mode, mode) } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 读取 arguments 里的 category // 根据 currentIndex 渲染题目 } override fun onSaveInstanceState(outState: Bundle) { outState.putInt(current_index, currentIndex) outState.putLong(remaining_millis, remainingMillis) outState.putString(selected_option, selectedOption) super.onSaveInstanceState(outState) } }newInstance 静态工厂是 Fragment 传参的标准姿势不能直接往构造方法里塞数据否则系统重建 Fragment 时会丢参数。onSaveInstanceState 保存的只是内存状态进程被系统杀掉后靠它恢复页面如果连进程都没了就靠 SharedPreferences 里存的题号恢复现场。4.2 倒计时CountDownTimer 与生命周期联动倒计时是答题 app 最容易翻车的位置。很多人用 Thread.sleep 或 Handler.postDelayed 循环实现。前者一 sleep 就卡 UI后者隐藏一个坑Activity 销毁后 handler 队列还在等用户再进来旧回调触发新页面出现“自动跳题连跳两题”的灵异现象。正确写法是用 CountDownTimer并且在 onDestroy 里 cancelprivate var countDownTimer: CountDownTimer? null private fun startCountDown(totalSeconds: Long) { countDownTimer?.cancel() countDownTimer object : CountDownTimer(totalSeconds * 1000, 1000) { override fun onTick(millisUntilFinished: Long) { remainingMillis millisUntilFinished binding.tvTimer.text 剩余 ${millisUntilFinished / 1000} 秒 } override fun onFinish() { autoSubmit() } }.start() } private fun stopCountDown() { countDownTimer?.cancel() countDownTimer null } override fun onDestroy() { super.onDestroy() stopCountDown() }小技巧把启动和取消封装成 startCountDown / stopCountDown 两个函数。用 Android Studio 的抽取方法快捷键默认 CtrlAltM一键把重复逻辑抽出来。倒计时回调里不要做 UI 之外的逻辑只要页面不可见就调用 stopCountDown避免后台电量消耗。4.3 自定义选项按钮选中、答对、答错三种状态一次画清楚系统 RadioButton 只能表达选中态没法表达“答对/答错”的对比态。答题 App 做完一道题要立刻告诉用户你选对了还是选错了这就需要一个自定义组件。常见做法是继承 LinearLayout暴露一个 setState 方法class OptionButton(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) { enum class State { NORMAL, SELECTED, CORRECT, WRONG } private val tvTag: TextView private val tvContent: TextView fun setState(state: State) { val bgRes when (state) { State.NORMAL - R.drawable.bg_option_normal State.SELECTED - R.drawable.bg_option_selected State.CORRECT - R.drawable.bg_option_correct State.WRONG - R.drawable.bg_option_wrong } background ContextCompat.getDrawable(context, bgRes) } }自定义组件在 Android Studio 里是高频操作四个背景 Drawable 都用 selector 写。注意 selector 里 pressed 和 checked 的顺序写反了点击效果不生效。答题页和解析页共用这一套状态解析页直接把正确答案对应的选项设成 CORRECT用户答案如果错了就设成 WRONG两套页面代码一致不会出现样式漂移。4.4 切后台回来倒计时对不上onPause 停表、onResume 续表现象很经典答题页按 Home 键退回桌面过五分钟再回来倒计时不是继续走就是瞬间交卷。原因在于 CountDownTimer 的 tick 按消息队列排后台时系统会延迟甚至暂停消息时间轴不可靠。解决方式是在 onPause 里停表并把剩余毫秒存下来在 onResume 里重建计时器override fun onPause() { super.onPause() stopCountDown() saveProgressToSp() } override fun onResume() { super.onResume() val remaining loadProgressFromSp() if (remaining 0L) { startCountDown(remaining / 1000) } }onPause 里不要做耗时操作SharedPreferences 的写入走异步刚好合适。onResume 里根据剩余秒数重新 startCountDown这时候 CountDownTimer 的起始值不再是全量时长而是真正剩下多少就倒多少。5. 开发与打包阶段的 5 个真实踩坑记录以下五条踩坑记录是我前后接手过三个答题类项目后沉淀下来的。每一条都按“现象 → 原因 → 解决”讲清楚。这些坑在模拟器上很难暴露基本都是真机跑出来的。5.1 编译期报资源重复错误现象sync 和 build 都正常一运行 assembleDebug 就报 Duplicate Resources错误信息里出现两个同名 drawable 或 layout。原因多模块工程里 res 文件重名或者从旧项目拷文件时同名字文件被重复导入。答题 App 常见于把别人的题库 Demo 直接拷进自己工程drawable 里同名 selector 撞车。解决全工程搜索报错资源名删掉重复副本。更根治的写法是给个人资源加统一前缀比如选项按钮的 selector 全部叫 opt_bg_xxx题目行布局叫 item_quiz_xxx。这个错误在“android studio 资源重复错误”相关搜索里出现率很高出问题八成是从外部导入了工程文件。5.2 列表里的题目图片会“串图”现象RecyclerView 里题目缩略图滚动后变成上一题的图或者闪一下再变成对的图。原因View 复用加异步加载时序错乱。图片题在答题 App 里很常见选项、题干、解析都可能带图一旦列表复用了还没加载完的 ImageView就会看到上一题的图。解决加载前先给 ImageView 设 tag发现 tag 对不上就放弃这次加载。更省事是直接用 Glide内置了生命周期感知和缓存层。如果只是本地 assets 里的小图我用 setTag 判断后手动 decode 就够了没必要为几张图引入一个大的图片库。5.3 批量导入后题目少了一半现象第一次打开题库显示 500 题关掉再打开变 300 题有些题目选项缺失。原因数据库事务没提价或主键冲突。最常见是 beginTransaction 后漏调 setTransactionSuccessful或者 JSON 里 id 重复导致 insert 抛异常。解决回看 3.3 节的导入代码确认 setTransactionSuccessful 在 try 块里、endTransaction 在 finally解析时对每道题做 try-catch 并打印日志。二次导入前先 DELETE 旧数据保证幂等。这个坑和导入代码是强绑定事务没走对就会出现“时好时坏”的灵异数据。5.4 真机白屏模拟器却正常现象模拟器上跑得好好的真机一打开就是白屏Logcat 没有崩溃日志。原因常见三类——主题资源在低版本系统上不兼容、设备开启了超大字体、WebView 组件没初始化。答题 App 白屏大概率是布局在系统字体缩放后溢出把整个页面挤出了屏幕。解决先把真机的字体设置有退回默认大小如果恢复正常说明布局用了固定 dp 且没开风险适配改为 ScrollView 包裹或百分比高度。答题页按钮被系统字体缩放撑爆是高频问题这跟 app 字体设置直接相关。再查 AndroidManifest 里 MainActivity 的 theme 是否指向不存在的 Style。5.5 打出的 APK 在别的手机上闪退现象自己手机 debug 包正常发给同事一打开就闪退Logcat 报 ClassNotFoundException。原因debug 包和 release 包行为不一致或者 targetSdk 高于对方系统版本新 API 在旧系统上直接就崩也可能签名没配置成 release。解决发布前用正式签名构建 release 包别拿 debug 包分发。targetSdk 尽量和测试机匹配不要盲目用最新。本地准备一台旧系统手机做冒烟测试比自己想象中的适配清单可靠得多。有条件接崩溃统计 SDK没条件就靠多设备手动验证。6. 发布前的最后一公里签名打包、包体瘦身与背题模式回归6.1 app 发布前的签名配置把 release 包做成能长久安装的版本发布前必须在 Android Studio 里走一次 Generate Signed Bundle / APK生成 jks 密钥文件。然后把签名信息写进 Gradle否则每次发版都要手动选文件android { signingConfigs { create(release) { storeFile file(../keystore/quiz.jks) storePassword System.getenv(QUIZ_STORE_PASSWORD) ?: keyAlias quiz keyPassword System.getenv(QUIZ_KEY_PASSWORD) ?: } } buildTypes { getByName(release) { signingConfig signingConfigs.getByName(release) isMinifyEnabled true isShrinkResources true proguardFiles( getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro ) } } }密码不要直接写死在 gradle 文件里。用环境变量或单独的 keystore.properties并把文件加进 .gitignore。我见过把密码提交到 Git 仓库再被同事顺手推到 GitHub 的翻车案例后悔药可不好找。6.2 用 APK Analyzer 看包体从哪里变胖Build Analyze APK 打开刚打出的包能看到每个文件的实际占用。答题 App 体积大的常见原因有三个assets 里的大体积 JSON、多套分辨率图标、未开启资源压缩。做法是图标只保留 xxhdpi 和 xxxhdpi 两套其他删掉打开 isShrinkResources 和 isMinifyEnabled让混淆器去掉未引用的代码资源压缩器去掉未引用的文件。题库 JSON 超过 5MB 就别放 assets 了考虑首次启动从远端拉取或拆成多分类文件按需加载。6.3 背题模式发布前最划算的回归验证背题模式就是直接展示答案和解析不判分、不记错题。实现上只需要一个 Boolean 参数控制提交逻辑val isReviewMode arguments?.getString(mode) review if (isReviewMode) { // 显示答案和解析不调用 recordAnswer } else { // 正常判分 记录答题结果 }这个模式不只是一个功能点它是最好的回归验证工具。发布前我用它在地铁上真实刷一轮模拟弱网、锁屏、来电打断。只要背题模式能完整跑完一套卷子并且切后台回来倒计时还对得上那正常答题模式大概率也稳。我之前吃过一次亏把切后台回来倒计时错乱当成偶发玄学上线一周后被用户截图打脸才回头把生命周期补齐。现在每发一个新版本我都先用背题模式完整刷一遍再提交。这个习惯帮我拦下了至少三次状态丢失的回归。希望帮到你。本文还有配套的精品资源点击获取