做Android开发这些年用Android Studio经手的项目不算少但如果让我推荐一个适合练手、又能在课程设计或者简历里拿得出手的完整案例剧院购票APP一定排在前列。原因很简单它麻雀虽小五脏俱全——从首页浏览、演出详情、场次筛选到选座、下单、支付模拟一整条业务链路走下来几乎能把Android开发里最常见的界面搭建、数据存储、列表复用、多线程和屏幕适配全部覆盖一遍。这篇内容就当是我自己做完这个项目之后的一份复盘记录把踩过的坑、想明白的问题和可以直接照抄的代码结构都写出来给正在用Android Studio做同类APP的同学一个参考。1. 项目整体定位剧院购票APP的技术选型与功能规划1.1 需求拆解购票场景下必须有的功能清单剧院购票和电影购票很像但又有自己的特点剧院演出的场次更稀疏座位分区更复杂票价往往跟区域强相关部分热门演出还存在“锁座”和“抢票”的并发压力。所以在规划功能时不能只做一个简单的“增删改查”演示至少要覆盖这样一条完整链路用户打开APP进入首页看到正在售票的演出列表列表项包含海报、名称、时间、最低票价。点击某个演出进入详情页看到演出的简介、场次列表、剩余票量提示。选择一个场次后进入选座页以图形化座位图展示哪些座位可售、哪些已售、哪些被暂时锁定。用户点击座位选择需要的张数进入确认订单页核对场次、座位、票价总额。提交订单后模拟支付支付成功生成电子票同时更新对应座位的状态。在订单列表和个人中心里用户可以查看历史订单、取消未支付的订单。如果还想加一点“实战感”可以把用户登录注册、搜索、分类筛选、收藏演出这些模块放进去。但从成本收益来看上面这六步是核心骨架登录和收藏属于锦上添花。我实际做的时候把登录模块做成了最简单的手机号密码本地校验没有接短信验证码服务因为接第三方服务不仅涉及费用还会拖慢项目进度对演示型APP来说完全没必要。1.2 技术选型为什么用Android StudioJava还是Kotlin关于开发工具没什么好纠结的Android Studio就是Android开发的官方IDE布局预览、模拟器、Gradle构建、APK分析这些功能都集成好了没有理由换别的。真正让人犹豫的是两个点用Java还是Kotlin数据层用本地SQLite还是直接上网络请求。语言选择上我最终用了Java。理由不是Kotlin不好而是这个项目如果定位为“课程设计”或者“新手实战”Java的生态资料极其丰富遇到报错随便一搜就是答案。Kotlin语法更现代、空安全设计更好但如果你对Java语法本来就不熟悉Kotlin的委托、协程、扩展函数这些概念反而会变成额外负担。先说清楚我这里写的所有逻辑用Kotlin重写也就是换一层语法的事核心思路完全一样。数据层我用了“本地数据库为主、SharedPreferences为辅”的方案。SQLite用来存用户、演出、场次、座位、订单这些有强关联关系的数据SharedPreferences用来存登录状态、主题偏好、上次选中的城市等轻量数据。没有引入Retrofit和远程服务器因为课程项目最怕的就是“后端接口不稳定”一旦服务器挂了整个演示都崩了。但如果你的项目要求必须联网可以把我的数据库访问层替换为API调用接口的返回结构直接映射成JavaBean即可后面我会讲到怎么改造。为了让大家心里有数我把方案选型整理成了一张表方向我选的方案备选方案选择理由开发工具Android StudioEclipse已过时官方IDE工具链完整开发语言JavaKotlin资料丰富适合课程设计数据存储SQLite SharedPreferencesRoom无需额外依赖理解底层原理列表展示RecyclerView CardViewListView性能和样式更强图片加载GlidePicasso、Coil缓存策略成熟用起来最省心线程处理Thread HandlerRxJava、协程逻辑简单可控适合展示代码1.3 项目结构包名划分与代码分层这个项目我用了很经典的三层结构界面层、业务层、数据层。包名按照com.example.theater划分下面再细分activity存放Activity比如MainActivity、DetailActivity、SeatSelectActivity。fragment首页、订单、个人中心等Fragment。adapterRecyclerView和GridView的适配器比如ShowAdapter、OrderAdapter。beanJavaBean实体类比如Show、Schedule、Seat、Order。db数据库帮助类和DAO类比如DBHelper、ShowDao、OrderDao。utils工具类比如订单号生成器、日期格式化器、Toast工具。这样的分包方式看起来很简单但好处很实在数据库表结构的改动不会影响到Activity里的界面代码Adapter只关心数据怎么展示。如果后续想引入MVVM把Activity里的业务逻辑抽到ViewModel里分包结构也完全兼容。2. 功能模块设计与数据模型搭建2.1 模块划分与页面流转逻辑整个APP使用了一个主Activity加底部导航栏的结构底部导航有三个选项卡首页、订单、我的。这样用户一进来就能在主界面完成绝大部分操作符合购票类APP的主流习惯。首页里嵌套了演出列表Fragment展示正在热售的演出支持按演出类型分类和关键词搜索。点击列表项跳转到演出详情Activity详情页里包含演出简介和场次列表场次列表使用多行卡片展示“日期时间剩余票量”。点击某个有票的场次跳转到选座Activity选座页是整个项目最核心也最复杂的界面。选座完成后进入确认订单Activity展示选中的座位、单价、总价、场次信息并提供“提交订单”按钮。提交后进入支付模拟页面点击确认支付后更新数据库中的座位状态和订单状态然后跳转到支付成功页面整个过程就是一个可以串起来的闭环。页面流转看起来简单但有一个细节值得注意不同页面之间传递数据时不能把所有对象都通过Intent序列化传递。比如选座页返回用户选择了哪些座位更合理的做法是选座页把座位信息写入一个临时表或全局变量确认订单页再去读取。我当时为了省事把座位数组用Gson转成JSON字符串塞进Intent后来发现一旦座位数量超过几十个Intent传参会变得非常臃肿。后续优化时改成了通过内存中的单例对象共享数据才舒服很多。2.2 数据库表结构设计从用户到订单的完整链路数据库是这个APP的基石我设计了五张表用户表、演出一览表、场次表、座位表、订单表。各自的职责如下用户表user字段id主键自增phone手机号登录凭证password密码实际项目里要加密存储我这里演示用的是MD5加盐nickname昵称create_time注册时间演出一览表tb_show字段id主键name演出名称type类型比如音乐剧、话剧、芭蕾舞poster_url海报图片路径intro演出简介min_price最低票价用于列表展示status是否上架场次表schedule字段id主键show_id关联演出一览表的idshow_time演出时间hall_name演出厅名称剧院通常有多个厅status是否开放售票座位表seat字段id主键schedule_id关联场次idrow_no行号col_no列号seat_area区域如一楼A区、二楼B区区域不同票价不同price该座位的售价status0表示可售1表示已售2表示锁定订单表tb_order字段id主键order_no订单编号唯一user_id下单用户idschedule_id场次idseat_ids购买的座位id集合用逗号分隔存储total_price订单总价status0待支付1已支付2已取消create_time下单时间pay_time支付时间这里有个很多初学者不理解的地方座位表为什么要关联schedule_id而不是直接关联演出因为同一个演出可能有多个场次每个场次的座位状态是独立的昨晚的演出卖完了不代表今晚的演出也卖完了。如果座位只挂在演出下面整个系统就没法区分场次了。这就是数据建模时最常见的“粒度”问题字段的粒度越细未来的扩展空间越大。建表SQL我用SQLiteOpenHelper在onCreate里统一执行为了省事把多张表的建表语句写在一个字符串数组里逐条执行。后面如果要升级就在onUpgrade里先判断旧版本再执行ALTER TABLE或者重建表用户原有的数据要尽量保住不能一升级就删库。2.3 界面布局与交互设计要点购票类APP的界面设计核心原则就是“信息清晰、操作路径短”。首页列表我用了RecyclerView配合CardView每一张卡片显示海报、剧名、类型、最低价。海报使用Glide加载加载失败时显示一个本地占位图因为不少模拟数据图片来自网络测试地址一旦URL失效界面不能白屏。详情页使用ScrollView嵌套上半部分是海报大图下半部分是演出介绍和场次列表。这里有个布局陷阱ScrollView里面再套RecyclerView会出现滑动冲突所以我不用RecyclerView直接用LinearLayout往里面动态添加场次卡片数据量不大这个方法反而又简单又不会卡。选座页是整个项目里布局设计最需要动脑子的地方。要实现的是一张类似剧院的座位图我用GridView每一行是一个座位行的可视表示。不同区域的座位用不同背景色区分。座位状态用颜色表达灰色表示已售明亮色表示可选红色表示当前选中。底部放一个“选了几张票多少钱”的确认栏。用户点击座位的瞬间GridView的Adapter需要刷新item背景色同时把选中的座位记录在一个集合里。底部导航栏我用的是FragmentBottomNavigationView。这里要提一个容易犯的错不要每次切换Fragment都重新new一个实例应该在MainActivity里缓存三个Fragment对象切换时通过FragmentTransaction的show和hide来显示、隐藏。这样用户切到订单页再切回首页时首页的滚动位置和加载状态都还在体验会好很多。3. 核心功能实现与实操过程3.1 在Android Studio中创建项目与配置Gradle打开Android Studio在新建项目向导里选“Empty Views Activity”模板语言选Java然后设置包名和项目路径。创建完成后第一件事不是写代码而是先检查Gradle和SDK版本。我的建议是compileSdk选当前Android Studio默认推荐的那个版本minSdk选24左右targetSdk跟着compileSdk走。不要追求最新也不用担心太旧。如果minSdk选太高会损失一部分老设备用户选太低又要处理一堆运行时权限兼容问题得不偿失。模块的build.gradle里需要加这些依赖我在项目里是这么配置的dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 implementation androidx.recyclerview:recyclerview:1.3.2 implementation androidx.cardview:cardview:1.0.0 implementation com.github.bumptech.glide:glide:4.16.0 annotationProcessor com.github.bumptech.glide:compiler:4.16.0 implementation com.google.code.gson:gson:2.10.1 }这里Glide的annotationProcessor在Java项目里要记得加不然生成的API用不了。Gson主要用来把对象转JSON文本方便调试打印Intent传参和日志。Material库提供BottomNavigationView等控件AppCompatActivity底下的工具栏也依赖它。3.2 演出列表与首页Fragment实现首页Fragment的代码结构大概是在onCreateView里Inflate布局初始化RecyclerView和它的LinearLayoutManager然后从本地数据库读取演出数据交给Adapter去绑定。数据源我用了一个ShowDao核心方法就是查询所有已上架的演出按演出时间倒序排列。这里不要直接在UI线程里查数据库我写了一个简单的异步方案用Thread子线程去执行查询拿到结果后用runOnUiThread切回主线程更新Adapter。代码大概是这样的new Thread(new Runnable() { Override public void run() { final ListShow showList showDao.getAllShows(); runOnUiThread(new Runnable() { Override public void run() { showAdapter.setData(showList); showAdapter.notifyDataSetChanged(); } }); } }).start();Adapter里最关键的是ViewHolder的复用。每个item的根布局用CardView设置圆角、阴影和边距CardView内部用垂直LinearLayout包含海报、剧名、类型和价格。item的点击事件在Adapter内部设置点击时从当前列表取出对应的Show对象通过Intent跳转到详情页。不要用getItemId和getItemViewType去手动区分普通和特殊item除非你确实需要不同样式否则只会徒增复杂度。3.3 选座功能的实现GridView、座位状态与并发处理选座页是整篇文章最值得展开的部分我在这个模块上花的时间占了整个项目的一大半。先分析一下需求用户进入选座页时要看到所选场次的座位图座位图需要区分不同区域区域之间用一个走道间隔每个座位要能看到行列号已被购买的座位不能点击用户最多只能选5张票选完后底部要实时显示总价。我用一个二维列表表示座位表结构每个元素是一个SeatBean对象。SeatBean里至少有四个字段row、col、area、status。界面上用GridView展示列数根据剧场座位的总列数决定每行显示多少个座位那就把GridView的numColumns设置成实际列数。这里有一个坑GridView不是按业务上的“行”来排列的它的item是线性排列如果座位表某一行缺少座位比如走道位置要放一个空白的占位View不然座位会错位。我就是因为没处理走道位置的占位导致第一版的座位图歪歪扭扭后来在数据层直接把缺口位置填充成一个不可见的占位item才解决。座位item我直接使用TextView通过selector或代码动态设置背景色。具体颜色状态如下可售浅绿色背景深色文字显示“几排几号”已售灰色背景不可点击选中橙色背景可再次点击取消选中点击事件的处理逻辑写在一个SparseArray或HashMap里存用户当前选中的座位。每次点击先判断该座位status是否为可售再判断总选中数量是否超过5满足条件就翻转选中状态然后刷新GridView并更新底部的价格栏。最核心的业务逻辑是提交订单时的座位锁定。如果不做任何处理用户A和用户B同时购买同一个座位会出现两个人都支付成功的情况。解决办法是在更新座位状态时加上条件判断用事务包裹整个更新过程。代码如下db.beginTransaction(); try { // 更新座位状态只有当前status0的座位才能被改成1影响行数等于1才算抢到 ContentValues values new ContentValues(); values.put(status, 1); int rows db.update(seat, values, id? AND status0, new String[]{seatId}); if (rows 0) { // 说明该座位已经被别人买走了 return false; } // 插入订单 db.execSQL(INSERT INTO tb_order ...); db.setTransactionSuccessful(); return true; } catch (Exception e) { return false; } finally { db.endTransaction(); }看似简单的“UPDATE ... WHERE status0”其实就是乐观锁的思路。SQLite在单文件数据库下事务本身会串行化处理写操作所以只要把“检查更新”和“插入订单”放在同一事务里并发安全问题基本就解决了。3.4 订单生成、模拟支付与数据持久化订单模块在设计时要注意订单主表和订单明细表要不要分开考虑到每日演出票量不大我没有拆明细表直接在主订单表里用“seat_ids”字段存逗号分隔的座位ID集合需要用的时候再根据ID去座位表查询。这种做法在小数据量场景下非常省事而且查询速度也可以接受。订单号生成也是一个小细节。业务上订单号必须唯一而且最好能看出大致时间。我是这么生成的String orderNo YT System.currentTimeMillis() String.format(%04d, new Random().nextInt(10000));“YT”是“剧院”的拼音缩写后面跟着毫秒级时间戳和四位随机数。在本地数据库环境里这个方案基本不会碰撞。如果未来要接入服务端订单号一般由服务端生成规则也差不多。模拟支付页面就简单了上面显示订单金额下面放一个“确认支付”按钮。点击后先执行上一节说的座位锁定逻辑成功就把订单状态改为“已支付”更新支付时间然后跳转到支付成功页。很多人会忽略“支付失败或改端口径”的回滚其实在事务里已经覆盖了座位更新失败时直接return订单不会插入不会出现“买了票但没座位”的脏数据。3.5 图片加载与列表缓存细节演出海报如果全部从网络加载在没有网的环境下APP体验会非常糟糕。我的做法是在dao层查询到演出数据后先判断本地有没有缓存海报没有就用Glide从网络加载加载成功的同时把图片文件写入应用私有目录下次再查的时候直接使用本地路径。这一步虽然不复杂但确实大幅提升了演示时的稳定度。Glide的缓存策略本身已经很强它默认会做内存缓存和磁盘缓存所以即使不手动干预用户不会重复下载同一张图片。不过要记得在AndroidManifest里添加网络权限不然网络图片全部加载不出来uses-permission android:nameandroid.permission.INTERNET /3.6 真机调试与虚拟模拟器的选择项目开发期间我大部分时间用的是Android Studio自带的模拟器因为截屏和处理布局预览比较方便。但模拟器在启动时会占大量内存电脑配置一般的话很容易卡得生无可恋。后来我改为“布局用模拟器验证逻辑功能用真机调试”的双轨模式效率高了不少。真机调试的操作也很简单手机打开开发者选项开启USB调试数据线连上电脑Android Studio会自动识别设备。需要注意小米、华为等不同品牌手机第一次连接时手机上会弹一个“是否允许USB调试”的授权对话框必须手动确认一下。另外建议打开手机里“仅充电模式下允许USB调试”之类的选项不然插上以后没反应。4. 常见问题与排查技巧实录4.1 模拟器启动慢、运行卡顿怎么办这个问题在Windows电脑上尤其明显。如果电脑没有开启硬件加速模拟器启动一次可能要五分钟。解决办法是先检查BIOS里有没有开启虚拟化技术然后在Android Studio的AVD Manager里给模拟器分配更大的内存推荐2GB以上。如果实在卡得不能用可以用真机调试真的比模拟器顺畅太多。再退一步现在很多电脑配置一般跑模拟器就是折腾老老实实用真机最实在。4.2 SQLite在主线程操作导致界面卡死或崩溃很多新手第一次写数据库操作直接在主线程执行复杂查询。数据量小的时候看不出问题数据量一大或者查询里面有联表操作主线程就被卡住系统弹出“Application Not Responding”。我做这个项目时也踩过这个坑后来所有数据库查询都放到了子线程。Android不允许在非UI线程更新界面所以查询完要用runOnUiThread或者Handler切回主线程再来刷新界面。4.3 GridView座位错位走道位置显示异常出现这个问题的根本原因是没有处理“缺口座位”。如果想在座位图中间留一条走道数据列表里就必须为走道位置填充空对象或者设置一个不可见的item。我第一版图省事只在GridView的item里通过按条件隐藏控件实现结果因为GridView的行布局高度不一致产生了非常严重的错位。后来的解法是提前在数据生成阶段把每个座位的位置计算清楚包括走道位置也作为“座位”传入GridView但是item设置为一个透明的、不可点击的空白View。这样GridView的网格线对齐了座位图看起来才像一张真正的剧院座位图。4.4 RecyclerView刷新时图片闪烁或item错乱在Adapter中加载网络图片时如果用户快速滑动列表item会频繁复用到不同的位置容易出现“图片闪烁”甚至“显示错位”的问题。解决方案有两个一是在加载图片前给ImageView设置tag并在onBindViewHolder里判断tag二是直接依赖Glide的placeholder和error机制Glide默认会正确处理复用问题。我最后只用Glide的标准写法就解决了没有额外处理。需要注意的是如果用普通的Bitmap加载图片还是得自己管理好图片位置与View复用的关系。4.5 打包APK后安装失败或者打开闪退安装失败最常见的原因是签名冲突。如果你手机上装过debug版而打包的release版用的签名不同就会出现“应用未安装”的情况。先卸载旧版本再安装新包或者直接用同一个签名文件打包。打开闪退的原因就要看Logcat了常见的有这么几类一是minSdk版本比手机系统版本高二是加入依赖库后没有同步Gradle三是代码开发阶段运行正常但release模式下混淆规则没配置好。解决方案是先把release的混淆关掉等能跑通后再研究混淆规则。混淆这块得单独提醒一句如果项目用了Gson需要把JavaBean实体类加入keep规则否则发布后运行时Gson会解析失败报错非常隐晦。在proguard-rules.pro里加这样一段-keep class com.example.theater.bean.** { *; }4.6 数据库字段或表结构改了但程序还是用旧数据这个问题的根源是SQLite没有自动迁移机制。如果开发过程中修改过表结构比如给tb_order加了pay_time字段而手机里已经装过旧版APP数据库不会自动增加这个字段。最简单粗暴的解决方案是卸载APP重装让SQLiteOpenHelper重新创建数据库如果不想丢数据就需要在onUpgrade里写增量修改SQL。作为演示项目我在开发阶段就直接卸载重装但文档里写了升级方案方便后续维护。4.7 中文乱码问题如果从数据库读出来的中文显示成乱码先检查SQLiteOpenHelper创建数据库时使用的编码。SQLite本身存储的是UTF-8编码正常情况下不会乱码。乱码往往出现在Android模拟器和Windows控制台之间或者是网络请求时的编码问题导致。解决办法是在连接数据库时没有专门的编码设置但可以在读取数据后用new String(value.getBytes(ISO-8859-1), UTF-8)这类方式兜底。更根本的还是保证在写入数据时就用UTF-8编码不乱转码就不会乱码。4.8 常见问题速查表为了方便大家快速定位问题我把上面这些整理成了排查表现象根本原因解决方案模拟器启动慢、闪退未开启硬件加速内存不足开启虚拟化调整AVD内存换真机界面卡顿ANR弹窗主线程执行耗时操作数据库操作放入子线程回调主线程刷新座位图错位走道位置没有用占位item数据层提前填充空白占位对象列表图片闪烁错位item复用导致图片位置错乱使用Glide并配置placeholder安装失败、打开闪退签名冲突或混淆配置缺失保持同一签名文件补keep规则表结构更新后老数据报错SQLite没有自动迁移在onUpgrade里写增量迁移SQL中文显示乱码编码不一致统一UTF-8编码不乱转码5. 接下来的扩展方向从课程项目到真正能上线的APP做完了整个项目我个人最大的体会是购票类APP的业务状态管理比界面呈现复杂得多。界面要做的无非是把数据摆对位置而真正的难点在于座位状态、订单状态、支付状态之间的关系流转。如果逻辑里有一条状态没有更新到位用户端可能一直买到“实际上已经被别人占掉”的座位或者出现支付成功但订单里没有座位的严重bug。后续如果要让这个APP更贴近真实上线场景建议从这几个方向入手把本地SQLite替换为服务端MySQL或PostgreSQL把数据访问层改成Retrofit网络请求。引入Room替代SQLiteOpenHelper减少手写SQL的出错率配合LiveData和ViewModel可以让界面代码大幅精简。使用WebSocket或轮询机制让远程端在“座位被锁定”时马上感知到避免多个用户同时抢同一个座位。界面视觉统一规范特别是底部导航与各页面的状态切换做一套品牌色体系别让每个页面配色各干各的。权限与数据安全方面真实项目必须对密码做加密传输并对订单接口做防刷处理这些在演示项目里可以暂时不碰但上线前一定要补。如果时间允许我建议你在把这个项目收尾之后再挑其中一个模块做一次重构比如把选座功能从GridView改成自定义View绘制深度会比较不一样。很多人做课程项目做到能跑就觉得结束了但其实“从能跑”到“设计合理”之间的距离才是一个开发者真正拉开差距的地方。希望这份记录对你做同类项目有帮助如果你在选座或者订单事务上踩到了别的坑也可以按这个思路排查先画数据流再查状态更新最后看界面刷新。