简介:这份资源是面向安卓初学者与高校课程设计学生的 Android Studio 学生信息管理 App 完整工程,基于官方 IDE 与 Gradle 构建,围绕增删改查这一典型业务串起界面布局、数据存储与页面跳转等核心技能点。工程内可见 XML 布局、Java 源码、drawable 与 mipmap 图片、rawproto 与 json 配置、class 与 dex 字节码以及多个 apk 产物,完整覆盖从布局设计、RecyclerView 列表展示、SQLite 与 SharedPreferences 存储,到 Activity 生命周期、Intent 传值、对话框、菜单、运行时权限和自定义 Adapter 的实现链路,也保留了 Logcat 调试与打包发布所需的构建文件。压缩包共 1426 个文件,以 338 个 xml、280 个 png、39 个 java 及若干 class、dex、apk 为主,整体约 21.14MB,目录层级贴合真实项目结构,便于按模块检索与对照阅读。已有 3740 人学习,适合需要参照成熟工程完成课设、梳理安卓基础架构或积累作品集素材的读者。
1. 一份课程设计压缩包,先看清它能替你省掉哪些活
期末周最典型的场景:安卓课程设计要交一个能登录、能增删改查、最好还带搜索的学生信息管理 App,而机房上机时间只剩两个下午。Mystudent 这个基于 Android Studio 的安卓课程设计完整版,干的就是把最耗时间的那条链路先铺平:登录注册、学生列表、新增编辑、条件查询、SQLite 本地存储全都接好了,解压导入就是一个能跑起来的业务闭环。它适合三类人——赶课程设计交付节点的学生、想找一个干净 Android 工程练手的转行者、以及需要给学生演示「一个完整 App 该长什么样」的助教。整套东西不依赖任何后端服务,数据全部落在手机本地 SQLite 里,断网照样演示,答辩现场最怕的网络玄学直接绕开。
2. 工程骨架与技术选型:为什么这套组合在课程设计里最稳
先把选型摆在桌面上。这套工程是标准 Java 风格的原生 Android 项目,没有引入 Kotlin、没有 Compose、没有 Retrofit 和 Room,数据层直接用SQLiteOpenHelper手写 DAO,列表用RecyclerView加自定义Adapter,界面以Activity + XML 布局为主。听起来「老」,但对课程设计这个场景恰恰是最优解:老师看得懂、答辩问得住、你自己改得动,出问题的时候能在 StackOverflow 风格的问答里精准搜到答案,而不是被困在某个新框架的版本兼容里。
反过来说,如果你打算把它当简历项目,这套骨架也不是不能升级——后面第 6 章会讲怎么在不动业务逻辑的前提下,把数据层换成 Room、把界面换成 Fragment,让它从「课设」长成「能聊的工程」。但在跑通之前,别急着改架构,先让它在你机器上编译出来。
2.1 目录结构:照着这张清单找文件,五分钟摸清全貌
解压后是个标准的 Android Studio 工程,根目录有settings.gradle、build.gradle、gradle.properties,主模块叫app。把app/src/main展开,代码基本落在下面这几个位置:
| 路径 | 职责 | 改代码时的优先级 |
|---|---|---|
java/com/example/mystudent/LoginActivity.java | 登录页,校验账号密码并写入登录态 | 高,改登录逻辑必看 |
java/com/example/mystudent/MainActivity.java | 首页/导航入口 | 中 |
java/com/example/mystudent/StudentListActivity.java | 学生列表、搜索、删除入口 | 高,核心页面 |
java/com/example/mystudent/StudentEditActivity.java | 新增与编辑复用同一个页面 | 高 |
java/com/example/mystudent/db/DBHelper.java | 建库建表、版本升级 | 高,加字段必改 |
java/com/example/mystudent/dao/StudentDao.java | 所有 SQL 集中在这一层 | 高 |
java/com/example/mystudent/dao/UserDao.java | 账号表读写 | 中 |
java/com/example/mystudent/bean/Student.java | 实体类,字段与表列一一对应 | 高 |
java/com/example/mystudent/adapter/StudentAdapter.java | 列表项渲染与点击回调 | 中 |
res/layout/*.xml | 各页面布局 | 中,改样式必看 |
res/values/strings.xml | 文案集中管理 | 低 |
这种分法谈不上多优雅,但它有一个非常实在的好处:SQL 只有一处。很多同学写课设习惯在 Activity 里直接db.rawQuery,写着写着就变成五个页面五套 SQL,改一个字段名要全局搜索半小时。这里把所有数据库操作收进dao包,Activity 只负责取数据和刷 UI,改表结构的时候你只需要动DBHelper和对应的 Dao,改动面是可控的。
再看bean包。Student实体里的字段名跟数据库列名保持一致(sno、name、gender、age、major、phone),好处是Cursor读出来直接new Student(...),不用做一层字段映射。代价是实体类跟表结构强耦合,将来换 Room 的时候得补注解——但这属于后面的事,先把功能跑通。
2.2 Gradle 与依赖:几行配置决定了你能不能一次同步成功
课程设计工程最常见的翻车点不是代码,是 Gradle 同步。老工程用的 AGP 版本和新版 Android Studio 对不上,一导入就是满屏红字。这套工程的主模块配置大致长这样,拿到之后先确认这几个值和你的 IDE 版本是否匹配:
// app/build.gradle android { namespace 'com.example.mystudent' compileSdk 33 // 编译用的 SDK,建议跟本机已装版本一致 defaultConfig { applicationId "com.example.mystudent" // 应用唯一标识,改包名要同步改这里 minSdk 21 // 最低支持 Android 5.0,机房老机器也能装 targetSdk 33 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_11 // 与 JDK 对齐,避免编译报语法错 targetCompatibility JavaVersion.VERSION_11 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' // 提供 TextInputLayout、CardView implementation 'androidx.recyclerview:recyclerview:1.3.1' // 列表控件 implementation 'androidx.constraintlayout:constraintlayout:2.1.4' }先看compileSdk和sourceCompatibility。前者决定你能调用哪些系统 API,后者决定编译器按哪个 Java 版本解析语法;两个都跟本机环境错位的话,报错信息往往非常迷惑(比如提示找不到某个 lambda 相关的方法)。minSdk 21是个保守选择,机房里的老测试机通常能装;如果你确定只在 Android 8 以上演示,可以抬到 24,能省掉一批兼容判断。
applicationId这一行要特别留意。很多同学改包名只改了namespace或者只改了目录名,结果装到手机上两个图标并存,或者覆盖安装时报INSTALL_FAILED_UPDATE_INCOMPATIBLE。正确做法是用 Android Studio 的Refactor → Rename改包,它会同步改namespace、applicationId、AndroidManifest里的package和 Java 文件的package声明,改完再手动搜一遍残留的旧包名。
提示:
gradle.properties里的org.gradle.jvmargs建议给到-Xmx2048m,低配笔记本同步大工程时容易 OOM,加这一行能省掉一次重启。
2.3 数据库设计:两张表撑起整个业务
数据层是这个项目的骨架。一张user表管登录,一张student表管业务数据,够用且不容易乱。DBHelper继承SQLiteOpenHelper,核心在onCreate建表和onUpgrade升级两处:
// db/DBHelper.java public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "mystudent.db"; private static final int DB_VERSION = 1; // 加字段时 +1,onUpgrade 才会触发 public static final String TABLE_STUDENT = "student"; public static final String TABLE_USER = "user"; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { // 学号设为 UNIQUE,从数据层面挡住重复录入 db.execSQL("CREATE TABLE " + TABLE_STUDENT + " (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "sno TEXT NOT NULL UNIQUE," + "name TEXT NOT NULL," + "gender TEXT," + "age INTEGER," + "major TEXT," + "phone TEXT," + "create_time TEXT)"); db.execSQL("CREATE TABLE " + TABLE_USER + " (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "username TEXT NOT NULL UNIQUE," + "password TEXT NOT NULL)"); // 预置一个演示账号,避免首次启动无法登录 db.execSQL("INSERT INTO " + TABLE_USER + " (username, password) VALUES ('admin', '123456')"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 课设阶段直接重建最简单,真上生产要写增量迁移 if (oldVersion < newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TABLE_STUDENT); db.execSQL("DROP TABLE IF EXISTS " + TABLE_USER); onCreate(db); } } }onCreate只在数据库文件第一次创建时调用,所以预置的admin/123456只写一次;之后你手动往表里加数据,重装 App 才会重新触发。sno TEXT NOT NULL UNIQUE这个约束很关键——它把「学号重复」这种业务校验下沉到了数据库层,即使 UI 层漏判,插入也会失败并抛SQLiteConstraintException,你能第一时间发现逻辑漏洞。
onUpgrade里直接DROP TABLE是课设阶段最省事的写法,代价是升级即清库。答辩演示前如果你想加一个「班级」字段,记得先把已有测试数据导出或者截图留档,否则升一次版本数据全没,血泪经验。真要做增量迁移,标准做法是在onUpgrade里按oldVersion逐版本ALTER TABLE ADD COLUMN,写成switch分段执行。
2.4 Manifest 与页面约定:四个 Activity 的跳转关系
AndroidManifest.xml里把这四个 Activity 都注册了,LoginActivity打上LAUNCHER作为入口。页面之间的跳转约定很简单:登录成功 → 列表页;列表页点悬浮按钮 → 编辑页(新增模式);列表页点某一项 → 编辑页(编辑模式,带id);编辑页保存后setResult(RESULT_OK)返回并刷新列表。
<!-- AndroidManifest.xml 片段 --> <application android:allowBackup="true" android:label="@string/app_name" android:theme="@style/Theme.MyStudent"> <!-- 入口页:登录 --> <activity android:name=".LoginActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name=".MainActivity" android:exported="false" /> <activity android:name=".StudentListActivity" android:exported="false" /> <activity android:name=".StudentEditActivity" android:exported="false" /> </application>android:exported这个属性从 Android 12 起是必填项,漏写会直接编译失败,报错信息是「Apps targeting Android 12 and higher are required to specify an explicit value for android:exported」。带intent-filter的入口页必须写true,其余内部页面写false更安全,也能避免被外部应用拉起。
跳转时用Intent传id而不是传整个对象,这一点值得强调。传对象要么实现Serializable(性能差、易出错),要么实现Parcelable(样板代码多)。传主键、回到编辑页再用id查一次库,代价是一次毫秒级查询,换来的是数据永远是最新的——列表页改了别的地方,编辑页拿到的不会是一份过期快照。
3. 核心链路拆解:一次「新增学生」在代码里究竟走了哪几步
功能跑通的关键不在页面多漂亮,而在这条链路是否闭合:点击保存 → 校验 → 写库 → 回传结果 → 列表刷新。任何一环断了,表现出来都是「点了没反应」或者「列表不更新」。这一章按调用顺序把四段核心代码拆开看,每段都能直接抄进你自己的工程。
3.1 登录与会话:SharedPreferences 记住登录态
登录页做两件事:校验账号密码、把登录状态存下来。校验走UserDao,状态存SharedPreferences。别小看这一步,很多课设的问题是「按返回键退出后再进来又要重新登录」,体验分直接掉一档。
// LoginActivity.java 登录逻辑 private void doLogin() { String username = etUsername.getText().toString().trim(); String password = etPassword.getText().toString().trim(); // 空值校验放在最前面,避免把空串丢进 SQL if (TextUtils.isEmpty(username) || TextUtils.isEmpty(password)) { Toast.makeText(this, "账号或密码不能为空", Toast.LENGTH_SHORT).show(); return; } UserDao userDao = new UserDao(this); boolean ok = userDao.checkLogin(username, password); if (!ok) { Toast.makeText(this, "账号或密码错误", Toast.LENGTH_SHORT).show(); return; } // 写入登录态,key 用常量,避免多处硬编码写错 getSharedPreferences("app_config", MODE_PRIVATE) .edit() .putBoolean("is_login", true) .putString("current_user", username) .apply(); // apply 异步落盘,比 commit 更适合 UI 线程 startActivity(new Intent(this, StudentListActivity.class)); finish(); // 关键:销毁登录页,防止返回键退回登录界面 }checkLogin内部用Cursor遍历查询结果,这里有个细节值得说:不要用db.rawQuery拼接字符串。像"select * from user where username='" + username + "'"这种写法,遇到带单引号的输入会直接抛语法异常,虽然课设场景没人攻击你,但这个习惯一旦养成,写别的项目迟早翻车。参数化查询db.rawQuery(sql, new String[]{username, password})只是多写一行,收益是长期的。
apply()和commit()的区别也顺手记一下:apply()把修改先写内存、异步刷盘,不阻塞 UI;commit()同步返回布尔值。登录态这种丢了也不致命的配置,apply()完全够用。finish()那行更不能省,否则用户按返回键会回到登录页,看起来像「退出了但没完全退出」。
3.2 列表渲染:RecyclerView + Adapter 的绑定套路
列表页是整个 App 出现频率最高的界面。数据来源是StudentDao.queryAll(),渲染靠StudentAdapter。这套组合的坑集中在「数据变了但界面没变」,本质是 Adapter 里的 list 引用和外部持有的不是同一个对象。
// adapter/StudentAdapter.java public class StudentAdapter extends RecyclerView.Adapter<StudentAdapter.VH> { private List<Student> data = new ArrayList<>(); private OnItemClickListener listener; public interface OnItemClickListener { void onItemClick(Student student); // 点击进入编辑 void onItemLongClick(Student student); // 长按弹删除确认 } public void setOnItemClickListener(OnItemClickListener l) { this.listener = l; } // 用新数据整体替换,并刷新,避免外部直接操作内部 list public void setData(List<Student> newData) { this.data.clear(); this.data.addAll(newData); notifyDataSetChanged(); } @NonNull @Override public VH onCreateViewHolder(@NonNull 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(@NonNull VH holder, int position) { Student s = data.get(position); holder.tvName.setText(s.getName()); holder.tvSno.setText("学号:" + s.getSno()); holder.tvMajor.setText(s.getMajor() == null ? "未填写" : s.getMajor()); // 点击事件绑在 itemView 上,不要绑在单个 TextView 上 holder.itemView.setOnClickListener(v -> { if (listener != null) listener.onItemClick(s); }); holder.itemView.setOnLongClickListener(v -> { if (listener != null) listener.onItemLongClick(s); return true; // 返回 true 表示消费事件,否则会同时触发点击 }); } @Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { TextView tvName, tvSno, tvMajor; VH(@NonNull View itemView) { super(itemView); tvName = itemView.findViewById(R.id.tv_name); tvSno = itemView.findViewById(R.id.tv_sno); tvMajor = itemView.findViewById(R.id.tv_major); } } }setData这个方法看着啰嗦,实际是在防一个高频 bug:如果外部直接adapter.data = newList然后notifyDataSetChanged(),看上去能跑,但只要有人持有了旧的data引用去add,界面就会和预期不一致。统一走setData,刷新逻辑只有一个出口,出问题好定位。
onBindViewHolder里有个容易忽略的点:setOnLongClickListener要返回true。返回false的话,长按事件没被消费,会继续冒泡触发点击,结果就是「长按弹删除框的同时跳进了编辑页」。
列表页拿到 Adapter 回调后做两件事:点击时Intent带上id跳编辑页;长按时弹AlertDialog,确认后调StudentDao.delete(id)再重新queryAll()刷进 Adapter。
3.3 新增与编辑复用同一个页面
StudentEditActivity用Intent里有没有id来区分模式,这是最省事的写法,比写两个几乎一样的页面少一半维护量。
// StudentEditActivity.java 保存逻辑 private void save() { String sno = etSno.getText().toString().trim(); String name = etName.getText().toString().trim(); String ageStr = etAge.getText().toString().trim(); // 必填校验 if (TextUtils.isEmpty(sno) || TextUtils.isEmpty(name)) { Toast.makeText(this, "学号和姓名不能为空", Toast.LENGTH_SHORT).show(); return; } int age = 0; if (!TextUtils.isEmpty(ageStr)) { try { age = Integer.parseInt(ageStr); } catch (NumberFormatException e) { // 输入了非数字,直接拦下,不要让异常冒到主线程 Toast.makeText(this, "年龄请填写数字", Toast.LENGTH_SHORT).show(); return; } } Student s = new Student(); s.setSno(sno); s.setName(name); s.setGender(rgGender.getCheckedRadioButtonId() == R.id.rb_male ? "男" : "女"); s.setAge(age); s.setMajor(etMajor.getText().toString().trim()); s.setPhone(etPhone.getText().toString().trim()); StudentDao dao = new StudentDao(this); boolean success; if (studentId > 0) { // 编辑模式:Intent 带回了主键 s.setId(studentId); success = dao.update(s) > 0; } else { // 新增模式 long newId = dao.insert(s); success = newId > 0; } if (success) { setResult(RESULT_OK); // 通知列表页刷新 finish(); } else { Toast.makeText(this, "保存失败,学号可能已存在", Toast.LENGTH_SHORT).show(); } }三个参数值得单独说。studentId > 0是模式开关,onCreate里用getIntent().getLongExtra("id", -1)取值,默认-1表示新增。setResult(RESULT_OK)配合列表页的onActivityResult(或者registerForActivityResult)才能触发刷新,漏掉这行就是经典的「保存成功了但列表没变,退出重进才出现」。dao.insert返回的是新行主键,-1表示插入失败——学号重复时会走到这里,所以 Toast 文案里带一句「学号可能已存在」,用户才知道该改哪儿。
3.4 删除、搜索与数据刷新的一致性
Dao 层把四种操作写全,Activity 就只剩调用。update和delete返回受影响行数,用这个数字判断是否真的命中,比单纯看有没有抛异常更可靠。
// dao/StudentDao.java public long insert(Student s) { 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", new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.CHINA) .format(new Date())); // 学号冲突时直接失败,把判断权交回业务层 return getWritableDatabase().insert(TABLE_STUDENT, null, cv); } public List<Student> queryByKeyword(String keyword) { List<Student> list = new ArrayList<>(); // 姓名或学号模糊匹配,参数化查询防止单引号导致语法错误 String sql = "SELECT * FROM " + TABLE_STUDENT + " WHERE name LIKE ? OR sno LIKE ? ORDER BY id DESC"; String like = "%" + keyword + "%"; Cursor c = getReadableDatabase().rawQuery(sql, new String[]{like, like}); while (c.moveToNext()) { Student s = new Student(); s.setId(c.getLong(c.getColumnIndexOrThrow("id"))); s.setSno(c.getString(c.getColumnIndexOrThrow("sno"))); s.setName(c.getString(c.getColumnIndexOrThrow("name"))); s.setGender(c.getString(c.getColumnIndexOrThrow("gender"))); s.setAge(c.getInt(c.getColumnIndexOrThrow("age"))); s.setMajor(c.getString(c.getColumnIndexOrThrow("major"))); list.add(s); } c.close(); // 必须关,否则游标泄漏,数据量大了会出问题 return list; } public int delete(long id) { return getWritableDatabase().delete(TABLE_STUDENT, "id=?", new String[]{String.valueOf(id)}); }getColumnIndexOrThrow比getColumnIndex好用的地方在于:列名写错会立刻抛异常,而不是静默返回-1然后getString(-1)崩在一个看起来毫无关系的地方。调试期用带OrThrow的版本,能省下大量找错时间。
c.close()那行是硬要求。Cursor 不关,数据量小的时候看不出问题,几百条之后就可能出现「查询越来越慢」或者SQLiteDatabase相关警告。规范写法是try-with-resources或者try/finally里关,但在课设代码里显式一行close()也够用,前提是别在return之前提前退出。
搜索框的联动建议加个 300ms 防抖,TextWatcher每敲一个字就查一次库,在小数据量下无感,但这是个值得养成的好习惯。
4. 从导入到跑通:一份可以直接照着敲的启动清单
代码看懂是一回事,让它在你机器上跑起来是另一回事。这一章按顺序走一遍,每一步都给出「怎么判断这一步成功了」,出问题能立刻定位到哪一环。
4.1 导入工程与首次同步
解压后不要直接双击.rar里的工程,先解压到不含中文和空格的路径,比如D:\code\mystudent。路径带中文会导致 Gradle 某些插件解析失败,报错信息往往跟路径毫无关系,排查起来极其费劲。
Android Studio 里选Open,指向解压后的根目录(含settings.gradle那一层),等它自动同步。第一次同步会下载 AGP 和依赖,视网络情况 3~15 分钟。判断成功的标志是底部Build窗口出现BUILD SUCCESSFUL,左侧Project面板切到Android视图能看到app模块和java、res目录树。
如果卡在Downloading gradle-x.x-bin.zip,说明 Gradle 发行版没下下来。这种情况优先检查 IDE 自带的 Gradle 版本,或者用本机已缓存的版本:File → Project Structure → Project里把 Gradle 版本改成你~/.gradle/wrapper/dists目录下已有的那个。别在这上面硬等,换个版本往往 5 分钟解决。
4.2 模拟器与真机:SDK 版本对齐
跑之前确认三件事:compileSdk对应的 SDK Platform 已安装、minSdk 21的设备可用、模拟器镜像是 x86_64 或 arm64(跟主机 CPU 匹配)。
用命令行检查设备连接情况:
# 查看已连接设备,列表为空说明真机没识别到(检查 USB 调试和驱动) adb devices # 查看设备 Android 版本,确认不低于 minSdk 21 adb shell getprop ro.build.version.release # 编译 debug 包(不经过 IDE,能快速判断是代码问题还是 IDE 问题) ./gradlew assembleDebug # 安装到已连接设备(覆盖安装,保留数据) adb install -r app/build/outputs/apk/debug/app-debug.apk # 安装失败又想彻底重来时,先卸载(会清空本地数据库) adb uninstall com.example.mystudent./gradlew assembleDebug这一步很有价值。IDE 里的报错经常被各种插件包裹得看不清,命令行跑一次,失败原因会原原本本打在终端里。Windows 上把./gradlew换成gradlew.bat。
真机连接不上的话,按这个顺序查:数据线是不是只能充电的劣质线 → 手机开发者选项里「USB 调试」有没有开 → 连接时手机弹的授权框有没有点「允许」→adb kill-server && adb start-server重启服务。这几步能解决九成的「设备列表为空」。
4.3 首次启动的数据初始化
App 装好后第一次打开,DBHelper.onCreate会建库并插入演示账号。用admin / 123456登录进去,列表是空的——这是正常的,工程没有预置学生数据。
想快速看到效果,有两个办法。一是在界面上手动加两三条,验证增删改查闭环;二是往数据库里灌一批测试数据,方便测分页和搜索。用adb shell直接操作 SQLite 最直接:
# 进入应用的数据库目录(debuggable 包才能访问) adb shell "run-as com.example.mystudent ls databases" # 用 sqlite3 插入几条测试数据;注意 sqlite3 在部分系统镜像里不存在, # 缺失时改用 Android Studio 的 Database Inspector adb shell "run-as com.example.mystudent sqlite3 databases/mystudent.db \ \"INSERT INTO student(sno,name,gender,age,major,phone,create_time) \ VALUES('20240101','张三','男',20,'计算机科学与技术','13800000001','2024-01-01 09:00');\""灌 20 条左右就能看出列表滚动是否流畅、搜索是否生效。Android Studio 的Database Inspector(View → Tool Windows → App Inspection)更友好,能直接看表、改数据、跑查询,还不用敲命令,推荐用它做调试期的主要工具。
4.4 打包 APK 与交付前自检
课程设计通常要交一个能装到老师手机上的 APK。Build → Generate Signed Bundle / APK走签名流程,或者更简单的Build → Build Bundle(s) / APK(s) → Build APK(s),产物在app/build/outputs/apk/debug/。debug 包也能装,只是签名是调试证书,部分手机(尤其是开了应用完整性校验的)会拦一下。
交付前按这张表过一遍,能挡掉大部分「到我这儿怎么打不开」的尴尬:
| 检查项 | 怎么看 | 不合格的后果 |
|---|---|---|
| 包名是否已改成自己的 | applicationId与AndroidManifest | 与别人工程冲突,覆盖安装失败 |
| 应用名与图标 | strings.xml的app_name、mipmap目录 | 装上去叫 MyStudent,扣印象分 |
| 最低版本 | minSdk与目标机型 | 老师的老手机装不上 |
| 首次启动能否登录 | 卸载重装后试一次 | 数据库残留导致演示时登不进 |
| 无网络时功能是否正常 | 开飞行模式走一遍 | 答辩现场断网就演示不了 |
| 数据是否已清空 | 删掉测试数据再打包 | 一堆「张三李四」显得没做完 |
最后一条尤其重要。演示用的数据和调试用的数据要分开,交上去的包里塞满自己乱敲的测试记录,第一印象就没了。
5. 避坑与排查:五个让我返工过的真实问题
5.1 现象、原因与解决办法
现象一:Gradle 同步报Could not find com.android.tools.build:gradle:x.x.x。原因是工程里写的 AGP 版本在仓库里拉不到,常见于老工程配了新 IDE,或者settings.gradle里缺了google()仓库。解决办法是先看settings.gradle的pluginManagement.repositories和dependencyResolutionManagement.repositories,确认google()和mavenCentral()都在;然后去 AGP 官方版本列表里挑一个和你 IDE 版本相邻的,改build.gradle(项目级)里的classpath。改完记得点Sync Now,别直接 Run,Run 用的是上一次同步的产物,会误导你以为没改成功。
现象二:新增学生成功后,列表页没变化,杀掉 App 重进才看到。九成是三个原因之一:编辑页忘了setResult(RESULT_OK)、列表页没在onActivityResult里重新查库、或者查完库没调adapter.setData()只改了局部变量。排查方式很土但有效——在列表页的onResume里打印一条Log.d("ListPage", "onResume, count=" + dao.queryAll().size()),如果这里数字是新的但界面还是旧的,问题就在 Adapter 刷新;如果这里数字就是旧的,问题在回传链路。一次定位,别凭感觉改。
现象三:插入时报UNIQUE constraint failed: student.sno,App 直接崩。insert遇到唯一约束冲突会抛SQLiteConstraintException,如果没捕获就崩在 UI 线程。两种处理方式:一是插入前先queryBySno判重,把校验提前到业务层,体验更好;二是用insertWithOnConflict(TABLE_STUDENT, null, cv, SQLiteDatabase.CONFLICT_IGNORE),冲突时返回-1而不抛异常。课设场景建议走第一种,因为要给用户明确的「学号已存在」提示,静默忽略反而让人困惑。
现象四:真机安装报INSTALL_FAILED_UPDATE_INCOMPATIBLE或INSTALL_FAILED_VERSION_DOWNGRADE。前者是签名不一致——手机上已经装了一个同包名但用不同证书签名的版本,比如之前装过同学改过的包。后者是versionCode比已装版本低。两个都用adb uninstall 包名解决,代价是本地数据库被清空。所以调试期频繁改包名、频繁装不同人版本的机器上,养成「改包名后先卸载旧包」的习惯,能少踩很多次。
现象五:RecyclerView一滑动就卡,或者列表项内容错乱(明明点了张三,跳过去是李四)。卡顿通常是onBindViewHolder里做了耗时操作,比如每次都查一次数据库取专业名,正确做法是查询时用JOIN或者提前把字段读进Student对象。内容错乱则是 ViewHolder 复用没处理好:if/else分支里只设置了部分控件,另一部分沿用了上一个 item 的值。解决办法是每个分支都把该控件显式设一遍,别指望默认值,尤其是Visibility和Background。
5.2 排查工具箱:logcat 与 Database Inspector
出问题时先别改代码,先看现场。logcat 按包名过滤,能把无关日志全挡掉:
# 只看本应用的日志,-s 表示静默其他 tag adb logcat -s "ListPage:D" "StudentDao:D" "AndroidRuntime:E" # 清空日志缓冲,然后复现问题,日志更干净 adb logcat -c && adb logcat -s AndroidRuntime:EAndroidRuntime:E这一条尤其关键,所有未捕获异常的堆栈都在这个 tag 下。崩溃之后第一条要看的永远是它,堆栈第一行的Caused by直接指向出问题的类和方法,比在 IDE 里翻半天快得多。
数据库层面的问题用Database Inspector看。它能看到实时表数据、执行 SQL、还能在 App 运行时改值验证逻辑。有个实际好用的技巧:怀疑某个查询写错时,直接在 Inspector 里跑一遍同样的 SQL,如果结果和 App 显示的不一致,问题就在 Java 侧的Cursor读取,而不在 SQL 本身——这一步能砍掉一半的排查范围。
注意:
Database Inspector只对debuggable的包生效。打 release 包之后就连不上了,调试期别急着切 release。
6. 二次开发:把课设改造成能讲二十分钟的项目
跑通之后,真正的价值在于它是个可以往上长东西的底座。答辩时老师最爱问的一句是「如果让你再加一个功能,你会怎么做」,这时候能答出「改哪几个文件、加哪张表、动哪一层」,和答「我再研究研究」,是两种完全不同的印象分。
最推荐的第一个改造是加字段。假设要加「班级」和「邮箱」,完整步骤是:Student.java加两个属性 + getter/setter →DBHelper把DB_VERSION改成 2,onCreate的建表语句加两列,onUpgrade里补ALTER TABLE student ADD COLUMN class_name TEXT→StudentDao的insert、update、queryByKeyword都补上对应的ContentValues.put和Cursor读取 → 编辑页布局加两个TextInputLayout→StudentAdapter的 item 布局加一行显示。五处改动,一次跑通,你就完整走了一遍「数据结构变更如何影响全链路」,这个经验比写十个页面都值钱。
// 增量迁移的正确姿势:不要 DROP,逐版本 ADD COLUMN @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE student ADD COLUMN class_name TEXT"); db.execSQL("ALTER TABLE student ADD COLUMN email TEXT"); } // 将来 v3 再加一段 if (oldVersion < 3) { ... },历史数据全程保留 }第二条路是换数据层。把SQLiteOpenHelper + 手写 Dao换成Room,业务代码几乎不用动,因为 Activity 只依赖 Dao 接口。Room 的收益是编译期校验 SQL、自动生成Cursor映射、支持LiveData观察数据变化——列表页从此不用手动setData,数据一变界面自己刷新。改造量集中在bean加@Entity注解、Dao改成@Dao接口、DBHelper换成RoomDatabase子类,两三个小时能搞定,但足够你在简历上写一句「熟悉 Room 与数据持久化最佳实践」。
第三条是加导出功能,实用且实现短。把queryAll()的结果拼成 CSV 写到getExternalFilesDir()下,再配合FileProvider分享出去,整个功能不到 80 行。面试聊到「你怎么处理文件权限和 Android 7.0 之后的 URI 变更」,这就是现成的素材。
还有两个成本更低但效果明显的小改造:列表加SwipeRefreshLayout下拉刷新(顺手理解一次异步回调)、搜索框加 300ms 防抖、列表项加左滑删除(ItemTouchHelper)。每个都是几十行,但拼起来就是一个「有交互设计感」的 App,而不是一个只能点按钮的作业。
我自己的习惯是:每改完一个功能,先卸载重装、再用飞行模式走一遍全流程,再提交代码。因为课设项目最容易出问题的永远不是新功能本身,而是升级数据库时把老数据搞没了,或者某个页面忘了处理空数据直接空指针。这个动作固定下来之后,我交出去的包就再没出现过「在我这儿是好的」这种对话。希望帮到你。
本文还有配套的精品资源,点击获取