简介:一份完整的基于Android平台开发的“驾校一点通”App项目源码,适合Android课程设计、毕业设计及移动开发初学者学习使用。项目围绕驾考用户核心场景,包含用户注册登录、角色权限校验、驾校选择与注意事项、考试报名材料、科目一至科目四考试说明等模块,从需求到实现均有完整代码支撑。压缩包大小为14.52MB,共1122个文件,含Java源码136个、XML布局与配置223个、class编译文件269个、PNG图片295个,另有GIF/JPG图片、JAR依赖库及SQL数据库脚本等,便于按模块阅读和调试。已有196人学习下载。通过这份源码,可了解Android项目用户管理、数据库访问、界面跳转及资源组织思路,也可直接导入开发工具运行,在现有基础上扩展驾校资讯或考试题库等功能,节省从零搭建时间,适合课设参考与入门进阶。
1. Android驾校一点通:从源码清单看一个完整APP的骨架
拿到一个Android学习项目源码包,先不急着跑起来,看一遍class文件清单就能知道作者的分层习惯。驾校一点通这类APP看起来业务复杂,科目一题库、科目二三四流程说明、用户注册登录、倒计时交卷,拆开看核心就是Activity + DAO + SQLite,再套一个ViewPager做题目翻页。这个项目里RegisterAction、ExameAction、RegisterDao、ExameDao、TimeSelector、AnimationView这些类把业务动作和数据访问做了明确区分,对于正在做Android app开发、尤其是准备系统课设或入职练手的人,是一个值得拆解的样本。下文按数据层、用户模块、答题引擎三个方向展开,最后聊一下从class反推源码时值得保留和重写的点。
2. 数据层解构:RegisterDao与ExameDao背后的SQLite设计
2.1 从class文件名反推数据层职责划分
RegisterDao和ExameDao两个类名放在一起看,作者是严格按业务模块切DAO的。这种划分在中小型Android项目中比单例DBHelper + 通用DAO更实用——每个模块维护自己的表结构和查询语句,改科目题库时不会动到用户表,出问题也好定位。
常见做法是给每个DAO配一个SQLiteOpenHelper子类,或者在DAO内部持有同一个helper实例。驾考类APP的表结构至少有这几张:
user表:用户ID、用户名、密码(md5摘要)、角色(学员/管理员)、注册时间school表:驾校名称、地址、评分,学车流程模块里选择驾校时读取subject表:科目编号(1~4)、考试说明、内容简介,对应摘要里的学车流程模块question表:题目ID、所属科目、题干、选项A/B/C/D、正确答案、题目类型(判断/单选/多选)
其中question表是题库核心,科目一和科目四共用一张表,靠subject字段区分,这样分页加载题目时只需要WHERE subject = ?一句SQL。
2.2 建表与升级:SQLiteOpenHelper的常规写法
ExameDao内部持有helper是常见做法,helper负责版本管理。驾考APP每次更新可能增减题目,onUpgrade里不能直接删表重建,会把用户答题记录一起清掉。
public class DbHelper extends SQLiteOpenHelper { // 版本号每次升级+1,增量更新而不是删表 private static final int DB_VERSION = 2; public DbHelper(Context context) { super(context, "drive_db", null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE IF NOT EXISTS user (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "username TEXT UNIQUE," + "password TEXT," + "role INTEGER DEFAULT 0," + "created_at TEXT)"); db.execSQL("CREATE TABLE IF NOT EXISTS question (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "subject INTEGER," + "question TEXT," + "option_a TEXT," + "option_b TEXT," + "option_c TEXT," + "option_d TEXT," + "answer TEXT," + "type INTEGER DEFAULT 0)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 只在版本2时增加列,不影响已有数据 if (oldVersion < 2) { db.execSQL("ALTER TABLE question ADD COLUMN analysis TEXT DEFAULT ''"); } } }DB_VERSION从1升到2时onUpgrade只执行ALTER TABLE,老数据完整保留。如果某次改动表结构比较复杂,可以在onCreate里直接删掉旧表重建,但生产环境不建议,课设场景倒是无所谓。role INTEGER DEFAULT 0区分学员和管理员,管理员端可以看注册用户列表,普通学员只能访问题库和考试功能。
2.3 DAO层常见误用与改造点
很多Android入门项目把SQL写在Activity里,这项目单独拆出RegisterDao和ExameDao是一层进步,但仍然有两个常见问题:
| 问题 | 项目中的表现 | 建议改造 |
|---|---|---|
| 连接未关闭 | 每次查询后没有及时close(),页面频繁跳转后内存上涨 | DAO方法内用finally块关闭Cursor和Database |
| 线程阻塞主线程 | 初始化数据、同步题库等耗时操作直接在主线程执行 | 用Handler或Executors包一层,题目批量插入用Transaction |
初始化题库是典型的耗时操作,一次性插入几百道题目如果不用事务,每秒只能写几十条。用db.beginTransaction()包住循环插入,速度能提升一个数量级,考试模块启动时明显不卡。
3. 注册登录模块:RegisterAction的校验链路与会话保持
3.1 RegisterAction的职责边界
RegisterAction从类名看是负责注册登录动作的控制器,配合RegisterDao做数据持久化。Android app开发里Controller层容易被忽略,很多人直接从Activity写SQL,而这个项目用Action包了一层,好处是Activity只处理界面和弹窗,业务规则集中在Action里,后续加找回密码、第三方登录都不用动界面代码。
3.2 注册合法性检查
摘要里提到“系统对注册账号进行合法性检查”,这里讲的注册接口包括三步:格式校验、重复性校验、密码强度校验。
public class RegisterAction { private RegisterDao mDao; // 返回值:0成功 1用户名已存在 2密码强度不够 3输入不合法 public int register(String username, String password, String confirmPwd) { if (username.length() < 4 || username.length() > 16) { return 3; } if (!password.equals(confirmPwd)) { return 3; } // 密码至少6位且包含字母和数字 if (!password.matches("^(?=.*[A-Za-z])(?=.*\\d).{6,}$")) { return 2; } try { return mDao.insertUser(username, md5(password)) ? 0 : 1; } catch (SQLiteConstraintException e) { // 捕获唯一索引冲突,说明账号已注册 return 1; } } public static String md5(String input) { // MessageDigest计算MD5摘要,数据库不存明文密码 try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(input.getBytes("UTF-8")); return Base64.encodeToString(digest, Base64.NO_WRAP); } catch (Exception e) { return ""; } } }insertUser返回false和抛出SQLiteConstraintException都代表注册失败,但前者是插入结果,后者是唯一索引冲突被捕获,区别在于:用户名重复时即使没有事先query也会在插入时被数据库拦截,避免并发场景下两个请求同时通过了“用户名不存在”校验。密码用MD5 + Base64存,虽然现在更推荐加盐的SHA-256,但课设项目用MD5能跑通全流程,容易被理解。
3.3 登录会话与权限分配
登录校验通过后要做两件事:缓存会话和按角色跳转。SharedPreferences是轻量级选择,存一个session_flag,App重启后根据这个标记直接进入主界面;角色不同跳转不同Activity或Fragment。
public class LoginAction { private RegisterDao mDao; public boolean login(String username, String password) { // 先比对账号密码 if (!mDao.checkPassword(username, md5(password))) { return false; } // 获取角色:0学员 1管理员 int role = mDao.getRoleByUsername(username); SharedPreferences sp = mAppContext.getSharedPreferences("session", Context.MODE_PRIVATE); sp.edit() .putString("username", username) .putInt("role", role) .putLong("login_time", System.currentTimeMillis()) .apply(); // 根据角色跳转 Intent intent = role == 1 ? new Intent(mAppContext, AdminActivity.class) : new Intent(mAppContext, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); mAppContext.startActivity(intent); return true; } }FLAG_ACTIVITY_NEW_TASK是必要的,login方法不一定在Activity上下文中被调用,ApplicationContext启动Activity不加这个flag会崩。SharedPreferences.apply()是异步写盘,相比commit()的同步写,不会在登录跳转时卡主线程,应用不大时差异不明显,但这个习惯值得保持。
3.4 容易被忽略的输入校验细节
合法性检查里要防的不只是空字符串,还有SQL注入。虽然SQLiteDatabase的query方法带了selectionArgs参数可以自动转义,但很多项目喜欢用字符串拼接SQL,这就给了注入空间。username' OR '1'='1拼进WHERE username =后面,等于绕过了密码校验。判断一个Android项目数据层是否合格,第一眼看DAO里有没有用selectionArgs,比看注释管用得多。
另一个细节是密码错误不要直接提示“用户名或密码错误”还是“密码错误”,前者更安全,避免攻击者通过报错差异枚举出已注册账号。
4. 答题考试引擎:ViewPager题库翻页与TimeSelector计时实现
4.1 ExameAction如何驱动答题流程
ExameAction负责考试流程的控制,包括加载题目、记录答案、计算得分。它和ExameDao配合:先根据subject参数拿到题目列表,再按答题卡封装成List<Question>传给ViewPager。这里要重点说明一点——题库加载是分页还是全量,直接决定体验。
科目一题库通常一千道左右,实际考试随机抽100题。把全部题目一次性加载进内存,每个Question对象带题干、四个选项、正确答案和解析,内存占用约几MB,在Android设备上是可以接受的。但如果是分批从网络拉题,或者题目带图片,就需要用PagerAdapter的instantiateItem做懒加载:只创建当前页和相邻页。
4.2 ViewPager答题卡:PagerAdapter与分页加载
驾考APP的答题界面几乎都是ViewPager左右滑动翻题,底部配上一排答题卡小方块标识已答未答。注意class清单里有ViewPager而没有RecyclerView,说明作者用的还是ViewPager方案,这在课设项目里很常见。
public class QuestionPagerAdapter extends PagerAdapter { private List<Question> mQuestions; private Map<Integer, String> mAnswers = new HashMap<>(); @Override public int getCount() { return mQuestions.size(); } @Override public boolean isViewFromObject(View view, Object object) { return view == object; } @Override public Object instantiateItem(ViewGroup container, int position) { View itemView = LayoutInflater.from(container.getContext()) .inflate(R.layout.item_question, container, false); Question q = mQuestions.get(position); TextView tvQuestion = itemView.findViewById(R.id.tv_question); RadioGroup rgOptions = itemView.findViewById(R.id.rg_options); tvQuestion.setText(position + 1 + ". " + q.getQuestion()); // 将选项A/B/C/D动态添加到RadioGroup setOptions(rgOptions, q); // 回显之前选过的答案 if (mAnswers.containsKey(position)) { rgOptions.check(mAnswers.get(position).equals("A") ? R.id.rb_a : mAnswers.get(position).equals("B") ? R.id.rb_b : R.id.rb_c); } rgOptions.setOnCheckedChangeListener((group, checkedId) -> { String answer = parseAnswer(checkedId); mAnswers.put(position, answer); }); container.addView(itemView); return itemView; } private String parseAnswer(int checkedId) { if (checkedId == R.id.rb_a) return "A"; if (checkedId == R.id.rb_b) return "B"; if (checkedId == R.id.rb_c) return "C"; return "D"; } }instantiateItem返回的是itemView,isViewFromObject判断view == object,这两点是ViewPager标准写法,缺一个都会白屏。Map<Integer, String>用来暂存每题的答案,切页后重新instantiateItem时通过mAnswers.containsKey回显,保证用户滑回来时之前的选项还在。
控件是RadioGroup而非CheckBox是合理的——单选和多选可以共用布局,多选时再把RadioGroup切成CheckBox,这也是考试类APP的通用做法。题目类型字段type在这里生效,type=2时换成多选逻辑。
4.3 TimeSelector计时组件的边界:从倒计时到交卷
TimeSelector从名字看是一个时间选择器,考试场景里通常做成倒计时进度条。每次考试45分钟,倒计时结束后自动交卷,这点是用CountDownTimer实现的。关键是计算剩余时间要用系统实时时钟,不能用Thread.sleep累计,否则息屏或跳后台后计时会漂移。
public class ExamCountDown extends CountDownTimer { private TextView mTvTime; private OnTimeOutListener mListener; // millisInFuture: 考试时长,countDownInterval: 刷新频率 public ExamCountDown(long millisInFuture, long countDownInterval) { super(millisInFuture, countDownInterval); } @Override public void onTick(long millisUntilFinished) { long totalSec = millisUntilFinished / 1000; String mm = String.format("%02d", totalSec / 60); String ss = String.format("%02d", totalSec % 60); mTvTime.setText(mm + ":" + ss); // 最后5分钟文字变红提示 if (totalSec <= 300) { mTvTime.setTextColor(0xFFFF0000); } } @Override public void onFinish() { // 时间到,强制交卷 if (mListener != null) { mListener.onTimeOut(); } } }onTick回调用String.format("%02d", ...)补齐两位数,秒数低于10时显示09而不是9,这是倒计时UI的细节。flagship点在于onFinish回调里做交卷动作,可以把Activity实例传给ExamCountDown,Activity.runOnUiThread里弹出确认对话框并跳转成绩页。
4.4 交卷判分与AnimationView的反馈动画
答题记录汇总在Map<Integer, String>里,交卷时遍历所有Question对比answer字段。AnimationView在class清单里单独出现,说明作者做了一个自定义View来展示考试结果,比如通过或未通过的动画反馈。
判分逻辑不算复杂,但有一个课设项目经常踩的坑:判断题和多选题的答案格式不统一。判断题答案是“对/错”,多选题答案是“ABD”,如果存在同一个answer字段里,判分时就要分类型处理。建议统一用大写字母拼接,判断题正确为A(对)、B(错),多选题按字母排序后拼接,解析时统一equals比较,少写不少if分支。
5. 从class反推源码:驾考类APP重构时的边界与取舍
5.1 拿到class文件怎么定位业务逻辑
如果手上只有编译后的class清单,没有Java源码,第一件事不是反编译,而是按名字分组:Action结尾的类大概率是业务入口,Dao结尾的是数据访问,Util结尾的是静态工具,R$dimen、R$drawable是资源索引,说明项目里用到了尺寸和图片资源。先定位Activity入口类——找到AndroidManifest里配置了MAIN意图的那些类,顺着Activity的成员变量索引到Action和Dao,整个调用链就通了。否则面对一百多个class无从下手。
5.2 三个值得保留的设计与两个建议重写的点
这个项目有三个设计点保留下来是有价值的:
| 设计 | 原因 | 改造方向 |
|---|---|---|
| DAO分层 | 数据访问独立成类,便于替换Room或GreenDao | 保留接口不变,内部实现替换 |
| Action控制器 | 注册、登录、考试流程从Activity剥离 | 扩展为ViewModel,配合LiveData |
| 科目题库单表 | 科目一至四统一存question表,复用同一套加载逻辑 | 增加索引CREATE INDEX idx_subject ON question(subject) |
两个建议重写的点:一是TimeSelector建议换成CountDownTimer封装,原实现大概率是Handler轮询,存在秒数跳变问题;二是数据库迁移策略,驾考APP的题库是定期更新的,直接在onUpgrade里删除重建虽然省事,但只要用户手机上已有答题记录,升级后就会被清空,标准做法是把question表的更新做成增量同步。
5.3 数据迁移与签名验证
驾考类APP从开发机搬到新项目有一个容易忽略的点:SQLite数据库文件路径是/data/data/包名/databases/,换签名或换包名后系统会当成全新应用,旧数据访问不到。测试时想保留题库数据,把drive_db文件用adb pull导出再推到新包名目录下即可。正式在Android Studio跑起来之前,记得Build > Generate Signed APK换掉debug签名,否则卸载重装后用户数据直接丢失——这也是用之前从class清单和资源文件里反推出来的包结构做的一次完整校验。
adb pull /data/data/com.example.driving/databases/drive_db client.db拉出来的client.db可以直接放在assets目录里作为预置题库,首次启动时检查数据库版本,版本一致就不做任何拷贝,这样既不写sdcard也不需要WRITE_EXTERNAL_STORAGE权限,适配Android 6.0以上的运行时权限模型时能少处理一个分支。
本文还有配套的精品资源,点击获取