简介:这是一份面向安卓初学者与课程设计学习者的学生信息管理应用完整工程包,基于Android Studio开发,可用于课堂项目、期末课设和个人作品集练习。压缩包共1426个文件、约21.14MB,以xml布局、Java源码、apk安装包及png图片素材为主,目录结构覆盖从界面搭建到打包运行,工程组织较为完整。目前已有3740人学习下载。项目围绕学生信息的新增、查询、修改和删除展开,涉及列表控件与适配器绑定、本地数据库和轻量键值存储、活动生命周期与意图跳转、提示对话框、菜单、输入校验及运行时权限等知识点,并保留日志调试与安装包生成内容,便于读者对照工程理解安卓应用架构、梳理课程设计实现思路,并复用界面布局与数据管理代码。
1. 选题切入:Android Studio 学生信息管理 App 为什么是安卓课程设计的合适选择
选题会上最容易卡住的环节不是写代码,而是定题目。计算器、记事本这类太单薄,演示两分钟就没东西可讲;电商、社交类又难以控制范围,首页做完就临近截止时间。学生信息管理 App 恰好卡在中间:业务对象只有一张学生表,但增删改查、列表渲染、条件搜索、统计汇总这些 Android Studio 课程设计的核心考点一个不落,答辩老师随便挑一个点都能问出深度。这套完整版把登录注册、学生列表、新增编辑删除、按学号姓名模糊搜索、按班级统计平均分全部打通,还配了一份能照着改的课程设计文档。适合两类人:赶安卓课程设计截止时间的本科生,和拿它当毕设原型、后续要往上加模块的同学。
2. 需求拆解与数据模型:从一张学生表落到六个可演示的功能点
课程设计翻车最多的原因,是需求范围定得太大。我见过不少同学开口就是「做一个校园综合管理平台」,结果三周过去,登录页的阴影还没调好。学生信息管理这个题目看着简单,但如果只做「列表 + 新增」,答辩时很难撑过十分钟。因此需求拆解的核心动作是:先把功能切成必做和加分两层,再给必做层安排一条能在三分钟内跑完的演示路径,最后倒推数据表应该长什么样。
2.1 功能边界的划定:三个必做模块和三个加分模块
必做模块以「能体现数据持久化和界面交互」为标准。第一个是账号模块,包含注册与登录,目的是让 App 有一个入口,也让 SharedPreferences 这类轻量存储有地方用。第二个是学生信息的增删改查,这是整个课程设计的主体,涉及 Activity 跳转、Intent 传值、表单校验、数据库写入。第三个是列表展示与关键字搜索,用来考察 RecyclerView 和 Adapter 的掌握程度,同时把「数据变化后界面怎么刷新」这个必考点引出来。
加分模块按投入产出比排序。成绩统计排第一,一条GROUP BY聚合查询就能出一张班级平均分排行,代码量小、演示效果直观。批量删除排第二,长按列表项进入多选状态,考的是 Adapter 状态管理和集合操作。导出 CSV 排第三,写文件到外部存储再把路径打到 Log 里,属于「有最好、没有也能过」的部分。
提示:加分模块一定要在必做模块全部跑通之后再动,顺序反了就是给自己埋雷。
2.2 学生表字段设计与建表语句
课设阶段不建议把表拆得太细。班级、专业如果单独建表,就要多写两张表的关联查询和两个管理界面,收益远小于成本。通常的做法是全部塞进一张t_student,用文本字段冗余存储,查询简单、调试直观。
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键,Adapter 里当唯一标识用 |
| stu_no | TEXT | NOT NULL UNIQUE | 学号,业务主键,重复插入会抛异常 |
| name | TEXT | NOT NULL | 姓名 |
| gender | TEXT | 可空 | 存「男」「女」字符串,不存 0/1,方便直接展示 |
| class_name | TEXT | 可空 | 班级,统计模块按这个字段分组 |
| major | TEXT | 可空 | 专业 |
| phone | TEXT | 可空 | 联系电话 |
| score | REAL | DEFAULT 0 | 成绩,用 REAL 而不是 INTEGER,方便后面加小数 |
| create_time | TEXT | 可空 | 创建时间,存yyyy-MM-dd HH:mm:ss字符串 |
对应的建表语句如下,我一般直接写在SQLiteOpenHelper.onCreate()里,注意class_name不要写成class,那是 Java 关键字,看着没事,但后面写实体类时很容易顺手打出来然后编译报错。
CREATE TABLE t_student ( id INTEGER PRIMARY KEY AUTOINCREMENT, stu_no TEXT NOT NULL UNIQUE, name TEXT NOT NULL, gender TEXT, class_name TEXT, major TEXT, phone TEXT, score REAL DEFAULT 0, create_time TEXT );UNIQUE约束加在stu_no上是有意为之。很多同学在 Java 层用「先查一遍再插入」的方式判重,逻辑上没错,但并发或者连续点击时会漏。交给数据库约束,插入失败时insert()返回 -1,代码里判断这个返回值就行,省事且不会错。
2.3 界面清单与页面跳转关系
页面数量控制在五个,多了写不完,少了撑不住。登录页负责账号入口,注册页可以做成登录页上的一个弹窗或者独立 Activity;主界面是学生列表,顶部放搜索框,右下角放悬浮按钮;新增/编辑页复用同一个 Activity,靠 Intent 带不带 id 区分模式;统计页单独一个 Activity,从主界面菜单进去。
页面之间的传参约定要提前定死,否则写到一半改来改去。我的习惯是:跳转到编辑页时传id(long 型),编辑页拿到 id 后回查数据库填充表单;从编辑页返回时用setResult(RESULT_OK)加startActivityForResult(新版本用 ActivityResultLauncher),主界面在回调里重新查询列表。这样一来,新增和编辑走同一条刷新链路,不用维护两套逻辑。
2.4 数据流:一次「新增学生」在四层里怎么走
把一次新增拆开看,链路是这样的:新增页 Activity 收集输入框内容并做非空与格式校验,通过后组装成Student实体对象;实体对象交给 DBHelper 的insert()方法,内部用ContentValues装字段、调SQLiteDatabase.insert();返回的 long 型 id 大于 0 表示成功,此时setResult(RESULT_OK)并finish();主界面收到回调,重新调queryByKeyword()拿到最新列表,再adapter.setData(list)配合notifyDataSetChanged()刷新。
这条链路里最容易出问题的是最后一步:数据明明插进去了,列表就是不刷新。原因通常是 Activity 生命周期没走对,或者回调用的startActivityForResult被launchMode干扰了。后面避坑章节会专门讲。
3. 工程结构与技术选型:Android Studio 下的四层包结构与 SQLite 落地方式
新建工程时选的模板,直接影响后面改代码的顺手程度。我一般选 Empty Views Activity,语言选 Java。Kotlin 当然更好,但课程设计阶段多数教材和实验指导还是 Java 示例,遇到问题能对上的资料更多,答辩老师问起来也好回答。包名统一用com.example.mystudent这种形式,别用中文,也别用带大写字母的段。
3.1 包结构划分:db、bean、adapter、ui 四层的职责
四层划分不是为了好看,是为了在答辩时能指着项目结构讲清楚「哪块负责什么」。具体职责如下。
bean包放实体类,一个Student类,字段和表字段一一对应,写全 getter/setter 和一个空构造、一个全参构造。db包放DBHelper和StudentDao(或者直接叫StudentDBManager),前者管建库建表,后者管所有 SQL。adapter包放StudentAdapter和它的ViewHolder。ui包放所有 Activity,按功能再分子目录,比如ui.login、ui.list、ui.edit、ui.stat。
这种分法有个好处:改数据库字段时只需要动bean和db两个包,界面层基本不用碰。有同学把所有类堆在默认包下,十几个文件平铺,交作业时自己都找不到文件,更别说讲清楚了。
3.2 SQLiteOpenHelper 与 Room 的取舍
这是选型上最值得说的一段。Room 是官方推荐的 ORM,注解 + 编译期校验,写起来确实舒服。但课程设计场景下,我通常还是选SQLiteOpenHelper,理由有三个。
第一是依赖问题。Room 需要引入androidx.room:room-runtime和annotationProcessor,机房电脑或者校园网环境拉依赖经常超时,一旦卡在同步阶段,后面什么都干不了。第二是可讲性。答辩时老师问「数据库怎么建的」,你答「写了个接口加 @Dao 注解」,老师大概率会继续追问底层实现,反而露怯;用 SQLiteOpenHelper 的话,onCreate里那几行execSQL就是最直接的答案。第三是可控性。课设数据量就几十条,ORM 带来的性能优势体现不出来,反而在联表查询和自定义聚合时,写rawQuery比拼 Room 的 @Query 更自由。
如果确实想用 Room,也不是不行,但要提前把 Gradle 依赖和注解处理器配好,并且准备好应对版本不匹配的报错。
3.3 Gradle 配置与 SDK 版本对齐
版本号是玄学重灾区。同一份代码,A 同学机器上能跑,B 同学拉下来就红一片,十有八九是 compileSdk 和依赖版本对不上。我一般把 app 模块的build.gradle写成下面这样,注意版本号以你本机能成功同步到的为准,别硬抄。
android { namespace 'com.example.mystudent' compileSdk 34 // 与本地已下载的 SDK Platform 保持一致 defaultConfig { applicationId "com.example.mystudent" minSdk 21 // 覆盖绝大多数模拟器与真机 targetSdk 34 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } } dependencies { 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定 21 是个经验值,低于这个值的模拟器现在已经很少见,定太高又会漏掉一些老设备。sourceCompatibility用 17 是因为新版 Android Studio 自带的 JDK 就是 17,如果你本地是 JDK 11,把这两行一起改掉,不要只改一个,sourceCompatibility和targetCompatibility必须一致。
注意:改完 Gradle 一定要点 Sync Now,看到 BUILD SUCCESSFUL 再继续写代码。跳过同步直接跑,报的错往往和真正原因隔着十万八千里。
4. 核心模块实现:登录校验、增删改查与列表交互的关键代码
这一章把几个真正要写进项目的代码块摊开讲。代码不是越多越好,课程设计里最值钱的是「知道每行在干什么,出了问题知道去哪找」。下面的示例都基于 Java,包名沿用上一节的结构。
4.1 数据库帮助类与 CRUD 封装
先看DBHelper,重点是onCreate和onUpgrade两个回调。onCreate只在数据库文件第一次创建时执行一次,所以建表语句必须写在这里;onUpgrade在版本号变大时触发,课设阶段最省事的做法是直接删表重建,别去写复杂的ALTER TABLE,数据丢就丢了,反正是演示数据。
public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "student.db"; // 库文件名,卸载 App 时一并删除 private static final int DB_VERSION = 1; // 表结构变更后必须 +1,否则 onUpgrade 不触发 public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { // 建表语句与 2.2 节的字段设计严格一致 db.execSQL("CREATE TABLE t_student (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "stu_no TEXT NOT NULL UNIQUE," + "name TEXT NOT NULL," + "gender TEXT," + "class_name TEXT," + "major TEXT," + "phone TEXT," + "score REAL DEFAULT 0," + "create_time TEXT)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 课设阶段直接重建,避免遗漏字段导致升级后查询报错 db.execSQL("DROP TABLE IF EXISTS t_student"); onCreate(db); } }封装 DAO 的时候,插入和查询是最常用的两个方法。插入注意判断返回值,查询注意Cursor用完必须close(),否则反复进出列表页会积累资源泄漏。
public class StudentDao { private final DBHelper helper; public StudentDao(Context context) { this.helper = new DBHelper(context); } /** 插入一条学生记录,返回新行 id;返回 -1 表示失败(多为学号重复) */ public long insert(Student s) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues cv = new ContentValues(); cv.put("stu_no", s.getStuNo()); cv.put("name", s.getName()); cv.put("gender", s.getGender()); cv.put("class_name", s.getClassName()); cv.put("major", s.getMajor()); cv.put("phone", s.getPhone()); cv.put("score", s.getScore()); cv.put("create_time", s.getCreateTime()); long rowId = db.insert("t_student", null, cv); db.close(); return rowId; } /** 按学号或姓名模糊查询,关键字为空时返回全部 */ public List<Student> queryByKeyword(String keyword) { SQLiteDatabase db = helper.getReadableDatabase(); List<Student> list = new ArrayList<>(); String kw = keyword == null ? "" : keyword.trim(); // 用 ? 占位符传参,避免拼接字符串带来的引号与注入问题 Cursor cursor = db.query("t_student", null, "stu_no LIKE ? OR name LIKE ?", new String[]{"%" + kw + "%", "%" + kw + "%"}, null, null, "id DESC"); while (cursor.moveToNext()) { Student s = new Student(); s.setId(cursor.getLong(cursor.getColumnIndexOrThrow("id"))); s.setStuNo(cursor.getString(cursor.getColumnIndexOrThrow("stu_no"))); s.setName(cursor.getString(cursor.getColumnIndexOrThrow("name"))); s.setGender(cursor.getString(cursor.getColumnIndexOrThrow("gender"))); s.setClassName(cursor.getString(cursor.getColumnIndexOrThrow("class_name"))); s.setMajor(cursor.getString(cursor.getColumnIndexOrThrow("major"))); s.setPhone(cursor.getString(cursor.getColumnIndexOrThrow("phone"))); s.setScore(cursor.getDouble(cursor.getColumnIndexOrThrow("score"))); s.setCreateTime(cursor.getString(cursor.getColumnIndexOrThrow("create_time"))); list.add(s); } cursor.close(); db.close(); return list; } }参数说明几个容易忽略的点。getColumnIndexOrThrow比getColumnIndex好,字段名写错会立刻抛异常,而不是悄悄返回 -1 然后在别处崩。query的第四个参数是selectionArgs,LIKE的通配符%要拼在值里,不要拼在条件字符串里。排序用id DESC,让最新添加的排在最上面,演示时更直观。
4.2 登录与注册的校验链路
课设的登录不需要真的接后端,用 SharedPreferences 存账号密码就够。注册时把用户名当 key、密码当 value 写进去,登录时读出来比对。为了不让密码明文躺在 xml 里太难看,加一层 MD5 摘要,虽然安全性谈不上,但至少是个可讲的点。
/** 注册:用户名作为 key,密码摘要作为 value 存入私有 SharedPreferences */ public boolean register(String user, String pwd) { SharedPreferences sp = context.getSharedPreferences("user", Context.MODE_PRIVATE); if (sp.contains(user)) { return false; // 用户名已存在 } sp.edit().putString(user, md5(pwd)).apply(); return true; } /** 登录:比对摘要值 */ public boolean login(String user, String pwd) { SharedPreferences sp = context.getSharedPreferences("user", Context.MODE_PRIVATE); String saved = sp.getString(user, null); return saved != null && saved.equals(md5(pwd)); }表单校验不要只判断isEmpty()。手机号字段可以用^1\\d{10}$简单校验,成绩字段用Double.parseDouble包一层 try-catch,输入「九十」这种内容直接 toast 提示,别让异常冒到上层把 App 崩掉。这类细节在答辩时是加分项,因为它体现了「考虑过用户乱输」。
4.3 RecyclerView 列表与搜索刷新
列表这块,Adapter 的写法决定了后面改需求有多痛苦。我习惯在 Adapter 里定义一个点击回调接口,把条目点击和长按都暴露给 Activity,而不是在 Adapter 内部直接 startActivity。这样统计页复用同一个 Adapter 时不用改一行代码。
public class StudentAdapter extends RecyclerView.Adapter<StudentAdapter.VH> { public interface OnItemAction { void onClick(Student s); // 单击进入编辑 void onLongClick(Student s); // 长按进入多选删除 } private final List<Student> data = new ArrayList<>(); private final OnItemAction action; public StudentAdapter(OnItemAction action) { this.action = action; } /** 整批替换数据并刷新,避免 clear + addAll 之后忘记 notify */ public void setData(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 holder, int position) { Student s = data.get(position); holder.tvName.setText(s.getName()); holder.tvNo.setText(s.getStuNo()); holder.tvClass.setText(s.getClassName()); holder.tvScore.setText(String.valueOf(s.getScore())); holder.itemView.setOnClickListener(v -> action.onClick(s)); holder.itemView.setOnLongClickListener(v -> { action.onLongClick(s); return true; // 返回 true 表示事件已消费,不再触发单击 }); } @Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { TextView tvName, tvNo, tvClass, tvScore; VH(View v) { super(v); tvName = v.findViewById(R.id.tv_name); tvNo = v.findViewById(R.id.tv_no); tvClass = v.findViewById(R.id.tv_class); tvScore = v.findViewById(R.id.tv_score); } } }搜索框的联动用TextWatcher,在afterTextChanged里拿关键字重新查库并调setData。数据量大的时候可以加 200 毫秒防抖,课设数据量小不加也没关系,但知道这个概念可以写进文档。item_student.xml用 ConstraintLayout 排四个 TextView,别用 LinearLayout 套三层,渲染层级越浅越好。
4.4 成绩统计的聚合查询
统计页是整个项目里代码最少、演示效果最好的一块。核心就一条 SQL,按班级分组算人数、平均分和最高分。
/** 按班级聚合:人数、平均分、最高分,按平均分降序 */ public List<ClassStat> statByClass() { SQLiteDatabase db = helper.getReadableDatabase(); List<ClassStat> result = new ArrayList<>(); String sql = "SELECT class_name, COUNT(*) AS total, AVG(score) AS avg_score, " + "MAX(score) AS max_score FROM t_student " + "GROUP BY class_name ORDER BY avg_score DESC"; Cursor c = db.rawQuery(sql, null); while (c.moveToNext()) { ClassStat stat = new ClassStat(); stat.setClassName(c.getString(c.getColumnIndexOrThrow("class_name"))); stat.setTotal(c.getInt(c.getColumnIndexOrThrow("total"))); stat.setAvgScore(c.getDouble(c.getColumnIndexOrThrow("avg_score"))); stat.setMaxScore(c.getDouble(c.getColumnIndexOrThrow("max_score"))); result.add(stat); } c.close(); db.close(); return result; }AVG返回的是浮点数,展示时用String.format("%.1f", avg)保留一位小数,别直接toString(),否则界面上会出现一长串小数位。班级字段为空的记录会被分到一组,class_name显示为 null,界面里加个判空显示「未分班」即可。这套统计逻辑同样可以扩展成「按专业统计」,把GROUP BY的字段换掉就行,讲的时候可以顺带提一句,显得考虑过扩展性。
5. 避坑与常见问题排查:五个让课设半夜返工的高频问题
下面这几条基本都是我或者身边的同学真踩过的,写在这里省得你再走一遍。每条按现象、原因、解决的顺序说。
5.1 数据层的坑
现象一:新增成功但列表不刷新,退出重进才看得到。原因多半是新增页finish()之前没有setResult(RESULT_OK),或者主界面用的startActivityForResult被onActivityResult里的requestCode判断写错了。解决方式是把 requestCode 抽成常量,回调里先判断resultCode == RESULT_OK再查库,两步都不能省。更现代的做法是换用ActivityResultLauncher,在registerForActivityResult的回调里直接刷新,少一层判断。
现象二:插入时报UNIQUE constraint failed,App 直接闪退。原因是学号字段加了 UNIQUE 约束,重复插入时insert()内部捕获异常并返回 -1,但如果代码里没判断返回值,后面又拿这个 -1 去查记录,就会出问题。解决是在插入后立刻判断rowId == -1,是的话 toast 提示「学号已存在」,直接 return,不要继续往下走。
现象三:查询出来的中文全是乱码或者空值。这种情况通常不是编码问题,而是Cursor取列时字段名拼错了,getColumnIndex返回 -1,getString(-1)拿到 null。把getColumnIndex全部换成getColumnIndexOrThrow,字段名写错会当场抛异常,定位速度快十倍。
5.2 界面与生命周期的坑
现象四:从编辑页返回后,列表位置跳到最顶部,用户刚看到的那条不见了。原因是notifyDataSetChanged()会重绘全部条目,RecyclerView 丢失了滚动位置。解决是用DiffUtil做增量更新,或者简单点,在刷新前记录LinearLayoutManager的findFirstVisibleItemPosition(),刷新后scrollToPosition回去。课设阶段后者够用。
现象五:模拟器上跑得好好的,换到真机就白屏。先看 Logcat 有没有NetworkOnMainThreadException或者权限相关的报错。信息管理 App 一般不联网,白屏多数是主题问题——AndroidManifest.xml里 Activity 的 theme 继承了NoActionBar但布局里又用了Toolbar,或者反过来。另一个常见原因是真机 Android 版本高于 compileSdk,minSdk设得比真机版本还高,安装时就被拦了。对着 Logcat 从头往下读,第一条红色报错往往就是根因,别急着看后面几十行。
注意:调试阶段把
adb logcat过滤到自己包名,信息量会小很多,别在几万行日志里大海捞针。
6. 验收与答辩:三分钟演示脚本和评委最爱追问的四个问题
课程设计的分数,一半在代码,一半在演示。我见过功能做全了但讲不明白只拿及格的,也见过功能一般但演示流畅拿了优的。演示脚本建议提前写成文字,掐着表走一遍。
第一分钟,打开 App,展示登录页,用注册好的账号登进去,说明账号数据存在 SharedPreferences 里,顺便提一句密码做了摘要处理。第二分钟,主界面展示预置的十来条学生数据,输入「张」演示模糊搜索,再新增一条记录,返回列表看到新记录出现在顶部,长按删除一条并确认列表同步减少。第三分钟,进统计页,展示班级平均分排行,说明用的是GROUP BY聚合查询,最后回到项目结构,用十秒钟点一下 db、bean、adapter、ui 四个包,让老师知道分层清晰。
评委的追问基本集中在四个方向,提前准备答案:
| 追问 | 回答要点 |
|---|---|
| 数据存在哪里,卸载后还在吗 | SQLite 数据库文件在应用私有目录,卸载即清除;可提一句 SharedPreferences 同样如此 |
| 学号重复怎么处理 | 数据库 UNIQUE 约束 + 插入返回 -1 判断,双层保证 |
| 数据量大了列表会卡吗 | 目前全量查询,可扩展为分页查询LIMIT ? OFFSET ?,并说明 DiffUtil 减少重绘 |
| 密码明文存安全吗 | 已做 MD5 摘要,进一步可用加盐哈希,课设环境未接后端故未做传输加密 |
最后说个血泪经验。我第一次交课设的时候,演示到一半模拟器突然弹窗提示存储空间不足,App 直接崩了,当场冷汗。后来每次答辩前,我都会强制走一遍「清空模拟器数据 → 全新安装 → 完整演示一遍」的流程,并且把演示要用的数据提前用代码预置进去,不依赖现场手动输入。这个习惯一直保留到现在做正式项目,发布前的回归走查也是同一个道理。希望帮到你。
本文还有配套的精品资源,点击获取