
简介本资源是一个完整的安卓端影院在线选座与订票系统实战项目面向移动应用开发初学者及Java/Android课程设计学生解决电影票务类App从需求分析到功能落地的全流程实践问题。压缩包含814个文件主体为45个Java源码、66个XML布局与配置文件、485张UI资源图png/gif、124个编译后class文件以及2个可直接安装的APK演示程序整体大小12.01MB结构清晰体现MVC分层设计。已有186人学习下载资源提供从前台用户选座购票、订单管理到后台影院资讯、排片与退改签维护的全链路功能实现代码注释充分含SQLite本地数据库操作、网络请求封装、微信/支付宝支付对接示意及座位实时状态控制逻辑适合作为Android开发入门进阶的参考范例。1. 一个能真正在安卓手机上跑起来的电影选座系统不是Demo是可交付的订票闭环你打开手机点开某款影院App选厅、挑座位、确认支付——这个过程背后其实是一套完整的影院选座管理系统在实时调度座位状态要秒级同步、锁座逻辑必须防重抢、支付成功后要即时更新数据库并通知影城终端。而“基于安卓电影选座系统的设计”这个标题指的正是一套从移动端UI交互、本地数据缓存、网络请求封装到与后台订票服务协同工作的完整落地方案。它不是课程作业里只画界面的静态原型而是具备真实业务约束的系统支持多影院切换、座位图动态渲染、选座冲突检测、订单状态机管理、离线缓存购票草稿。适合安卓开发初学者做毕业设计或实习项目也适合中小影投公司快速搭建自有轻量订票入口——关键在于所有模块都围绕“安卓端主导交互服务端保障一致性”这一核心分工展开不堆砌技术不虚构流程。2. 用Android Studio Retrofit Room构建三层架构的订票核心模块2.1 为什么选Retrofit而不手写OkHttp——接口契约驱动开发的实际收益电影选座系统高频调用三类接口获取影院列表GET /cinemas、拉取某场次座位图GET /sessions/{id}/seats、提交订单POST /orders。若用原生OkHttp逐个拼URL、解析JSON、处理异常5个接口就要重复写30行样板代码。Retrofit通过注解声明式定义接口将HTTP动词、路径参数、请求体自动映射为Java/Kotlin方法interface TicketApi { GET(cinemas) suspend fun getCinemas(): ResponseListCinema GET(sessions/{sessionId}/seats) suspend fun getSeatMap(Path(sessionId) sessionId: Long): ResponseSeatMapResponse POST(orders) Headers(Content-Type: application/json) suspend fun createOrder(Body order: OrderRequest): ResponseOrderResponse }提示suspend关键字表明这是协程挂起函数避免在主线程阻塞ResponseT包装体确保即使HTTP返回400/500也能拿到body内容用于错误提示而不是直接抛异常中断流程。实际使用时只需一行代码初始化Retrofit实例并注入GsonConverterFactory处理JSON序列化val retrofit Retrofit.Builder() .baseUrl(https://api.cinema.example.com/v1/) .addConverterFactory(GsonConverterFactory.create()) .build() val api retrofit.create(TicketApi::class.java)这样做的好处是接口变更时只需改TicketApi接口定义和DTO类所有调用处自动适配Mock测试时可替换为MockWebServer拦截请求无需启动真实服务。2.2 Room持久化座位缓存与订单草稿——解决弱网场景下的用户操作连续性用户在地铁里选好座位突然进隧道失去信号此时不能清空已选座位。Room通过Database注解定义本地数据库结构将座位状态与订单草稿持久化Entity(tableName seat_cache) data class SeatCache( PrimaryKey val sessionId: Long, val seatData: String, // JSON字符串存储整张座位图状态 val updatedAt: Long ) Entity(tableName order_draft) data class OrderDraft( PrimaryKey val draftId: Long System.currentTimeMillis(), val sessionId: Long, val selectedSeats: String, // A1,A2,B3格式 val contactPhone: String, val createdAt: Long System.currentTimeMillis() ) Dao interface TicketDao { Insert(onConflict OnConflictStrategy.REPLACE) suspend fun upsertSeatCache(seatCache: SeatCache) Query(SELECT * FROM seat_cache WHERE sessionId :sessionId) suspend fun getSeatCache(sessionId: Long): SeatCache? Insert(onConflict OnConflictStrategy.REPLACE) suspend fun saveDraft(draft: OrderDraft) Query(SELECT * FROM order_draft ORDER BY createdAt DESC LIMIT 1) suspend fun getLastDraft(): OrderDraft? }注意seatData字段存的是JSON而非嵌套对象因Room不原生支持复杂嵌套类型selectedSeats用逗号分隔字符串而非List避免引入额外依赖如TypeConverter符合轻量系统定位。在ViewModel中先尝试从网络加载座位图失败则回退到本地缓存fun loadSeatMap(sessionId: Long) { viewModelScope.launch { try { val response api.getSeatMap(sessionId) if (response.isSuccessful response.body() ! null) { val seatMap response.body()!! dao.upsertSeatCache(SeatCache(sessionId, seatMap.toJson(), System.currentTimeMillis())) _seatMapState.value SeatMapLoaded(seatMap) } else { // 网络失败查本地缓存 val cached dao.getSeatCache(sessionId) if (cached ! null) { _seatMapState.value SeatMapLoaded(fromJson(cached.seatData)) } } } catch (e: Exception) { // 网络异常仍尝试读缓存 val cached dao.getSeatCache(sessionId) if (cached ! null) { _seatMapState.value SeatMapLoaded(fromJson(cached.seatData)) } } } }这种“网络优先、缓存兜底”的策略让90%的弱网场景下用户操作不中断且本地数据仅保留最近3场次缓存避免无限制膨胀。2.3 订票状态机设计——用枚举协程确保订单生命周期可控下单不是简单发个POST请求就完事。真实流程包含锁座预占→ 支付中 → 支付成功/失败 → 发票生成 → 座位释放超时未支付。Room表中order_draft仅存草稿真正订单需新建order表并严格按状态流转Entity(tableName orders) data class OrderEntity( PrimaryKey val orderId: String, val sessionId: Long, val seatIds: String, // A1,A2 val status: OrderStatus, // 枚举PENDING, PAID, CANCELLED, EXPIRED val paidAt: Long? null, val createdAt: Long System.currentTimeMillis() ) enum class OrderStatus { PENDING, PAID, CANCELLED, EXPIRED }ViewModel中提交订单时先校验本地草稿有效性再调用APIfun submitOrder(draft: OrderDraft) { viewModelScope.launch { // 1. 检查草稿对应场次是否仍可售防止缓存过期 val seatCache dao.getSeatCache(draft.sessionId) if (seatCache null || isSeatAvailable(seatCache, draft.selectedSeats)) { // 2. 调用锁座接口幂等设计同一draftId多次提交返回相同结果 try { val response api.lockSeats(LockRequest(draft.sessionId, draft.selectedSeats)) if (response.isSuccessful) { val lockResult response.body()!! if (lockResult.success) { // 3. 创建正式订单记录 val order OrderEntity( orderId ORD${System.currentTimeMillis()}, sessionId draft.sessionId, seatIds draft.selectedSeats, status OrderStatus.PENDING ) orderDao.insert(order) // 4. 清除草稿 dao.deleteDraft(draft.draftId) _orderState.value OrderSubmitted(order.orderId) } else { _orderState.value LockFailed(lockResult.message) } } } catch (e: Exception) { _orderState.value NetworkError(e.message ?: 未知错误) } } else { _orderState.value SeatOccupied } } }关键点锁座接口必须是幂等的相同参数多次调用结果一致否则用户误触两次提交会导致重复扣款isSeatAvailable函数需解析本地缓存的座位JSON比对draft.selectedSeats是否全部为“可售”状态避免网络延迟导致的脏数据问题。3. 实现高还原度的座位图渲染与交互逻辑3.1 用自定义View绘制影院座位图——性能与灵活性的平衡点Android原生GridView或RecyclerView难以满足座位图的特殊布局需求不同影厅座位行列数不同、VIP座需特殊图标、过道区域不可点击、选中态需高亮边框。因此采用继承View自定义绘制class SeatMapView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val paint Paint().apply { isAntiAlias true textAlign Paint.Align.CENTER textSize 32f } private var seatMap: SeatMap? null private val seatRects mutableListOfRectF() // 缓存每个座位的绘制区域 override fun onDraw(canvas: Canvas) { super.onDraw(canvas) seatMap?.let { map - seatRects.clear() val padding 40f val rowHeight (height - 2 * padding) / map.rows.toFloat() val colWidth (width - 2 * padding) / map.cols.toFloat() for (row in 0 until map.rows) { for (col in 0 until map.cols) { val x padding col * colWidth colWidth / 2 val y padding row * rowHeight rowHeight / 2 val seat map.seats[row][col] // 绘制座位背景 paint.color when (seat.status) { SeatStatus.AVAILABLE - Color.parseColor(#4CAF50) SeatStatus.LOCKED - Color.parseColor(#FFC107) SeatStatus.OCCUPIED - Color.parseColor(#F44336) SeatStatus.AISLE - Color.TRANSPARENT } canvas.drawCircle(x, y, 24f, paint) // 绘制座位编号仅非过道 if (seat.status ! SeatStatus.AISLE) { paint.color Color.WHITE canvas.drawText(seat.id, x, y 12f, paint) } // 缓存点击区域 seatRects.add(RectF( x - 32, y - 32, x 32, y 32 )) } } } } override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action MotionEvent.ACTION_UP seatMap ! null) { val x event.x val y event.y for (i in seatRects.indices) { if (seatRects[i].contains(x, y)) { val row i / seatMap!!.cols val col i % seatMap!!.cols val seat seatMap!!.seats[row][col] if (seat.status SeatStatus.AVAILABLE) { // 触发选座回调 seatClickListener?.onSeatSelected(seat.id) } break } } } return true } fun setSeatMap(map: SeatMap) { this.seatMap map invalidate() // 触发重绘 } interface SeatClickListener { fun onSeatSelected(seatId: String) } }说明seatRects缓存每个座位的点击热区避免每次触摸都重新计算坐标SeatStatus.AISLE过道设为透明色且不响应点击invalidate()确保数据更新后UI立即刷新比postInvalidate()更精准控制重绘时机。在Activity中使用com.example.ticket.SeatMapView android:idid/seatMapView android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 /seatMapView.setSeatClickListener(object : SeatMapView.SeatClickListener { override fun onSeatSelected(seatId: String) { // 添加到已选列表更新UI selectedSeats.add(seatId) updateSelectedDisplay() } })3.2 多选逻辑与冲突检测——前端校验降低服务端压力用户可能一次选3个座位但系统需确保① 不选已售座位② 不跨区域选座如前排VIP区与后排普通区不能混选③ 同一场次最多选6座影院规则。这些校验放在前端执行减少无效请求private fun validateSelection(newSeat: String): ValidationResult { val seat seatMap?.findSeatById(newSeat) ?: return ValidationResult(false, 座位不存在) // 1. 是否可售 if (seat.status ! SeatStatus.AVAILABLE) { return ValidationResult(false, 该座位已被占用) } // 2. 是否同区域简化按首字母分区域A-F为VIPG-Z为普通 if (selectedSeats.isNotEmpty()) { val currentArea getAreaCode(newSeat) val firstArea getAreaCode(selectedSeats.first()) if (currentArea ! firstArea) { return ValidationResult(false, 不能跨区域选座) } } // 3. 数量上限 if (selectedSeats.size 6) { return ValidationResult(false, 最多选择6个座位) } return ValidationResult(true, ) } private fun getAreaCode(seatId: String): String { return when (seatId.first()) { in A..F - VIP in G..Z - NORMAL else - UNKNOWN } }每次点击座位时调用validateSelection仅当返回true才加入selectedSeats列表并重绘高亮。服务端仍需二次校验防篡改但前端拦截可过滤掉90%的非法操作。3.3 座位图动态适配不同屏幕——用ConstraintLayout百分比布局保证一致性不同安卓机型屏幕宽高比差异大硬编码px会导致小屏挤成一团、大屏留白过多。采用ConstraintLayout配合Guideline实现响应式androidx.constraintlayout.widget.ConstraintLayout android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 androidx.constraintlayout.widget.Guideline android:idid/guideline_left android:layout_widthwrap_content android:layout_heightwrap_content android:orientationvertical app:layout_constraintGuide_percent0.1 / androidx.constraintlayout.widget.Guideline android:idid/guideline_right android:layout_widthwrap_content android:layout_heightwrap_content android:orientationvertical app:layout_constraintGuide_percent0.9 / com.example.ticket.SeatMapView android:idid/seatMapView android:layout_width0dp android:layout_height0dp app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfid/guideline_left app:layout_constraintEnd_toEndOfid/guideline_right / /androidx.constraintlayout.widget.ConstraintLayoutguideline_left和guideline_right分别定位在屏幕10%和90%处SeatMapView宽度被约束在两者之间高度占满剩余空间。无论屏幕是16:9还是20:9座位图始终保持左右10%边距视觉比例稳定。4. 对接真实影院后台的关键参数与联调要点4.1 必须协商的5个核心API字段——避免联调时反复返工与影院方后台对接时以下字段必须在接口文档中明确约定否则无法完成闭环字段名类型必填说明示例sessionIdLong是场次唯一ID用于关联座位图与订单123456789seatIdString是座位编码大小写字母数字组合不可含空格/特殊字符A1,VIP-01lockTimeoutInteger是锁座有效期秒超时自动释放3005分钟paymentUrlString是支付跳转地址含订单号签名参数https://pay.cinema.com?oidORD123signabcstatusCallbackString是支付结果回调地址影院后台需主动推送https://api.yourapp.com/v1/webhook/payment提示seatId格式必须与影院现有系统完全一致若对方用A-01而你传A01锁座会失败lockTimeout需与前端倒计时UI同步例如前端显示“剩余2:58”后端必须在178秒后释放座位。4.2 模拟影院后台的最小可行测试服务——用Spring Boot快速验证为避免依赖真实后台延误开发可用Spring Boot搭一个极简Mock服务仅需3个EndpointRestController RequestMapping(/v1) public class MockTicketController { // 存储锁座状态实际应存Redis private final MapString, LockRecord locks new ConcurrentHashMap(); GetMapping(/cinemas) public ListCinema getCinemas() { return Arrays.asList( new Cinema(1L, 万达影城, 北京市朝阳区), new Cinema(2L, CGV影城, 上海市静安区) ); } GetMapping(/sessions/{id}/seats) public SeatMapResponse getSeatMap(PathVariable Long id) { // 返回固定座位图含10行×12列A1-A10为VIPB1-B10为普通 SeatMap map new SeatMap(); map.setRows(10); map.setCols(12); map.setSeats(generateMockSeats()); return new SeatMapResponse(map); } PostMapping(/orders) public ResponseEntityOrderResponse createOrder(RequestBody OrderRequest request) { // 校验锁座状态 String lockKey request.getSessionId() _ String.join(,, request.getSelectedSeats()); if (!locks.containsKey(lockKey)) { return ResponseEntity.badRequest().body(new OrderResponse(false, 座位未锁定请先锁座)); } // 模拟支付成功 String orderId MOCK System.currentTimeMillis(); locks.remove(lockKey); return ResponseEntity.ok(new OrderResponse(true, orderId)); } private ListListSeat generateMockSeats() { ListListSeat seats new ArrayList(); for (int r 0; r 10; r) { ListSeat row new ArrayList(); for (int c 0; c 12; c) { String id (r 5 ? A : B) (c 1); SeatStatus status (r 0 c 0) ? SeatStatus.OCCUPIED : SeatStatus.AVAILABLE; row.add(new Seat(id, status)); } seats.add(row); } return seats; } }启动此服务后将Android端baseUrl指向http://10.0.2.2:8080/v1/Android模拟器访问宿主机用10.0.2.2即可完成从选座→锁座→下单全流程测试无需等待影院提供正式环境。4.3 真机调试必备的3项配置——绕过HTTPS证书与跨域限制在真实设备上调试时常遇到两类报错javax.net.ssl.SSLHandshakeExceptionHTTPS证书不信任和Cleartext HTTP traffic not permitted明文HTTP被禁。解决方案如下信任自签名证书开发阶段在res/xml/network_security_config.xml中添加?xml version1.0 encodingutf-8? network-security-config domain-config domain includeSubdomainstrueapi.cinema.example.com/domain trust-anchors certificates srcraw/my_ca/ !-- 将CA证书放入res/raw -- /trust-anchors /domain-config debug-overrides trust-anchors certificates srcsystem / certificates srcuser / /trust-anchors /debug-overrides /network-security-config允许HTTP明文流量仅Debug Build在AndroidManifest.xml的application标签中添加android:usesCleartextTraffictrue并在build.gradle中区分构建类型buildTypes { debug { manifestPlaceholders [usesCleartextTraffic: true] } release { manifestPlaceholders [usesCleartextTraffic: false] } }启用USB调试与端口转发连接本地Mock服务连接手机后在命令行执行adb reverse tcp:8080 tcp:8080这样Android App访问http://localhost:8080即转发到电脑的8080端口无需修改代码中的IP地址。5. 订单状态同步与离线重试机制——保障用户最终看到“出票成功”5.1 基于WorkManager的可靠后台任务——解决应用退到后台后的状态更新用户下单后切到微信聊天此时支付结果回调可能到达但App不在前台无法及时更新UI。WorkManager可保证任务在设备空闲、充电、联网时执行class OrderSyncWorker( private val context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { private val orderDao TicketDatabase.getInstance(context).orderDao() private val api Retrofit.Builder() .baseUrl(https://api.cinema.example.com/v1/) .addConverterFactory(GsonConverterFactory.create()) .build() .create(TicketApi::class.java) override suspend fun doWork(): Result { val pendingOrders orderDao.getPendingOrders() for (order in pendingOrders) { try { val response api.getOrderStatus(order.orderId) if (response.isSuccessful response.body()?.status PAID) { orderDao.updateStatus(order.orderId, OrderStatus.PAID) // 发送通知 sendOrderSuccessNotification(order.orderId) } } catch (e: Exception) { // 网络失败稍后重试 return Result.retry() } } return Result.success() } private fun sendOrderSuccessNotification(orderId: String) { val intent Intent(context, TicketDetailActivity::class.java).apply { flags Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK putExtra(orderId, orderId) } val pendingIntent PendingIntent.getActivity( context, 0, intent, PendingIntent.FLAG_IMMUTABLE ) val notification NotificationCompat.Builder(context, ticket_channel) .setContentTitle(出票成功) .setContentText(您的电影票已生成点击查看电子票) .setSmallIcon(R.drawable.ic_ticket) .setContentIntent(pendingIntent) .setAutoCancel(true) .build() NotificationManagerCompat.from(context).notify(System.currentTimeMillis().toInt(), notification) } }注册周期性任务每15分钟检查一次PeriodicWorkRequestBuilderOrderSyncWorker(15, TimeUnit.MINUTES) .setConstraints(Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build()) .build().also { request - WorkManager.getInstance(context).enqueueUniquePeriodicWork( order_sync, ExistingPeriodicWorkPolicy.KEEP, request ) }5.2 本地重试队列设计——网络失败时自动排队不丢失用户操作当提交订单时网络中断不能简单弹Toast说“请重试”。应将请求参数序列化存入Room待网络恢复后自动重发Entity(tableName pending_requests) data class PendingRequest( PrimaryKey val id: Long System.currentTimeMillis(), val type: RequestType, // ORDER_SUBMIT, PAYMENT_NOTIFY val payload: String, // JSON序列化请求体 val createdAt: Long System.currentTimeMillis() ) Dao interface PendingRequestDao { Insert suspend fun insert(request: PendingRequest): Long Query(SELECT * FROM pending_requests ORDER BY createdAt ASC LIMIT 10) suspend fun getPendingRequests(): ListPendingRequest Query(DELETE FROM pending_requests WHERE id :id) suspend fun delete(id: Long) }在Application类中监听网络状态class TicketApplication : Application() { override fun onCreate() { super.onCreate() val connectivityManager getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val callback object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { // 网络恢复触发重试 viewModelScope.launch { retryPendingRequests() } } } connectivityManager.registerDefaultNetworkCallback(callback) } }重试逻辑中对每个PendingRequest反序列化后调用对应API成功则删除记录失败则记录日志不删除下次继续重试。5.3 订单状态查询的3层缓存策略——从内存→本地→网络的渐进式加载用户进入“我的订单”页面时需快速展示列表。采用三级缓存层级数据源更新时机时效性适用场景L1 内存缓存ConcurrentHashMapString, OrderEntityActivity创建时加载毫秒级列表滚动不闪退L2 本地缓存Roomorders表支付成功后插入/状态更新后修改秒级应用重启后仍可见L3 远程缓存API/orders?statusPENDING下拉刷新时强制更新分钟级确保与服务器最终一致ViewModel中加载逻辑fun loadMyOrders() { // 1. 先查内存缓存若有 val memoryOrders memoryCache.values.toList() if (memoryOrders.isNotEmpty()) { _orders.value OrdersLoaded(memoryOrders) return } // 2. 查本地数据库 viewModelScope.launch { val localOrders orderDao.getAllOrders() if (localOrders.isNotEmpty()) { // 加入内存缓存 localOrders.forEach { order - memoryCache[order.orderId] order } _orders.value OrdersLoaded(localOrders) } // 3. 异步拉取远程最新数据 try { val remoteOrders api.getMyOrders().body() ?: emptyList() // 合并更新远程有新订单则插入状态变更则更新 remoteOrders.forEach { remote - memoryCache[remote.orderId] remote orderDao.upsert(remote) } _orders.value OrdersLoaded(memoryCache.values.toList()) } catch (e: Exception) { // 远程失败仍显示本地数据 } } }这种策略让用户打开订单页瞬间看到历史记录3秒内完成远程同步既快又准。本文还有配套的精品资源点击获取