做这个习题界面之前我其实纠结了一段时间。博学谷这个模拟在线学习App做到第十一期前面的章节页、详情页、视频播放流程都已经跑通了用户能浏览课程、能看介绍但一直都缺一个自我检验的入口。习题界面就是补上这个缺口让用户从“被动看课”变成“主动做题”。用eclipse android sdk这一套老环境做下来踩了不少跟现代编辑器完全不同节奏的坑但整个过程反而让我把Activity、Adapter、数据绑定这些基本功重新夯实了一遍。这篇就把博学谷习题界面的完整实现路线记录下来从需求拆解到数据建模再到列表渲染和判分逻辑一套全部走通适合正在用eclipse做android项目实战、或者想复习ListView自定义Adapter做交互的同学参考。1. 习题界面功能拆解这一篇到底要交付什么1.1 从我正在做的博学谷整体功能看习题模块的位置博学谷是一个模拟在线学习App我前面几篇已经实现了课程列表、章节树和详情展示页数据源用的是本地assets里的模拟数据。用户能够看到一门课有多少章、每章讲了什么也可以进入详情页看简介。但问题在于这个App只能“看”不能“练”学习闭环没有完成。习题界面在这个时间节点补进来承担的是在线教育产品里的“检验”环节。它的使用场景很明确用户在某个章节学完之后点进习题模块系统展示一组跟该章节配套的题目用户完成作答之后提交并得到正确率反馈。所以这个界面不是一个纯展示页它有明确的交互诉求答题、判分、反馈。1.2 功能点清单跟产品经理对齐以后再动手我在写代码之前先列了一下这个界面需要覆盖的功能点这一步非常有必要因为自己一个人做项目的时候很容易写着写着就跑偏。最终定下来的功能清单是展示某个章节下的题目列表题目数量不固定但每次至少8到10题每道题包含题干、4个选项单选、正确答案、解析文本用户点击选项后该题的选中状态要立刻反馈到界面上并且只允许选一个底部放一个“提交答案”按钮点击后对所有题目统一判分判分完成后界面刷新正确选项高亮、用户错选的选项标红、解析区域展开顶部显示当前进度例如“已完成 5/10”提醒用户还有几题没做未做题目数大于0时提交弹Toast提醒并且不进行判分。这几个点看起来简单但后面每个都会牵扯到具体实现细节尤其是“状态反馈”和“判分高亮”这两块是坑最集中的地方。1.3 为什么我一开始没有急着写布局代码很多新手拿到这个需求第一反应就是打开activity_exercise.xml开始堆控件。我的经验是这个界面跟前面的课程列表不一样它不是一个纯静态展示而是“列表 每条item内部又有交互子控件”的结构布局只是最后呈现结果真正的复杂度在数据模型和Adapter里的状态管理上。所以我的顺序是先定义数据结构 → 再设计交互状态 → 最后写XML布局。这样做的好处是当你在Adapter里处理点击事件时已经很清楚数据从哪里来、要写回哪里去而不是边写布局边发明数据结构。后面代码写起来会顺很多。2. 从数据模型到题库填充先把题目管理起来2.1 一道题在Java代码里应该长什么样习题界面最核心的数据单元是“题目”。我把它封装成一个实体类QuestionBean字段设计如下public class QuestionBean { // 题目唯一标识 private int id; // 所属章节编号用于从课程详情页携带参数跳转进来 private int chapterId; // 题干文本 private String question; // 4个选项文本索引0~3分别代表A、B、C、D private String[] options; // 正确答案索引0A1B2C3D private int answerIndex; // 答案解析文本 private String analysis; // 用户当前选中的选项索引默认-1表示未作答 private int userAnswer -1; // 下面是getter和setter方法省略 }这里我特意加了userAnswer这个字段而不是让它等于answerIndex或者放在别的地方。因为在Adapter渲染item的时候我需要根据这个字段判断当前题目的作答状态如果不把它放进实体类我就得在Adapter外面单独维护一个SparseIntArray那样反而更绕因为ListView滚动时item会回收复用一个全局数组很容易跟自己内部的位置索引闹矛盾。2.2 题库数据从哪里来本地解析而不是硬编码博学谷目前是个离线优先的模拟项目所以题库我用的是assets目录下的questions.txt文本文件每行一道题用竖线分隔字段。格式定成这样1|1|Java中以下哪个关键字用于定义抽象类|abstract|final|static|synchronized|0|抽象类必须用abstract声明不能实例化。 2|2|以下哪个不是面向对象的特性|封装|继承|多态|重载|3|重载不属于面向对象三大特性多态相关的概念才是。解析代码写在一个QuestionParser类里读取assets文件按行切割逐字段填入QuestionBean。这样做的直接好处是后面想加题目只需要改文本文件不用重新编译Java代码。public ListQuestionBean parseFromAssets(Context context) { ListQuestionBean list new ArrayList(); try { InputStream is context.getAssets().open(questions.txt); BufferedReader br new BufferedReader(new InputStreamReader(is, UTF-8)); String line; while ((line br.readLine()) ! null) { String[] parts line.split(\\|); QuestionBean bean new QuestionBean(); bean.setId(Integer.parseInt(parts[0])); bean.setChapterId(Integer.parseInt(parts[1])); bean.setQuestion(parts[2]); String[] options new String[4]; System.arraycopy(parts, 3, options, 0, 4); bean.setOptions(options); bean.setAnswerIndex(Integer.parseInt(parts[7])); bean.setAnalysis(parts.length 8 ? parts[8] : 暂无解析); list.add(bean); } } catch (IOException e) { e.printStackTrace(); } return list; }2.3 为什么先做本地题库而不是直接上网络这个选择要解释一下因为很多读者可能觉得“都做应试类App了题库肯定要从服务端拉”。但博学谷这个项目从头到尾就是一个离线优先的演示型App引入网络请求会让这一篇的内容失控要处理接口文档、回调线程、加载态、失败重试那习题界面本身的逻辑反而会变得面目模糊。更重要的是本地题库把数据层和界面层解耦得非常干净。我在调试判分逻辑的时候完全不用关心数据来源改一道错题答案直接改txt然后跑一遍解析非常高效。如果你后面想接真实接口只需要把QuestionParser换成网络数据解析器接口保持一致即可Activity里的其他代码几乎不用动。3. 自定义Adapter与列表项布局把每道题渲染成可点击的卡片3.1 item_exercise_layout的布局结构设计题目列表我用的是ListView配自定义Adapter。每道题在列表中呈现为一个独立的小卡片item布局我用LinearLayout嵌套完成外层竖排里面主要有四块内容题干区一个TextView文本是“1. Java中以下哪个关键字用于定义抽象类”这种带题号的格式选项区四个水平排列的选项条。我一开始想用RadioGroup包RadioButton但实测之后发现RadioGroup放进ListView item会有状态同步问题后面细说所以我改成了四个独立的LinearLayout每个里面放一个TextView解析区一个默认GONE的TextView判分之后才显示隐藏的正确/错误标记通过背景色变化来反馈不额外加图标。item列表项大多数时候不需要太花哨选中的选项用一个浅蓝色背景边框未选中的保持白底灰字提交后正确的项换绿色、错误项换红色。我这里没有用selector drawable直接代码里setBackgroundColor因为选项个数固定逻辑简单。3.2 ExerciseAdapter里最关键的setData逻辑Adapter的核心工作就是根据当前item对应的QuestionBean把所有控件的状态全部重新刷一遍。这里偷懒不得任何字段漏掉都会造成“第一次显示正常滑动几次以后错乱”的经典问题。Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView LayoutInflater.from(mContext).inflate(R.layout.item_exercise, parent, false); holder new ViewHolder(); holder.tvQuestion (TextView) convertView.findViewById(R.id.tv_question); holder.optionViews new TextView[4]; holder.optionViews[0] (TextView) convertView.findViewById(R.id.tv_option_a); holder.optionViews[1] (TextView) convertView.findViewById(R.id.tv_option_b); holder.optionViews[2] (TextView) convertView.findViewById(R.id.tv_option_c); holder.optionViews[3] (TextView) convertView.findViewById(R.id.tv_option_d); holder.tvAnalysis (TextView) convertView.findViewById(R.id.tv_analysis); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } final QuestionBean bean getItem(position); // 绑定题干 holder.tvQuestion.setText((position 1) . bean.getQuestion()); // 绑定四个选项文本并恢复状态 for (int i 0; i 4; i) { final int index i; holder.optionViews[i].setText(bean.getOptions()[i]); // 根据bean.userAnswer和bean.answerIndex来确定显示成什么颜色 applyOptionStyle(holder.optionViews[i], bean, index); // 设置点击监听把选择写回bean holder.optionViews[i].setOnClickListener(new View.OnClickListener() { Override public void onClick(View v) { onOptionClick(bean, index); } }); } // 解析区域判分后显示 if (bean.isAnswered()) { holder.tvAnalysis.setVisibility(View.VISIBLE); holder.tvAnalysis.setText(解析 bean.getAnalysis()); } else { holder.tvAnalysis.setVisibility(View.GONE); } return convertView; }用getItem(position)而不是直接用position去拿外部list里的对象这件事一定要养成习惯。因为ListView的position和数据源list的下标在他们数值相等之外可靠性依赖于Adapter的数据结构万一你外层加了一个headerViewposition就对不上了而getItem是Adapter内部帮你校准过的标准做法。3.3 applyOptionStyle里的三态区分这个方法是本界面的视觉灵魂它要处理三种状态未完成时用户选中项、判分后的正确项、判分后的错误项。贴一下完整逻辑private void applyOptionStyle(TextView tv, QuestionBean bean, int optionIndex) { if (!mIsSubmitted) { // 未提交状态下 if (bean.getUserAnswer() optionIndex) { // 用户已选中的这个选项高亮 tv.setBackgroundResource(R.drawable.bg_radio_selected); tv.setTextColor(Color.parseColor(#ffffff)); } else { // 未选中的恢复默认样式 tv.setBackgroundResource(R.drawable.bg_radio_normal); tv.setTextColor(Color.parseColor(#333333)); } } else { // 提交后状态 int correctIndex bean.getAnswerIndex(); if (optionIndex correctIndex) { // 正确答案无论用户是否选它都显示绿色 tv.setBackgroundResource(R.drawable.bg_radio_correct); tv.setTextColor(Color.parseColor(#ffffff)); } else if (bean.getUserAnswer() optionIndex) { // 用户选错了这个选项是用户选的但不是正确答案显示红色 tv.setBackgroundResource(R.drawable.bg_radio_wrong); tv.setTextColor(Color.parseColor(#ffffff)); } else { // 其他无关选项恢复默认 tv.setBackgroundResource(R.drawable.bg_radio_normal); tv.setTextColor(Color.parseColor(#333333)); } } }3.4 关于RadioGroup在ListView item里水土不服的问题这是我在Eclipse时代踩过最典型的ListView交互坑。一开始我在每个item里放一个RadioGroup包四个RadioButton逻辑上看起来天经地义但跑起来就发现下拉到后面的题目时前面的RadioButton会被ListView的item回收机制影响导致选中状态错乱A题的选中项跑到B题上去了。原因在于RadioButton的选中状态跟View自身绑定而不是跟数据绑定。ListView滚动时一个item view从屏幕滚出去后会进入RecycledViewPool滚回来时复用了这个view数据变了但RadioButton的checked状态可能还没来得及刷新。要解决它就得手动调用setChecked(false)再重新设置来回折腾非常痛苦。我的做法是干脆不用RadioGroup四个选项TextView全部自己处理点击点击后把bean.getUserAnswer()改成当前索引再调用notifyDataSetChanged()刷新整个列表。虽然从性能上看notifyDataSetChanged()会重新渲染当前可见item但这只有10道题的场景下完全无所谓换来的是状态永远跟随数据走心态稳得多。4. 答题判分与进度反馈习题界面的灵魂在这里4.1 选项点击后的状态写回与进度更新用户点了一个选项之后需要做两件事把当前题目标记为已答把ListView刷新一下。我在Adapter内部定义了一个接口回调把事件抛给Activitypublic interface OnAnswerChangedListener { // 已答题数变化时通知外部刷新进度 void onAnswerChanged(); }在onOptionClick里先写数据、再刷新、最后回调private void onOptionClick(QuestionBean bean, int index) { // 判分之后不允许修改答案 if (mIsSubmitted) { return; } if (bean.getUserAnswer() index) { // 点击同一个选项视为取消选择业务里我允许用户反悔 bean.setUserAnswer(-1); } else { bean.setUserAnswer(index); } notifyDataSetChanged(); if (mListener ! null) { mListener.onAnswerChanged(); } }4.2 提交按钮的判分逻辑怎么写提交按钮在Activity底部点击后先统计未答题目数。如果还有题目没做直接Toast提示“还有X题未完成”不做任何提交动作。等所有题都答完才进入判分环节。private void submitAnswers() { int unanswered 0; for (QuestionBean bean : mDataList) { if (bean.getUserAnswer() -1) { unanswered; } } if (unanswered 0) { Toast.makeText(this, 还有 unanswered 题未完成, Toast.LENGTH_SHORT).show(); return; } // 全部已答开始判分 mAdapter.setSubmitted(true); int correctCount 0; for (QuestionBean bean : mDataList) { if (bean.getUserAnswer() bean.getAnswerIndex()) { correctCount; } } mAdapter.notifyDataSetChanged(); // 弹窗显示成绩 AlertDialog.Builder builder new AlertDialog.Builder(this); builder.setTitle(答题结果); builder.setMessage(共 mDataList.size() 题答对 correctCount 题正确率 (correctCount * 100 / mDataList.size()) %); builder.setPositiveButton(确定, null); builder.show(); }4.3 进度条与已答题数联动Activity的顶部放了一个ProgressBar和TextView默认显示“已完成 0/10”。每次onAnswerChanged回调时重新统计userAnswer ! -1的题目数量更新进度条Override public void onAnswerChanged() { int doneCount 0; for (QuestionBean bean : mDataList) { if (bean.getUserAnswer() ! -1) { doneCount; } } mTvProgress.setText(已完成 doneCount / mDataList.size()); mProgressBar.setMax(mDataList.size()); mProgressBar.setProgress(doneCount); }mDataList在Activity里和Adapter里指向同一个List对象所以Adapter里改bean的userAnswerActivity这个循环立刻能感知到。这里用同一份引用是关键不要copy一份list给Adapter否则你两个地方各玩各的回调来回调去永远对不上。4.4 判分后解析区的展开逻辑提交后所有题目的解析TextView从GONE变成VISIBLE。这个功能不用说太多但有一个细节值得讲——就是解析区的展开不重新创建item而是通过applyOptionStyle里对tvAnalysis的控制因为submit之后notifyDataSetChanged()已经触发了getView会把每一行的解析TextView都刷成可见。所以解析区显示逻辑不需要额外写一堆循环你只需要在getView里判断bean.isAnswered()或者Adapter的mIsSubmitted标志位。mIsSubmitted是Adapter内部的一个boolean提交后置truegetView里就自然全展开了。5. 真机调试中的几个实际问题Eclipse时代的细节5.1 布局预览与真机呈现不一致的问题用eclipse写布局最让人抓狂的就是Layout Preview和真机效果经常对不上。特别是item布局这种在ListView里展示的卡片在预览里看是好看的上真机之后各种间距问题都冒出来了。后来我的经验是每写一个布局第一时间在多个尺寸模拟器上跑一遍模拟器不行就用真机传一次别依赖布局预览。item_exercise.xml里最值得注意的设置是外层的padding和选项条的margin。我把选项条高度设成了固定的48dp拉伸和压缩都不会变形字号用sp就不用解释间距用dp。因为题目选项文本有时候长有时候短如果高度不固定四个选项条参差不齐会很难看所以固定高度是个省心的选择。5.2 快速滑动时的状态错乱convertView复用带来的问题这个问题如果不在真机上连续上下划几十次基本发现不了。我描述一下现象题库有10道题你快速从上往下滑然后再从下往上滑回来会发现某些选项突然被选中了而且选中的情况根本不是你之前点的。原因就在convertView复用。上一屏的item滚动出屏幕时它上面的TextView背景色可能还是“选中蓝色”滚回来之后如果getView里没有把用户未选中的项恢复成默认背景视觉上就会“残留”。所以我特别强调applyOptionStyle必须覆盖所有分支未提交状态下的选中和未选中提交状态下的正确、错误、普通三个分支一个都不能少。否则就会踩到“状态残留”这种隐蔽的坑。5.3 补一个顺手优化把findViewById收敛到ViewHolder之外在老的Eclipse项目里ListView item的findViewById开销虽然不算大到不能用但作为一个长期维护的项目我还是在ViewHolder之外额外做了一层收敛四个选项TextView的引用都放在holder数组里而不是写四行重复的findViewById。这样Adapter的代码行数会短很多读起来也更清晰。对Eclipse这种没有实时提示那么智能的老环境来讲代码越直白问题越少。另外一个小技巧提交答案之后我已经把Adapter的mIsSubmitted置为true此时我调用mBtnSubmit.setEnabled(false)把按钮置灰防止用户反复提交。这个细节一开始我没做测试的时候发现连点两次提交弹窗会叠两层相当掉价。置灰之后弹窗体验干净了很多。6. 一套代码跑通后我再回看这几个设计决策习题界面做完之后我又重新从头到尾看了一遍代码有几个决策想再复盘一下因为它们确实影响了这个模块的完成度和后续可维护性。第一个是“把userAnswer放进QuestionBean而不是单独存状态”。Android开发里经常会有人用position作为状态关联的key但ListView这种视图复用的组件里position永远是脆弱的。数据实体是自己人状态跟着实体走界面永远只是数据的投影这个原则在复杂交互的列表页里几乎是万能的。第二个是“submit之后立刻notifyDataSetChanged而不是逐个改View”。这种方法副作用是性能上多消耗一点但换来的是状态统一逻辑简单。列表只有10条数据的时候开发效率优先级永远高于微秒级的性能优化。如果以后题目量大了可以换成局部刷新但那属于另一个问题了。第三个是“不要贪图RadioGroup的现成分组能力”。它确实帮你处理了单选互斥但在ListView的复用机制下它带来的状态管理负担远大于它节省的代码量。自己做四个TextView每个点击事件里先把旧的清除再设置新的这个互斥逻辑你还能看得清清楚楚出了问题也能很快定位。第四个是“进度反馈不要只做数字用进度条配合”。一开始我纠结过10道题用进度条是不是有点小题大做但实际用起来发现用户看到进度条比看文字更有动力去填满剩下的题。这算是一个产品层面没什么成本的小优化。做完这个界面之后博学谷的离线学习主流程基本上就闭环了学完课程进入习题页做题得到正确率反馈查看每道题的解析。整个过程不依赖任何网络服务单机就能跑通。下一篇我再补上一个答题统计页把上次练习的正确率、错题数这些数据沉淀下来这个系列就能完整收尾了。