简介:面向安卓课程设计与Android Studio入门者的学生信息管理App完整工程,围绕学生信息的增删改查组织界面、数据存储与活动跳转等核心训练,适合需要完成课程设计、期末项目或作品集演示的开发者参考。压缩包共1426个文件,约21.14MB,包含xml布局与资源、java源码、gradle构建脚本、apk安装包及png图标等,既有可运行产物,也保留了工程配置与调试痕迹,便于对照学习RecyclerView列表、SQLite数据库、SharedPreferences、AlertDialog和运行时权限等常见实现。已有3740人学习下载,可作为从界面搭建到打包发布流程的完整案例,帮助读者理解Android项目结构、适配器数据绑定和Logcat排错思路,并在此基础上改造成自己的学生管理应用。
1. 从一份课设工程看学生信息管理 App:它能跑起来靠的是什么
有人把 Mystudent.rar 解压完,在 Android Studio 里 File → Open 直接打开,Gradle Sync 转了十分钟,右下角弹出Could not resolve com.android.tools.build:gradle,于是怀疑包被压坏了。实际情况往往相反:代码一行没动,问题出在 Gradle、Android Gradle Plugin 和 compileSdk 三处版本没对齐,IDE 用了本机缓存里的另一套工具链去解析这个工程。
这份资源是一整套 Android Studio 学生信息管理 App 课程设计工程:Java 侧的实体类、SQLiteOpenHelper、RecyclerView Adapter、增删改查四个页面,加上 res 下的布局、字符串、主题和 Manifest,是一个能完整走通「列表—新增—编辑—删除—搜索」的最小闭环。适合三类人:要交课设、需要一份能演示的骨架的;刚学完四大组件、想看看它们怎么串起来的;带学生做实训、想找参照实现拆讲的。后面按导入、数据层、UI 层、排错、自测的顺序拆,每一步都落到具体文件和参数上。
2. Android Studio 工程导入:Gradle、AGP 与 SDK 的三处对齐
拿到一个陌生工程,我不会急着点 Run。先把目录翻一遍,确认它是什么年代的写法、依赖链有多长、有没有已经废弃的 API,再决定是在原工程上改还是新建工程把源码搬过去。这一章的顺序就是我自己导入的顺序。
2.1 解开压缩包先看什么:目录结构与模块职责
Android Studio 工程有固定的骨架,认准下面这几个位置,基本就能判断这份工程的质量。
| 路径 | 职责 | 出问题时的典型症状 |
|---|---|---|
settings.gradle | 声明参与构建的模块 | 只有 app 一个模块时最容易漏看,多模块工程必须在这里 include |
项目级build.gradle | 声明 AGP 版本和仓库 | Sync 卡在Resolving dependencies或直接报插件找不到 |
app/build.gradle | compileSdk、minSdk、依赖库 | 编译报红一片、Cannot resolve symbol R |
gradle/wrapper/gradle-wrapper.properties | 锁定 Gradle 发行版 | 下载超时、版本与 AGP 不匹配 |
app/src/main/AndroidManifest.xml | 组件注册、权限声明 | 运行即崩,日志里Unable to instantiate activity |
app/src/main/java/... | Activity、Adapter、Bean、DB Helper | 类找不到、包名与目录不一致 |
app/src/main/res/layout | 页面与 item 布局 | R.layout.xxx解析不到 |
学生信息管理这类课设工程,java 目录下通常就四类文件:bean/Student.java实体、db/StudentDbHelper.java数据库帮助类、adapter/StudentAdapter.java列表适配器、若干XxxActivity.java。res 下会有activity_main.xml、activity_edit.xml、item_student.xml三套主要布局。先把这个对应关系在脑子里建立起来,后面改哪个文件、为什么改,心里有数。
一个容易被忽略的点:namespace和applicationId。老工程用 Manifest 里的package属性,新版本 AGP 要求写在app/build.gradle的namespace里,两边不一致会报Namespace not specified。打开工程第一件事,就是把这两个值和 Java 源码的包路径对一遍,三者必须一致。
2.2 Gradle Wrapper、AGP 与 compileSdk 的版本对齐
这是导入阶段最容易翻车的地方,也是前面那个报错的根源。三者的关系是:Gradle 是构建引擎,AGP 是跑在它上面的 Android 插件,compileSdk 决定你能调哪个 API 级别的类。AGP 对 Gradle 有最低版本要求,compileSdk 又受 AGP 支持上限约束,任何一环超出去都会在 Sync 阶段挂掉。
先看 wrapper 里锁的是哪个版本:
# gradle/wrapper/gradle-wrapper.properties distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists # 这一行决定用哪个 Gradle,不要去改成本机已装的版本,先看它 distributionUrl=https\://services.gradle.org/distributions/gradle-8.x-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/distsdistributionUrl里的-bin和-all有区别:all带源码和文档,体积大一倍,课设阶段用bin就够。改完这个文件,Android Studio 会在下次 Sync 时重新下载对应版本,下载进度在底部 Build 窗口能看到。如果公司或学校网络对services.gradle.org不友好,这一步会长时间停在Downloading gradle-8.x-bin.zip,此时把distributionUrl指向内网或本地已有的发行版压缩包路径即可。
再看 AGP:
// 项目级 build.gradle buildscript { repositories { google() // AGP 只在 google() 仓库里,缺了必然找不到 mavenCentral() } dependencies { // AGP 版本与 Gradle 版本强绑定,改一个就得同步改另一个 classpath 'com.android.tools.build:gradle:8.x.x' } }然后是模块级的三档 SDK:
// app/build.gradle android { namespace 'com.example.mystudent' // 必须和 Java 源码包路径一致 compileSdk 34 // 编译期用的 SDK,决定能调哪些 API defaultConfig { applicationId "com.example.mystudent" // 安装到设备上的唯一标识 minSdk 21 // 低于此值的设备装不上,课设建议 21 起 targetSdk 34 // 影响运行时行为,一般跟 compileSdk 齐平 versionCode 1 versionName "1.0" } }这三档的含义经常被混为一谈。minSdk是下限,决定你的 App 能不能装在某台手机上;compileSdk是编译时用的那套 API,调用了高于它的方法编译不过;targetSdk影响系统对你的兼容策略,比如存储权限、后台限制。课设演示用的模拟器一般是最新系统镜像,targetSdk太低会出现界面被拉伸、通知不显示之类的怪现象。我一般把compileSdk和targetSdk设成同一个值,minSdk按演示设备来定。
对齐规则记一条就够:AGP 大版本往上走,对 Gradle 和 JDK 的最低要求跟着往上走。AGP 8 起步要求 JDK 17,AGP 7.x 用 JDK 11 就够。所以第三步是确认 IDE 用的 JDK:Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK,选成和 AGP 要求匹配的那个。这一项设错,报错信息往往是Unsupported class file major version,看起来和 Android 毫无关系,实际就是 JDK 版本不对。具体的版本对应关系以官方兼容性表为准,这里不给死数字,因为不同小版本之间会有微调。
2.3 第一次 Sync 报错的定位顺序
Sync 失败时窗口里会刷出一大段日志,很多人从最后一行开始看,那是错的。构建日志的因果链是自顶向下的,第一条 error 才是根因,后面的多半是它的连带反应。
我自己固定按这个顺序排查:
- 先看 Gradle JDK 是否匹配。这一项不匹配会引发一连串莫名其妙的类加载错误。
- 再找日志里第一条
error或FAILURE,跳过所有warning和info。 - 依赖下载类报错,看是哪个仓库连不上,在项目级
build.gradle的repositories里补mavenCentral()或本地镜像。 - 最后才动源码。Sync 没过之前改代码没有任何意义,编译都没开始。
想确认到底是工程问题还是 IDE 缓存问题,绕开 IDE 直接在命令行跑一次最直接:
# 工程根目录执行,先确认工程本身能不能构建 # Windows 用 gradlew.bat,macOS/Linux 用 ./gradlew ./gradlew :app:assembleDebug --stacktrace # 依赖解析卡住时加 --info,能看到每个仓库的请求过程 ./gradlew :app:assembleDebug --info # 怀疑缓存脏了,先清再构建,比在 IDE 里点 Clean 彻底 ./gradlew clean :app:assembleDebug--stacktrace打全栈,报错定位到具体行;--info打详细过程,适合排查依赖下载;clean清掉build/目录下的中间产物。如果命令行能构建成功而 IDE 不行,那就是 IDE 的缓存或 JDK 配置问题,File → Invalidate Caches 走一遍通常就好。反过来,命令行也失败,说明问题在工程配置本身,按 2.2 的三处对齐重新核一遍。
3. 学生信息的数据层:SQLiteOpenHelper 建表与增删改查
UI 层怎么改都是表面,这份工程真正决定「能不能用」的是数据层。学生信息管理本质上就是一张表的增删改查,把这块写扎实,界面部分是纯体力活。
3.1 课设为什么用 SQLite 而不是 Room 或云端
现在新项目基本都上 Room,编译期校验 SQL、支持协程、迁移有框架兜底。但课设场景我依然建议保留原生 SQLite,理由有三个。
第一是可解释性。答辩时被问到「数据存在哪、怎么查出来的」,你能直接把建表语句和Cursor遍历讲清楚,比说「用了 Room,注解自动生成」有说服力得多。第二是依赖最少。Room 要额外引入 androidx.room 三个包加注解处理器,多一层依赖就多一处导不进来的风险,压缩包在别人机器上打不开的概率直接翻倍。第三是调试直观。Android Studio 的 App Inspection 能直接看 SQLite 表里的数据,改一条、刷一下就能验证,不需要懂生成的 DAO 实现。
云端数据库在这个场景下完全没必要:课设演示是单机、离线、几个人的数据量,为了同步去搭服务端只会把问题复杂化。真要做多端,那是另一个课题。
3.2 建表:字段设计与建表语句
字段设计上有个小决定影响后面所有代码:主键用自增id还是学号sno。我的做法是两个都留——id做物理主键,供 Adapter 和增删改使用;sno加唯一约束,做业务主键,防止录入重复学号。原因是学号可能被改(转专业、录错了要修正),一旦拿它当主键,改学号就等于删一条再插一条,编辑页的逻辑会变得很别扭。
// app/src/main/java/com/example/mystudent/db/StudentDbHelper.java public class StudentDbHelper extends SQLiteOpenHelper { public static final String DB_NAME = "student.db"; // 改了下面的建表语句就必须把它 +1,否则老设备上不会重新建表 public static final int DB_VERSION = 1; public static final String TABLE = "student"; // 建表语句:id 自增主键,sno 加唯一约束防重复录入 private static final String SQL_CREATE = "CREATE TABLE " + TABLE + " (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + // 物理主键,Adapter 里当 item 标识 "sno TEXT NOT NULL UNIQUE, " + // 学号,业务唯一键 "name TEXT NOT NULL, " + // 姓名,非空 "gender TEXT, " + // 性别,允许空 "age INTEGER, " + // 年龄 "major TEXT, " + // 专业 "phone TEXT)"; // 联系方式 public StudentDbHelper(Context context) { // 第三个参数传 null 表示用默认的数据库文件路径 super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE); // 只在数据库文件不存在时执行一次 } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 课设阶段最省事的做法:删表重建;真实项目必须走 ALTER TABLE 保住数据 db.execSQL("DROP TABLE IF EXISTS " + TABLE); onCreate(db); } }SQLiteOpenHelper的构造参数里,第三个CursorFactory传null走默认实现,第四个是版本号。onCreate只在student.db这个文件第一次被创建时调用一次,之后不管你怎么改建表语句,只要版本号没变,它都不会再执行——这是新手最常翻的车,改完字段发现查询报no such column,八成就是这个原因。
TEXT NOT NULL UNIQUE这个组合值得单独说。UNIQUE会在sno上自动建一个索引,插入重复学号时底层直接拒绝,比在 Java 层先查一遍再插入要可靠得多——后者在快速连点保存按钮时会有并发窗口。
3.3 增删改查封装:方法签名与参数含义
数据库操作全部收在 Helper 里,Activity 只管调方法拿结果,不直接碰SQLiteDatabase。这样做的好处是换存储方案时只改一个文件。
// 新增:用 ContentValues 而不是拼字符串,避免引号转义和注入问题 public long insert(Student s) { SQLiteDatabase db = 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()); // CONFLICT_ABORT:学号重复时放弃本次插入并返回 -1,不会覆盖已有记录 return db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_ABORT); }insertWithOnConflict的第三个参数是冲突策略。可选值有CONFLICT_ABORT(默认,放弃)、CONFLICT_REPLACE(删旧插新,会连带把自增 id 换掉)、CONFLICT_IGNORE(静默忽略)。课设场景要的是「学号重复就提示用户」,所以用ABORT,返回值是-1就说明撞了唯一约束。
// 查询:selection 用 ? 占位,第二个数组按顺序填值,这是防注入的标准写法 public List<Student> query(String keyword) { SQLiteDatabase db = getReadableDatabase(); String selection = null; String[] args = null; if (keyword != null && !keyword.isEmpty()) { // 学号或姓名任一命中即可,两个 ? 对应下面数组的两个值 selection = "sno LIKE ? OR name LIKE ?"; args = new String[]{"%" + keyword + "%", "%" + keyword + "%"}; } Cursor c = db.query(TABLE, null, selection, args, null, null, "id DESC"); // 按 id 倒序,新增的排最前 List<Student> list = new ArrayList<>(); while (c.moveToNext()) { Student s = new Student(); // getColumnIndexOrThrow 比 getColumnIndex 安全,列名写错会直接抛异常而不是返回 -1 s.setId(c.getLong(c.getColumnIndexOrThrow("id"))); s.setSno(c.getString(c.getColumnIndexOrThrow("sno"))); s.setName(c.getString(c.getColumnIndexOrThrow("name"))); s.setMajor(c.getString(c.getColumnIndexOrThrow("major"))); list.add(s); } c.close(); // 必须关,不关会在长列表滚动时累积泄漏 return list; }db.query的七个参数依次是:表名、要返回的列(null表示全部)、selection条件、条件参数、groupBy、having、orderBy。大部分课设只用得到前四个和最后一个,中间三个传null。
Cursor用完必须close(),这一点在模拟器上看不出问题,但真实设备上反复查询几十次之后会明显卡顿。更稳妥的写法是包在 try-with-resources 里,不过Cursor实现了Closeable,直接try (Cursor c = ...)也行。
// 更新与删除:统一以 id 为条件,不用 sno,因为学号本身是可编辑字段 public int update(Student s) { SQLiteDatabase db = 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()); // 返回受影响行数,0 表示 id 不存在 return db.update(TABLE, cv, "id=?", new String[]{String.valueOf(s.getId())}); } public int delete(long id) { SQLiteDatabase db = getWritableDatabase(); // 返回值同样是受影响行数,可以据此判断是否真的删掉了 return db.delete(TABLE, "id=?", new String[]{String.valueOf(id)}); }这里有个设计上的取舍:update没有用updateWithOnConflict,因为编辑时改学号撞上别人的学号,会直接抛SQLiteConstraintException。稳妥做法是在 Activity 里捕获这个异常转成提示,或者在update里也指定CONFLICT_ABORT并让调用方判断返回值。
3.4 onUpgrade 与数据库版本管理
课程设计答辩经常被问「如果要加一个字段怎么办」,这个问题答不好会显得整个数据层是抄的。
标准动作是两步:改SQL_CREATE里的建表语句,同时把DB_VERSION加一。版本号一变,onUpgrade就会被调用,参数里带着旧版本和新版本两个值,可以按版本区间写迁移逻辑。
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 按版本号逐级迁移,每一步只处理一个版本的差异 if (oldVersion < 2) { // 第二版加了邮箱字段,用 ALTER TABLE 保留已有数据 db.execSQL("ALTER TABLE " + TABLE + " ADD COLUMN email TEXT"); } if (oldVersion < 3) { // 第三版给专业字段加了默认值 db.execSQL("UPDATE " + TABLE + " SET major = '未填写' WHERE major IS NULL"); } }课设阶段用DROP TABLE重建也行,演示机上数据本来就少,删了重新录一遍不费事。但要在文档里写清楚这是简化处理,并把上面这段按版本区间迁移的写法附上——答辩时被追问,这就是加分项。真按删表重建写进正式项目,用户升级一次 App 数据全没了,属于典型的血泪经验。
4. 列表与表单:RecyclerView 绑定与编辑页回传
数据层通了之后,UI 层要解决的是三个问题:列表怎么把数据显示出来、点进去编辑之后怎么刷新、删除怎么防止误操作。
4.1 RecyclerView + Adapter 的绑定链路
RecyclerView的链路是:Activity 里拿到数据列表 → 交给 Adapter → Adapter 创建 ViewHolder 并把数据塞进 item 布局的控件里。中间任何一环断了,表现都是「列表空白但没报错」,比崩溃更难查。
// StudentAdapter:把 List<Student> 绑到 item_student.xml public class StudentAdapter extends RecyclerView.Adapter<StudentAdapter.VH> { // 用接口把点击事件抛回 Activity,Adapter 不持有 Activity 引用,避免内存泄漏 public interface OnItemAction { void onEdit(Student s); void onDelete(Student s); } private final List<Student> data = new ArrayList<>(); private final OnItemAction action; public StudentAdapter(OnItemAction action) { this.action = action; } // 统一入口刷新数据,避免外部直接操作 data 造成状态不一致 public void submit(List<Student> list) { data.clear(); data.addAll(list); notifyDataSetChanged(); // 课设数据量小够用;上百条以上换 DiffUtil } @NonNull @Override public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { // 第三个参数必须传 false,传给 RecyclerView 自己处理测量,传 true 会丢布局参数 View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_student, parent, false); return new VH(v); } @Override public void onBindViewHolder(@NonNull VH h, int position) { Student s = data.get(position); h.tvSno.setText(s.getSno()); h.tvName.setText(s.getName()); h.tvMajor.setText(s.getMajor() == null ? "未填写" : s.getMajor()); // 用 getBindingAdapterPosition 而不是闭包里的 position // position 在删除一条后就会错位,点了 A 删掉 B 的经典 bug 就是这么来的 h.itemView.setOnClickListener(v -> { int p = h.getBindingAdapterPosition(); if (p != RecyclerView.NO_POSITION) action.onEdit(data.get(p)); }); h.btnDel.setOnClickListener(v -> { int p = h.getBindingAdapterPosition(); if (p != RecyclerView.NO_POSITION) action.onDelete(data.get(p)); }); } @Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { TextView tvSno, tvName, tvMajor; Button btnDel; VH(@NonNull View v) { super(v); tvSno = v.findViewById(R.id.tv_sno); tvName = v.findViewById(R.id.tv_name); tvMajor = v.findViewById(R.id.tv_major); btnDel = v.findViewById(R.id.btn_del); } } }inflate的第三个参数是这几行里最容易被忽略的。传true意味着立刻把 item 挂到parent上,而RecyclerView要自己管理挂载时机,两者冲突会导致 item 宽度变成match_parent或者干脆不显示。
getBindingAdapterPosition()和闭包里的position的区别也值得记一下。onBindViewHolder传进来的position是绑定那一刻的位置,列表删除一项之后,后面所有 item 的位置都变了,但已经绑定的点击回调里还留着旧值。表现就是「点了第三条,删掉的是第四条」,属于必现的玄学 bug。getBindingAdapterPosition()每次都实时算,还会返回NO_POSITION让你判断是否已经被移除。
Activity 侧的接线:
// MainActivity:列表初始化只做一次,数据刷新走 reload() recycler.setLayoutManager(new LinearLayoutManager(this)); // 竖向列表 adapter = new StudentAdapter(new StudentAdapter.OnItemAction() { @Override public void onEdit(Student s) { openEdit(s); } @Override public void onDelete(Student s) { confirmDelete(s); } }); recycler.setAdapter(adapter); private void reload() { // 每次都从数据库重新查,不在内存里增删,保证列表和库里的数据一致 adapter.submit(db.query(etSearch.getText().toString().trim())); }reload()每次都走一遍数据库查询,看起来有点浪费,但换来的是「界面上看到的一定是库里的」,省掉了大量状态同步的代码。课设数据量几十条,查询耗时在毫秒级,不值得为它做内存缓存。
4.2 新增/编辑页与 Activity 结果回传
新增和编辑共用一个页面是最省事的做法:传id = -1就是新增,传真实id就是编辑,保存时根据id决定走insert还是update。
回传数据这块,startActivityForResult已经废弃了,现在用ActivityResultLauncher:
// 在字段位置注册,必须在 onCreate 之前或之中完成,不能放在点击回调里 private final ActivityResultLauncher<Intent> editLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() == RESULT_OK) { reload(); // 编辑页说存成功了,回来重新查一遍 } }); private void openEdit(Student s) { Intent i = new Intent(this, EditActivity.class); // 新增时传 -1,编辑时传主键,编辑页据此判断走哪条分支 i.putExtra("id", s == null ? -1L : s.getId()); editLauncher.launch(i); }编辑页在保存成功后必须调setResult(RESULT_OK)再finish(),否则主界面收不到信号,用户会看到「明明保存了但列表没变」。
// EditActivity:保存逻辑 btnSave.setOnClickListener(v -> { String sno = etSno.getText().toString().trim(); String name = etName.getText().toString().trim(); // 前端校验只做最基本的非空,唯一性交给数据库的 UNIQUE 约束 if (sno.isEmpty()) { etSno.setError("学号不能为空"); return; } if (name.isEmpty()) { etName.setError("姓名不能为空"); return; } Student s = new Student(); s.setId(getIntent().getLongExtra("id", -1L)); // -1 表示新增 s.setSno(sno); s.setName(name); s.setGender(rgGender.getCheckedRadioButtonId() == R.id.rb_male ? "男" : "女"); s.setAge(parseIntSafe(etAge.getText().toString())); s.setMajor(etMajor.getText().toString().trim()); s.setPhone(etPhone.getText().toString().trim()); boolean ok; try { ok = s.getId() > 0 ? db.update(s) > 0 : db.insert(s) > 0; } catch (SQLiteConstraintException e) { // 学号撞了唯一约束,这里必须捕获,否则直接崩 Toast.makeText(this, "学号已存在", Toast.LENGTH_SHORT).show(); return; } if (ok) { setResult(RESULT_OK); // 通知主界面刷新 finish(); } else { Toast.makeText(this, "保存失败,请重试", Toast.LENGTH_SHORT).show(); } });parseIntSafe是个小工具方法,把空字符串和非法输入都转成 0,避免Integer.parseInt("")抛NumberFormatException。年龄这种字段用户完全可能留空,不做兜底就是一个必崩点。
4.3 搜索过滤与删除确认
搜索用TextWatcher监听输入,每次变化就重查。课设数据量小,直接在主线程查没有问题;如果要做大,得换LiveData加后台线程,那是进阶话题。
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 e) { // 输入变化就按关键字重查,空字符串等于查全部 adapter.submit(db.query(e.toString().trim())); } });删除一定要二次确认。直接点了就删,演示时手滑一下数据就没了,而且 SQLite 没有回收站。
private void confirmDelete(Student s) { new AlertDialog.Builder(this) .setTitle("确认删除") // 弹窗里把学号和姓名打出来,让用户确认删的是哪一条 .setMessage("学号 " + s.getSno() + "(" + s.getName() + ")将被永久删除,不可恢复") .setPositiveButton("删除", (d, w) -> { db.delete(s.getId()); reload(); // 删完必须重查,不能只从 Adapter 的 list 里 remove }) .setNegativeButton("取消", null) .show(); }reload()这一步不能省。如果只从 Adapter 的内存列表里remove,界面看着是对的,但数据库里那条还在,退出重进就「复活」了。这种问题在演示时不会暴露,答辩老师随手杀个进程再打开就露馅了。
5. 避坑:课设工程跑不起来的五类现场问题
下面这些是我在不同机器上导入这类工程时反复遇到的,按「现象 → 原因 → 解决」记下来,遇到了直接对号入座。
5.1 构建与导入阶段的三个现场问题
现象一:Sync 卡在Downloading gradle-x.x-bin.zip十几分钟不动,最后超时。
原因:wrapper 指定的 Gradle 发行版本地没有,Android Studio 去官方地址下载,网络不稳定就卡死。这个报错不会说「网络问题」,只会给一个Could not download,很容易被误判成工程坏了。
解决:先看gradle/wrapper/gradle-wrapper.properties里的版本号,确认本机~/.gradle/wrapper/dists下有没有对应目录。有的话把distributionUrl改成本地路径;没有就换一个网络环境重试,或者手动下载压缩包放进对应目录。改完记得 File → Sync Project with Gradle Files,不是在 Settings 里改。
现象二:Sync 过了,但满屏红的Cannot resolve symbol R。
原因:资源文件有语法错误,aapt编译失败就不会生成R.java,所有引用R.xxx的地方一起报红。真正的原因通常藏在 Build 窗口里一条不起眼的error: not found上,比如布局里引用了不存在的 drawable,或者strings.xml里有未转义的&。
解决:关掉代码编辑区,只看 Build 窗口,从第一条 error 开始修。修完资源再 Sync 一次,R才会重新生成。不要在代码里到处加import,那是治标不治本。
现象三:能编译,安装时报INSTALL_FAILED_OLDER_SDK。
原因:minSdk设得比演示设备或模拟器的系统版本还高。常见于从别人那儿拿来的工程,对方用的是新手机,minSdk随手写了 26,而你手上的模拟器是 API 21 的镜像。
解决:两个方向选一个——把app/build.gradle的minSdk降到设备支持的值,或者换一个系统版本更高的模拟器镜像(Device Manager → Create Device 里选 API 30 以上的镜像)。课设演示建议后者,因为降低minSdk可能触发某些 API 的兼容性检查,反而引入新问题。
5.2 运行与数据阶段的三个现场问题
现象四:列表页一片空白,Logcat 没有任何异常。
原因有三个高发点:RecyclerView忘了setLayoutManager(没有布局管理器它根本不排版);Adapter 的getItemCount()返回 0;查询方法的selection拼接写错,条件永远不成立。第一种最常见,因为不设LayoutManager时 RecyclerView 自己也不报错。
解决:按顺序排查——先确认setLayoutManager和setAdapter都调了,再在reload()里打一行日志看查出来的 list 大小,最后检查query()的selection和args数量是否匹配(两个?必须配两个值,少一个就是空结果)。
现象五:新增保存成功,返回列表也看到了,杀掉进程重进就没了。
原因:数据只写进了内存列表,没落库。典型写法是在 Adapter 里data.add(s)然后notifyItemInserted,db.insert那行压根没调,或者调了但返回值没检查、实际因为唯一约束失败了。
解决:所有写操作必须走StudentDbHelper,写完用insert的返回值判断成败(-1就是失败)。验证方法很直接:Android Studio 打开 App Inspection → Database Inspector,选中student.db,直接在表里看有没有那条记录。有记录但界面不显示,问题在查询;没记录,问题在写入。
现象六:点了删除,界面上消失了,下拉刷新或者重进又回来了。
原因:只从 Adapter 的内存列表里移除了元素,没有执行db.delete。或者执行了,但删的条件用的是position而不是id——position是列表位置,和数据库主键完全是两回事。
解决:删除必须走db.delete(id),条件永远是主键id,删完统一调reload()从库里重查。顺带检查一下 Adapter 里点击回调用的是不是getBindingAdapterPosition(),用旧的position会导致删错行。
把这六条过一遍,这类工程 90% 的「跑不起来」都能定位。剩下 10% 基本是环境问题,换台机器或者重装一次 Android Studio 反而最快。
6. 从能跑到能讲清楚:数据一致性自测与二次开发落点
工程能跑起来只是及格线。答辩或者交付之前,我会固定跑一遍下面这份自测清单,每一条都对应一个具体的操作和预期结果,跑完心里才有底。
6.1 交之前必须走一遍的自测清单
| 操作 | 预期结果 | 不通过说明什么 |
|---|---|---|
| 空库首次启动 | 列表为空,显示空状态提示 | 没有空态处理,或查询逻辑写死 |
| 连续新增 3 条不同学号 | 列表按 id 倒序显示 3 条 | orderBy写错或notifyDataSetChanged没调 |
| 新增一条重复学号 | 提示「学号已存在」,列表不变 | UNIQUE 约束没生效,或异常没捕获 |
| 编辑第 2 条的姓名 | 列表第 2 条同步更新,其余不变 | 更新条件用的不是 id,可能整表被改 |
| 删除中间一条 | 只少那一条,其余顺序正确 | 用的是 position 而不是 id |
| 搜索框输入姓氏 | 列表只剩匹配项,清空后恢复全部 | LIKE 拼接少了% |
| 杀进程后重进 | 数据与退出前完全一致 | 写操作没落库 |
| 横竖屏切换 | 数据不丢,输入框内容保留 | 没做状态保存,属可选优化项 |
横竖屏这一条课设阶段可以不做,但要在文档里提一句「未做配置变更处理」,主动说明比被问出来强。
6.2 二次开发可以从哪几个点切入
如果这份工程只是拿来交作业,跑到这里就够了。想让它更像一个完整作品,有三个改动成本低、演示效果明显的方向。
数据导出。加一个「导出 CSV」按钮,把query(null)的结果拼成逗号分隔的字符串,写到getExternalFilesDir()下,再用FileProvider分享出去。涉及的知识点是文件 IO 加FileProvider配置,正好把课上学的内容串起来。注意getExternalFilesDir()在 Android 10 以上不需要额外权限,比直接往 SD 卡根目录写省事得多。
分页加载。数据量上去之后一次性查全表会卡。用LIMIT ? OFFSET ?配合RecyclerView的滚动监听做分页,每页 20 条。这个改动会逼着你把query()的参数从「关键字」扩展成「关键字 + 页码 + 页大小」,是理解 SQL 分页的好机会。
线程切换。现在所有数据库操作都在主线程,几十条数据看不出来,上千条就会出现掉帧。把查询包进ExecutorService,结果通过runOnUiThread回主线程刷新,改动量不大,但能讲清楚「为什么数据库操作不能放主线程」这个必考题。
我自己的习惯是:每次交这类工程之前,一定按 6.1 的表从第一行走到最后一行,尤其是「杀进程重进」和「删中间一条」这两项——这两条挂掉的概率最高,而且都是演示时老师随手一点就会暴露的。早几年我第一次交课设,就是删完数据看着没问题,第二天重启模拟器发现全回来了,那次之后我把自测清单打印出来贴在显示器边上。希望帮到你。
本文还有配套的精品资源,点击获取