iOS影院选座:自定义绘制、缩放命中测试与座位状态机

发布时间:2026/10/24 4:41:53
iOS影院选座:自定义绘制、缩放命中测试与座位状态机 简介这是一份面向 iOS 开发者的电影选座功能实战工程围绕类似猫眼电影的在线选座场景重点解决影厅座位图的滚动布局、座位状态管理与点击选座交互等问题适合已掌握 Objective-C 基础、希望补齐自定义视图与业务交互经验的中级开发者参考。压缩包共 110 个文件、约 906KB以 Objective-C 源文件.m、.h、界面与工程配置.plist、.xcconfig、.pbxproj、.xcscheme、座位示意图与切图28 个 png以及 JSON 数据样例为主工程结构完整可直接打开对照学习。目前已有 555 人学习下载。内容覆盖座位模型与座位数组的状态设计、可滚动座位视图的适配算法、自定义座位视图的绘制与选中反馈并延伸到选座限制、场次座位冲突、网络预订与错误提示等环节读者可据此理清从界面搭建到数据同步的完整链路也可将座位布局与交互模块拆出复用到自己的购票类项目中。1. 影院选座页为什么值得单独做一层三个坐标系、四种座位状态和一次手势冲突电影选座这个界面用户平均只停留十几秒但它把 iOS 上最容易翻车的三件事凑齐了自定义绘制、手势冲突、状态同步。影厅座位图不是一张图片而是一份带行列坐标的稀疏数据点击不是一次 didSelectRow而是要在缩放后的坐标系里做命中测试选中的座位还要跟锁座倒计时、情侣座成对、单人最多选四个这些业务约束对齐。这类源码包适合正在做票务、影院、演出选座模块的 iOS 开发者也适合想练自定义绘制和手势的人。下面按「数据怎么建、图画怎么画、手势怎么接、状态怎么管、坑在哪」走一遍。2. 座位图建模与渲染把影厅 JSON 拆成网格层、状态层和高亮层2.1 一份座位图 JSON 里到底有什么很多人第一版会用一个rows × cols的二维数组存座位结果过道位置全是空对象一个 12 排 20 列的厅里能塞进去 40 个占位符后面无论是遍历还是序列化都很难受。常见的做法是拆成三块网格参数行列数、座位宽高、间距、稀疏座位列表只包含真实存在的座位、装饰信息幕布位置、过道文字、分区价格。座位是否存在由列表决定位置由行列号加网格参数算出来。还有一个必须提前分开的东西seatId和(row, col)是两个概念。真实影厅经常跳号第四排可能没有 13 座情侣座可能是 9 和 10 拼一个售卖单元。(row, col)是布局坐标只负责画在哪里seatId是业务主键负责下单和锁座。把这两个混成一个字段后面接口一对齐就得返工。{ hallId: H1208, grid: { rows: 12, cols: 20, seatW: 1.0, seatH: 1.0, gapX: 0.15, gapY: 0.2 }, screen: { row: 0, width: 18.4 }, seats: [ { id: A0501, row: 0, col: 4, type: normal, status: available, group: null }, { id: E0905, row: 4, col: 8, type: love, status: available, group: L0408 }, { id: E0906, row: 4, col: 9, type: love, status: available, group: L0408 }, { id: F0307, row: 5, col: 6, type: wheelchair, status: available, group: null } ] }这里所有尺寸都用「一个座位宽」作为单位 1gapX 0.15表示座间缝是座位宽度的 15%。好处是一套数据能同时喂给 iPhone SE 和 iPad渲染时再乘一个seatSize就行不用后端按机型下发像素值。group字段是情侣座、亲子座这类必须成对售卖的绑定关系后面状态机全靠它。座位状态建议在客户端收敛成一个枚举不要直接用服务端字符串状态含义是否可点视觉建议available可选是描边空心selected已选是取消实心主色sold已售否深灰实心locked他人锁座中否浅灰 斜纹broken维修 / 不可售否灰 虚线love情侣座成对是与普通座区分圆角2.2 坐标换算从行列号到 CGRect换算本身只有一行公式但有几个边界要先定死。行号方向绝大多数数据源第 0 排靠银幕y 向下递增也有院线从后往前编号遇到这种数据加一个isFlipped标记在换算里取反别去改原始数据。原点偏移左右两侧各留一点安全边距避免第一列贴边导致点击热区被裁掉。struct SeatGrid { let rows: Int, cols: Int let seatW: CGFloat, seatH: CGFloat let gapX: CGFloat, gapY: CGFloat let origin: CGPoint var isFlipped: Bool false // 行号从后往前编号的院线 // 一个座位的绘制矩形不含间隙间隙在命中测试里单独判 func rect(row: Int, col: Int) - CGRect { let r isFlipped ? (rows - 1 - row) : row let x origin.x CGFloat(col) * (seatW gapX) let y origin.y CGFloat(r) * (seatH gapY) return CGRect(x: x, y: y, width: seatW, height: seatH) } var contentSize: CGSize { CGSize(width: origin.x * 2 CGFloat(cols) * (seatW gapX) - gapX, height: origin.y * 2 CGFloat(rows) * (seatH gapY) - gapY) } }contentSize一定要减掉最后一个间隙否则右侧会多出一条 15% 座位宽的空白缩放贴边时很明显第一版基本都会漏。另一个细节是seatSize的取值我用min(可用宽度 / (cols 过道占位), 34)再兜一个 22pt 的下限低于 22pt 时不再压缩改为允许横向滚动——手指点不准的座位图画得再好看也没用。2.3 三种渲染方案的取舍与选型座位图的渲染方案我前后试过三种差别在座位数上去以后非常明显方案300 座首帧缩放流畅度说明每座一个 UIView120ms差图层数爆炸圆角触发离屏渲染每座一个 CAShapeLayer60ms 左右一般单层多 path 好一些但仍是 300 个图层单视图 CoreGraphics 绘制15ms 左右好一次 draw 画完缩放只改 transform选第三种。核心思路是「按状态批量填色」先在字典里按状态把座位分组每组拼成一条CGMutablePath一次fillPath()把setFillColor的调用次数从 300 次降到 5 次以内。高亮层单独抽出来因为已选座位最多四个用独立的 CAShapeLayer 做动画抖动或者描边不影响底图。final class SeatCanvasView: UIView { var layout: SeatLayout! // 已解析的座位表 var selected: SetString [] // 存 sellUnit id不存单个 seatId override init(frame: CGRect) { super.init(frame: frame) isOpaque true contentMode .redraw layer.contentsScale UIScreen.main.scale // 缺这行在 3x 屏上描边发虚 } required init?(coder: NSCoder) { fatalError() } override func draw(_ rect: CGRect) { guard let ctx UIGraphicsGetCurrentContext() else { return } ctx.setFillColor(UIColor.systemBackground.cgColor) ctx.fill(bounds) // 按状态分组一组一条 path减少状态切换 for (status, seats) in layout.groupedByStatus(selected: selected) { guard let color status.fillColor else { continue } ctx.setFillColor(color.cgColor) let path CGMutablePath() for seat in seats { path.addPath(seat.outline(in: layout.grid)) // 圆角由 path 生成不用 cornerRadius } ctx.addPath(path) ctx.fillPath() } ctx.setStrokeColor(UIColor.separator.cgColor) ctx.setLineWidth(1 / max(1, window?.screen.scale ?? 1)) // 描边单独一遍避免和填充互相覆盖 for seat in layout.selectableSeats { ctx.addPath(seat.outline(in: layout.grid)) } ctx.strokePath() } }圆角必须用UIBezierPath(roundedRect:cornerRadius:)生成 path不能给图层设cornerRadius。原因很实在cornerRadius在父视图 transform 缩放到 3 倍时会跟着变形圆角变成椭圆而且cornerRadius masksToBounds是典型的离屏渲染组合三百个座位同时挂着滚动时掉帧掉到没法看。描边线宽用1 / scale也是同理否则放大后描边会跟着变粗视觉上和未选中的实心座位糊成一片。底图幕布梯形、过道文字、「银幕方向」提示不参与每帧绘制用UIGraphicsImageRenderer渲成一张图缓存起来只在viewDidLayoutSubviews里尺寸变化时重建。这样缩放时真正重绘的只有座位帧率能稳住。3. 缩放与点击UIScrollView 缩放下命中测试不偏移的做法3.1 手势优先级单击、双击、拖拽的三角关系选座页的手势有三个参与者单击选中座位、双击缩放、拖拽平移。UIKit 默认的处理其实已经够用——单击和 pan 可以共存因为 pan 只有在手指移动超过一定距离后才 begin原地抬手不会触发。我第一版画蛇添足加了一句tap.require(toFail: scrollView.panGestureRecognizer)结果用户滑动座位图之后抬手会顺手选中一个座位投诉量直接上去。真正需要建的依赖只有一条单击要等双击失败。双击放大是选座页的高频操作如果不等用户双击时第一次点击会先选中一个座位再放大交互就乱了。func setupGestures() { let tap UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) scrollView.addGestureRecognizer(tap) let doubleTap UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) doubleTap.numberOfTapsRequired 2 scrollView.addGestureRecognizer(doubleTap) // 只让单击等待双击不要再去 require(toFail:) 那个 panGestureRecognizer tap.require(toFail: doubleTap) scrollView.delegate self scrollView.minimumZoomScale 0.5 scrollView.maximumZoomScale 2.0 scrollView.bouncesZoom true scrollView.showsHorizontalScrollIndicator false } func viewForZooming(in scrollView: UIScrollView) - UIView? { seatCanvas }require(toFail:)会带来大约 0.3 秒的点击延迟这是双击判定的固有代价改不掉。如果产品对选中反馈的即时性要求很高替代做法是把手势挂到seatCanvas上而不是scrollView上并且给选中的座位加一个轻微的缩放 触觉反馈用视觉补偿掉那 0.3 秒的延迟。3.2 缩放锚点与 contentInset 补偿UIScrollView 的zoomScale变化默认以内容中心为锚点双击影厅角落时内容会整体飘走得再手动拖回来。用zoom(to:animated:)指定矩形把点击点算成矩形的中心缩放就落在手指下面。objc func handleDoubleTap(_ g: UITapGestureRecognizer) { if scrollView.zoomScale scrollView.minimumZoomScale * 1.01 { scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) return } let p g.location(in: seatCanvas) let w scrollView.bounds.width / 2.6 let h scrollView.bounds.height / 2.6 // 直接把点击点当作缩放后可视区域的中心 let target CGRect(x: p.x - w / 2, y: p.y - h / 2, width: w, height: h) scrollView.zoom(to: target, animated: true) }最小缩放比例不能写死要按影厅实际宽度算否则 8 排的小厅会缩成一坨20 排的大厅缩完还是超宽。在viewDidLayoutSubviews里重算最稳因为旋转和分屏都会走到这里。override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let fit min(scrollView.bounds.width / seatCanvas.bounds.width, scrollView.bounds.height / seatCanvas.bounds.height) scrollView.minimumZoomScale min(fit, 1.0) scrollView.maximumZoomScale max(fit * 3.0, 1.8) // 布局变化后 zoomScale 可能落在区间外需要夹一次 if scrollView.zoomScale scrollView.minimumZoomScale { scrollView.zoomScale scrollView.minimumZoomScale } } func scrollViewDidZoom(_ sv: UIScrollView) { // 内容比可视区小时居中否则 zoom 完会贴在左上角 let dx max(0, (sv.bounds.width - sv.contentSize.width) / 2) let dy max(0, (sv.bounds.height - sv.contentSize.height) / 2) sv.contentInset UIEdgeInsets(top: dy, left: dx, bottom: dy, right: dx) }contentInset这个补偿看起来不起眼但没有它双击缩小之后座位图会一直粘在左上角用户以为图加载失败了。血泪经验任何用 UIScrollView 做缩放的页面scrollViewDidZoom里都要补这一段。3.3 命中测试的坐标系对齐点击偏移的根因几乎都是坐标系搞混了手势给出了 scrollView 坐标系里的点绘制用的是seatCanvas的坐标系中间隔了contentOffset和zoomScale两层变换。解决办法只有一条——把点convert到 canvas 坐标系之后再算永远不在 scrollView 坐标系里做几何判断。objc func handleTap(_ g: UITapGestureRecognizer) { let p g.location(in: seatCanvas) // 关键直接转成绘制坐标系 guard let seat seat(at: p) else { return } let result viewModel.toggle(seat, limit: 4) handle(result, seat: seat) } func seat(at point: CGPoint) - Seat? { let stepX grid.seatW grid.gapX let stepY grid.seatH grid.gapY let col Int(floor((point.x - grid.origin.x) / stepX)) let row Int(floor((point.y - grid.origin.y) / stepY)) guard row 0, row grid.rows, col 0, col grid.cols else { return nil } // 稀疏表查找是 O(1)不需要遍历全部座位 guard let seat layout.seatAt(row: row, col: col) else { return nil } // 点在座位间隙里不算命中否则相邻两座会互相抢点击 let r grid.rect(row: row, col: col) return r.contains(point) ? seat : nil }先算行列再查稀疏表复杂度是 O(1)比遍历 300 个 rect 稳得多而且天然支持「过道位置点不中」——因为稀疏表里根本没有那个键。最后那句r.contains(point)是「后悔药」去掉它用户点两座之间的缝隙会随机选中左边或右边那个座缩放越大越容易出现而这个问题在模拟器里几乎测不出来真机手指一粗就暴露。4. 选座状态机情侣座、锁座倒计时与最大选座数的业务约束4.1 用 sellUnit 而不是 seat 做状态载体UI 层点的是一个座位业务层卖的却常常是两个。情侣座、亲子座、包厢双人位都属于「售卖单元sellUnit」一个单元对应 1 到 N 个座位且必须整单进出。如果状态只记seatId情侣座被拆开选是迟早的事最后下单接口直接报错。enum SelectionResult { case ok(SeatUnit) case overLimit(current: Int, limit: Int) case rejected(reason: String) } func toggle(_ seat: Seat, limit: Int) - SelectionResult { let unit layout.sellUnit(for: seat) // 普通座返回 1 个情侣座返回 2 个 let ids unit.seats.map(\.id) // 已全选 - 整单取消只要有一个没选就走整单选中 if ids.allSatisfy(selectedUnits.contains) { selectedUnits.subtract(ids) return .ok(unit) } // 数量上限按座位数算不是按售卖单元算 guard selectedUnits.count ids.count limit else { return .overLimit(current: selectedUnits.count, limit: limit) } selectedUnits.formUnion(ids) return .ok(unit) }上限判断的坑在于「按座位数还是按单元数」。用户视角是我最多买四张票所以必须按座位数算选了三个普通座之后再点情侣座3 2 5 4正确行为是拒绝并提示而不是提示完还把半对情侣座塞进去。UI 上我一般会让整个情侣座单元一起做缩放回弹用户一眼就知道这两个是一体的。4.2 锁座与倒计时别用本地 Timer 累加选完座调锁座接口服务端返回lockToken和一个过期时间页面开始十五分钟倒计时。第一版我用Timer每秒remain - 1测试环境一切正常上线后大量用户反馈「切出去接个电话回来倒计时还在走但下单提示座位已释放」。原因很直接App 进后台后Timer会被挂起回到前台它并不补偿跳过的秒数而服务端那边该过期还是过期。正确做法是只存一个截止时刻每次都拿当前时间去减并额外维护一个服务端与本地的时间偏移防止用户手动改系统时间。struct SeatLock { let token: String let deadline: Date // 服务端过期时间戳 serverOffset 换算而来 let serverOffset: TimeInterval // serverNow - localNow建单时算一次 var remain: TimeInterval { max(0, deadline.timeIntervalSinceNow) } var isExpired: Bool { remain 0 } // 前台恢复时立刻重算不做任何累加 func refresh() - Bool { !isExpired } } // 生命周期回调里主动校准一次 func sceneDidBecomeActive() { guard let lock currentLock else { return } if lock.isExpired { viewModel.clearSelection() // 清空选中态并提示座位已释放 reloadSeatStatus() // 拉一次最新座位状态别信本地缓存 } }另外Timer只用来驱动 UI 刷新不要让它承担业务判断。判断永远是remain 0这个纯函数这样单元测试里注入一个假的deadline就能覆盖过期路径不用等十五分钟。4.3 超限提示与交互反馈超过上限时最常见也最没用的反馈是弹一个系统 Alert用户点了确定还得自己去找哪个座位取消了。做法是三步被点的那一个座位做 0.15 秒的横向抖动同时触发一次UINotificationFeedbackGenerator的 warning 触觉顶部用一条不遮挡座位图的浮层提示「最多可选 4 个座位」。抖动动画只作用在高亮层上不影响底图重绘。无障碍这块别省。座位图对 VoiceOver 来说是纯自定义绘制不处理的话整块区域都不可达。我一般给每个售卖单元设一个accessibilityElementaccessibilityLabel拼成「第 5 排 9 座情侣座可选」accessibilityTraits在选中时切到.selected已售的设成.notEnabled。另外屏幕上的热区最小 44pt如果算出来的seatSize只有 26pt就用「矩形外扩 8pt 参与命中但视觉不变」的方式补不要为了让热区达标把座位画大那会直接改变座位图的比例关系。5. 避坑排查选座页上线前后最容易被用户骂的五件事5.1 渲染与坐标类问题现象缩放之后点击位置偏移点 A 选中 B放大倍率越大偏得越多。原因手势回调里用了g.location(in: scrollView)而绘制坐标是seatCanvas的。中间隔着contentOffset和zoomScale未缩放时两者恰好相等所以不缩放测不出来。解决命中测试统一在绘制坐标系里做g.location(in: seatCanvas)一行解决。凡是「不缩放时正常、一缩放就错位」的问题先查坐标系再查锚点。现象座位描边在放大后变粗、圆角变成椭圆3x 屏上还有一圈虚边。原因用layer.cornerRadius masksToBounds做圆角用固定lineWidth描边两者都会随父视图 transform 缩放再加上没设contentsScale位图按 1x 渲染后被放大。解决圆角改成 path 绘制线宽按1 / 当前缩放比动态算contentMode .redraw让缩放结束后触发一次重绘。重绘频率用scrollViewDidEndZooming控制别在didZoom里每帧重画。现象影厅座位数上到 500 以上首次进入白屏接近一秒滑动时明显掉帧。原因每个座位一个 UIView 或 CAShapeLayer图层树过大加上圆角离屏渲染合成开销直接压垮主线程。解决换成单视图 CoreGraphics 批量绘制按状态分组拼 path。我实测过同一个 500 座的影厅从 300 多个图层降到 2 个图层底图 高亮层首帧从 400ms 掉到 20ms 以内。5.2 业务与状态类问题现象情侣座被单独选中一个下单接口报「售卖单元不完整」。原因状态集合里存的是seatId点击处理时只 toggle 了当前座位没有按group展开。解决把toggle的入参从座位改成售卖单元任何状态变更都以单元为最小粒度同时给情侣座设一个合并的绘制路径视觉上就是一个整体用户不会尝试去点其中一半。现象用户从后台切回来倒计时显示还有 6 分钟点下单提示座位已释放。原因Timer在后台被挂起回来继续从暂停处累加和真实施加的时间脱节。解决只存绝对截止时刻前台恢复时重算差值sceneDidBecomeActive和willEnterForeground两处都触发一次状态重拉服务端说没了就清空选中态别用本地缓存自我安慰。6. 连座推荐与还原度验证两个滑动窗口加一套截图基线用户点进选座页十有八九想的是「给我找几个挨着的座」而不是自己一格一格数。连座推荐用滑动窗口就能做得足够好按「距银幕的黄金排数」给行排序从中间排往两边扩散每行按列升序扫一遍遇到不可售或者不满足类型要求的座位就把窗口清空。func recommend(count: Int) - [Seat]? { // 行优先从中间区域开始避免推荐第一排角落 for row in layout.rowsSortedByCenterDistance() { var window: [Seat] [] for seat in layout.seatsInRow(row).sorted(by: { $0.col $1.col }) { let usable (seat.status .available) seat.type ! .wheelchair guard usable else { window.removeAll(); continue } window.append(seat) if window.count count { // 情侣座必须成对奇数结果直接跳过这一窗口 let loveCount window.filter { $0.type .love }.count return loveCount % 2 0 ? window : nil } } } return nil }这段逻辑不复杂但边界全在细节里窗口遇到空隙要整个清空而不是跳过跳过会推荐出中间隔着过道的「假连座」轮椅位不能算进普通连座情侣座的数量必须是偶数。推荐结果不要自动选中而是把目标座位做一次高亮呼吸动画让用户自己点确认——自动选中在锁座计费的场景里是事故源。验证我一般走两条线。第一条是几何验证写单元测试随机生成 1000 个点断言「命中的座位其 rect 必然包含该点」以及「任意点最多命中一个座位」把间隙重叠、边界溢出这类问题在 CI 阶段就挡掉# 只跑几何相关的测试用例几十秒出结果 xcodebuild test \ -project YourApp.xcodeproj \ -scheme YourApp \ -destination platformiOS Simulator,nameiPhone 15 \ -only-testing:YourAppTests/SeatHitTestTests第二条是视觉基线给三档厅型小厅 8 排、中厅 12 排、大厅 20 排各截一张 2x 和 3x 图和设计稿叠一起看像素差重点看三处——座位间隙是否等宽、幕布到第一排的间距比例、缩放最小档下座位是否还能区分出状态。改绘制代码之后这三张图必须重新过一遍否则很容易出现「某个状态下描边被填充盖住」这种肉眼要盯很久才发现的问题。从那以后我每次接选座需求都会先拿 1000 个随机点跑一遍命中测试再开界面写 UI。希望帮到你。本文还有配套的精品资源点击获取