体育场预约管理系统Android课程设计:基于Room的时段冲突与事务实现
2026/9/14 12:28:14 网站建设 项目流程

简介:这是一款基于Android Studio开发的体育场预约管理系统,面向高校软件技术、移动开发相关专业学生,尤其适合需要完成安卓课程大作业或毕业设计的读者。系统重点解决线下排队预约体育场馆的痛点,提供用户注册登录、普通会员与管理员双角色权限控制、体育馆信息浏览、个人中心管理、在线预约下单、历史订单查询修改及状态跟踪等功能,覆盖从界面搭建到业务处理的完整开发链路。开发过程中综合运用Activity与Fragment组件、XML布局文件、SQLite数据持久化、适配器列表展示、角色权限管理等核心Android技术,有助于读者理解真实预约类应用的业务流程与代码组织方式。资源以zip压缩包形式发布,包体大小约10.8MB,便于下载后直接导入Android Studio查看或调试。目前已有6022人学习下载,可作为课程设计参考模板,并可在基础上扩展地图定位、在线支付等高级功能。

1. 体育场预约管理系统:课程设计里最容易被低估的是“时段冲突”

体育场预约管理系统在课程设计清单里常被归进“简单 CRUD”那一类,可实际动手以后你会意识到,真正的复杂度集中在“时间”上:场地可以被不同人预约、用户会在临近截止时反复提交、时段粒度一旦从半天改成小时,数据量立刻翻倍。所有这些问题最终都会落到同一个不变量上——同一块场地在同一个时间段内,不能同时存在两个有效预约订单。

这个项目也很适合用 Android Studio 单独交付。大作业场景下,单人开发最常见的取舍是做一个本地单机版本:用 Room 完成持久化,预约状态全部放到本地事务里管理,不引入后端服务器。这样既能在答辩时完成从选场地、选时段到提交预约的完整演示,也能把有限的代码量集中到界面逻辑和数据处理上。与其盲目堆功能,不如把时间片设计、状态流转和重复点击防护做扎实,这是这门课题更容易拿分的地方。

2. 预约系统的数据模型:Room 表结构、时间片与状态字段

2.1 为什么在课程设计里优先选 Room

Android 本地持久化的惯例是 SQLite,但课程设计里直接写 SQLiteOpenHelper 的维护成本偏高:表结构变更要手工改版本号、写迁移脚本,查询结果还要手动映射成对象。Room 是 Android 官方提供的 ORM 框架,它在编译期生成 SQLite 访问代码,把 DAO、Entity 和 Database 类串起来。用 Room 有三个很现实的好处:写法上接近“定义数据类 + 加注解”,SQL 集中在 DAO 接口里,查询错误在编译期就能暴露一部分;Room 对 LiveData 的支持也让列表页可以自动刷新,预约成功后不用手动调用 Adapter 的 notifyDataSetChanged。

针对体育场预约系统这种课设,Room 的迁移机制也值得设计进方案。比如你一开始设计了“开始时间 + 结束时间”两个字段,答辩前想改成“日期 + 起始时段 + 时段数”的模型,Room 的 Migration 可以在不删表的前提下完成升级。课程设计阶段不强制要求数据不丢失,但能把 Migration 写对,本身就是一个加分点。

Room 的三件套通常这样组织:Entity 定义表结构,DAO 定义数据访问接口,Database 类持有数据库实例并在应用启动时初始化。下面先从 Entity 设计开始。

2.2 三张核心表:场地、预约、用户

体育场预约系统的表结构不需要设计得过于复杂,评分点集中在“预约”核心流程上,三张表足够:

表名关键字段用途
venuevenueId, name, sportType, pricePerHour, capacity记录场地基础信息和单价
bookingbookingId, venueId, userId, date, startSlot, durationSlots, status存储预约记录,包含时段与状态
useruserId, username, role, phone区分用户与管理员,管理员可作废任意预约

booking 表是整个系统的核心。设计时建议把时段拆成“date + startSlot + durationSlots”,而不是直接存开始时间和结束时间。原因很直接:场地预约的时间粒度通常是固定的,比如半小时或一小时。存时间戳虽然更通用,但在冲突判断时要处理大量的边界比较;改用整数 slot 表达时段后,冲突判断只需要比较两个整数区间,代码量少一半,也更容易用单元测试覆盖。

