Android习题界面开发:SQLite、ListView与RadioGroup打造学习闭环

发布时间:2026/10/16 10:06:39
Android习题界面开发:SQLite、ListView与RadioGroup打造学习闭环
做了快十期的“博学谷”项目前面的课程列表、播放页、登录注册都跑通了App 已经能做到“看课”。但每次打开应用总感觉少了点东西——用户看完视频之后去哪了总不能再退出去干别的。第十一期“习题界面”要做的事情就是给“学”配上一个“练”的出口让整个学习闭环在客户端内先转起来。这一期我把习题模块拆成了三个界面来落地章节列表、题目列表、答题页。用户从专栏点进习题选一个章节看到该章节的所有题目逐题作答提交后马上看到对错和解析。中间涉及 SQLite 存本地题库、ListView 加载列表数据、RadioGroup 做单选交互、SharedPreferences 记录答题进度这些都是老 Android 工程里非常经典又绕不开的技术点。适合做到课程类 App 中期、开始往功能完整性上补模块的开发者参考也适合正在跟练博学谷整套实战项目的人照着往下做。下面把这一期的思路、建表、界面代码和踩过的坑按我实际做项目的顺序完整过一遍。1. 先把习题流程拆明白再决定怎么实现1.1 这一期处理的是哪三个界面做项目最怕一上来就写代码。我拿到“习题界面”这个任务之后先画了一条用户操作链进入习题页 - 看到章节列表 - 点某个章节 - 看到该章节的题目列表 - 点某道题 - 进入答题页 - 选择答案并提交 - 看到正确/错误和解析这一整条链其实是三个界面串起来的任何一个环节断掉功能都无法成立章节列表页展示当前学科下的所有章节每一行右侧显示该章节包含的题目数量。题目列表页点击章节后进入展示题目序号和题干摘要。这里不做翻页一屏用 ListView 滚完。答题页一题一屏标题栏显示“第 x 题 / 共 y 题”题干下方放四个单选按钮底部一个提交按钮提交后显示结果和答案解析。有些学习 App 会把答题设计成“进来就直接开始做第一题”不展示题目列表做完一题自动滑到下一题。那种做法刷题效率很高但第一次做版本的时候用户对“这个章节有几道题、自己做到哪里”完全没有感知体验反而不好。保留题目列表的做法看起来多一层跳转实际上给了用户一个随时回到中间的抓手也方便后面加“错题记录”“收藏题目”之类的入口。1.2 技术方案为什么还是用多 Activity ListView SQLite这期项目是跑在 Eclipse ADT 环境里的老 Android 工程不是现在 Android Studio 的 Jetpack 全家桶。在设计方案的时候我考虑过几个选择用 Fragment 在一个 Activity 内部切换可以但博学谷前面的模块已经用了多 Activity 的方式保持风格统一维护起来最省心。用 RecyclerView 替代 ListViewEclipse 时期自带的支持库里 RecyclerView 属于后起的控件引入依赖和兼容成本反而多而且这个项目前期题目数据量也就几十条ListView 加 ViewHolder 完全够用。题目数据直接从服务端接口拉当时后台还没就绪我选择先落 SQLite后续服务端通了再做数据源替换界面层不用动。多 Activity、ListView、SQLite 这三个词放现在看确实老但在这个项目阶段里是“最稳”的组合工程量小、调试简单、资料好找。技术选型不是越新越好而是要在当前项目约束下把功能跑通。2. 数据先行习题表和题型实体怎么设计2.1 本地题库的表结构习题界面最核心的东西是题目数据。用 SQLite 存题目首先得把表建对。我给这张表起名 exercise字段设计如下字段名类型说明idinteger主键自增subjectIdinteger所属学科 IDchapterIdinteger所属章节 IDquestionvarchar题干内容optionAvarchar选项 AoptionBvarchar选项 BoptionCvarchar选项 CoptionDvarchar选项 Danswerinteger正确答案1 到 4 对应 A 到 Danalysisvarchar题目解析建表时有个细节我特意加了 subjectId 和 chapterId 两个字段。虽然当前版本从“章节列表”进入题目列表只需要 chapterId 过滤但上一级入口是学科把学科 ID 一起存进去以后想做“按学科刷题”或者“学科知识点图谱”就不用重新加字段和导数据了。提前留出扩展维度代价几乎为零后面改起来却省很多事。建表语句放在自定义的 SQLiteOpenHelper 里数据库名叫 boxuegu.dbpublic class MySqliteHelper extends SQLiteOpenHelper { public MySqliteHelper(Context context) { super(context, boxuegu.db, null, 1); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(create table exercise( id integer primary key autoincrement, subjectId integer, chapterId integer, question varchar(500), optionA varchar(100), optionB varchar(100), optionC varchar(100), optionD varchar(100), answer integer, analysis varchar(500))); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { } }这里有个很容易忽略的问题onUpgrade 方法现在是空的。开发阶段如果改了表结构要么卸载应用重装要么手动把版本号加 1 并在 onUpgrade 里写升级逻辑。我前期就吃了这个亏改了字段以后一直没升级版本号程序反复报“no such column”排查了一圈才发现是旧表没删掉。真机调试阶段图省事可以直接卸载重装但如果已经有用户数据了升级逻辑必须写完整。2.2 实体类与数据访问层的写法表建好后定义对应的实体类。为了编码方便字段名和数据库列名保持一致public class ExerciseBean { public int id; public int subjectId; public int chapterId; public String question; public String optionA; public String optionB; public String optionC; public String optionD; public int answer; public String analysis; }实体类字段全是 public可能在不少“规范党”眼里不太好看但在这个项目里这就是最直接的做法没有复杂的访问控制需求getter/setter 写一堆反而让类变得又臭又长。真到了要加业务逻辑的那天再封装也不迟。数据访问层我单独抽了一个类叫 ExerciseDao主要提供两个方法getChaptersBySubject(int subjectId)查某个学科下的章节列表章节信息里带上题目数量。getExercisesByChapter(int chapterId)查某个章节下的全部题目。第二个方法的实现是这一期的核心查询public ListExerciseBean getExercisesByChapter(int chapterId) { ListExerciseBean list new ArrayListExerciseBean(); SQLiteDatabase db helper.getReadableDatabase(); Cursor cursor db.rawQuery( select * from exercise where chapterId ?, new String[]{String.valueOf(chapterId)}); while (cursor.moveToNext()) { ExerciseBean bean new ExerciseBean(); bean.id cursor.getInt(cursor.getColumnIndex(id)); bean.subjectId cursor.getInt(cursor.getColumnIndex(subjectId)); bean.chapterId cursor.getInt(cursor.getColumnIndex(chapterId)); bean.question cursor.getString(cursor.getColumnIndex(question)); bean.optionA cursor.getString(cursor.getColumnIndex(optionA)); bean.optionB cursor.getString(cursor.getColumnIndex(optionB)); bean.optionC cursor.getString(cursor.getColumnIndex(optionC)); bean.optionD cursor.getString(cursor.getColumnIndex(optionD)); bean.answer cursor.getInt(cursor.getColumnIndex(answer)); bean.analysis cursor.getString(cursor.getColumnIndex(analysis)); list.add(bean); } cursor.close(); db.close(); return list; }注意所有取值都用了 cursor.getColumnIndex() 动态拿索引而不是硬编码 0、1、2 这样的数字。这样即使以后调整了 select 语句里字段顺序代码也不用改。硬编码列索引在练习小程序里可能看不出问题但一旦让查询字段变多、语句变复杂这就是一个隐性炸弹。2.3 初始化题库数据的一个省事技巧数据库表建好了数据怎么进去项目没有任何后台接口所以我用了一个很朴素的办法把题目数据写成 SQL 语句放在 assets 目录下第一次进入习题界面时判断数据库是否为空为空则逐条执行 SQL 导入。这样做的好处明显数据是明文可视的改题目不用重新编译代码直接改 assets 里的 SQL 文件就行。缺点是 SQL 文件里中文一旦没存成 UTF-8 编码插入后全是乱码。我后面专门在排查部分说这个问题。如果你在 Android Studio 环境里做用 Room 加预填充数据库会更优雅但在 Eclipse 老工程里assets 放 SQL 是最不折腾的做法。插入数据的核心代码private void initExerciseData() { SQLiteDatabase db helper.getWritableDatabase(); Cursor cursor db.rawQuery(select count(*) from exercise, null); cursor.moveToFirst(); int count cursor.getInt(0); cursor.close(); if (count 0) return; try { InputStream is getAssets().open(exercise.sql); BufferedReader reader new BufferedReader( new InputStreamReader(is, UTF-8)); String line; while ((line reader.readLine()) ! null) { if (!TextUtils.isEmpty(line.trim())) { db.execSQL(line); } } reader.close(); } catch (IOException e) { e.printStackTrace(); } }这里必须用 count 判断是否已经初始化过否则每次进入习题页都重复执行一遍题目会出现大量重复记录。3. 界面落地三个布局文件和它们的实现细节3.1 章节列表入口布局与跳转逻辑章节列表页我放在 ExerciseActivity 里布局很简单顶部一个标题栏下面一个 ListViewLinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical LinearLayout android:layout_widthmatch_parent android:layout_height48dp android:gravitycenter_vertical android:paddingLeft12dp android:background#3F51B5 TextView android:layout_widthwrap_content android:layout_heightwrap_content android:text习题练习 android:textColor#FFFFFF android:textSize18sp / /LinearLayout ListView android:idid/lv_chapter android:layout_widthmatch_parent android:layout_heightmatch_parent / /LinearLayoutListView 的 item 用一个简单的 LinearLayout 横向排布左边章节名右边题目数LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:padding12dp android:gravitycenter_vertical TextView android:idid/tv_chapter_name android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:textSize15sp / TextView android:idid/tv_question_count android:layout_widthwrap_content android:layout_heightwrap_content android:textColor#666666 android:textSize13sp / /LinearLayout章节数据从哪里来我是在 ExerciseActivity 里硬编码了一个章节数组然后从数据库查询每章的题目数。这个方案说明一下本地数据不足以支撑“章节表”的时候先拿代码维护章节列表是临时方案。如果章节数量很多还是应该建 chapter 表把章节名、排序、题目数都放进去统一从数据库读。章节列表的 Adapter 写法是标准的 BaseAdapter关键是 getView 里要做 ViewHolder 缓存否则 ListView 滑动时会卡顿甚至掉帧。这个工程数据量不大但写 Adapter 时保留 ViewHolder 是个好习惯后面把题目列表也用同一套写法时直接复用。点击事件在 Activity 里设置lvChapter.setOnItemClickListener(new AdapterView.OnItemClickListener() { Override public void onItemClick(AdapterView? parent, View view, int position, long id) { int chapterId chapterList.get(position).chapterId; Intent intent new Intent(ExerciseActivity.this, ExerciseListActivity.class); intent.putExtra(chapterId, chapterId); startActivity(intent); } });3.2 题目列表数据加载与列表 Adapter 写法题目列表页叫 ExerciseListActivity实际上就是一个 ListView 加一个空数据提示。进入时先取 Intent 里的 chapterId再查数据库int chapterId getIntent().getIntExtra(chapterId, -1); ListExerciseBean exerciseList dao.getExercisesByChapter(chapterId); if (exerciseList.size() 0) { tvEmpty.setVisibility(View.VISIBLE); lvExercise.setVisibility(View.GONE); } else { lvExercise.setAdapter(new ExerciseListAdapter(this, exerciseList)); }这个页面的 item 布局只显示两个信息题号和第几题、题干摘要。题干如果太长用 ellipsize 截断TextView android:idid/tv_question android:layout_widthmatch_parent android:layout_heightwrap_content android:maxLines2 android:ellipsizeend android:textSize14sp /题目列表 Adapter 的 getView 里ViewHolder 是标准写法Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView LayoutInflater.from(context).inflate( R.layout.item_exercise_list, parent, false); holder new ViewHolder(); holder.tvIndex (TextView) convertView.findViewById(R.id.tv_index); holder.tvQuestion (TextView) convertView.findViewById(R.id.tv_question); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } ExerciseBean bean list.get(position); holder.tvIndex.setText(第 (position 1) 题); holder.tvQuestion.setText(bean.question); return convertView; }这里我特别强调 inflate 方法带了 parent 参数。老代码里很多人写成 inflate(R.layout.item, null)这样会导致 item 的 layout_width 和 layout_height 参数失效表现出来就是 item 宽度异常、间距不对。带 parent 是正确姿势但注意不能在 parent 里直接添加视图所以第三个参数传 false。点 item 进入答题页时把当前题目列表的所有数据一次性传过去不合适因为一个章节可能有几十道题用 Intent 传递大量对象在 Serializable 序列化上开销高。我的做法是答题页根据 chapterId 重新查一次题再根据传入的 position 定位到具体题目。这样每次只传 chapterId 和 index数据量非常小。3.3 答题页单选交互、提交判断和答案解析答题页是这期最复杂的界面我单独建了一个 ExerciseDetailActivity。布局包括题号、题干、四个 RadioButton、一个提交按钮、一个结果显示区域。LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical android:padding16dp TextView android:idid/tv_index android:layout_widthwrap_content android:layout_heightwrap_content android:text第 1 题 / 共 10 题 android:textSize14sp android:textColor#666666 / TextView android:idid/tv_question android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop16dp android:textSize16sp android:lineSpacingExtra4dp / RadioGroup android:idid/rg_options android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop20dp RadioButton android:idid/rb_a android:layout_widthmatch_parent android:layout_heightwrap_content android:padding8dp android:textSize15sp / RadioButton android:idid/rb_b android:layout_widthmatch_parent android:layout_heightwrap_content android:padding8dp android:textSize15sp / RadioButton android:idid/rb_c android:layout_widthmatch_parent android:layout_heightwrap_content android:padding8dp android:textSize15sp / RadioButton android:idid/rb_d android:layout_widthmatch_parent android:layout_heightwrap_content android:padding8dp android:textSize15sp / /RadioGroup Button android:idid/btn_submit android:layout_widthmatch_parent android:layout_height48dp android:layout_marginTop30dp android:text提交答案 / TextView android:idid/tv_result android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop20dp android:textSize15sp android:visibilitygone / TextView android:idid/tv_analysis android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop8dp android:textSize14sp android:lineSpacingExtra4dp android:visibilitygone / /LinearLayoutActivity 里的核心逻辑分成三块显示当前题目、判断答案、切到下一题。显示当前题目比较简单根据传入的题目列表 position 取 bean然后把题干、四个选项依次 setText 填进去。判断答案这块有个坑RadioGroup 拿到的 checkedId 是 RadioButton 的资源 id而不是选项序号。我是这样映射的btnSubmit.setOnClickListener(new View.OnClickListener() { Override public void onClick(View v) { int checkedId rgOptions.getCheckedRadioButtonId(); if (checkedId -1) { Toast.makeText(ExerciseDetailActivity.this, 请先选择一个答案, Toast.LENGTH_SHORT).show(); return; } int userAnswer; if (checkedId R.id.rb_a) { userAnswer 1; } else if (checkedId R.id.rb_b) { userAnswer 2; } else if (checkedId R.id.rb_c) { userAnswer 3; } else { userAnswer 4; } boolean correct (userAnswer currentBean.answer); if (correct) { tvResult.setTextColor(getResources().getColor(R.color.answer_correct)); tvResult.setText(回答正确); } else { tvResult.setTextColor(getResources().getColor(R.color.answer_wrong)); tvResult.setText(回答错误正确答案是 convertAnswer(currentBean.answer)); } tvResult.setVisibility(View.VISIBLE); String analysisText 解析 currentBean.analysis; tvAnalysis.setText(analysisText); tvAnalysis.setVisibility(View.VISIBLE); btnSubmit.setEnabled(false); if (position list.size() - 1) { btnSubmit.setText(下一题); } else { btnSubmit.setText(查看结果); } } });这里有几个关键设计提交前先判断 RadioGroup 是否有选中项checkedId 为 -1 说明没有选中直接提示。提交后立刻禁用提交按钮防止用户手快连点两次。正确和错误的提示颜色用两种色值区分我在 res/values/colors.xml 里加了 answer_correct 和 answer_wrong 两个颜色。提交按钮在最后一题时文本变成“查看结果”点击后进入统计展示。下一题的逻辑很简单position 加 1重新调用显示题目方法重置 RadioGroup 选中状态和结果显示区。private void showQuestion() { currentBean list.get(position); tvIndex.setText(第 (position 1) 题 / 共 list.size() 题); tvQuestion.setText(currentBean.question); rbA.setText(currentBean.optionA); rbB.setText(currentBean.optionB); rbC.setText(currentBean.optionC); rbD.setText(currentBean.optionD); rgOptions.clearCheck(); tvResult.setVisibility(View.GONE); tvAnalysis.setVisibility(View.GONE); btnSubmit.setEnabled(true); btnSubmit.setText(提交答案); }如果用户中途退出答题页进度就丢了这个体验不好。我在这一期里用了 SharedPreferences 记录答题进度每次提交一道题就把当前 chapterId 和 position 存起来下次进入答题页时提示“是否从上次位置继续”。这个功能代码量不大但对完整性的提升很明显。下面单独说。进度保存的代码private void saveProgress() { SharedPreferences sp getSharedPreferences(exercise_progress, MODE_PRIVATE); sp.edit() .putInt(chapterId, currentChapterId) .putInt(position, position) .putBoolean(hasProgress, true) .commit(); }SharedPreferences 不需要引入额外框架适合做这种轻量进度标记。如果要存更复杂的整套答题记录就得建表了目前阶段没必要。4. 老 Eclipse 环境下常见的坑与排查记录4.1 中文乱码字符编码引发的连锁事故Eclipse 代码和资源文件的默认编码在不同系统上不一样Windows 上常见 GBKAndroid 官方推荐 UTF-8。我在 assets 里的 exercise.sql 文件用 UTF-8 编码保存但 Eclipse 工作区默认可能仍按 GBK 读取导致插入到 SQLite 里的中文全部变成乱码。排查的过程不复杂先在 db 浏览器工具里看数据发现题目解析字段是“锟斤拷”一类字符基本可以确定是编码问题。解决方法是把文件编码统一改成 UTF-8并在代码里显式指定读取字符集new InputStreamReader(is, UTF-8);Java 代码文件本身如果出现中文乱码在 Eclipse 里右键项目选择 Properties - Resource - Text file encoding改成 UTF-8。不要只在某一个文件上改整个工程的编码不一致迟早出问题。4.2 ListView 复用时 RadioButton 状态错乱这期项目虽然只有答题页用了 RadioGroup不是直接在 ListView 里放 RadioButton但我在做题目列表的时候踩到过另一个复用问题item 里有背景色状态比如“做过的题目”标记成灰色如果 holder 缓存没处理好会出现之前 item 的样式串到后面的情况。ListView 的 getView 里 convertView 是复用的如果只在 convertView null 时做 ViewHolder 创建但每次复用后没有根据当前数据重新设置所有 UI 状态就很容易出现错乱。解决思路就一条所有会变化的内容在每次 getView 里无条件重新赋值不要依赖“上次已经设置过”。4.3 R 文件丢失和 Clean 操作Eclipse 年代最经典的报错之一是 R.java 找不到或者 R 符号变红。我遇到过的情况是资源文件里改了一个 id但保存后 gen 目录没有自动同步整个工程所有 R.id 都报错。网上的第一建议永远是 Project - Clean我实测这个操作大多数时候能解决但真正的根因其实是资源文件有语法错误导致 R.java 没有成功生成。所以遇到 R 报错先看 res 目录下有没有红色报错图标没有的话再做 Clean。清一次不行就再清一次顺便重启 Eclipse这已经是那个时代的传统艺能了。4.4 真机调试的经典故障速查Eclipse 连真机调试经常遇到设备列表为空的问题。这里给一张速查表都是排查顺序里的高频项现象排查步骤设备列表找不到手机先看手机是否开启 USB 调试换一根数据线排除供电线在命令行执行 adb kill-server 后 adb start-server安装成功但打开即闪退查看 LogCat 红色报错最常见是 Activity 没在 AndroidManifest.xml 里注册logcat 不输出日志在 DDMS 里选择对应设备确认日志级别没有过滤掉安装时报 INSTALL_FAILED_UPDATE_INVALID卸载手机上旧包再安装跑起来界面布局错位检查 item 布局 inflate 时是否传了 parent 参数4.5 Activity 未注册导致的即时崩溃答题页做完第一次点击题目列表跳转时直接闪退LogCat 里报的是Unable to find explicit activity class ... Have you declared this activity in AndroidManifest.xml?这个错误在新手里出现频率极高。Eclipse 时代不会像 Android Studio 那样自动把 Activity 加进 Manifest每次新建一个 Activity 类必须手动到 AndroidManifest.xml 里补一段activity android:name.ExerciseDetailActivity /报错信息其实已经把原因写在脸上但我第一次遇到还是找了好几圈才反应过来。以后只要新增界面第一步先注册 Manifest再写逻辑。4.6 数据库初始化重复执行问题还有一个很隐蔽的坑数据库版本升级后onCreate 不会再次执行。我在开发过程中频繁改表结构加了一个字段后一直不生效弄了半天才发现 onCreate 只在数据库第一次创建时调用。如果数据库已经存在系统只会调 onUpgrade而你又在 onUpgrade 里什么都没写。开发期的补救办法是把应用卸载重装但正式发布前一定要把升级路径想清楚。简单点可以在 onUpgrade 里 drop 旧表再重新建表缺点是用户数据全没了正式项目里要用迁移逻辑逐表处理但这个项目阶段 drop 重建足够。5. 从这期项目里提炼的几点细节经验安卓项目做到习题界面这一步最考验人的不是哪个控件不会用而是流程能不能“完整地走通”。我个人的感受是把三屏流程做完后整个 App 的完成度一下子就上来了用户使用路径从“只看视频”变成了“看完即练”留存逻辑完全不一样。这里还想补充一个小的布局技巧答题页四个选项如果选项文字很长A、B、C、D 四个字不要和选项内容混在一个 TextView 里。我用的是 RadioButton 的 button 属性只保留圆圈按钮文字部分单独画出来放在左端。这样即使选项换行题号对行文也不会被拉扯变形。做题进度保存这个功能我是在整期功能跑通、自己实际用了两天之后才决定加的。第一次使用时一口气做到第八题接了个电话回来 App 被系统杀了再打开从头再来。这让我意识到流程完整度不能只看代码逻辑还要看真实使用的连续性。SharedPreferences 存章节 ID 和位置 ID 是最经济实惠的方案后续如果要做更完善的做题记录再升级成数据库表也不迟。把这三层界面维护好的前提下其实后面可以很自然地扩展两个方向一个是错题本把 answer 判断为错误的题目单独归档二次练习另一个是做题统计按章节维度统计正确率这样用户在 App 里的行为数据就慢慢有了骨架。这些扩展的根基都是这一期打下的表结构和页面流程。我自己的建议是做完这期之后把数据访问层单独拿出来多测几遍尤其注意 Cursor 的关闭和数据库连接的释放这是以后所有报表功能不出莫名 Bug 的基本盘。