简介:这是一套面向安卓课程设计学生与初学者的学生信息管理App完整工程包,围绕期末项目场景,提供增删改查、列表展示与本地存储的可运行参考实现。压缩包共1426个文件,约21.14MB,以xml布局资源、java源码、class与dex编译产物、apk安装包为主,并含png图标、json配置和Gradle构建文件,兼顾源码阅读与运行验证。已有3740人学习,说明其在同类课设资料中参考热度较高。项目覆盖布局设计、RecyclerView适配器、SQLite与SharedPreferences、Activity生命周期与Intent跳转、对话框、菜单、运行时权限、Logcat调试及APK打包发布等知识点,读者可据此梳理完整项目结构、对照关键模块实现,并作为个人作品集素材。
1. 用最少的依赖做出能演示的增删改查
每年到这个节点,总有人翻遍网络找一份能直接跑的 Android Studio 学生信息管理 App 课程设计,下载下来却发现只有半截代码,缺布局、缺数据库、缺跑通的截图,改个包名就编译不过。这份 Mystudent 完整版针对的就是这种尴尬:它把登录、学生列表、新增、修改、删除、搜索这几条主流程串成了一个能装机演示的安卓课设,工程可以直接用 Android Studio 打开,不用再拼七零八落的片段。它适合两类人:一是需要在一两周内交出一份能答辩的安卓课程设计或毕设初稿的学生,二是不想从空工程开始、希望拿现成分层结构改造成自己题目的开发者。下面从需求边界讲到数据库、界面、避坑,最后落到答辩验收,把这套工程当作一份完整方案拆开来看。
2. 需求拆解与技术选型:一个课设该做多大才算刚好
课设最容易出问题的地方不是写不出来,而是范围没定死。一开始想着做课程表、成绩、考勤、导出 Excel,做到一半发现每个模块都半成品,答辩时演示到第三个功能就崩了。所以在动手之前,先把「必须做」和「可以做」分开,剩下的时间全部投给主链路的稳定性和可讲性。
2.1 功能边界的划定:两个角色与六条主流程
这套工程的角色设计走的是最小可用路线:管理员和学生。管理员登录后能看到全部学生信息,可以做增、删、改、查;学生账号登录后只能浏览列表和查看详情,没有编辑入口。这个设计看似简单,但答辩时非常好讲——你能顺理成章地解释权限判断在哪一层做、UI 上怎么隐藏按钮、后端(这里是本地数据库)怎么防止越权。
六条主流程分别是:登录校验、列表展示、条件搜索、新增学生、编辑学生、删除学生。每一条都要能在真机或模拟器上独立跑通,并且有明确的失败反馈。比如新增时学号重复,要弹出提示而不是静默失败;删除前要有二次确认弹窗,避免误触把演示数据删光。
通常的做法是把「可以做」的部分列成一个清单放在旁边,比如成绩表、头像上传、数据导出 CSV,这些在主流程全部稳定之后再挑一个加进去,作为加分项。顺序千万别反,先做加分项、主干没跑通,是最常见的翻车方式。
提示:演示数据准备 8 到 12 条,学号、姓名、专业各不相同,其中留一两条姓名带生僻字或长专业名,用来验证列表的省略与换行处理。
2.2 技术栈取舍:Java 配原生 SQLite,还是 Kotlin 配 Room
这是个绕不开的选择。两种做法都合法,差别在于答辩时你能不能把原理讲圆。原生 SQLiteOpenHelper 需要自己写建表 SQL、自己管 Cursor 的关闭、自己处理版本升级,代码量大一些,但每一行你都知道在干什么;Room 用注解生成实现,写起来快,可一旦被追问「它底层怎么建表」「迁移怎么写」,答不上来就很被动。
我在课程设计场景下一般推荐 Java + 原生 SQLite,理由是三条:一是不引入额外注解处理器,Gradle 配置简单,不容易在新版本 Android Studio 上出兼容问题;二是建表语句就摆在 onCreate 里,导师一眼能看到字段和约束;三是网上能查到的示例多,卡住时容易找到参照。
| 维度 | Java + SQLiteOpenHelper | Kotlin + Room |
|---|---|---|
| 建库方式 | 手写 CREATE TABLE | 注解自动生成 |
| 版本升级 | 手写 onUpgrade 分支 | 写 Migration 对象 |
| 答辩可讲性 | 每一行 SQL 都能解释 | 需要额外理解生成逻辑 |
| 上手成本 | 略高,但可控 | 低,但依赖配置更敏感 |
| 适合场景 | 课程设计、需要讲清原理 | 有一定基础、追求开发效率 |
如果确实想用 Room,也不是不行,但要提前把「实体类怎么映射表」「查询语句怎么编译期校验」这两个问题想清楚,别在答辩现场卡壳。
2.3 包结构约定:让分层这件事一眼看得出来
很多课设的代码全堆在 MainActivity 里,两千行一个文件,导师翻两页就知道是拼的。这份工程按职责分了包,结构不复杂,但层次清楚:
// app/build.gradle 关键片段,版本号按你本地 SDK 里能拉到的填 android { compileSdk 34 // 按本地已下载的 SDK 调整 defaultConfig { applicationId "com.example.mystudent" minSdk 24 // 低于 24 的老机型不在考虑范围 targetSdk 34 versionCode 1 versionName "1.0" } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' // 不引入网络库、不引入图片加载库,课设阶段没必要 }依赖刻意压到最少:appcompat 提供兼容支持,material 提供 MaterialButton、TextInputLayout 这些控件,recyclerview 负责列表。不引网络库是因为数据全在本地;不引图片库是因为没有远程头像需求。依赖越少,别人拿到工程导入时踩的坑越少。
包结构按activity、adapter、dao、bean、db、util划分,每个包里放什么职责固定下来,后续加模块直接按位置放就行,不会出现「这个类到底该塞哪」的纠结。
3. 数据库设计:三张表撑起整套增删改查
学生信息管理 App 的骨架就是数据库。表设计得不合理,后面界面层怎么改都别扭;表设计对了,增删改查基本是填空题。这份工程用了三张表:用户表、学生表、成绩表。
3.1 表结构与字段约束
学生表是整个 App 的核心。学号用 TEXT 类型做主键的替代唯一键,而不是用自增 id 当学号——因为学号本身就有业务含义,改成自增 id 之后,搜索、去重、导入导出都要多做一层映射。姓名不能为空,性别用 0/1 存整型而不是存「男」「女」字符串,专业和手机号允许为空,创建时间存时间戳,用来给列表排序。
用户表存账号、密码和角色。角色用整型区分,1 是管理员,0 是学生。密码在课设阶段可以先明文,但我一般会在代码注释里写清楚真实项目必须加盐哈希,这样答辩时被问到安全问题,你有话说。
成绩表是加分项,字段是学生学号、课程名、分数,通过学号关联学生表。它让整个工程从「一个列表增删改查」升级成「有关联关系的小系统」,讲起来层次更高。
| 表名 | 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|---|
| student | _id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| student | sno | TEXT | UNIQUE NOT NULL | 学号,业务唯一 |
| student | name | TEXT | NOT NULL | 姓名 |
| student | gender | INTEGER | DEFAULT 0 | 0 女 1 男 |
| student | age | INTEGER | 可空 | 年龄 |
| student | major | TEXT | 可空 | 专业 |
| student | phone | TEXT | 可空 | 联系电话 |
| student | create_time | INTEGER | 可空 | 创建时间戳,用于排序 |
| user | username | TEXT | UNIQUE NOT NULL | 登录名 |
| user | password | TEXT | NOT NULL | 密码 |
| user | role | INTEGER | DEFAULT 0 | 1 管理员 0 学生 |
| score | sno | TEXT | NOT NULL | 关联学号 |
| score | course | TEXT | NOT NULL | 课程名 |
| score | point | REAL | 可空 | 分数 |
把学号设成 UNIQUE 是个关键决策:它把「重复录入」这件事挡在数据库层,而不是靠界面判断。界面判断永远有遗漏路径,数据库约束是最后一道保险。
3.2 SQLiteOpenHelper 建库与版本升级
建库代码集中在 DBHelper 里,用单例保证全局只有一个实例。这里有个细节很多人忽略:SQLiteOpenHelper 不要每次用完就 close,频繁开关反而更容易出问题,正确做法是让它在应用生命周期内存活。
public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "mystudent.db"; private static final int DB_VERSION = 2; // 改字段必须改这个值 private static volatile DBHelper instance; // 单例:避免多处 new 出多个 helper 导致锁竞争 public static DBHelper get(Context ctx) { if (instance == null) { synchronized (DBHelper.class) { if (instance == null) { instance = new DBHelper(ctx.getApplicationContext()); } } } return instance; } private DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { // 学生表:学号唯一,防止重复录入 db.execSQL("CREATE TABLE student (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + "sno TEXT UNIQUE NOT NULL," + "name TEXT NOT NULL," + "gender INTEGER DEFAULT 0," + "age INTEGER," + "major TEXT," + "phone TEXT," + "create_time INTEGER)"); // 用户表:role 区分管理员与学生 db.execSQL("CREATE TABLE user (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + "username TEXT UNIQUE NOT NULL," + "password TEXT NOT NULL," + "role INTEGER DEFAULT 0)"); // 初始账号,省得第一次进不去系统 db.execSQL("INSERT INTO user(username,password,role) VALUES('admin','123456',1)"); db.execSQL("INSERT INTO user(username,password,role) VALUES('stu001','123456',0)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 版本 1 升 2:新增 email 字段,用 ALTER 而不是重建表,保住已有数据 if (oldVersion < 2) { db.execSQL("ALTER TABLE student ADD COLUMN email TEXT"); } } }onCreate 只在数据库文件第一次创建时执行,这一点是后面很多坑的根源。你改了建表语句但没改 DB_VERSION,App 跑起来依然是老表结构,然后报no such column。onUpgrade 里用 ALTER TABLE 加字段,比 DROP 重建温和得多,至少演示数据不会丢。
3.3 数据访问层封装:把 Cursor 关在 DAO 里
所有 SQL 都收在 StudentDao 和 UserDao 里,Activity 不直接碰 Cursor。这样做的好处是:查询逻辑集中,改字段只改一处;Cursor 的关闭有统一位置,不会忘;Activity 里的代码读起来是业务语言而不是数据库语言。
public class StudentDao { private final Context ctx; public StudentDao(Context ctx) { this.ctx = ctx.getApplicationContext(); } // 新增:返回 -1 表示学号重复,由上层决定怎么提示 public long insert(Student s) { SQLiteDatabase db = DBHelper.get(ctx).getWritableDatabase(); ContentValues cv = new ContentValues(); cv.put("sno", s.getSno()); cv.put("name", s.getName()); cv.put("gender", s.getGender()); cv.put("age", s.getAge()); cv.put("major", s.getMajor()); cv.put("phone", s.getPhone()); cv.put("create_time", System.currentTimeMillis()); try { return db.insertOrThrow("student", null, cv); } catch (SQLiteConstraintException e) { return -1; // 唯一约束冲突 } } // 条件查询:关键字同时匹配姓名、学号、专业 public List<Student> search(String keyword) { List<Student> list = new ArrayList<>(); SQLiteDatabase db = DBHelper.get(ctx).getReadableDatabase(); String like = "%" + keyword.trim() + "%"; Cursor c = db.rawQuery( "SELECT * FROM student WHERE name LIKE ? OR sno LIKE ? OR major LIKE ? " + "ORDER BY create_time DESC", new String[]{like, like, like}); try { while (c.moveToNext()) { list.add(readRow(c)); // readRow 内部按列名取值,避免下标错位 } } finally { c.close(); // Cursor 必须关,否则大量查询后会内存告警 } return list; } }这里有两个参数值得说明。insertOrThrow在唯一约束冲突时抛异常,比insert返回 -1 更容易区分「失败原因」,代价是要 try-catch。LIKE ?用占位符而不是字符串拼接,既避免了 SQL 注入,也让代码更干净——虽然课设数据量小,但这个习惯值得养成。
另外,按列名取值(c.getColumnIndexOrThrow("name"))比按下标取值稳,因为一旦你在 SELECT 里调整了字段顺序,按下标取值的代码会静默取错列,这种 bug 排查起来非常费劲。
4. 界面与交互落地:登录、列表、编辑三段式
数据库搭好之后,界面层要做的事其实很线性:登录进去、看到列表、点进去改、回来刷新。这四步串起来,整个 App 就活了。
4.1 登录与角色分流
登录页有两个输入框、一个登录按钮、一行提示文字。点击登录时,先去 user 表按用户名查记录,再比对密码,匹配成功就把用户名和角色写进 SharedPreferences,然后跳转主界面。角色决定了主界面是否显示「新增」和「编辑」入口。
private void doLogin() { String name = etUser.getText().toString().trim(); String pwd = etPwd.getText().toString().trim(); if (name.isEmpty() || pwd.isEmpty()) { tip("账号和密码不能为空"); return; } User u = userDao.findByName(name); if (u == null || !u.getPassword().equals(pwd)) { tip("账号或密码不正确"); return; } // 登录态落盘,下次打开免登录 getSharedPreferences("login", MODE_PRIVATE).edit() .putString("username", u.getUsername()) .putInt("role", u.getRole()) .apply(); startActivity(new Intent(this, MainActivity.class)); finish(); // 关掉登录页,避免返回键又回到登录 }finish()这一行容易被漏掉。不调用它,用户按返回键会回到登录页,演示时看起来像「登录没成功」,很尴尬。密码明文比对在课设里可以接受,但代码里要留注释说明生产环境应做哈希加盐,这是加分项而不是减分项。
4.2 RecyclerView 列表与自定义 Item
主界面用 RecyclerView 展示学生列表,每个条目显示姓名、学号、专业三行信息,右侧放一个箭头图标暗示可点击。Adapter 里做三件事:绑定数据、处理点击、暴露刷新方法。
public class StudentAdapter extends RecyclerView.Adapter<StudentAdapter.VH> { public interface OnItemClick { void onClick(Student s, int pos); } private final List<Student> data = new ArrayList<>(); private final OnItemClick clickListener; public StudentAdapter(OnItemClick l) { this.clickListener = l; } // 统一入口:先清空再添加,最后整体刷新 public void submit(List<Student> newData) { data.clear(); data.addAll(newData); notifyDataSetChanged(); } @Override public VH onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_student, parent, false); return new VH(v); } @Override public void onBindViewHolder(VH h, int position) { Student s = data.get(position); h.tvName.setText(s.getName()); h.tvSno.setText("学号:" + s.getSno()); h.tvMajor.setText(s.getMajor() == null ? "未填写专业" : s.getMajor()); h.itemView.setOnClickListener(v -> clickListener.onClick(s, h.getAdapterPosition())); } @Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { TextView tvName, tvSno, tvMajor; VH(View v) { super(v); tvName = v.findViewById(R.id.tv_name); tvSno = v.findViewById(R.id.tv_sno); tvMajor = v.findViewById(R.id.tv_major); } } }submit方法里先clear再addAll,是为了复用同一个 List 对象,避免外部持有引用导致数据源不一致。如果直接在外部把 List 换掉再调notifyDataSetChanged,很容易出现「数据变了但界面没变」的情况,这是课设里最常见的列表 bug 之一。
item 布局本身不复杂,一个横向 LinearLayout 套两个垂直区域,左侧放三行文本,右侧放 ImageView。文本用singleLine加ellipsize="end",防止长专业名把布局撑破。
4.3 编辑页数据回填与 Intent 传值
点击列表项进入编辑页,需要把当前学生对象传过去。课设里最省事的做法是让 Student 实现 Serializable,用 Intent 的 putExtra 直接传对象;数据量大的时候才考虑只传学号、进页面再查一次。
// 列表页跳转 Intent intent = new Intent(this, EditActivity.class); intent.putExtra("student", s); // Student 需实现 Serializable intent.putExtra("mode", "edit"); startActivityForResult(intent, REQ_EDIT); // 编辑页回填 Student s = (Student) getIntent().getSerializableExtra("student"); if (s != null) { etName.setText(s.getName()); etSno.setText(s.getSno()); etSno.setEnabled(false); // 学号不允许改,避免主键漂移 etMajor.setText(s.getMajor()); }学号在编辑态禁用是个细节决策:学号是业务唯一键,改学号等于改身份,关联的成绩记录会全部失联。禁用之后,新增页和编辑页可以共用一个 Activity,靠mode参数区分标题和按钮文案,代码量少一半。
保存完成后调用setResult(RESULT_OK)再finish(),列表页在onActivityResult里重新查一次数据库刷新,这样新增和修改走的是同一条刷新路径,不用区分。
4.4 搜索与排序:一个 TextWatcher 解决大部分加分项
搜索框放在列表顶部,输入即查。用 TextWatcher 监听输入变化,每次变化都调 DAO 的 search 方法,再提交给 Adapter。数据量在几百条以内,直接查库完全够用,不必上防抖。
etSearch.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) {} @Override public void onTextChanged(CharSequence s, int a, int b, int c) {} @Override public void afterTextChanged(Editable s) { List<Student> result = dao.search(s.toString()); adapter.submit(result); tvEmpty.setVisibility(result.isEmpty() ? View.VISIBLE : View.GONE); } });空状态提示是个小成本高收益的设计。搜索没有结果时列表一片空白,演示时看着像卡住了;放一个「没有找到匹配的学生」的 TextView,观感立刻不同。
排序方面,DAO 里已经用ORDER BY create_time DESC让最新录入的排在最前。如果想再进一步,可以加一个按学号升序的切换按钮,改一行 SQL 的 ORDER BY 即可,成本极低。
5. 避坑与排查:课程设计里最容易出错的五个地方
下面这五个问题,我见过太多次,几乎每一份学生信息管理类课设都会踩中其中两三个。把它们提前排掉,能省下大量返工时间。
5.1 数据层的坑
第一,改了表结构但没升版本号。现象是 App 一启动就崩溃,日志里写着no such column: email或table student has no column named xxx。原因是 onCreate 只在数据库文件首次创建时执行,你改了建表语句,但手机上还留着旧库文件,新字段根本不存在。解决办法是把 DBHelper 里的 DB_VERSION 加一,并在 onUpgrade 里写对应的 ALTER 语句;如果只是调试阶段、数据不重要,直接卸载重装或者清除应用数据也能立刻恢复,但交付前一定要把 onUpgrade 补全,否则换台手机就出新问题。
第二,Cursor 没关闭导致查询越来越慢。现象是用久了列表加载明显变卡,Logcat 里开始出现 CursorWindow 相关警告。原因是 rawQuery 返回的 Cursor 在使用后没有 close,游标一直占着内存,查询次数一多就累积。解决办法是用 try-finally 把遍历逻辑包起来,finally 里统一 close;更彻底的做法是在 DAO 里提供一个模板方法,把所有查询的关闭动作收进去。
第三,学号重复录入没有被拦住。现象是列表里出现两条一模一样的学生,删掉一条另一条还在,成绩关联也乱了。原因往往是界面层做了一次判重,但漏掉了编辑保存、批量导入这些路径。解决办法是把 UNIQUE 约束建在数据库上,插入时用 insertOrThrow 捕获 SQLiteConstraintException,把异常转成界面提示。数据库约束不会漏,界面判断会。
5.2 界面与构建的坑
第四,列表数据更新了但界面不动。现象是新增一条学生后返回列表,还是原来的几条,退出重进才出现。原因是 Adapter 持有的是最初那个 List 的引用,或者刷新时只重新赋值了外部变量却没调 notifyDataSetChanged。解决办法是统一走 adapter.submit(newList) 这一个入口,内部 clear + addAll + 通知刷新,杜绝各处零散地改数据源。
第五,打包成 apk 后闪退,但 Debug 模式跑得好好的。现象是导出安装包发给别人,对方一打开就崩,日志里是ClassNotFoundException或字段丢失。原因通常是开启了代码混淆,而实体类没加保留规则,字段名被混淆后反序列化对不上。解决办法是在 Debug 阶段先关掉 minifyEnabled,交付时如果必须开混淆,就在混淆规则里保留 bean 包和序列化相关的类,并且用真实安装包完整跑一遍六条主流程再交。
注意:答辩前一天一定要用「卸载后重新安装」的方式验证一遍,而不是只在已安装旧版本的机器上覆盖升级。首次安装路径和升级路径走的是不同分支,很多问题只在其中一条路径上出现。
6. 验收与答辩:三分钟演示脚本和一张自检表
功能都写完只是及格线,能不能讲清楚才是分水岭。我一般的做法是在答辩前整理一张自检表,把每个功能点对应到操作路径和预期结果,逐条跑一遍并打勾,跑不通的当场修,绝不带到现场。
| 功能点 | 操作路径 | 预期结果 | 是否通过 |
|---|---|---|---|
| 登录校验 | 输入 admin/123456 点击登录 | 进入主界面,显示全部学生 | 待测 |
| 错误提示 | 输入错误密码 | 弹出「账号或密码不正确」,停留原页 | 待测 |
| 列表展示 | 进入主界面 | 显示 8 条以上演示数据,按时间倒序 | 待测 |
| 新增学生 | 点击新增,填学号姓名专业,保存 | 返回列表,新记录出现在首行 | 待测 |
| 重复学号 | 用已存在的学号新增 | 提示学号已存在,不写入数据库 | 待测 |
| 编辑学生 | 点击列表项,改专业后保存 | 列表对应项专业已更新,学号不可改 | 待测 |
| 删除学生 | 长按列表项,确认删除 | 弹二次确认,确认后该条消失 | 待测 |
| 搜索 | 输入姓氏或专业关键字 | 列表实时过滤,无结果时显示空状态 | 待测 |
| 角色权限 | 用学生账号登录 | 新增、编辑入口不可见 | 待测 |
| 持久化 | 杀掉进程重进 | 登录态保留,数据不丢 | 待测 |
演示脚本控制在三分钟:先用管理员账号登录,讲一句「账号信息存在 user 表,角色字段控制权限」;然后展示列表并搜索一个关键字,讲「关键字同时匹配姓名、学号和专业,走的是 SQLite 的 LIKE 查询」;接着新增一条学生,故意用重复学号触发一次唯一约束提示,这一步非常加分,因为它证明你考虑过异常路径;最后编辑并删除一条,再切到学生账号登录展示权限差异,收尾。
答辩提问通常围绕三个方向:为什么用 SQLite 而不是 MySQL,答案是本地存储、无需服务端、适合单机场景;数据库版本升级怎么处理,答案指向 onUpgrade 的版本分支;如果数据量涨到几万条怎么办,答案是加索引、分页查询、把查询挪到子线程。这三个问题提前准备好,基本不会冷场。
从那以后我每次交付这类课设,都强制走一遍「卸载重装 + 用表逐条打勾」的流程,宁可多花半小时,也不愿在演示时被一个空指针打断。希望帮到你。
本文还有配套的精品资源,点击获取