为什么需要 status 字段?预约不是一条 INSERT 就结束了,它会经历用户取消、超时未生效、管理员作废等状态变化。用整型字段管理状态,比物理删除记录更适合课程设计的展示和答辩提问。实际订单表里建议保留的取值是:0 表示已提交待确认,1 表示预约成功,2 表示已取消,3 表示已过期。如果指导老师要求流程更简单,0 和 1 可以合并,但 status 字段本身仍然值得保留,因为它让后续的状态机设计变得可行。

2.3 用 DAO 和 Repository 封装查询逻辑

定义好 Entity 之后,DAO 是 Room 里最重要的层。这个系统至少需要三个数据访问方法:场地列表查询、预约插入、冲突检测。冲突检测可以在 DAO 里通过 SQL 直接完成,检查同一日期同一个场地是否存在时间重叠的记录:

@Dao interface BookingDao { @Query(""" SELECT * FROM booking WHERE venue_id = :venueId AND date = :date AND status IN (0, 1) AND start_slot < :endSlot AND (start_slot + duration_slots) > :startSlot """) suspend fun findConflictingBooking( venueId: Long, date: String, startSlot: Int, durationSlots: Int ): List<BookingEntity> }

这段 SQL 判断冲突的思路是:新预约的区间是 [startSlot, startSlot + durationSlots),已存在预约的区间是 [start_slot, start_slot + duration_slots),两个区间重叠的条件是“新起点小于旧终点”且“新终点大于旧起点”。用这种区间方式是因为要覆盖三种情况:新预约完全包含旧预约、部分重叠、旧预约完全包含新预约。如果只是比较两个时间戳谁大谁小,必然漏掉其中一两种。

Room 的 DAO 支持 suspend 函数,这要求项目引入协程依赖。后面会给完整的 build.gradle 配置。把 DAO 包在 Repository 层里的好处是业务逻辑可复用:预约按钮点击后的冲突检测、列表页的剩余时段计算、管理员的强制作废,都调用同一组 Repository 方法,而不是在 ViewModel 里重复写 SQL。

Repository 层的职责要划分清楚:只负责数据获取和事务组合,不负责界面状态。界面的 loading、错误提示、预约按钮的可用状态应该由 ViewModel 处理。用一个 ViewModel 可以同时管理这几件事:拉取场地列表、根据当前选中的日期计算可预约时段、提交预约并处理冲突结果。

3. 用 Android Studio 实现“选场地 → 选时段 → 提交预约”主流程

3.1 Gradle 依赖配置

开始写界面前,先把 build.gradle 里的依赖确认好。一个基于现代 Android Studio 课设项目的依赖组合如下:

plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-kapt' } android { compileSdk 34 defaultConfig { applicationId "com.example.stadiumbooking" minSdk 24 targetSdk 34 versionCode 1 versionName "1.0" } } dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-ktx:2.6.1' kapt 'androidx.room:room-compiler:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' }

room-ktx 提供协程支持,lifecycle-viewmodel-ktx 提供 viewModelScope。kapt 是 Room 在编译期生成代码的注解处理器,也可以换成 ksp,但 kapt 在 Studio 里开箱即用,对课设来说更稳妥。如果遇到 Gradle 依赖下载过慢的问题,可以在 settings.gradle 里配置阿里云镜像仓库,方法是在 dependencyResolutionManagement 的 repositories 中添加 maven { url 'https://maven.aliyun.com/repository/public' }。

3.2 场地列表页:RecyclerView + ViewModel 的组合

系统主界面建议采用“场地列表 + 预约入口”的结构。RecyclerView 作为列表容器,每个 item 显示场地名称、运动类型、单价和容量。这里涉及两个 layout 文件:item_venue.xml 只做展示,item_venue_booking.xml 多一个“立即预约”按钮,点击后跳转到时段选择页。

场地列表的 ViewModel 需要暴露两个 LiveData:venueList 和 isRefreshing。获取列表可以这样写:

