简介针对iOS开发中电影选座功能的完整实现代码包适合有一定iOS基础、希望快速上手座位选择交互的开发者。资源围绕类似猫眼的选座流程详细展示了如何搭建界面布局以Seat模型管理空闲、已选、禁选等状态并基于自定义SMScrollView实现可滚动座位图与手势选座同时覆盖点击高亮反馈、同排连坐限制、不同场次座位冲突处理以及网络请求预订和常见错误提示能帮助理解选座功能从UI到数据交互的完整链路。压缩包共110个文件以Objective-C的m/h源码和png图片资源为主png为座位图与界面图标plist、json存放工程配置与座位数据示例xcscheme为Xcode工程配置整体仅906KB结构清晰便于按目录查阅。已有555人学习下载。通过阅读源码与注释可掌握自定义座位视图绘制、滚动缩放和座位状态管理的方法并复现类似猫眼的选座界面适合作为独立功能模块直接复用或二次开发。1. 类似猫眼电影选座(iOS)一张座位图的背后是整套状态机选座页我做过几版最难的点从来不是把座位画好看而是画完之后它怎么不“乱”。类似猫眼电影选座(iOS)这个需求表面上是在一个影厅背景图上摆几十排可点的座位块实际上一张座位图背后挂着一套座位状态机、一套行偏移坐标、一套锁定与释放策略再加上高并发下两个用户同时抢同一张座位的库存一致性。适合正在做 iOS 端在线选座的客户端开发、要接影院场馆订单系统的技术负责人以及想搞清楚“选座到底难在哪”的产品同学。下面按一套可以直接进工程的思路拆开讲并给出一版能跑的 Swift 代码骨架。2. 选座的数据结构网格、行排号与状态建模2.1 座位状态机四个状态为什么缺一不可真实影院选座页面座位绝不是“可点”和“不可点”两种状态。至少要拆成四种可选、当前设备已选、被其他端锁定、已售出。available所有人都能看到、都能选。localSelected当前用户在这个设备上点中了但还没向后端上报。locked已经上报后端并被确认进入结算流程的座位其他人不能再选。sold支付成功座位永久不可选。很多新手方案会把localSelected和locked合并成一个“已选”。合并之后最直接的后果是两个用户在同一时间看到同一个座位都点了一下两边界面都显示“已选”然后各自进入支付最后只有一个订单能成功另一个用户被退款或提示失败。选座业务对“同时点击同一座位”的容忍度极低所以必须把“本地已选”与“服务端锁定”拆成两个阶段才算真正把状态机立住。2.2 用二维数组落一版座位模型Swift 枚举加网格参数先给出一份最小可用的 Swift 模型代码。它对应的是一个普通 IMAX 厅的座位布局有排、有列、中间有过道某些位置是空位。enum SeatStatus: Int, Codable { case available 0 // 可选 case localSelected 1 // 当前设备已选尚未上报锁定 case locked 2 // 已被某端锁定进入结算 case sold 3 // 已售出 } struct Seat: Equatable { let row: Int // 排下标从 0 开始 let col: Int // 列下标从 0 开始 let code: String // 后台座位编号例如 A-05-12 let price: Double // 票价影厅内可能分区域定价 var status: SeatStatus } final class SeatGrid { private(set) var rows: [[Seat?]] let rowOffsets: [CGFloat] // 每排相对第 0 排的 X 方向偏移单位是座位宽 init(rows: [[Seat?]], rowOffsets: [CGFloat]) { self.rows rows self.rowOffsets rowOffsets } subscript(row: Int, col: Int) - Seat? { guard row 0, row rows.count else { return nil } guard col 0, col rows[row].count else { return nil } return rows[row][col] } }这段代码有几个关键取舍。第一用rows二维数组而不是[String: Seat]字典原因是选座里所有高频操作都依赖“相邻关系”找一排里连续可选的座位、判断左右是否越界、翻转一个座位状态后刷新前后邻居。二维数组天然表达行内左右顺序扫一遍就能完成连座判断字典适合按编号精确查找做不了相邻遍历每次都要把索引转回坐标。影厅最多一千来个座位二维数组的内存开销几乎可以忽略所以选它没有性能负担。第二rows[row][col]里存Seat?。nil表示过道、设备检修空位、或弧形影厅那一排的缺口。这些位置不参与连座推荐也不可点击。第三rowOffsets是弧形影厅的关键参数。影院普遍不是规整矩形后排座位相对前排会有弧度偏移如果所有排都按同一列坐标硬排屏幕上看会是歪的。这个数组会在渲染章节全面用到。2.3 行排号、列号与后台座位编号映射关系别混前端网格下标是(row, col)从 0 开始但后端接口通常返回H3-A-05-12这样的座位编号厅号 H3、排号 A、列号 05、座位号 12。前端每次操作座位接口参数里带的是这个长编号而不是(row, col)。常见做法是进入选座页时后端一次性把影厅的座位全量数据下发前端根据每个座位的row、col字段构造成二维网格同时维护一个[String: Seat]字典用于把座位编号反查回网格坐标。这里最容易翻车的是 0 起始和 1 起始混用后端字段若从 1 开始前端代码里要么统一加 1要么全部保持 0 起始绝不能在同一个方法里混着算。我一般会在接口字段注释里直接约定“所有下标从 0 开始”省掉后续所有减一操作。网格构造代码也是这个思路接口返回座位数组后一次性填表extension SeatGrid { static func makeGrid(from seats: [Seat], rowCount: Int, columnCount: Int, rowOffsets: [CGFloat]) - SeatGrid { var rows: [[Seat?]] Array(repeating: Array(repeating: nil, count: columnCount), count: rowCount) for seat in seats { rows[seat.row][seat.col] seat } return SeatGrid(rows: rows, rowOffsets: rowOffsets) } }参数说明rowCount和columnCount从接口的影厅配置里读不要自己用seats.max(row) 1去推。因为影厅可能存在整排为空没有下发座位的情况自己推出的小数组会把边界截掉滚动时越界崩溃是早晚的事。rowOffsets有可能是后台按百分比下发前端再乘以座位宽度换算成物理偏移。2.4 连座推荐一段可复用的行内扫描算法用户选电影票时最常用的是“自动选两个相邻座位”。这个需求看似简单落地时有一个隐性要求优先推荐“排内最长连续可选段”里的位置而不是从第一排开始硬凑。比如用户要两张连座第 5 排有 6 个连续空座第 6 排只有 2 个空座但左右都有人显然应该推荐第 5 排因为第 6 排那两个座位虽然连座但紧贴过道体验差很多。extension SeatGrid { /// 在某一排中查找指定数量的连续可选座位 func bestContiguous(in rowIndex: Int, count: Int) - [Seat?]? { guard rowIndex rows.count else { return nil } let row rows[rowIndex] var bestRange: RangeInt? var bestLength 0 var cursor 0 while cursor row.count { // 跳过不可用座位 if row[cursor]?.status ! .available { cursor 1 continue } let start cursor while cursor row.count, row[cursor]?.status .available { cursor 1 } let length cursor - start if length count length bestLength { bestRange start..(start count) bestLength length } } guard let range bestRange else { return nil } return Array(row[range]) } }这个算法只做了一次行内扫描时间复杂度 O(n)。count是用户选票数通常为 1 到 6。选出来的结果直接拿去做座位高亮和锁定上报不用再走一次SeatGrid查询。要注意算法只覆盖“行内连座”如果要支持跨排连座就需要按影院规则做加权推荐一般是给“更靠中间、更靠后排”的排加分再逐排扫描取最优这是另一个话题不做展开。3. 渲染选座页用 UIScrollView 摆座位、调缩放、回传回调3.1 渲染选座为什么我不用 UICollectionView 而是手动摆视图选座页的渲染方案有两种主流路线用UICollectionView的cell渲染座位或者用一个UIScrollView当容器、在里面按坐标手动摆放座位视图。UICollectionView在列表滚动场景很强但选座页有几个它不好应付的特点行宽参差不齐每排开头有弧形偏移座位之间还可能跨过道留空选座操作伴随着频繁的单点状态刷新复用机制很容易把当前选中的高亮状态带乱。相比之下手动摆放视图更直观一个座位就是一个可点击的按钮视图frame 是算出来的刷新状态时只改这一个视图的外观不牵涉复用队列的错乱。func layoutSeats(on container: UIView, grid: SeatGrid) { let seatWidth: CGFloat 14 let seatHeight: CGFloat 14 let gapX: CGFloat 6 let gapY: CGFloat 6 let paddingX: CGFloat 16 for row in 0..grid.rows.count { let offsetX grid.rowOffsets[row] * seatWidth for col in 0..grid.rows[row].count { guard let seat grid[row, col] else { continue } let x paddingX offsetX CGFloat(col) * (seatWidth gapX) let y 48 CGFloat(row) * (seatHeight gapY) let seatView SeatView(seat: seat) seatView.frame CGRect(x: x, y: y, width: seatWidth, height: seatHeight) container.addSubview(seatView) seatView.onTap { [weak self] in self?.handleSelect(seat) } } } }这段代码的核心是把网格坐标换算成屏幕坐标。offsetX来自后台下发的rowOffsets乘以座位宽度后就是这一排向右偏移的物理距离。gapX和gapY控制座位间距一般横向间距比纵向间距小很像真实影厅座位排列的感觉。容器高度计算可以直接用“最大行数 * (座位高 行距) 上下留白”宽度则用“最大列数 * (座位宽 列距) 最大行偏移 左右留白”拼到UIScrollView的contentSize上即可。这套手动布局方案能扛住一千个座位。iOS 上几百个轻量 UIView 的渲染和点击响应毫无压力也能顺利支持缩放。比起UICollectionView的方案它对新手更友好对后维护的人来说也更直观。3.2 缩放与滚动参数怎么调一套不打架的手势配置座位图必须支持缩放和拖动用户要看整厅还是放大看某排细节。缩放用UIScrollView原生能力关键参数只有几个。func setupScrollView() { scrollView.minimumZoomScale 0.8 scrollView.maximumZoomScale 3.0 scrollView.delegate self scrollView.contentInsetAdjustmentBehavior .never scrollView.showsHorizontalScrollIndicator false scrollView.showsVerticalScrollIndicator false } func viewForZooming(in scrollView: UIScrollView) - UIView? { return seatContainer }minimumZoomScale设成 0.8 而不是 1.0是为了让用户在高分辨率大屏上也能一眼看全整厅布局。maximumZoomScale设成 3.0再大座位块会糊成色块没什么实际意义。viewForZooming返回装着所有座位视图的seatContainer缩放时整个容器等比缩放座位之间的相对位置不会乱。单击选中与UIScrollView的拖拽手势天然冲突。直接给seatView加UITapGestureRecognizer在快速拖动时容易误触发。常见做法是给整个seatContainer加一个单击手势然后通过位置换算判断点到了哪个座位。let tap UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.numberOfTapsRequired 1 tap.numberOfTouchesRequired 1 seatContainer.addGestureRecognizer(tap)handleTap里先取点击点在seatContainer中的坐标再做一次坐标反算找到(row, col)。坐标反算是把 3.1 节的公式反着写先根据y求row再根据x减掉rowOffsets[row] * seatWidth后求col。这里要注意边界判断点击到两个座位缝隙时不能选中任何座位直接返回即可。3.3 选中座位怎么回传用闭包把状态同步给购票栏选座页面最终要把“当前选中的座位列表”和“总价”同步给底部购票按钮。我一般用闭包回调选中变化后由控制器刷新底部栏。final class SeatPickerViewController: UIViewController { var onSeatSelection: (([Seat], Double) - Void)? private var selectedSeats: [Seat] [] func handleSelect(_ seat: Seat) { guard seat.status .available || seat.status .localSelected else { return } if let index selectedSeats.firstIndex(where: { $0.code seat.code }) { selectedSeats.remove(at: index) updateSeatState(seat, to: .available) } else { selectedSeats.append(seat) updateSeatState(seat, to: .localSelected) } let total selectedSeats.reduce(0) { $0 $1.price } onSeatSelection?(selectedSeats, total) } }onSeatSelection在选中变化时立刻触发控制器收到后更新底部“选座购票”按钮的文案和金额。selectedSeats维护的是“本地已选”状态真正向后端上报锁定在点击“确认选座”后再做。这段代码里的updateSeatState只需要刷新对应座位视图的样式不用整页重排避免闪烁。一个容易忽略的细节用户快速点击两个座位时回调会触发两次。底部栏的总价以最后一次回调为准按钮文案“选座购票 (2张)”也要在回调里同步更新不要做异步延迟汇总。4. 避坑选座页落地常见的 5 个问题上线前一定要过一遍4.1 排内有缺口时整排座位偏移错位现象接口数据里某一排中间缺了两个座位可能是设备维修或 VIP 隔离渲染出来后这排所有座位整体向左或向右偏了一格和背景图上影厅的实际座位对不上。原因后台下发的是“座位编号”和“排内序号”前端渲染时用col直接作为屏幕列号没有考虑缺口对列号的影响。排内有一个缺口缺口之后的座位应该在屏幕坐标上继续向后挪但纯用col算坐标时缺口直接被跳过后续座位会挤在一起。解决前端不要用“座位总数里的下标”当列坐标而是用后端下发的“物理列号”。构造网格时把每个座位放进它真正的col位置缺口位置留nil。这样屏幕坐标计算依然用CGFloat(col)缺口自然空出来。上线前把影厅座位图与真实影厅做一次人工比对尤其是带弧形偏移和 VIP 隔断的影厅。4.2 两个用户同时抢同一张座都没报错现象用户 A 和用户 B 同时打开同一个场次的选座页都点了 5 排 8 座两个端上都显示“已选”。两个人分别下单支付只有一个成功另一个支付流程里才被告知座位冲突体验极差。原因前端把localSelected当成了锁定点选之后没有向后端上报锁定座位或者后端没有实现锁座接口。选座页面上的“已选”只在自己设备上有意义服务端并没有为座位做任何占用标记。解决在handleSelect里点选成功后立刻向后端发起锁座请求带上影院编号、场次编号、座位编号列表。后端确认后返回锁定状态并设置锁定时长。锁座成功后再把座位状态改成locked并在页面上把样式改成“锁定中”。锁座失败要立即回滚本地状态把座位恢复成可选。锁座接口必须有唯一约束保证同一个座位只能被一个锁定单占用。4.3 用户快速连点时误选相邻座位现象用户本来想点 6 排 5 座手指稍微一抖页面把 6 排 6 座也选中了。或者是双击同一个座位第一次选中第二次取消用户以为座位没点上。原因座位视图尺寸差不多 14pt x 14pt加上 6pt 间距实际热区很小。如果直接用frame作为点击区域误触概率会很高。双击操作也没有做防抖。解决给每个座位视图扩大点击热区hitTest里把热区扩大 8pt或者直接用一个更大的透明按钮覆盖在座位图形上。双击处理则用“单击延迟”用户第一次点击后等 200ms如果第二次点击落在同一个座位就只算一次选中不再做取消。4.4 轮询刷新座位状态导致已选座位闪烁现象选座页通过轮询每 5 秒同步一次座位状态。刷新时用户已经选中的座位先变成灰色刷新中默认不可选然后又变回选中态页面一闪一闪甚至滚动位置还会跳。原因轮询返回全量座位状态后直接把整个网格重建并reloadData。所有座位上原有的选中状态、滚动位置、缩放比例都被重置了。解决轮询返回后做差量更新。遍历新数据和旧数据只更新状态发生变化的座位视图并且永远不覆盖localSelected的座位除非后端明确告知锁座失败。滚动位置和缩放比例在刷新前后保持不变通过记录scrollView.contentOffset和zoomScale在差量更新完成后手动恢复。如果影厅只有几百个座位差量更新的计算量几乎为零。4.5 高版本 iOS 上真机调试选座页Xcode 运行不了现象把工程跑到一台新买的 iPhone 上Xcode 提示“Developer Mode”未开启运行不了选座项目更没法断点调试选座逻辑。原因从 iOS 16 开始新设备默认关闭开发者模式首次用 Xcode 调试必须手动开启这不是证书或签名问题而是系统安全机制。解决在真机“设置-隐私与安全性-开发者模式”里开启开发者模式手机会重启一次重启后再连 Xcode 就能正常安装和调试。如果是公司统一购机分发的新设备这个开关要在设备初始化时批量打开否则研发同事拿到手会卡在第一步。配置好证书后真正要上线再走签名、描述文件、TestFlight 的流程选座页这种带手势交互的页面务必在真机上验证缩放流畅度模拟器上很多手感问题看不出来。5. 进阶选座锁定、冷静期与高并发下的库存一致性选座页面从“能用”到“真正可用”只差后端锁定策略这一段。客户端画得再好锁座不稳上线后依然会被用户骂。服务端常见的锁座策略分三种按业务阶段选择策略适用阶段风险核心要点下单前不锁座低并发冷门场次热门场次并发抢座时必出超卖依赖支付回调扣库存体验靠运气点击场次锁座 15 分钟主流做法用户选座后不支付座位被占住锁定期倒计时必须在前端明确展示支付前二次确认锁座高价票、特殊座位支付环节变长支付页座位号不可变变更需重新选我一般建议直接选第二种这是猫眼、淘票票这类产品的通用做法。用户选完座位点击确认后客户端向后端发起“锁定座位”请求。后端生成一个锁座单记录锁定到期时间一般设 5 到 15 分钟。锁座单生命周期内有冷静期倒计时前端在页面上显示剩余时间冷静期结束前必须完成支付否则座位自动释放用户可以重新选择。这里有一个容易被忽略的细节锁定接口要支持“续期”和“主动释放”。用户停留在支付页超过锁定期时客户端需要在后台静默发起续期用户主动放弃支付则调用释放接口把座位立即恢复为可选。如果只做超时释放用户取消支付后会看到座位一直被占直到倒计时走完这期间别人不能买损失的是影厅的销量。高并发下最常见的冲突场景是用户 A 和用户 B 同时点中同一张票后端锁座接口要保证只有一个能成功。实现上可以在锁座表中对“影院编号 场次编号 座位编号”建唯一索引锁座用“先插入后确认”的方式插入成功就是锁座成功。锁座失败时客户端要做回滚处理代码如下func lockSeats(_ seats: [Seat], completion: escaping (Bool) - Void) { lockService.lock(seats: seats.map { $0.code }) { [weak self] result in DispatchQueue.main.async { switch result { case .success: // 把本地座位状态改为 locked并启动冷静期倒计时 self?.applyLockState(to: seats) completion(true) case .failure: // 锁座失败恢复已选座位为 available并提示用户重新选择 self?.rollbackLocalSelection(seats) completion(false) } } } }applyLockState刷新的是座位视图外观和冷静期倒计时rollbackLocalSelection把本地localSelected状态恢复为available同时清空已选列表、更新底部购票栏。锁座失败时用户看到的不是“一个灰色的等待弹窗”而是“座位变回可选、底部栏金额归零、提示换座”的完整闭环。我在做选座这类业务时有个习惯测试环境一定专门建一个新的低版本系统设备做回归专门验证锁座超时、冷静期归零、支付页返回重新选座这几条链路。这类问题在模拟器上基本测不出来真机上却会因为网络延迟、APP 切后台触发各种极限情况。客户端选座页的每一个本地状态变更都应该能在服务端处处得到验证这个原则比任何框架选型都重要。希望帮到你。本文还有配套的精品资源点击获取