剧院购票APP这类题目,几乎每年都能在毕设和练习项目里见到,但真正把它做到能跑、能演示、能完整讲清“为什么这么设计”的,其实不多。我去年从零做了一版基于Android Studio的剧院购票APP,覆盖了注册登录、剧目浏览、选座、下单、支付模拟和订单管理,最后打包成APK在真机上跑通了整个闭环。这篇文章就把整个过程拆开讲,重点聊聊那些文档里通常不会写的东西:座位状态怎么保证不超卖、Gradle版本到底怎么配对、选座自定义View的触摸映射怎么做、release打包为什么老提示找不到类。如果你正在准备毕设,或者想自己练一个业务逻辑完整的Android原生项目,这篇应该能帮你少走不少弯路。
1. 剧院购票的业务建模:先想清楚要解决什么问题再碰代码
1.1 剧院购票和普通电商真的不一样
很多人一看到“购票APP”,第一反应就是照着电商模板做:商品列表、商品详情、加入购物车、结算。这个思路不能说错,但放到剧院场景里会漏掉最核心的东西:购票卖的不是一件可复制的商品,而是特定场次里的一个座位坐标。
电商卖一箱牛奶,库存1000件,卖掉100件还剩900件,逻辑上就是数字加减。剧院不一样,同一场《雷雨》,楼下池座1排1座被买了,这个座就没了,哪怕旁边1排2座还空着,你也无法把1排1座“补货”。座位是一个有边界、有形状、相互关联的二维资源,选座过程本身就涉及状态冲突。
所以我在做数据建模前,先花了两天时间梳理业务,而不是急着建工程。我把整个购票流程走了一遍,确认了几个核心对象:
- 用户:注册、登录、查看订单。
- 剧目:名称、简介、时长、票价、海报图。
- 场次:同一剧目在不同日期或时段有不同场次,每场对应一个演出厅。
- 演出厅/座位:厅里有若干排若干列,每个座位的状态分为可选、已锁定、已售出。
- 订单:一次选座操作产生一个订单,包含用户、场次、座位集合、金额和支付状态。
这个业务流程一旦理清楚,后面写代码就是“翻译”的过程。如果上来就建表,很容易做成“商品+库存”的模型,座位状态和订单状态纠缠不清,后面并发测试时必出大问题。
1.2 核心角色与用例梳理
我按最简单的角色模型来划分:普通用户和管理员。管理员部分我后期才补上,主要用来管理剧目和场次信息,方便演示时不用每次手动往数据库里塞数据。
用户的用例相对固定:
- 注册新账号,注册时检查手机号和用户名是否重复。
- 登录后浏览剧目列表,点击进入剧目详情。
- 查看该剧目某个场次的座位图。
- 点击座位进行选择,已售座位不可点,已选座位可取消。
- 确认订单,核对场次、座位和金额。
- 进入模拟支付页面,支付成功后生成有效订单。
- 随时查看“我的订单”,区分待支付、已支付、已取消等状态。
管理员的用例我只做了一部分:新增剧目、新增场次、可以看到某个场次的售票情况。这些功能对业务闭环来说不是必需的,但对演示和验收特别有帮助,尤其是“看售票情况”这个页面,可以直接验证你写的座位状态有没有被正确更新。
1.3 MVP范围怎么切
我见过很多同学在需求阶段就给自己加戏:优惠券、积分、会员等级、评论区、票务转赠、退改签……这些功能单拎出来每一个都不难,但全部堆在一起,项目就会变得非常臃肿,而且核心的购票链路反而容易被忽略。
我给自己的MVP(最小可行产品)范围是这样切的:
| 模块 | 必须做 | 可延后 |
|---|---|---|
| 用户 | 注册、登录、退出、订单查询 | 密码找回、第三方登录 |
| 剧目 | 列表、详情、场次切换 | 搜索、分类筛选、评论 |
| 选座 | 座位图展示、点选、状态区分 | 拖拽缩放、情侣座、无障碍席 |
| 订单 | 生成订单、待支付、支付模拟 | 退款、改签、电子票二维码 |
| 支付 | 模拟支付页面跳转回写状态 | 接入支付宝/微信真实支付 |
| 管理 | 新增剧目/场次、查看售卖 | 统计报表、排班日历 |
事实证明这个切法是对的。整个项目从功能开发到打包交付,主线一直很清晰,没有被边角功能带偏。
2. 技术选型与工程搭建:Android Studio原生方案的取舍与踩坑
2.1 为什么选原生Java而不是跨平台方案
题目指定了Android Studio,这本身就是最实际的理由:它是Android官方IDE,模拟器、布局预览、内存分析、Profiler这些工具链齐整,调试体验远好于在跨平台框架里折腾原生控件。
开发语言我选了Java而不是Kotlin。原因倒不是Kotlin不好,而是这个项目要覆盖Android四大组件、SQLite操作、自定义View、AsyncTask这一整套基础知识点,Java的语法门槛对刚接触Android的人来说更友好,网上现成的资料也更多。如果你已经有Kotlin基础,换成Kotlin完全没问题,架构上没有任何区别。
另一个需要提前做的决定是数据层。我没搭后端服务器,数据全部存在本地的SQLite数据库里,通过SQLiteOpenHelper管理。这样做的最大好处是项目可以独立运行,不需要联网,演示的时候不会出现“服务器挂了整个APP白屏”的尴尬。缺点也很明显:座位状态和订单状态只存在一台手机上,无法做到真正的多用户并发。所以在代码设计上,我把数据访问层单独抽了出来,以后要接后端,只需要把DAO层的实现从“查SQLite”换成“请求API”,上层业务代码基本不用动。
2.2 工程目录结构与Gradle配置要点
工程结构上,我按常见的按层分包方式组织,方便后期维护:
com.example.theater ├── activity // 各页面Activity ├── adapter // RecyclerView适配器 ├── bean // 实体类 ├── dao // 数据库访问对象 ├── db // 数据库帮助类 ├── view // 自定义View └── util // 工具类Gradle配置是整个项目最容易卡住的地方,尤其是Android Studio版本、AGP(Android Gradle Plugin)版本、Gradle版本和JDK版本这四者的对应关系。我用的Android Studio是2023.1.1,对应的项目配置大致如下:
android { compileSdkVersion 34 defaultConfig { applicationId "com.example.theater" minSdkVersion 21 targetSdkVersion 34 versionCode 1 versionName "1.0" } }这里有一个经验:minSdkVersion别定太高。我一开始觉得反正现在都是新手机了,直接minSdk 26不行吗?结果测试时发现,不少老机型和模拟器跑不了。21的要求意味着5.0以上都能装,适配范围更广,测试时更容易暴露兼容性问题。
2.3 环境搭建的真实痛点
如果你是从零安装Android Studio,有四个坑几乎是必踩的:
- SDK组件下载慢。第一次创建项目时,它会自动下载对应版本的SDK Platform和Build-Tools。在网络环境不理想的情况下,建议在SDK Manager里只勾选自己需要的版本,不要贪多。
- Gradle下载卡住。Gradle本身是个大块头,首次构建会下载整个发行包。解决方法是打开gradle-wrapper.properties,把
distributionUrl指向国内镜像,或者手动下载好对应版本放到本地目录。 - AS中文设置。在Settings里安装中文语言包插件,重启后界面变中文,这对初学者很友好,但不建议用中文版学技术,因为搜答案时你会发现全网报错信息都是英文的。
- AGP版本不匹配。最常见的报错就是
Could not determine the dependencies of task ':app:compileDebugJavaWithJavac'. Could not resolve all task dependencies for configuration ':app:debugCompileClasspath'.,这个错误我排查了很久,最后发现是项目里的AGP版本和Gradle版本对不上。Android Studio 2023.1.1对应AGP 8.0及以上,Gradle framework版本需要8.0以上,建议直接查官方的“Android Gradle Plugin版本兼容表”来核对。
关键版本对应关系可以参考下面这个表,这是我多次踩坑后整理出来的:
| Android Studio版本 | 推荐AGP版本 | 最低Gradle版本 | 推荐JDK |
|---|---|---|---|
| 2021.2.1 | 7.1.x | 7.2 | JDK 11 |
| 2022.3.1 | 8.1.x | 8.0 | JDK 17 |
| 2023.1.1 | 8.2.x | 8.2 | JDK 17 |
| 2024.1.1 | 8.5.x | 8.7 | JDK 17 |
3. 核心功能实现:注册登录、剧目浏览与选座购票
3.1 用户模块:注册逻辑里最容易忽略的细节
用户模块看似简单,但有几个细节值得注意。
注册时,除了基本的非空校验和两次密码一致性校验,我额外做了:用户名唯一性校验、手机号格式校验、密码加密存储。密码我没用明文,而是采用MD5加盐的方式:
public static String md5WithSalt(String password, String salt) { String value = password + salt; try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); return null; } }你可能会问,本地数据库加密有没有意义?我的看法是,哪怕数据只存在本地,也要养成不存明文密码的习惯。安全设计是一种工程思维,不是等接后端了才考虑的事。
登录态我用SharedPreferences保存当前登录用户ID和用户名,这样APP重启后不需要重新登录。但要记得在退出登录时清掉这些字段,否则会出现“退出登录后上一单的数据还能看到”的尴尬bug。
3.2 剧目列表与详情页:RecyclerView的复用陷阱
剧目列表用RecyclerView + CardView实现,这个没有难点。真正容易出问题的是图片加载。如果演出海报用的是本地资源还好,一旦换成网络图片,就必须考虑内存问题。我在项目里用Glide加载图片,同时把图片URL存在剧目表里,本地没有图片时显示一个默认占位图。
详情页里有三个关键交互:剧目信息展示、场次选择、点击场次后跳转座位图。场次列表我放在另一个RecyclerView里,横向滑动。这里有个交互细节:用户点击某个场次后,需要先判断这个场次是否还有可选座位。如果全卖光了,就直接提示“该场次已满场”,而不必跳到座位图再灰掉所有座位。这需要在数据库层做一个统计查询:SELECT COUNT(*) FROM seat WHERE session_id = ? AND status = 0。查询速度很快,用户的体验会好很多。
3.3 选座模块:自定义View的核心是坐标映射
选座是整个项目里最有技术含量、也最容易写崩的部分。我不用Android自带的Button或TextView去拼座位,而是通过一个自定义View,直接在onDraw()里绘制整个座位图。这样做的好处是:即便一个演出厅有30排30列共900个座位,也只需要一个View,不会有900个View节点拖垮渲染性能。
自定义View的逻辑核心有三块:
第一,座位布局数据。我从数据库加载该场次的座位记录,用一个二维数组int[][] seatStatus存状态。0表示可选,1表示已锁定,2表示已售出。同时在内存里维护一个List<Seat> selectedSeats,记录当前用户点了哪些座位。
第二,绘制逻辑。先在onMeasure()里确定画布实际宽高,然后根据列数计算每个座位的格子宽度。比如一个厅有22列,画布宽为1080像素,那么每个格子直径就是1080 / 22,再按这个值画出一排排圆角矩形或圆形座位。舞台上我用一个横向的半透明矩形表示,让它和座位区明显区分开。
第三,触摸命中测试。这是最关键的。自定义View不能给每个座位单独加监听器,而是在onTouchEvent(MotionEvent event)里拿到手指按下的坐标,再反算出对应的行列号:
@Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { float x = event.getX(); float y = event.getY(); int row = (int) ((y - topPadding) / seatHeight); int col = (int) ((x - leftPadding) / seatWidth); if (row >= 0 && row < rowCount && col >= 0 && col < colCount) { Seat seat = getSeatAt(row, col); if (seat != null && seat.getStatus() == 0) { toggleSeat(seat); } } return true; } return super.onTouchEvent(event); }这里需要注意,由于我设置了topPadding来给舞台留空间,所以在反算行列时一定要把这个偏移量减掉,否则点第一排会命中舞台区域。这个bug我调试的时候被坑了很久,原因就是坐标转换没有考虑padding。
座位颜色我用三色区分:可选座位是浅绿色,已售座位是灰色,用户当前选中的座位是橙色。每次点击座位后,调用invalidate()刷新重绘。为什么不用requestLayout()?因为座位格子大小没有变化,只是状态颜色变了,invalidate()只触发重绘,开销更小。
3.4 订单创建与模拟支付流程
用户选好座位后,点击“立即购买”,我把选中的座位列表、场次信息、总金额一起传到订单确认页。订单确认页展示用户、场次、座位号(比如“池座1排3座”)、合计金额,点击“确认支付”进入模拟支付页面。
模拟支付是本地项目常见的做法,但不意味着可以做得太假。我的支付页有银行卡号输入框、支付按钮和支付结果回调。用户点击支付后,我故意让进度条转1.5秒再回调,模拟网络请求过程。回调成功后,执行两件事:
- 把订单状态从0(待支付)改成1(已支付)。
- 把该订单关联的所有座位状态从1改为2(已售出),并把座位表的
order_id字段写上当前订单ID。
为什么要把座位状态分“锁定”和“已售出”两种?因为这里需要区分:用户下单但没支付时,座位先变成锁定状态,防止别人选走;支付成功后才是已售出。这样设计是为了应对接下来的超时释放逻辑。
4. 数据库设计:座位状态与订单一致性才是整个项目的核心难点
4.1 核心表结构
我设计的数据表一共有五张,说实话这五张表的结构决定了整个业务逻辑的复杂度。
CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, phone TEXT, create_time TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE play ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, duration INTEGER, price REAL, image_url TEXT ); CREATE TABLE session ( id INTEGER PRIMARY KEY AUTOINCREMENT, play_id INTEGER NOT NULL, hall_name TEXT, start_time TEXT, row_count INTEGER, col_count INTEGER, FOREIGN KEY (play_id) REFERENCES play(id) ); CREATE TABLE seat ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, row_num INTEGER, col_num INTEGER, status INTEGER DEFAULT 0, order_id INTEGER, FOREIGN KEY (session_id) REFERENCES session(id), FOREIGN KEY (order_id) REFERENCES ticket_order(id) ); CREATE TABLE ticket_order ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, session_id INTEGER NOT NULL, total_price REAL, status INTEGER DEFAULT 0, create_time TEXT DEFAULT CURRENT_TIMESTAMP, expire_time TEXT, FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (session_id) REFERENCES session(id) );注意seat表里有个order_id字段,这是为了在“我的订单”里能通过order_id反查该订单买了哪些座位。这个关系如果在代码里用字符串拼接存,后期查起来会非常痛苦。
4.2 座位状态更新的原子性:防超卖的硬道理
如果只按“先查询座位是否可选,再更新座位状态”的方式选座,在单机SQLite里可能看不出问题,但一旦数据逻辑稍复杂,就会出现脏读。比如用户A和用户B同时在一个界面上选座,都看到1排1座可选,然后都去下单,那个座位就可能被两个订单占用。
我采取的方案是用SQL条件更新配合事务:
UPDATE seat SET status = 1 WHERE id = ? AND status = 0这条SQL的关键在于AND status = 0。如果影响行数为1,说明这个座位从“可选”变成了“锁定”,当前用户抢占成功;如果影响行数为0,说明这个座位已经被别人抢掉或被锁定了,那就不允许继续下单。
在Java代码里,我先开启事务,再对选中的每个座位执行这条更新,然后才创建订单:
db.beginTransaction(); try { for (Seat selected : selectedSeats) { int updated = updateSeatStatus(selected.getId(), 1, 0); if (updated == 0) { db.setTransactionSuccessful(); // 中途一个都没抢占成功,直接回滚并提示用户重新选座 db.endTransaction(); return false; } } long orderId = insertOrder(userId, sessionId, totalPrice); for (Seat selected : selectedSeats) { updateSeatOrder(selected.getId(), orderId); } db.setTransactionSuccessful(); } finally { db.endTransaction(); }这里有一个容易忽略的点:db.endTransaction()执行完后发现某个座位没抢占成功,此时前面已经更新的座位状态怎么办?这就是事务的作用。只要有任何一步失败,整个事务回滚,不会出现“订单没生成但座位被锁死”的残留数据。
4.3 订单超时释放:本地项目也要有状态机思维
订单如果不支付,座位不能一直锁着,否则会把这部剧的座位慢慢锁光。我设计了一个简单的超时机制:订单创建时写入expire_time为15分钟后,超过15分钟未支付,订单状态变成2(已取消),座位状态从1恢复为0。
但这个“释放”动作什么时候触发呢?我一开始想用Handler定时任务,后来发现这会使代码复杂度飙升,而且手机息屏、进程被杀后定时任务根本不准。最终采用一个很实用的方案:在APP主界面onResume时加一个清理函数,每次用户回到首页,就执行一次:
UPDATE ticket_order SET status = 2 WHERE status = 0 AND expire_time < datetime('now', 'localtime')再把这个超时订单关联的座位状态改回0。这样既不需要后台线程,又能在每次进入APP时自动清理脏座位。虽然做不到毫秒级实时释放,但对一个单机版项目来说已经足够。
这个设计给我最大的启发是:业务里的“状态”不是孤立的,而是有流转路径的。座位经历“可选→锁定→已售”的变化,订单经历“待支付→已支付/已取消”的变化,这两条状态机之间还有外键关联。把这个关系想清楚,数据库就不会变成一坨只有增删改查的散表。
5. 打包上线前的实战:APK构建、签名与真机适配
5.1 Gradle构建效率与版本匹配
很多人在模拟器里跑通项目就以为结束了,结果一到打包APK阶段就各种报错。我把实战中遇到的三类高频问题记一下。
第一类是Gradle下载慢或卡死。项目根目录的gradle/wrapper/gradle-wrapper.properties里写的distributionUrl默认指向services.gradle.org,国内访问很慢。我改成阿里云镜像后,下载速度从几分钟降到十几秒。
第二类是AGP版本与Gradle版本不匹配。这个前面小标题里提过,你只要看到类似Could not resolve all task dependencies或者Minimum supported Gradle version is X.X的报错,先别急着清理缓存,先去查兼容表。
第三类是SDK版本缺失。比如compileSdkVersion 34但本地没装28~34中间的某个SDK Platform,构建时会提醒你安装。直接在SDK Manager里勾选对应版本即可。
5.2 签名打包与混淆配置
生成release APK必须先签名。我使用的是Android Studio自带的Generate Signed APK功能:Build -> Generate Signed APK,创建一个.jks密钥库文件,填写别名和密码。这里有一个非常重要的经验:密钥库文件一定要放到项目目录之外,并且不要提交到Git仓库。一旦密钥丢失或泄露,应用市场更新时无法用相同签名,老用户会直接没法覆盖安装。
release构建我默认开了代码混淆,用的是ProGuard。但混淆在购票项目里带来了一个很典型的坑:Gson反序列化失败。因为我的实体类在混淆时被改了类名和字段名,导致从JSON解析到Java对象时全部变成null。解决办法是在proguard-rules.pro里保留实体类:
-keep class com.example.theater.bean.** { *; }如果不想深究混淆规则,对于内部项目可以直接把minifyEnabled设为false,但接着面向应用市场发布时建议还是认真配置一下混淆规则。
5.3 真机适配与性能优化
模拟器跑通不等于真机没问题。我在项目后期找了三台不同分辨率的手机测试,发现的问题基本集中在布局适配和图片内存上。
布局适配方面,我的选座View已经用了自定义绘制,按理说和分辨率无关,因为座位格子宽度是根据画布宽高动态计算的。但其他普通页面,比如剧目详情页,我最初用固定dp值,结果大屏和小屏上视觉比例相差很大。后来统一改用ConstraintLayout,把关键控件用app:layout_constraintXxx约束起来,避免使用绝对坐标。
图片内存方面,海报如果直接用原始照片,一张两三MB的图在列表里滑动时很容易触发内存抖动。我统一压缩到合适尺寸,配合Glide的override()方法限制加载宽高,整体内存占用下降非常明显。RecyclerView的复用机制也要注意:不要在onBindViewHolder里做耗时操作,比如数据库查询和Bitmap解码,应该提前在Activity层做好数据缓存,adapter只负责绑定显示。
6. 测试心得与扩展方向
整个项目做完,我整理了一份测试用例表,覆盖了主流程和异常分支:
| 测试场景 | 预期结果 |
|---|---|
| 注册重复用户名 | 提示用户名已存在,不写入数据库 |
| 密码错误登录 | 提示密码错误,不进入主界面 |
| 选座点击已售座位 | 无任何反应,不进入选中态 |
| 选座后退出页面重进 | 已锁定的座位状态消失(超时后释放) |
| 下单后立即退出APP重进 | 订单出现在“待支付”列表,座位保持锁定 |
| 支付成功后退回座位图 | 对应座位显示为已售,不可再选 |
| 订单超时后回到主界面 | 订单自动变为已取消,座位恢复可选 |
这些场景最难测的是超时释放,因为15分钟太久,我调试时把expire_time临时改成1分钟,专门验证释放逻辑正确后再改回来。
如果你想让这个项目更完整,后续有几个扩展方向:接入真实后端接口和鉴权,把本地数据库换成服务端数据库;选座加入缩放和拖拽,适合更大一些的演出厅;接入支付宝或微信的真实支付沙箱,把模拟支付替换为真实支付;增加后台管理端的座位售卖统计报表,用图表展示每场次的上座率。
我在做完这个项目后最大的体会是:一个看起来不复杂的购票APP,真正难的地方不在增删改查,而在业务状态的一致性。座位状态、订单状态、超时释放、并发抢占,这些逻辑如果没有在开始前想清楚,后面返工的代价特别大。希望这篇分享能让你在动手之前,先把自己的业务模型梳理明白,那样后面每一步都会顺很多。