class VenueListViewModel(private val repository: VenueRepository) : ViewModel() { private val _venueList = MutableLiveData<List<VenueEntity>>() val venueList: LiveData<List<VenueEntity>> = _venueList fun loadVenues() { viewModelScope.launch { _venueList.value = repository.getAllVenues() } } }

viewModelScope.launch 会在 ViewModel 销毁时自动取消协程,不需要在 Activity 的 onDestroy 里手动处理异步回调。如果你用的是 Java 写法,没有协程,就需要用 Executor 或 Thread,这两个方案在屏幕旋转时会有内存泄漏风险,不推荐用于新写的课设代码。

3.3 时段选择页:用 Grid 渲染可预约的 slot

时段选择页是交互最复杂的界面。常见做法是把一天划分为若干 slot,例如从 8 点到 22 点共 14 个 slot,每个 slot 代表一小时。用 RecyclerView 配合 GridLayoutManager 展示,每个 slot 有三种状态:可选、已被预约、当前选中。

可约时段的计算逻辑放在 ViewModel 里,核心函数如下:

fun computeAvailableSlots( allSlots: List<Int>, existingBookings: List<BookingEntity> ): Set<Int> { val occupied = existingBookings.flatMap { booking -> (booking.startSlot until booking.startSlot + booking.durationSlots).toList() }.toSet() return allSlots.filter { it !in occupied }.toSet() }

这里把已有预约展开成具体的 slot 集合,再与全部 slot 做差集。occupied 集合里的每个元素是一个小时单位,这种展开写法在 slot 数量少时非常直观。如果用户选中了多个连续 slot,提交时要再把选中集合折叠回 startSlot + durationSlots 的形式,这样才能复用上一章的冲突检测 SQL。

3.4 提交预约:事务、二次确认与防重复点击

点击“提交预约”按钮后,先弹出 AlertDialog 做二次确认,列出场地名称、日期、时间段和费用,确认后才写数据库。这里最容易踩的坑是快速双击按钮产生两条记录。防护手段分两层:UI 层在 Dialog 弹出后把按钮置为不可点击,数据层做冲突检测和唯一索引。两层缺一不可,UI 层解决用户体验,数据层兜底逻辑漏洞。

插入预约的 Repository 方法这样写:

suspend fun createBooking(booking: BookingEntity): BookingResult { return try { db.withTransaction { val conflict = bookingDao.findConflictingBooking( booking.venueId, booking.date, booking.startSlot, booking.durationSlots ) if (conflict.isEmpty()) { bookingDao.insert(booking) BookingResult.Success(booking.bookingId) } else { BookingResult.Failed("该时段已被预约,请重新选择") } } } catch (e: Exception) { BookingResult.Error(e.message ?: "保存失败") } }

withTransaction 是 Room 提供的协程事务扩展,能确保冲突检测和插入在同一个数据库事务内执行。为什么必须放在事务里?因为检测冲突和插入之间如果被其他线程插入了一条记录,两条预约就可能同时通过检测。Room 的 withTransaction 在同一数据库实例上是串行执行的,事务内的代码不会和其他事务并发,因此这个场景下可以放心使用。

4. 预约冲突的并发控制:事务、唯一索引与控制逻辑

4.1 重复点击与完全重复预约

课程设计虽然不像生产环境那样有高并发压力,但我在排查实际课设代码时发现,“重复点击”仍是最常见的失败场景。双击按钮通常产生两条完全一样的预约,原因在于插入前没做防抖,或者数据库层没有唯一性约束。两个手段可以同时加:UI 防抖解决操作体验,数据库约束兜底异常情况。

SQLite 里可以建复合唯一索引:

CREATE UNIQUE INDEX idx_booking_venue_time ON booking(venue_id, date, start_slot, duration_slots);

这个索引只能挡住完全相同的预约,也就是 start_slot 和 duration_slots 都一样的记录;两个部分重叠的预约它拦不住。因此 DAO 层的时间段重叠检测仍然必要。这个唯一索引的真正价值在于:挡住用户双击造成的完全一致请求,以及在程序逻辑因 bug 漏检时提供最后一道防线。

4.2 部分重叠的单元测试与边界情况

