Android大作业实战:体育场预约管理系统设计与SQLite数据库实现

发布时间:2026/9/13 16:26:24
Android大作业实战:体育场预约管理系统设计与SQLite数据库实现 简介基于 Android Studio 的体育场预约管理系统完整项目面向 Android 初学及正在完成课程大作业的高校学生。系统覆盖用户注册登录、会员与管理员双角色权限、场馆信息浏览、个人中心、订单提交与管理、订单状态跟踪及支付接口集成等核心模块能够帮助开发者理解实际业务场景中的功能拆解与实现流程。压缩包整体约 10.8MB包含可直接导入 Android Studio 的工程源码与相关资源文件。当前已有 6022 人学习下载。通过该项目读者可系统掌握 Android SDK 常用组件Activity、Fragment、XML 布局设计、SQLite 数据持久化以及角色权限控制等关键开发技术并借鉴一套完整的小型应用从界面到数据处理的落地思路适合作为毕业设计或课程大作业的参考范本。1. 体育场预约管理系统为什么是 Android 大作业里性价比最高的选题如果你的课程要求在 Android Studio 里独立完成一个能装进模拟器、能演示、能答辩的应用体育场预约管理系统几乎是覆盖知识点最全又不至于失控的题目。它同时包含用户注册与登录、角色权限区分、场馆信息列表、预约下单、订单状态流转、个人中心这些模块正好把 Android 四大组件里 Activity、SQLite 持久化、XML 布局、RecyclerView 这些核心技能全串起来。更关键的是业务闭环清晰用户注册登录、浏览场馆、提交预约、管理员审核这一条链路做完答辩时逻辑上挑不出大毛病。适合有一定 Java 或 Kotlin 基础、想在两周内冲刺出完整项目的在校生也适合想拿一套可用代码做二次开发的从业者。2. 用户体系与角色权限注册、登录到管理员路由任何预约系统第一步都是解决谁在操作的问题。普通会员只能查看场馆、管理自己的预约管理员要能看全部订单、维护场馆数据。如果角色权限不做区分后面的功能全部会乱套。2.1 SQLite 表结构设计与数据库初始化我一般用 SQLiteOpenHelper 建库不用第三方 ORM。课程设计场景下数据库结构简单SQLite 自带且零配置答辩时还能现场说清楚 SQL 语句比引入 Room 更容易解释清楚。先建两张核心表用户表和场馆表预约订单表放到第 4 章再展开。CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, phone TEXT DEFAULT , role INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS venue ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, location TEXT NOT NULL, open_time TEXT DEFAULT 08:00, close_time TEXT DEFAULT 22:00, fee REAL DEFAULT 0, description TEXT DEFAULT );这段 SQL 里role字段是关键0 表示普通会员1 表示管理员。username加了UNIQUE约束注册时即使不写额外判断代码数据库层也能挡住重复用户名。之所以把password明文存储是课程设计阶段的权衡——真正上线必须做加盐哈希但大作业里为了演示登录流程明文存储能减少无关代码量答辩时主动提一句生产环境需要加密反而是加分项。初始化数据库的辅助类写法有一个细节值得注意public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME sports_reservation.db; private static final int DB_VERSION 1; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE IF NOT EXISTS user (...)); db.execSQL(CREATE TABLE IF NOT EXISTS venue (...)); db.execSQL(CREATE TABLE IF NOT EXISTS appointment (...)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(DROP TABLE IF EXISTS appointment); db.execSQL(DROP TABLE IF EXISTS venue); db.execSQL(DROP TABLE IF EXISTS user); onCreate(db); } }onUpgrade里直接 DROP 重建虽然是常见做法但有一个坑一旦应用已经发布过用户升级后老数据全部清空。小项目能接受但如果你之后想往作品集里放建议改成逐版本ALTER TABLE或先备份再重建的策略。DB_VERSION每改一次结构就要 1否则系统不会触发onUpgrade。提示模拟器上调试数据库时用 Android Studio 自带的 App Inspection 面板查看 SQLite 内容比在代码里打印游标结果直观得多。2.2 登录校验与角色路由跳转登录界面拿输入框的内容去查库查到记录就按角色跳转不同的主页。这里有个工程上的取舍SQLite 查询不能跑在主线程但大作业数据量小、查询毫秒级返回直接在主线程执行一般不会触发 ANR。为了稳妥我倾向于用简单的方式同时预留替换成线程池的注释说明。fun login(username: String, password: String): User? { val db dbHelper.readableDatabase val cursor db.rawQuery( SELECT id, username, phone, role FROM user WHERE username ? AND password ?, arrayOf(username, password) ) return if (cursor.moveToFirst()) { User( id cursor.getInt(0), username cursor.getString(1), phone cursor.getString(2), role cursor.getInt(3) ) } else { null } }rawQuery的两个参数分别对应 SQL 语句和占位符数组。用?占位而不是字符串拼接是为了防止 SQL 注入——输入框里写 OR 11这类内容时占位符方案会把整段内容当成字段值而不是 SQL 片段。虽然本地应用安全风险有限但答辩时能说出这一层说明你真理解了参数化查询的意义。登录成功后角色路由我习惯用一个简单的判断完成val intent if (user.role 1) { Intent(this, AdminActivity::class.java) } else { Intent(this, MemberActivity::class.java) } startActivity(intent) finish()finish()必须调用否则用户按返回键会回到登录页演示时显得很不专业。管理员端和会员端拆成两个 Activity而不是在同一个页面里动态隐藏控件好处是两端后续迭代互不干扰坏处是部分代码会重复。对于课程设计这个体量拆开更清晰。3. 场馆信息展示列表加载、条件查询与详情联动会员登录后第一眼看到的就是场馆列表。这个模块不仅要把数据展示出来还要考虑搜索、空状态和点击跳转三个交互细节是 Android UI 基本功的综合演练。3.1 从 SQLite 到 RecyclerView 的完整数据链路场馆列表用 RecyclerView 是标配。关键不是怎么创建 RecyclerView而是数据怎么从数据库到界面。我习惯用 List 作为中间层Activity 里查库Adapter 里绑定视图。class VenueAdapter(private val items: ListVenue) : RecyclerView.AdapterVenueAdapter.ViewHolder() { class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val name: TextView view.findViewById(R.id.tvVenueName) val location: TextView view.findViewById(R.id.tvVenueLocation) val time: TextView view.findViewById(R.id.tvVenueTime) val fee: TextView view.findViewById(R.id.tvVenueFee) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_venue, parent, false) return ViewHolder(view) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val venue items[position] holder.name.text venue.name holder.location.text 位置: ${venue.location} holder.time.text ${venue.openTime} - ${venue.closeTime} holder.fee.text ¥${venue.fee}/小时 } override fun getItemCount(): Int items.size }这段代码里有几个细节容易在答辩时被追问。第一为什么用ListVenue而不是直接传 Cursor因为 Cursor 持有数据库连接资源跨层传递容易泄漏而且 Adapter 里写cursor.moveToNext()会让视图层和存储层耦合。第二LayoutInflater.from(parent.context)为什么不能用holder.itemView.context因为onCreateViewHolder时 ViewHolder 还不存在只能从父容器拿 context。这些细节自己动手写一遍才能答上来。3.2 场馆搜索与空态处理搜索功能本质上是拼接 SQL 的 WHERE 条件。这里有一个常见的性能误区在 Java/Kotlin 层把数据全部加载到内存然后用filter做遍历匹配。数据量小的时候看不出问题但如果场馆录入了上千条内存过滤的卡顿感会很明显。正确做法是重新查询数据库SQL 的 LIKE 匹配由 SQLite 引擎处理。fun searchVenues(keyword: String): ListVenue { val db dbHelper.readableDatabase val cursor db.rawQuery( SELECT * FROM venue WHERE name LIKE ? OR location LIKE ?, arrayOf(%$keyword%, %$keyword%) ) // 遍历 cursor 并封装成 ListVenue }%是 SQL 通配符表示任意长度的任意字符。放在$keyword两侧意味着包含关键字的匹配。这里要注意LIKE匹配是大小写不敏感的英文场馆名搜索时行为比较符合直觉。中文搜索没有大小写问题但要注意关键词里带了空格或特殊符号时建议先在 Kotlin 层trim()掉首尾空格。搜索结果为空时不能直接显示空白。常见的做法是给 RecyclerView 设置一个空视图数据为空时隐藏 RecyclerView显示一个带提示文字的布局。if (venueList.isEmpty()) { recyclerView.visibility View.GONE tvEmpty.visibility View.VISIBLE tvEmpty.text 没有找到匹配的场馆 } else { recyclerView.visibility View.VISIBLE tvEmpty.visibility View.GONE }这个逻辑虽然只有几行但很多新手会漏掉导致搜索不到结果时界面一片空白用户完全不知道发生了什么。答辩演示时特意搜索一个不存在的场馆名展示空态提示是能留下好印象的操作。3.3 列表项点击进入场馆详情场馆详情页承载的信息更多开放时间、收费标准、场地描述还有预约入口。列表项点击传参的方式常见做法是通过 Intent 传递场馆 id。holder.itemView.setOnClickListener { val intent Intent(parent.context, VenueDetailActivity::class.java) intent.putExtra(venue_id, venue.id) parent.context.startActivity(intent) }传一个 id 而不是把整个venue对象序列化传过去是一个值得养成的习惯。如果传整个对象某些字段类型不支持序列化时直接崩溃就算支持对象里的数据也可能在跳转前被修改导致详情页拿到旧数据。只传 id详情页重新查库保证数据一致性。详情页拿到venue_id后用前面写的查询方法单独查一条记录并填充 UI同时放一个立即预约按钮跳转到下单页。场馆详情页和预约下单页的衔接就是把venue_id再往后传一层整个链路从列表到下单保持同一份数据源。4. 预约订单模块状态设计、时段冲突与线程安全预约下单是整个系统的业务核心也是最容易在答辩时被追问如果两个人同时预约同一时段怎么办的地方。这一章把订单表结构、状态流转和冲突校验一次讲透。4.1 订单表设计与状态字段的语义从数据库层面先定好订单的骨架后面所有业务逻辑都围着这张表转。CREATE TABLE IF NOT EXISTS appointment ( id INTEGER PRIMARY KEY AUTOINCREMENT, venue_id INTEGER NOT NULL, user_id INTEGER NOT NULL, date TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, status INTEGER DEFAULT 0, create_time TEXT DEFAULT (datetime(now, localtime)) );status字段用整数而不是字符串是刻意为之。字符串pending、confirmed在代码里容易写错还不报编译错误整数 0/1/2 配合常量定义可维护性更好。create_time直接用 SQLite 内置的datetime(now, localtime)省去了在 Java/Kotlin 层取系统时间的代码也避免了设备时区不准导致的记录时间错误。4.2 预约时段冲突校验预约的逻辑核心不是插入数据而是判断某个场馆在目标时段是否已经被占用。这个查询写不好系统就会出现同一个场馆同一时间被两个人订走的问题。SELECT COUNT(*) FROM appointment WHERE venue_id ? AND status ! 2 AND date ? AND start_time ? AND end_time ?这条 SQL 里的四个参数按顺序是场馆 id、预约日期、新预约的结束时间、新预约的开始时间。区间重叠判断的原理是两个时间段[a, b]和[c, d]存在重叠的充要条件是a d且c b。所以start_time ?对应的是已有预约的开始时间早于新预约的结束时间end_time ?对应已有预约的结束时间晚于新预约的开始时间两者同时成立就是冲突。status ! 2排除了已取消的订单。这里要强调一个细节做业务系统时删除订单通常采用逻辑删除而不是物理删除。用户在个人中心看到取消预约数据库里只是把status从 0 改成 2记录还在方便后续对账和审计。物理删除虽然简单但演示时让评审老师看不到任何历史轨迹反而显不出系统的完整性。Kotlin 侧的调用代码如下fun isTimeSlotAvailable(venueId: Long, date: String, start: String, end: String): Boolean { val db dbHelper.readableDatabase val cursor db.rawQuery( SELECT COUNT(*) FROM appointment WHERE venue_id ? AND status ! 2 AND date ? AND start_time ? AND end_time ? .trimIndent(), arrayOf(venueId.toString(), date, end, start) ) cursor.moveToFirst() val count cursor.getInt(0) cursor.close() return count 0 }参数顺序和 SQL 里的占位符一一对应最容易搞混的是把end和start传反。传反的后果是永远查不到冲突因为条件变成了已有开始早于新开始且已有结束晚于新结束只覆盖了完全包含的情况漏掉了部分重叠。写完后建议用下面几张用例自测| 已有订单 | 新预约 | 是否冲突 | 原因 | | 09:00-10:00 | 09:30-10:30 | 冲突 | 部分重叠已有开始早于新结束 | | 09:00-10:00 | 08:00-09:30 | 冲突 | 部分重叠已有结束晚于新开始 | | 09:00-10:00 | 10:00-11:00 | 不冲突 | 边界相接不重叠 | | 09:00-10:00 | 08:00-09:00 | 不冲突 | 边界相接不重叠 |边界相接的情况要注意start_time ?用的是严格小于恰好相等时不算冲突。这符合直觉——上一场 10:00 结束下一场 10:00 开始场地不需要额外的清扫时间。如果实际运营要求两场之间留 15 分钟间隔可以把比较条件改成时间字段先做加减再比或者直接存成分钟数而不是字符串。提示start_time用字符串类型存储09:00这种格式在比较时按字典序排序因为格式统一为 HH:mm 时字符串排序和实际时间排序完全一致。但如果你用了 9:00 这种非补零格式字典序就会出错务必统一格式。4.3 并发场景两个用户同时抢同一时段这是答辩时的高频追问。上面的 SQL 在单线程应用里没问题但 Android 是事件驱动模型如果用户双击提交预约按钮或者两个真机同时操作应用进程内可能出现并发写入。更隐蔽的问题是跨进程序并发。Android 应用的多线程不能直接操作 SQLiteDatabase 实例否则抛SQLiteDatabase对象被多个线程同时使用的异常。常见做法是让DBHelper使用单例所有数据库操作通过同一个实例进行。class DBHelper private constructor(context: Context) : SQLiteOpenHelper(...) { companion object { Volatile private var instance: DBHelper? null fun getInstance(context: Context): DBHelper instance ?: synchronized(this) { instance ?: DBHelper(context.applicationContext).also { instance it } } } }双重检查锁加Volatile是单例的标准写法。.applicationContext在这里很关键——如果传的是 Activity 的 contextActivity 销毁时单例还持有它的引用会造成内存泄漏。按钮防抖是另一道防线点击提交后立刻把按钮置为不可用等请求结束再恢复能从源头上减少重复提交。btnSubmit.isEnabled false // 执行插入订单的一系列操作 btnSubmit.isEnabled true真正的硬并发控制也就是两个设备在毫秒级同时发起同一个时段的预约需要靠数据库层的BEGIN IMMEDIATE事务配合时间槽的唯一索引才能彻底解决。课程设计里能讲到这一层就足够了实现上可以写一段注释说明不建议真的去实现——会把项目复杂度抬高一个量级反而偏离了大作业的核心要求。5. 收尾阶段的三个打磨点环境兼容、数据初始化与演示脚本这个阶段的目标不是加功能而是让项目在任何一台电脑上打开都能跑起来演示时每一步都有预期效果。5.1 把 Gradle 环境调成开箱即用Android Studio 项目第一次同步 Gradle 是最容易卡住的地方。新建项目默认拉取google()和mavenCentral()仓库国内网络环境下经常超时。在项目根目录的settings.gradle或build.gradle里配置阿里云镜像同步速度会明显改善buildscript { repositories { maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/gradle-plugin } } }注意dependencyResolutionManagement模式的差异。新版 Android Studio 创建的模板默认在settings.gradle里声明仓库把google()替换成上面三个阿里云镜像即可。三个仓库分别覆盖 Google 依赖、Maven Central 依赖和 Gradle 插件。如果项目里还用了第三方库可以在allprojects里再加一个maven { url https://maven.aliyun.com/repository/central }兜底。5.2 预置演示数据与无头操作验证评委打开你的应用时如果还要现场注册账号、录场馆数据演示节奏会拖很久。正确做法是让DBHelper在onCreate时自动写入演示账号和场馆数据Override public void onCreate(SQLiteDatabase db) { // 建表语句... db.execSQL(INSERT INTO user (username, password, role) VALUES (admin, 123456, 1)); db.execSQL(INSERT INTO user (username, password, role) VALUES (test, 123456, 0)); db.execSQL(INSERT INTO venue (name, location, open_time, close_time, fee) VALUES (篮球馆, A区1层, 08:00, 22:00, 50)); }这样装好应用直接就有数据演示从登录开始不需要现场等待表面加载。预置数据时用户名和密码要写进答辩 PPT 的演示脚本里避免现场记错。这里没有做密码加密和前面说的一致演示场景下确实能少绕弯路。验证功能完整性也不一定全靠手点。可以写一个DebugActivity里面放几个按钮一键生成 50 条随机预约记录、清空全部订单、模拟冲突预约。答辩前跑一遍冲突用例把同一时段已预约的 Toast 截图放进 PPT比自己临时输入时间参数靠谱得多。5.3 真机调试时的小坑模拟器上跑通后换真机最常见的报错是INSTALL_FAILED_UPDATE_INCOMPATIBLE。这是因为应用签名不同或版本降级卸载重装就能解决。真机走 USB 调试时小米手机要开启USB 安装和USB 调试安全设置否则 Android Studio 点击 Run 后手机会弹安装确认框不点就一直在等待状态。另一个容易忽略的是真机和模拟器的文件路径访问权限有差异如果项目里往外部存储写了文件targetSdkVersion高于 29 时必须走MediaStore或应用专属目录不能直接写/sdcard/。课程设计里如果用了文件缓存尽量用context.filesDir省去权限适配的麻烦。最后把演示顺序固定成一条主路径登录管理员 → 查看场馆列表 → 查看订单 → 切换会员账号 → 搜索场馆 → 提交预约 → 查看订单状态 → 取消预约。每一步对应一个截图和一段代码位置说明答辩时照着走不会慌。本文还有配套的精品资源点击获取