尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android体育场预约系统开发:SQLite事务与并发控制实战

Android体育场预约系统开发:SQLite事务与并发控制实战 简介这套基于Android Studio开发的体育场预约管理系统面向Android初学者与课程设计场景针对场馆预约、订单管理等实际需求提供完整App解决方案。系统涵盖用户注册登录、会员管理员双角色权限以及场馆信息浏览、个人中心、订单提交与状态跟踪、在线支付对接等模块清晰展示了从界面设计到数据交互的完整流程。资源包为zip格式体积约10.8MB内含工程项目及使用说明文档便于导入开发环境直接运行学习。已有6022人学习下载热度较高。通过该项目可系统掌握Android SDK基础、Activity/Fragment组件化开发、SQLite数据库持久化、XML布局设计以及Java/Kotlin编程实践既能支撑课程大作业也能为后续商业级应用开发积累经验。1. 体育场预约管理系统Android Studio 里那份大作业的技术骨架很多人在第一次做体育场预约管理大作业时画界面很快建表也很快真正卡住的是交互逻辑明明显示还有名额点预约却提示失败取消一次预约后剩余名额被加了两次模拟器重启后预约记录全没了。这套系统放在 Android Studio 里本质上是三张表——场馆、场次、预约订单——加上一组状态流转规则。把这三张表的关系想清楚把预约和取消的并发问题处理干净整个大作业的评分点基本就拿到了。下面按数据层、UI 层、事务层、调试层的顺序给出一套能直接落地的实现思路新手可以照着敲做过的项目也能拿来对照补洞。2. 先把数据层钉死SQLite 表设计与约束2.1 为什么先设计表再做界面体育场预约管理系统的坑大多数出在数据模型上。界面写好了发现订单表里没有存“预约时间”要回填数据场次表没有“剩余名额”每次预约都要先 SELECT 再计算。这些改动会让代码越改越乱。反过来先把表结构定清楚界面层只是读和写的关系反而不容易出大问题。用 SQLite 而不是直接上 Room对本科大作业来说利大于弊。SQL 语句可见、可排查SQLiteOpenHelper 的回调机制也足够直白。Room 虽然省代码但注解、迁移、协程这些概念叠加起来答辩时反而说不清楚。等以后做生产项目再换 Room 不迟。2.2 核心建表语句与字段约束体育场预约管理至少需要三张核心表体育场场馆表 venue、具体可预约场次表 court_schedule、用户预约订单表 booking。下面这份 SQL 可以直接放进 SQLiteOpenHelper 的 onCreate。CREATE TABLE venue ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, location TEXT, capacity INTEGER NOT NULL DEFAULT 20, open_time TEXT NOT NULL, close_time TEXT NOT NULL ); CREATE TABLE court_schedule ( id INTEGER PRIMARY KEY AUTOINCREMENT, venue_id INTEGER NOT NULL, start_time INTEGER NOT NULL, end_time INTEGER NOT NULL, total INTEGER NOT NULL, remaining INTEGER NOT NULL, version INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (venue_id) REFERENCES venue(id), UNIQUE (venue_id, start_time) ); CREATE TABLE booking ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, schedule_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT CONFIRMED, book_time INTEGER NOT NULL, FOREIGN KEY (schedule_id) REFERENCES court_schedule(id), UNIQUE (user_id, schedule_id) );venue 表存体育场的物理属性比较稳定court_schedule 表存“某场馆某天某个时段的预约情况”这里UNIQUE (venue_id, start_time)保证同一场馆的同一开始时间只有一条场次记录避免后台生成场次时重复。remaining 单独存而不是每次用 total 减订单数是为了查询列表时不走聚合计算性能更好也方便后面做乐观锁。booking 表不加 user_id 的外键约束是因为不少大作业没做登录模块先留一个字段后面要扩展时再补外键也不迟。status 字段用字符串存储取值限定在 CONFIRMED、CANCELLED 两个状态简单够用。时间统一用 INTEGER 存 epoch 毫秒值排序和比较都直接走数值避免字符串日期格式不统一的问题。2.3 SQLiteOpenHelper 的版本管理与初始化数据数据库版本从 1 开始以后每改一次表结构就加 1。onUpgrade 里不要一上来就 DROP TABLE答辩时这属于减分操作。正确做法是按版本号做增量迁移。class StadiumDbHelper(context: Context) : SQLiteOpenHelper(context, stadium.db, null, 1) { override fun onCreate(db: SQLiteDatabase) { db.execSQL(SQL_CREATE_VENUE) db.execSQL(SQL_CREATE_COURT_SCHEDULE) db.execSQL(SQL_CREATE_BOOKING) insertDemoData(db) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (oldVersion 2) { db.execSQL(ALTER TABLE court_schedule ADD COLUMN price REAL DEFAULT 0) } } private fun insertDemoData(db: SQLiteDatabase) { db.execSQL(INSERT INTO venue (name, location, capacity, open_time, close_time) VALUES (中心体育场, A区一层, 200, 08:00, 22:00)) val now System.currentTimeMillis() db.execSQL(INSERT INTO court_schedule (venue_id, start_time, end_time, total, remaining) VALUES (1, $now, ${now 2 * 3600 * 1000}, 20, 20)) } }参数说明onUpgrade里的 oldVersion 和 newVersion 是数据库版本而不是 Android 系统版本判断条件写if (oldVersion 2)而不是 2这样从版本 1 直接升到 3 时也能执行中间迁移。insertDemoData 里的时间用System.currentTimeMillis()保证每次安装都有可预约的当前场次。还有一个常见坑SQLiteOpenHelper 不要写成全局静态单例。多线程环境下 getWritableDatabase 会被阻塞单例看似省事但一旦在子线程持有连接主线程再去写就会遇到 SQLiteDatabaseLockedException。大作业场景里每用一次就 getWritableDatabase 一次足够。2.4 字段设计对照表与常见误用设计选择推荐做法常见误用后果时间字段INTEGER 存毫秒TEXT 存 2025-06-01 18:00比较、排序、时区处理全乱剩余名额独立 remaining 字段每次 count 订单数列表加载慢查询逻辑复杂状态字段TEXT 代码内枚举用 is_valid 的 0/1 表示无法表达已取消、已完成等状态重复预约UNIQUE(user_id, schedule_id)应用层 if 判断并发场景下出现重复订单应用层判断能拦截正常操作但拦截不了并发。数据库约束是最后一道防线两道都要留。设计表时多花十分钟后面写预约和取消的逻辑会省几个小时。3. RecyclerView 搭预约主界面适配器、DiffUtil 与状态绑定3.1 为什么预约列表必须用 RecyclerView体育场预约管理系统的主界面无非是展示一批可预约的场次用户点进去完成预约。很多新手用 ScrollView 套 LinearLayout再循环 findViewById 动态 addView数据少时能跑数据一多就开始掉帧卡顿。RecyclerView 的优势在于 ViewHolder 复用屏幕只显示七八个 item它就不需要为几百条数据创建几百个视图对象。Android Studio 的新版项目模板已经把 RecyclerView 的依赖集成在默认依赖里不需要单独引包。布局上用RecyclerView占满主体区域item 布局单独建一个item_schedule.xml。3.2 ListAdapter DiffUtil 实现局部刷新ListAdapter 相比普通 Adapter最大的价值是自动计算新旧列表差异只刷新变化的 item。预约成功后那个 slot 的 remaining 变了不需要notifyDataSetChanged()重建整个列表。class ScheduleAdapter( private val onClick: (ScheduleItem) - Unit ) : ListAdapterScheduleItem, ScheduleAdapter.VH(DiffCallback) { object DiffCallback : DiffUtil.ItemCallbackScheduleItem() { override fun areItemsTheSame(oldItem: ScheduleItem, newItem: ScheduleItem): Boolean { return oldItem.id newItem.id } override fun areContentsTheSame(oldItem: ScheduleItem, newItem: ScheduleItem): Boolean { return oldItem newItem } } class VH(val binding: ItemScheduleBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val binding ItemScheduleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return VH(binding) } override fun onBindViewHolder(holder: VH, position: Int) { val item getItem(position) holder.binding.tvVenueName.text item.venueName holder.binding.tvTime.text item.timeRange() holder.binding.tvRemaining.text 剩余 ${item.remaining} 人 holder.binding.btnBook.isEnabled item.remaining 0 !item.booked holder.binding.root.setOnClickListener { onClick(item) } } }ListAdapter 的 submitList 方法代替原来的 setData。首次调用会完整渲染一次之后每次提交新列表DiffCallback 先比较 id 判断是否是同一个 item再比较全部字段判断内容是否有变化。预约成功后只需要把对应 item 的 booked 改为 true、remaining 减 1再 submitList 一次RecyclerView 内部的 AsyncListDiffer 只更新那一行。item 布局里注意android:layout_height不要写死用 wrap_content。按钮的 enabled 状态直接在 Bind 时根据 remaining 设置不要在点击事件里再判断这样数据和视图永远同步模拟器上复现“有名额但按钮点不动”的几率小很多。3.3 点击回调避免持有 Activity 引用上面适配器的构造参数是一个(ScheduleItem) - Unit的 lambda而不是直接把 Activity 传进去。Activity 实现这个 lambda点击时跳转详情页或弹出确认对话框。这种写法避免适配器持有 Activity 强引用界面旋转时不会导致旧 Activity 无法回收内存泄漏的问题从源头就掐掉了。Lambda 里做跳转时代码长这样val adapter ScheduleAdapter { item - val intent Intent(this, BookingActivity::class.java) intent.putExtra(schedule_id, item.id) startActivity(intent) }Intent 只传 schedule_id 一个 ID详情页自己从数据库查不把整个对象塞进 Intent。大作业里 Serializable 传整个 Bean 是常见写法但对象一改序列化版本对不上就会崩传 ID 是最稳的。3.4 RecyclerView 布局的几个必调参数参数推荐值说明layoutManagerLinearLayoutManager预约列表是单列纵向滚动item 根布局wrap_content高度写死会导致小屏机型显示不全clipToPaddingfalse列表底部最后一项不会被滑动条遮挡点击反馈foreground?attr/selectableItemBackground有点击涟漪效果交互感提升明显宿主 Activity 的布局里RecyclerView 不要放在 NestedScrollView 里否则滑动冲突会让列表滚动迟钝。如果页面顶部需要固定信息用 LinearLayout 普通布局放头部RecyclerView 单独占剩余空间不要图省事整页嵌套。4. 预约与取消的状态流转事务、版本号与时间冲突处理4.1 预约状态机先想清楚再写代码体育场预约的核心状态只有三个可预约、已预约、已取消。对应到代码court_schedule 表的 remaining 表示剩余名额booking 表的 status 表示单条订单状态。很多不必要的复杂度来自把“已支付”“待支付”“退款中”这些电商状态搬进体育场场景答辩时反而讲不清楚。建议的状态机CONFIRMED预约成功和 CANCELLED已取消不做支付。取消后剩余名额要还回去这是整个系统最重要的一条业务规则。把状态流转的条件列成一张表代码照着抄操作前置条件表变更失败场景预约remaining 0court_schedule.remaining - 1名额被抢完返回友好提示取消booking.status CONFIRMEDbooking.status CANCELLEDremaining 1重复取消幂等处理4.2 用事务保证扣名额和写订单的原子性预约动作涉及两步court_schedule 表减名额、booking 表插订单。两步之间如果程序崩溃会出现“扣了名额但没有订单”的脏数据。SQLite 的事务解决的就是这个问题要么两步都成功要么都回滚。fun bookSchedule(scheduleId: Long, userId: Long): Boolean { val db helper.writableDatabase db.beginTransaction() try { val cursor db.rawQuery( SELECT remaining, version FROM court_schedule WHERE id ?, arrayOf(scheduleId.toString()) ) if (!cursor.moveToFirst()) { cursor.close() return false } val remaining cursor.getInt(0) val version cursor.getInt(1) cursor.close() if (remaining 0) return false val updateValues ContentValues().apply { put(remaining, remaining - 1) put(version, version 1) } val updatedRows db.update( court_schedule, updateValues, id ? AND version ?, arrayOf(scheduleId.toString(), version.toString()) ) if (updatedRows 0) return false val bookingValues ContentValues().apply { put(user_id, userId) put(schedule_id, scheduleId) put(status, CONFIRMED) put(book_time, System.currentTimeMillis()) } db.insertOrThrow(booking, null, bookingValues) db.setTransactionSuccessful() return true } catch (e: SQLiteConstraintException) { return false } finally { db.endTransaction() } }逻辑说明代码里先查 current remaining 和 version更新时再拼上 version 条件。两个用户同时读到 remaining 20、version 3第一个用户更新成功后 version 变成 4第二个用户再执行 update 时WHERE version 3匹配不到任何行updatedRows 返回 0预约失败。这个机制叫乐观锁不用锁表就能阻止超卖。setTransactionSuccessful()必须在endTransaction()之前调用。没有调成功标记就直接 endTransaction等价于回滚扣掉的名额和插入的订单会一起撤销。try 块里return false的路径不需要单独处理finally 里的 endTransaction 会兜底回滚。insertOrThrow配合 UNIQUE(user_id, schedule_id) 约束是最后一道防重复线。即使应用层判断漏了数据库也会抛 SQLiteConstraintException被 catch 捕获后返回 false。这个异常不需要打印堆栈只记录一条日志就行因为它属于业务预期内的拒绝不是系统错误。4.3 取消预约要设计成幂等操作取消预约比预约容易写错。最容易犯的错误是先 SELECT 查订单状态再判断是否已取消最后执行 UPDATE。这个流程在重复点击的场景下会重复加名额。用户快速点两次取消第一次请求成功后 remaining 1第二次请求读到的订单还是 CONFIRMED 吗不一定取决于时序。正确做法是用 UPDATE 的返回值判断是否真正更新了状态fun cancelBooking(bookingId: Long): Boolean { val db helper.writableDatabase db.beginTransaction() try { val values ContentValues().apply { put(status, CANCELLED) } val rows db.update( booking, values, id ? AND status CONFIRMED, arrayOf(bookingId.toString()) ) if (rows 0) return false db.execSQL( UPDATE court_schedule SET remaining remaining 1, version version 1 WHERE id (SELECT schedule_id FROM booking WHERE id ?), arrayOf(bookingId.toString()) ) db.setTransactionSuccessful() return true } finally { db.endTransaction() } }WHERE id ? AND status CONFIRMED这个条件保证了第二次点击时订单状态已经是 CANCELLEDupdate 影响行数为 0直接返回 false不会执行给 appointment 加名额的语句。这就是幂等同一个操作执行任意多次对数据的影响只有一次。4.4 SQLite 常见错误码与排查方向错误码触发场景排查方向SQLITE_CONSTRAINT_UNIQUE (2067)重复预约同一场次查 UNIQUE 约束字段是否设计正确SQLITE_BUSY (5)多线程同时写数据库确认没有跨线程复用 getWritableDatabaseSQLITE_CORRUPT (11)数据库文件损坏检查是否在写入中途强杀 App调试阶段看到 SQLiteConstraintException 不要慌先在日志里打印约束名称能立刻定位是哪个唯一索引冲突。Android Studio 的 Logcat 会显示完整异常栈SQLite 的异常信息比大多数应用层异常更直白大部分时候一看就知道是哪个约束没满足。5. Android Studio 调试三板斧模拟器、日志与结果验证做 Android 大作业最浪费时间的是环境问题而不是业务代码。Gradle 同步慢、SDK 下载失败、模拟器起不来这几个坑每个都能耗掉半天。如果你用的 Android Studio 版本比较新第一次新建项目时 Gradle 会联网下载依赖国内网络环境下可以把仓库地址换成国内镜像源。在项目根目录的build.gradle.kts里把 google() 和 mavenCentral() 换成阿里云镜像就能显著提速这个操作不会影响最终 APK 的生成。SDK 路径也别放在带中文或空格的目录里Android Studio 的 SDK 管理界面经常因为路径问题导致组件勾选无效。模拟器起不来时先检查 AVD 的 API Level 和本机架构是否匹配。x86 架构的电脑装了 arm64 镜像启动会异常缓慢arm 架构则相反。Android Studio 自带的 Device Manager 里能直接看 AVD 的系统镜像版本2026 年以后的版本建议优先下载 API 30 以上但不追最新的 Preview 版本Preview 镜像经常有兼容性问题。真机调试连不上时用adb devices命令查看设备状态如果显示 unauthorized去手机上确认 USB 调试授权弹窗。数据库验证用 Android Studio 自带的 App Inspection 工具。应用跑起来后菜单栏 View - Tool Windows - App Inspection选中当前进程就能看到应用沙盒里的数据库文件。打开stadium.db后可以直接执行 SQL预约一次后执行SELECT remaining FROM court_schedule再执行SELECT * FROM booking比对两条结果就能验证事务是否生效。这个工具还能看 SharedPreferences 和网络请求大作业答辩时现场演示数据库变化比口头解释有说服力。取消预约幂等性验证有个取巧的办法在按钮的点击回调里连写两行cancelBooking(bookingId)正常情况下第一次返回 true、第二次返回 false用 Logcat 打印返回值就能确认。数据库字段恢复性验证则用 App Inspection 里的 SQL 执行一条更新语句手动把 remaining 改成异常值再跑一次预约流程看系统能否正确修正。这些验证结束之后把 App Inspection 的 SQL 截图放进报告能直接说明你考虑了并发安全问题。最后一个加分操作把预约和取消的两个核心方法写成 instrumented test放在androidTest目录下用 AndroidJUnit4 跑一次。不用写很多用例覆盖“预约成功”“预约失败不扣名额”“取消后名额恢复”“重复取消幂等”四个场景就够。答辩时当场跑测试比任何截图都有说服力。本文还有配套的精品资源点击获取
返回列表