
简介这是一份面向iOS开发者的电影选座功能实战参考源码以「类似猫眼电影选座」为目标场景适合具备Swift或Objective-C基础、希望掌握自定义视图与交互逻辑的中级开发者。资源围绕座位图布局、Seat与SeatArray数据模型、SMScrollView自定义滚动容器、SeatView点击反馈、选座业务规则限制以及网络请求同步等模块展开可帮助读者理解从界面搭建到数据校验的完整链路。压缩包共110个文件约906KB包含28个png界面素材、13个m实现文件、9个h头文件及若干xcscheme、plist、json、xcconfig等工程配置另有podspec与说明文档目录结构便于按模块查阅。目前已有555人学习下载适合作为课程设计或选座类App的起步脚手架对照源码可快速梳理座位状态管理与滚动适配思路。1. 类似猫眼电影选座iOS 上从零搭一套可复用的选座控件影院选座这个交互看起来只是「点几个格子」真动手做才发现坑全在细节里座位图要缩放拖拽、已售座位要实时同步、情侣座要连选、屏幕方向要自适应、弱网下还得防重复下单。很多 iOS 项目一开始用 UICollectionView 硬怼做到一半发现手势冲突、复用错乱、性能掉帧最后推倒重来。这篇笔记就围绕「类似猫眼电影选座」这个目标把 iOS 端选座控件的领域模型、渲染方案、手势处理和下单校验完整拆一遍适合正在做票务、影院、演出、机舱选座这类业务的 iOS 开发者也适合想搞清楚自定义绘图控件怎么落地的人。读完你能拿到一套能跑通的最小实现以及几个我踩过的血泪坑。2. 选座控件的领域模型座位、排、区域到底怎么抽象2.1 为什么不能把座位当成一堆 UIButton新手最容易犯的错是把每个座位做成一个 UIButton 或 UILabel塞进 UICollectionView 里。座位数一多一个 IMAX 厅动辄 300 座位滚动和缩放立刻掉帧而且状态管理会失控——已售、可选、已选、维修、情侣座、无障碍座六种状态散落在 cell 复用逻辑里滑两下就串状态。正确的做法是把「数据」和「渲染」彻底分开。座位在数据层只是一个结构体包含坐标、状态、类型渲染层用一个自定义 UIView 在 draw(_:) 里统一绘制或者用 CAShapeLayer 批量管理。这样缩放拖拽只是改 transform不触发任何 cell 复用性能稳定。常见做法是定义一个 Seat 模型struct Seat { let id: String // 座位唯一标识下单时回传 let row: Int // 排号 let col: Int // 列号 let x: CGFloat // 在座位图中的相对 x0~1 let y: CGFloat // 在座位图中的相对 y0~1 var status: SeatStatus // 当前状态 var type: SeatType // 普通 / 情侣 / 无障碍 } enum SeatStatus { case available // 可选 case sold // 已售 case selected // 已选 case broken // 维修 } enum SeatType { case normal case couple // 情侣座需成对选择 case accessible // 无障碍座 }用相对坐标0~1而不是绝对像素是为了让座位图能适配任意屏幕尺寸和缩放级别。渲染时再乘以当前画布的实际宽高得到最终绘制位置。2.2 排、区域、屏幕这些层级不能省真实影厅不是规则矩形。有的厅是弧形排布有的中间有过道有的分普通区和 VIP 区还有的座位朝向银幕有角度。如果只用一个二维数组存座位遇到过道和异形排布就会错位。我一般会加一层 Screen 模型把座位按区域分组struct SeatArea { let name: String // 区域名如「普通区」「VIP区」 let seats: [Seat] // 该区域所有座位 let priceLevel: Int // 价格档位用于显示不同颜色 } struct Screen { let id: String let name: String // 影厅名如「1号IMAX厅」 let areas: [SeatArea] let rowCount: Int let colCount: Int }这样渲染时按区域遍历价格档位直接映射颜色过道就是座位数组里缺失的坐标不需要额外标记。下单时把选中的 seat.id 收集起来传给后端即可。提示座位 id 一定要用后端下发的稳定标识不要用「排-列」拼字符串。有些影厅排号是 A、B、C有些是 1、2、3还有跳号的情况自己拼迟早出事。3. 用 UIScrollView 自定义绘制搭出可缩放座位图3.1 整体视图层级怎么搭选座控件的标准结构是三层最外层 UIScrollView 负责缩放和拖拽中间一个 contentView 承载座位图座位图本身用自定义 UIView 绘制。银幕指示条固定在顶部不随缩放移动。final class SeatMapView: UIView { private let scrollView UIScrollView() private let contentView UIView() private let seatCanvas SeatCanvasView() // 自定义绘制座位 private let screenBar UIView() // 顶部银幕条 override init(frame: CGRect) { super.init(frame: frame) setupUI() } private func setupUI() { scrollView.delegate self scrollView.minimumZoomScale 0.5 scrollView.maximumZoomScale 3.0 scrollView.bouncesZoom true scrollView.showsHorizontalScrollIndicator false scrollView.showsVerticalScrollIndicator false addSubview(scrollView) scrollView.addSubview(contentView) contentView.addSubview(seatCanvas) addSubview(screenBar) } override func layoutSubviews() { super.layoutSubviews() scrollView.frame bounds screenBar.frame CGRect(x: 0, y: 0, width: bounds.width, height: 32) // contentView 尺寸按座位图比例算这里假设宽高比 4:3 let canvasWidth bounds.width * 1.2 let canvasHeight canvasWidth * 0.75 contentView.frame CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight) seatCanvas.frame contentView.bounds } } extension SeatMapView: UIScrollViewDelegate { func viewForZooming(in scrollView: UIScrollView) - UIView? { return contentView } }关键点viewForZooming 返回 contentView缩放时整个座位图跟着变seatCanvas 内部不需要感知缩放级别。座位点击用 UITapGestureRecognizer 加在 seatCanvas 上通过触摸点反算座位坐标。3.2 座位绘制draw(_:) 里怎么算每个座位的位置SeatCanvasView 的核心是遍历所有座位把相对坐标转成绘制矩形再根据状态填色。为了性能已售和可选座位可以合并成一次绘制选中状态单独重绘。final class SeatCanvasView: UIView { var screen: Screen? { didSet { setNeedsDisplay() } } var selectedIds: SetString [] { didSet { setNeedsDisplay() } } override func draw(_ rect: CGRect) { guard let ctx UIGraphicsGetCurrentContext(), let screen screen else { return } let seatW bounds.width / CGFloat(screen.colCount) * 0.8 let seatH seatW * 0.85 let gapX bounds.width / CGFloat(screen.colCount) * 0.2 let gapY seatH * 0.4 for area in screen.areas { for seat in area.seats { let x CGFloat(seat.col) * (seatW gapX) gapX / 2 let y CGFloat(seat.row) * (seatH gapY) gapY let seatRect CGRect(x: x, y: y, width: seatW, height: seatH) let path UIBezierPath(roundedRect: seatRect, cornerRadius: 3) let color: UIColor if selectedIds.contains(seat.id) { color .systemGreen } else { switch seat.status { case .available: color area.priceLevel 1 ? .systemBlue : .systemOrange case .sold: color .systemGray case .broken: color .systemGray.withAlphaComponent(0.3) case .selected: color .systemGreen } } color.setFill() ctx.addPath(path.cgPath) ctx.fillPath() } } } }参数说明seatW 和 seatH 控制座位大小gapX 和 gapY 控制间距。0.8 和 0.2 这两个系数决定座位占格子的比例太大会挤在一起太小会显得稀疏。实际项目里我会把这两个值做成可配置不同影厅用不同密度。3.3 点击命中怎么从触摸点反算座位点击处理不能靠遍历所有座位做 hitTest座位多了会卡。正确做法是用触摸点反算行列号再查表。objc private func handleTap(_ gesture: UITapGestureRecognizer) { guard let screen screen else { return } let point gesture.location(in: self) let colWidth bounds.width / CGFloat(screen.colCount) let rowHeight colWidth * 0.85 * 1.4 // 与绘制时的 seatH gapY 对应 let col Int(point.x / colWidth) let row Int(point.y / rowHeight) guard row 0, row screen.rowCount, col 0, col screen.colCount else { return } // 在所有区域里找这个行列的座位 for area in screen.areas { if let seat area.seats.first(where: { $0.row row $0.col col }) { toggleSelection(seat) return } } }这里有个容易翻车的点绘制时的坐标计算和点击反算必须用同一套公式否则会出现「点这个座位选中了旁边那个」的玄学问题。我一般会把坐标计算抽成一个单独的函数绘制和点击都调它。4. 手势冲突与状态同步缩放、拖拽、点击怎么共存4.1 UIScrollView 的点击和缩放手势打架怎么办UIScrollView 自带 pan 和 pinch 手势再加一个 tap 手势默认情况下 tap 会被延迟识别用户感觉点击不灵敏。解决办法是设置 delaysContentTouches 和 canCancelContentTouches并让 tap 手势不依赖 scrollView 的手势失败。scrollView.delaysContentTouches false scrollView.canCancelContentTouches true let tap UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.numberOfTapsRequired 1 tap.require(toFail: scrollView.pinchGestureRecognizer!) // 双指缩放时不触发点击 seatCanvas.addGestureRecognizer(tap)如果还觉得点击迟钝可以在 scrollView 的 panGestureRecognizer 上加一个 delegate在 touchesBegan 时判断移动距离小于阈值就当点击处理。不过大多数场景下上面的配置就够了。4.2 已售座位实时同步轮询还是长连接选座页面最怕的是「我选了别人也选了下单时才发现被抢」。常见做法有两种一是进入页面时拉一次全量座位状态之后每隔几秒轮询增量二是用 WebSocket 或长连接推送座位变更。轮询实现简单适合中小流量private var pollTimer: Timer? func startPolling(screenId: String) { pollTimer Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in self?.fetchSeatUpdates(screenId: screenId) } } private func fetchSeatUpdates(screenId: String) { // 请求后端增量接口返回自上次以来变更的座位 // 更新本地 screen 数据后调用 seatCanvas.setNeedsDisplay() }参数说明轮询间隔 5 秒是平衡实时性和服务器压力的经验值。太短1 秒服务器扛不住太长30 秒用户体验差。如果影厅热门建议上长连接但要注意断线重连和消息去重。注意无论用哪种同步方式下单前必须再向后端确认一次座位状态。本地状态永远可能过期这是后悔药都救不回来的坑。4.3 情侣座连选选中一个自动带出另一个情侣座的逻辑是选中一个自动选中配对的另一个取消一个两个一起取消。实现时给情侣座加一个 pairId选中时查同 pairId 的座位一起改状态。private func toggleSelection(_ seat: Seat) { if seat.status .sold || seat.status .broken { return } if seat.type .couple { let pair allSeats.filter { $0.pairId seat.pairId } let shouldSelect !selectedIds.contains(seat.id) for s in pair { if shouldSelect { selectedIds.insert(s.id) } else { selectedIds.remove(s.id) } } } else { if selectedIds.contains(seat.id) { selectedIds.remove(seat.id) } else { selectedIds.insert(seat.id) } } seatCanvas.selectedIds selectedIds updateBottomBar() }这里要注意情侣座如果有一个已被售出另一个也不能选。判断逻辑要放在 toggleSelection 开头别等用户点了才提示。5. 避坑与排查选座控件最容易翻车的 5 个地方5.1 现象滑动座位图时座位状态串了原因用 UICollectionView 做座位cell 复用时没有重置状态或者用 indexPath 当座位 id。解决改用自定义绘制或者如果非要用 collectionView在 prepareForReuse 里彻底重置并且用数据源的 seat.id 而不是 indexPath 来绑定状态。5.2 现象点击座位没反应或者点 A 选中 B原因绘制坐标和点击反算坐标用了两套公式或者缩放后没有把触摸点转换到 seatCanvas 坐标系。解决把坐标计算抽成单一函数点击时用 gesture.location(in: seatCanvas) 而不是 in: self确保坐标系一致。5.3 现象座位数超过 200 后滚动明显掉帧原因每个座位一个 UIView 或 CALayer图层数量爆炸。解决用一个 draw(_:) 统一绘制或者用 CAShapeLayer 合并同状态座位。实测 300 座位用单层绘制iPhone 上稳定 60 帧。5.4 现象下单时提示座位已被占用但界面上还是可选原因本地状态没有及时同步或者轮询间隔太长。解决下单前强制刷新一次座位状态轮询间隔根据影厅热度动态调整下单接口做幂等和乐观锁后端返回冲突时前端要能回滚选中状态并提示用户。5.5 现象横屏或 iPad 上座位图变形原因座位图宽高比写死或者没有根据 sizeClass 调整布局。解决座位图宽高比由 rowCount 和 colCount 动态计算layoutSubviews 里重新算 contentView 尺寸iPad 上可以加大 minimumZoomScale让用户自己缩放。6. 进阶技巧用 diff 算法做座位状态增量刷新前面讲的都是「全量重绘」座位少的时候没问题但遇到上千座位的大厅每次状态变更都 setNeedsDisplay 会浪费性能。我后来改成增量刷新只重绘发生变化的座位区域。思路是维护一个 dirtyRects 集合状态变更时把对应座位的 rect 加进去draw(_:) 里只绘制这些区域。private var dirtyRects: SetCGRect [] func updateSeat(_ seat: Seat, newStatus: SeatStatus) { let rect seatRect(for: seat) dirtyRects.insert(rect) setNeedsDisplay(rect) // 只重绘这个区域 } override func draw(_ rect: CGRect) { // 只绘制与 rect 相交的座位 for area in screen.areas { for seat in area.seats { let seatRect self.seatRect(for: seat) if seatRect.intersects(rect) { drawSeat(seat, in: seatRect) } } } }seatRect(for:) 就是前面抽出来的坐标计算函数绘制和点击共用。这样每次只重绘变化的那一小块大座位图也能保持流畅。验证方法很简单在 draw(_:) 里打日志看每次刷新实际绘制的座位数。全量重绘时是总座位数增量刷新时应该只有几个到几十个。如果数字没降下来检查 setNeedsDisplay(rect) 传的 rect 是不是太大。另一个技巧是预计算座位矩形。座位图数据加载后把所有座位的 rect 算好缓存起来draw 和点击都直接查缓存避免每次重复计算。对于静态座位图这一步能省不少 CPU。我自己的习惯是任何选座控件先把坐标计算和状态管理写成纯逻辑用单元测试覆盖边界情况第一排、最后一排、过道、情侣座配对再接 UI。这样后面改渲染方案时逻辑层不用动心里有底。希望帮到你。本文还有配套的精品资源点击获取