部分重叠的检测逻辑已经在前面的 SQL 中给出了,这里补充一个配套的单元测试。答辩时被问“你怎么验证冲突检测是对的”,直接跑测试比口头解释有说服力得多:

@Test fun testOverlapDetection() { // 已有预约:10 点到 12 点 val existing = BookingEntity(venueId = 1, date = "2024-06-01", startSlot = 10, durationSlots = 2) // 新预约:11 点到 13 点,部分重叠 val newStart = 11 val newDuration = 2 val isOverlap = newStart < existing.startSlot + existing.durationSlots && newStart + newDuration > existing.startSlot assertTrue(isOverlap) }

这个测试覆盖的是“新预约开始在旧预约结束前,且新预约结束在旧预约开始后”的典型部分重叠场景。建议把相邻时段也测一下:10-12 和 12-14 的预约在边界相切,正确结果应该是 false,因为 12 点整开始的预约和已结束的预约不构成重叠。边界相切是否允许由业务决定,但课程设计里建议允许,否则用户的结束时间选择会很别扭。

4.3 状态机设计:用条件更新替代状态机框架

预约状态在实体中用整型字段管理,状态之间允许的迁移用代码约束。常见的状态机定义如下:

状态值含义可迁移到的状态
0已提交待确认1(确认)、2(取消)
1预约成功2(取消)、3(过期)
2已取消
3已过期

实现状态迁移时,不推荐引入状态机框架,课程设计用不上。直接在 Repository 里写条件更新即可:

suspend fun cancelBooking(bookingId: Long): Boolean { val current = bookingDao.getById(bookingId) ?: return false if (current.status in listOf(2, 3)) return false bookingDao.updateStatus(bookingId, 2) return true }

更新前先读当前状态,更新时按 bookingId 和旧 status 做条件更新,可以避免两个线程同时取消同一订单导致的状态漂移。如果观察得更仔细,会发现这里用的是乐观锁的简化版:先比较后更新。真正的乐观锁会要求 UPDATE 语句里带上旧的 status 作为 WHERE 条件,这种做法更严谨,但课程设计里用先读取再判断已经足够。

5. 答辩演示前的调试技巧:造数据、查库和验证过期状态

5.1 用模拟器快速造数据

演示阶段最大的痛点是“没有数据可展示”。与其手动点击十几次,不如在应用启动时判断数据库是否为空,为空则自动写入几条模拟场地记录:

class SeedDataWorker(private val dao: VenueDao) { fun seedIfEmpty() { if (dao.count() > 0) return dao.insertAll( VenueEntity(name = "室内篮球馆", sportType = "篮球", pricePerHour = 80), VenueEntity(name = "足球场A", sportType = "足球", pricePerHour = 200), VenueEntity(name = "羽毛球馆1号", sportType = "羽毛球", pricePerHour = 40) ) } }

这段代码必须加一个 BuildConfig.DEBUG 开关,只在调试包中执行,避免答辩演示完打包时把演示数据带进最终产物。

5.2 用 Database Inspector 查看预约记录

Android Studio 自带 App Inspection 工具,连接模拟器运行应用时,可以在工具窗口里直接查看应用沙盒内的数据库文件,浏览 booking 表的每一行记录,甚至可以手动修改字段值来测试界面响应。这个工具对调试状态机非常有用:你可以把一条记录的 status 从 1 改成 2,然后切回应用界面,观察列表是否立刻刷新。

如果更习惯命令行,也可以直接拉取数据库文件到本地,用 SQLite 工具检查:

adb exec-out run-as com.example.stadiumbooking cat databases/stadium.db > stadium.db

拿到文件后,在终端里用 sqlite3 直接查询冲突检测 SQL 的结果,这样可以绕开整个 Android 环境单独验证数据逻辑。

5.3 模拟“预约过期”的场景

如果系统里有“已提交但 30 分钟未确认则过期”的逻辑,不用真的等待三十分钟。用 adb 修改模拟器的系统时间就能制造过期场景:

adb shell date 062115002024.00

执行后杀掉应用进程重新打开,观察过期状态是否被正确刷新。注意只能改模拟器,不要改真机时间,否则系统日历、闹钟等应用会受到影响,而且真机上的时间修改通常需要手动同步校正,操作成本高。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询