简介:面向iOS开发者的“类似猫眼电影选座”完整工程,适合想练习座位图布局、自定义视图与选座交互的中级学习者。内容围绕电影信息区、可滚动座位图、Seat/SeatArray数据模型、SeatView绘制、SMScrollView布局算法及选座状态同步展开,并涉及网络预订、错误提示与动画反馈等常见业务环节,可作为影院购票类功能的学习参照。压缩包共110个文件,约906KB,包含28个png界面与座位素材、13个m实现文件、9个h头文件,以及xcscheme、plist、json、xcconfig等工程配置与资源文件,便于直接打开梳理项目结构。已有555人浏览学习,适合按模块阅读源码、理解自定义选座视图的组织方式并积累类似业务实现思路。
1. 电影选座这项目,难点不在选,而在画和锁
电影选座这类需求,几乎所有票务类 App 都会碰,但真正从零画过一张座位图的人并不多。多数人第一反应是 UICollectionView 铺一堆 cell,本地跑得好好的,一上真机、一进 800 座的大厅就开始掉帧、点偏、缩放发虚。这份 iOS 电影选座项目的价值就在这儿:它把「座位图怎么画、点击怎么判、锁座怎么算、冲突怎么回滚」这条链路完整摊开成一份能跑的工程,不是一个截屏级的 Demo。
它适合三类人:正在接票务或影院类外包、需要快速交付选座模块的开发者;手里有剧场、影院、剧院资源想自己做一套选座页的团队;以及想拿一个真实交互场景练手的中级 iOS。不适合只想调个 CollectionView 就交差的人,那种方案在 500 座以上、要支持缩放和连座推荐的场景里会直接躺平。
2. 项目定位与技术选型:UIKit 自绘座位图,别急着上 SwiftUI
先说结论:这套工程是纯 UIKit,座位图部分完全没有用 UICollectionView,而是一块继承自 UIView 的画布加若干 CAShapeLayer。听起来像回到十年前,但在座位图这个具体场景里,这是最省心的选择。原因后面拆。整个工程的代码组织按「渲染 / 交互 / 状态 / 网络」四层切开,每一层都可以单独替换,这是它能被二次开发的前提。
2.1 四层结构:把「画」和「选」彻底拆开
分层这件事,很多选座 Demo 是不做的,座位 view 里既画颜色又处理点击还顺手发请求,改一个颜色能牵出三个 bug。这份工程的做法是:
SeatMapRenderer只认数据和状态,输出像素,不关心用户点了谁;SeatInteractionController只做手势和命中测试,它拿到的是一个Seat对象,不知道这个座位已经被锁了;SeatSelectionStore持有选中集合和订单阶段,是唯一的状态源;LockService封装锁座、续约、释放三个接口和幂等键。
数据流是单向的:点击 → Interaction 算出 Seat → Store 判定能不能选 → Store 变了 → Renderer 增量刷新对应图层的 path。这条链路最大的好处是,你要换一套配色、换一套锁座协议,改动只落在其中一层。
2.2 为什么座位图不用 UICollectionView
UICollectionView 在座位图场景里有三个硬伤。第一是复用池:座位图需要整张可见,cell 滑出屏幕被回收再复用时,你精心做的选中动画会重置,用户缩放到边缘再缩回来,选中态闪一下。第二是缩放:collectionView 的 layout 是按 bounds 排的,你在外面套一层 transform 缩放,cell 的文字和圆角会跟着糊;改用zoomScale重排 layout,则每次缩放都要重新计算 800 个 attribute,卡。第三是绘制成本:每个 cell 至少一个 UIView、一个 layer,800 个座位就是 800 个图层树节点,hitTest一次要走一遍树。
CAShapeLayer 的方案反过来:所有同状态的座位合并成一条UIBezierPath,交给一个 layer 渲染。四个状态就是四个 layer,八百个座位还是四个图层节点。点击命中用自己维护的网格索引,跟图层树彻底解耦。缩放时对 layer 的transform做缩放,矢量路径不会糊。这套做法在 1000 座以内基本无压力。
代价也有:单个座位的动画(比如选中时弹一下)得自己用CAShapeLayer的 path 动画或额外的临时 layer 做,没有 cell 那么顺手。这是个明确的取舍,换来的是稳定。
2.3 目录结构逐层对照
工程的目录长这样,按职责划分而不是按类型堆在一起:
SeatBooking/ ├── Models/ # 纯数据,不 import UIKit │ ├── HallLayout.swift # 影厅布局:画布尺寸、银幕区域、座位数组 │ ├── Seat.swift # 座位模型与状态枚举 │ └── SeatSelection.swift # 选中集合 + 订单阶段状态机 ├── Rendering/ │ ├── SeatMapCanvasView.swift # 画布容器,负责坐标系与 scale │ ├── SeatMapLayer.swift # 四个状态图层 + path 重建 │ └── SeatTheme.swift # 颜色、圆角、字号的集中配置 ├── Interaction/ │ ├── SeatHitIndex.swift # 网格索引,O(1) 命中 │ └── SeatMapGestureHandler.swift # 单击/双击/缩放手势编排 ├── Services/ │ ├── LockService.swift # 锁座、心跳续约、释放 │ └── SeatMapRepository.swift # 拉影厅布局(含本地缓存兜底) ├── Features/ │ ├── SeatSelectionViewController.swift │ └── SeatLockCountdown.swift # 基于 uptime 的倒计时 └── Resources/ └── hall_sample.json # 一份 320 座的样例影厅数据Models不 import UIKit,是为了能在单元测试和后台数据校验里直接复用。Rendering和Interaction之间没有引用关系,两者只通过 ViewController 传话。Resources/hall_sample.json是二次开发时最该先看的东西,换影厅就是换这份 json。
3. 座位图渲染:800 个座位画进一个可缩放画布
渲染这块的核心就一句话:把座位图当成矢量图来画,不当成控件集合。下面从数据结构开始,一路写到手势。
3.1 数据模型与影厅坐标系
座位不用「第几排第几列」推导位置,而是直接给绝对坐标。这不是偷懒,是被真实影厅逼出来的:情侣座宽度是普通座的 1.6 倍,无障碍区中间有个缺口,IMAX 厅的排是弧形排布,靠row * rowHeight根本算不出来。
// HallLayout.json 对应的结构 struct HallLayout: Codable { let hallId: String // 影厅 ID,换厅就换这份数据 let canvasWidth: CGFloat // 影厅坐标系宽度,单位点,比如 720 let canvasHeight: CGFloat let screenRect: CGRect // 银幕位置,用来画顶部那条弧线 let seats: [Seat] } struct Seat: Codable, Hashable { let id: Int // 服务端下发的唯一 ID,锁座接口全靠它 let rowLabel: String // "7" 或 "A",直接来自数据,别本地拼 let col: Int // 列号,只用于连座判断 let frame: CGRect // 影厅坐标系下的座位矩形 let type: SeatType var status: SeatStatus } enum SeatType: String, Codable { case normal, couple, wheelchair, blocked } enum SeatStatus: String, Codable { case available, sold, locked, broken }rowLabel用字符串而不是 Int,踩过坑才知道为什么:有的厅排号是 A、B、C,有的是 1、2、3,还有的跳过 4 和 13。本地做数字转换,遇到跳过排号的厅就会把连座推荐的「相邻排」判断搞错。让数据下发字符串,本地只做展示和分组,不出错。
坐标系约定为「画布坐标」,即左上角原点、单位点,跟设备的 screen 坐标无关。这样做的好处是所有缩放、内边距都由最外层的 UIScrollView 处理,座位数据本身永远是稳定的,换设备、换屏幕尺寸都不用改 json。
3.2 用 CAShapeLayer 批量绘制
绘制层的职责很窄:拿一份 layout 和一份状态覆盖表,重建 path。状态覆盖表是关键,服务端推送的实时座位状态不写回原始 layout,而是单独存一个[Int: SeatStatus],这样重新拉取布局和实时状态互不干扰。
final class SeatMapLayer: CALayer { private var statusLayers: [SeatStatus: CAShapeLayer] = [:] private var layout: HallLayout? private var statusOverride: [Int: SeatStatus] = [:] override init() { super.init() // 关键:不设这一行,真机上路径边缘会发虚,尤其是放大之后 contentsScale = UIScreen.main.scale for status in [SeatStatus.available, .sold, .locked, .broken] { let layer = CAShapeLayer() layer.contentsScale = UIScreen.main.scale layer.lineWidth = 1 layer.fillColor = SeatTheme.fill(for: status).cgColor layer.strokeColor = SeatTheme.stroke(for: status).cgColor addSublayer(layer) statusLayers[status] = layer } } // 首次绑定,或换厅时调用 func bind(_ layout: HallLayout) { self.layout = layout frame = CGRect(x: 0, y: 0, width: layout.canvasWidth, height: layout.canvasHeight) rebuildAll() } // 只改状态时走这里,不做全量重排 func apply(_ changes: [Int: SeatStatus]) { changes.forEach { statusOverride[$0.key] = $0.value } rebuildAll() } private func rebuildAll() { guard let layout else { return } var paths: [SeatStatus: UIBezierPath] = [:] for seat in layout.seats { let status = statusOverride[seat.id] ?? seat.status let path = paths[status, default: UIBezierPath()] // 圆角取短边的 0.18 倍,视觉上接近主流票务 App 的方块 let radius = min(seat.frame.width, seat.frame.height) * 0.18 path.append(UIBezierPath(roundedRect: seat.frame, cornerRadius: radius)) paths[status] = path } for (status, layer) in statusLayers { layer.path = paths[status]?.cgPath // 没有该状态的座位时给 nil,图层自动空 } } }逻辑说明:一个CAShapeLayer只能有一种填充色,所以按状态分成四层,每种状态一条合并 path。八百个座位最终只有四条 path、四个图层。apply(_:)全量重建 path 在 1000 座以内是毫秒级的,不需要做增量 diff;真要优化到万级,再按行拆 layer 分块重建。
参数说明:lineWidth = 1是描边宽度,配合contentsScale才清晰;圆角系数 0.18 是视觉参数,改它不影响逻辑;fillColor和strokeColor全部集中在SeatTheme,二次开发要换配色只改那一个文件。
3.3 命中测试:网格索引代替 hitTest
座位图不参与图层树的 hitTest,点击位置直接查自己维护的索引。做法是把画布切成 48 点见方的格子,一个座位会被塞进它覆盖到的所有格子,查询时算出点落在哪个格子,取该格子里的座位做一次contains判断。
final class SeatHitIndex { private let cellSize: CGFloat = 48 // 约等于一个普通座位的宽度 private var buckets: [Int64: [Seat]] = [:] init(seats: [Seat]) { // 面积小的排前面:情侣座和普通座重叠时,优先命中普通座 let sorted = seats.sorted { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height } for seat in sorted { for key in coveredKeys(seat.frame) { buckets[key, default: []].append(seat) } } } private func key(_ cx: Int, _ cy: Int) -> Int64 { (Int64(cx) << 32) | Int64(UInt32(bitPattern: Int32(cy))) } private func coveredKeys(_ rect: CGRect) -> [Int64] { let c = cellSize let x0 = Int(floor(rect.minX / c)), x1 = Int(floor(rect.maxX / c)) let y0 = Int(floor(rect.minY / c)), y1 = Int(floor(rect.maxY / c)) var keys: [Int64] = [] for x in x0...x1 { for y in y0...y1 { keys.append(key(x, y)) } } return keys } func seat(at point: CGPoint) -> Seat? { let k = key(Int(floor(point.x / cellSize)), Int(floor(point.y / cellSize))) return buckets[k]?.first { $0.frame.contains(point) } } }逻辑说明:一个座位可能横跨 1 到 4 个格子(座位宽高都远小于 48 时只占一格),所以插入时遍历覆盖的所有格子。查询时只取目标格子那一小撮座位线性判断,桶内元素通常不超过 6 个,实际是常数时间。
参数说明:cellSize太小会让座位横跨太多格子、内存变多;太大又让桶内元素过多退化成线性扫描。48 点是拿普通座位宽度(约 40 点)做基准估出来的,如果你们的厅座位特别大或特别密,按实际座位宽度的中位数取。
这套索引还有一个附带好处:它跟缩放完全解耦。手势拿到的是画布坐标系里的点(下一步会讲),索引也只认画布坐标,缩放多少倍都不影响命中结果。
3.4 缩放、惯性滑动与单击的手势共存
三种手势:单击选座、双指缩放、单指拖动。最省事的编排是让 UIScrollView 管缩放和拖动,自己只加一个 tap。
final class SeatMapCanvasView: UIView, UIScrollViewDelegate { private let scrollView = UIScrollView() private let mapLayer = SeatMapLayer() private var hitIndex: SeatHitIndex? var onSeatPicked: ((Seat) -> Void)? private func setupGestures() { scrollView.delegate = self scrollView.minimumZoomScale = 0.6 scrollView.maximumZoomScale = 2.5 scrollView.bouncesZoom = true scrollView.showsHorizontalScrollIndicator = false scrollView.decelerationRate = .fast // 惯性滑动别太飘 let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.delaysTouchesEnded = false scrollView.addGestureRecognizer(tap) } @objc private func handleTap(_ g: UITapGestureRecognizer) { // 直接换算到画布坐标系:缩放和偏移都由 UIKit 算掉了 let point = g.location(in: mapLayer) guard let seat = hitIndex?.seat(at: point) else { return } onSeatPicked?(seat) } func viewForZooming(in scrollView: UIScrollView) -> UIView? { mapLayer } }逻辑说明:g.location(in: mapLayer)这一句是整个手势方案的命门。mapLayer的transform随缩放变化,UIKit 在换算时会自动把 zoomScale 和 contentOffset 一起算进去,所以你拿到的点天然就是画布坐标。手动用point.x / zoomScale去算,在缩放中心不在原点时会整体偏掉,这是选座模块最高频的翻车点之一。
参数说明:minimumZoomScale = 0.6保证小厅也能铺满,maximumZoomScale = 2.5够看清情侣座文字;decelerationRate = .fast让长图拖动不至于滑太久。如果要做双击放大,别自己算 transform,用scrollView.setZoomScale(2.0, animated: true)配合zoom(to:animated:),同时给 tap 加tap.require(toFail: doubleTap),否则双击的第一下会被当成选座。
4. 选座交互与订单状态机:连座推荐、锁座倒计时、并发冲突
渲染解决的是「看得对」,这一层解决的是「选得对」。票务场景里,用户点了不算数,服务端锁住了才算数,这中间的十几分钟是最容易出状态 bug 的地方。
4.1 选中不是布尔数组,是一个状态机
很多人把选中状态写成Set<Int>加一个isLocked布尔量,然后在各种回调里手动改这两个值。结果就是:锁座请求还没回来用户又点了两下,超时回调把已经支付的订单给释放了。正确的做法是把「阶段」和「选中集合」绑在一起,只允许合法的边。
enum SelectionPhase { case browsing // 自由点选 case locking // 锁座请求飞行中,禁止再点 case locked(deadline: Date) // 锁座成功,倒计时中 case paying // 拉起支付,禁止修改座位 case releasing // 释放请求飞行中 } struct SeatSelection { private(set) var phase: SelectionPhase = .browsing private(set) var selected: Set<Int> = [] private(set) var lockToken: String? let maxCount = 4 // 由影院配置下发,通常 4 或 6 mutating func toggle(_ seat: Seat) -> Bool { // 只有 browsing 阶段允许改选,其他阶段一律忽略点击 guard case .browsing = phase else { return false } guard seat.status == .available else { return false } if selected.contains(seat.id) { selected.remove(seat.id) } else { guard selected.count < maxCount else { return false } selected.insert(seat.id) } return true } mutating func beginLocking() { phase = .locking } mutating func markLocked(token: String, deadline: Date) { lockToken = token phase = .locked(deadline: deadline) } }逻辑说明:toggle在非browsing阶段直接返回 false,这一条就堵住了「锁座飞行中重复点选」和「支付中改座」两类 bug。阶段推进只能通过beginLocking、markLocked这些显式方法,调用点集中,日志好埋。
参数说明:maxCount不是写死的常量而是从影厅配置读,不同影院限制不同;selected用Set<Int>存座位 ID 而不是 Seat 对象,避免状态覆盖后对象引用不一致。
4.2 连座推荐:滑动窗口加一个评分函数
「帮我选两个连座」这个按钮,用户期望的不是随便两个空位,而是尽量靠中间、靠后的位置。实现是先在每排内做滑动窗口,再给候选窗口打分。
struct RecommendEngine { func best(count: Int, seats: [Seat], canvasWidth: CGFloat) -> [Seat]? { let centerX = canvasWidth / 2 var bestScore = -Double.infinity var bestSeats: [Seat]? let rows = Dictionary(grouping: seats, by: \.rowLabel) for (_, row) in rows { let sorted = row.sorted { $0.col < $1.col } guard sorted.count >= count else { continue } for i in 0...(sorted.count - count) { let window = Array(sorted[i..<i + count]) // 全部可选、同类型、列号连续,三个条件缺一不可 guard window.allSatisfy({ $0.status == .available && $0.type == .normal }) else { continue } guard isConsecutive(window) else { continue } let score = score(window, centerX: centerX, totalRows: rows.count) if score > bestScore { bestScore = score; bestSeats = window } } } return bestSeats } private func isConsecutive(_ window: [Seat]) -> Bool { zip(window, window.dropFirst()).allSatisfy { $1.col == $0.col + 1 } } // 横向越靠近中轴越好,纵向越靠后越好(排号越大视野越好) private func score(_ window: [Seat], centerX: CGFloat, totalRows: Int) -> Double { let avgX = window.map { $0.frame.midX }.reduce(0, +) / CGFloat(window.count) let drift = abs(avgX - centerX) / centerX // 0 最中间,1 最偏 let rowIndex = Double(window.first?.frame.midY ?? 0) / 600.0 return -drift * 0.7 + rowIndex * 0.3 } }逻辑说明:列号连续是关键判断,不能只看「有几个空位」,否则会把中间隔了过道或隔了已售座位的两个位置当成连座。评分函数里横向权重 0.7 高于纵向 0.3,是因为用户对「偏」的敏感度远高于「前后」。
参数说明:rowIndex用frame.midY / 600归一化,600 是画布高度的经验值,换成实际canvasHeight更稳;两个权重系数是产品口味参数,放到配置里更好。
4.3 锁座倒计时:锚点用 uptime,别信设备时间
票务选座最恶心的一条是用户改系统时间骗倒计时。解决方案是不用Date()算剩余,改用单调时钟,以服务端返回的「剩余秒数」为锚点。
final class SeatLockCountdown { private var timer: Timer? private var deadlineUptime: TimeInterval = 0 var onTick: ((Int) -> Void)? var onExpired: (() -> Void)? func start(serverRemaining: TimeInterval) { // uptime 不受用户改系统时间影响,且设备重启后归零,天然不会算错 deadlineUptime = ProcessInfo.processInfo.systemUptime + serverRemaining timer?.invalidate() let t = Timer(timeInterval: 1, repeats: true) { [weak self] _ in self?.tick() } // .common 模式,否则列表一滚动计时器就被 RunLoop 挂起 RunLoop.main.add(t, forMode: .common) timer = t } private func tick() { let left = deadlineUptime - ProcessInfo.processInfo.systemUptime guard left > 0 else { timer?.invalidate(); timer = nil onExpired?() return } onTick?(Int(left.rounded(.up))) } }逻辑说明:systemUptime是开机以来的秒数,改系统时间不动它。服务端下发剩余秒数的同时还会下发一个 lockToken,续约和释放都要带这个 token,超时后服务端自己会把座位放回池子,所以本地倒计时只是「体验层」,真正的一致性由服务端兜底。
参数说明:serverRemaining是锁座接口返回的剩余秒数(通常 900 秒);倒计时到 0 触发onExpired,此时应该把选中集合清空并拉一次最新座位状态,而不是本地直接把座位标成可用。
4.4 并发冲突:409 回来之后怎么收场
同一个座位,你和别人同时点,谁先到服务端谁赢,输的那个会收到 409 和一份冲突座位列表。这里的处理质量直接决定用户是「换个座位继续下单」还是「关掉 App」。
func handleLockResult(_ result: LockResult) { switch result { case .success(let token, let remaining): selection.markLocked(token: token, deadline: Date().addingTimeInterval(remaining)) countdown.start(serverRemaining: remaining) case .conflict(let seats, let remaining): // 冲突座位标成已售,其余选择保留,让用户少点一次 seats.forEach { renderer.apply([$0: .sold]) } selection.drop(seats) toast("有 \(seats.count) 个座位刚被别人选走了,换个位置试试") case .expired: selection.reset() countdown.stop() reloadLayout() case .soldOut: reloadLayout() } }逻辑说明:conflict分支不重置整个选择,只剔除冲突的那几个,这是留存率的关键细节。expired才需要全局重拉,因为此时本地状态可能整体过期。每个请求都带Idempotency-Key,值为座位 ID 排序后的哈希加设备标识加时间窗,重试不会重复下单。
参数说明:锁座接口建议约定三个:POST /lock(入参座位 ID 数组,返回 token 和剩余秒数)、POST /lock/heartbeat(每 30 秒续一次)、POST /lock/release(主动释放)。续约失败超过两次就当过期处理。
5. 避坑与排查:真机上的五个翻车现场
模拟器上一片祥和、真机上满地狼藉,是选座模块的常态。下面五条都是这份工程里真实存在过、或者社区里反复被问的问题。
5.1 座位图放大后边缘发虚、像贴了层毛玻璃
现象:模拟器上圆角干净利落,真机放大到 2 倍以上,座位边缘出现明显锯齿和灰边。原因:CAShapeLayer是矢量绘制,但它最终栅格化到屏幕时依赖contentsScale,默认值是 1.0,等于按 1x 渲染再拉伸。解决:给每一个参与绘制的 layer(包括父 layer)设contentsScale = UIScreen.main.scale,外接显示器或分屏场景还要跟随traitCollection.displayScale变化重新设一遍。另一种同源问题是有人图省事用UIGraphicsImageRenderer把整张座位图截成 Image 再显示,那个方案缩放必然糊,直接放弃。
5.2 手快的人一个座位能下两单
现象:用户连点两下座位,客户端发了两次锁座请求,服务端收到两个不同 token,座位被占了两份,退款流程都能写一天。原因:按钮没有在请求飞行中禁用,且请求本身不带幂等键。解决:一是状态机层面拦截,phase进入locking后toggle直接返回 false;二是网络层加Idempotency-Key,服务端按 key 去重,重复请求返回同一个 token;三是 UI 上加一个全屏遮罩或 loading,别让用户以为没点着。
5.3 倒计时走着走着不走了,一滚动就卡
现象:锁座倒计时在页面静止时正常,用户一开始拖座位图,秒数就停在某个数字不动,回到静止又跳回正确的值。原因:Timer默认加在RunLoop.Mode.default上,UIScrollView 拖动时 RunLoop 切到tracking模式,default 模式下的 timer 不触发。解决:RunLoop.main.add(timer, forMode: .common),common 包含 tracking。同一个坑还会出现在用performSelector(afterDelay:)做超时提示的场景,一并换掉。
5.4 点在座位边缘,选中的却是隔壁
现象:用户明确点在 A 座右下角,结果 B 座被选上。原因:命中测试里做了坐标换算但算错,典型写法是point.x / scrollView.zoomScale,而忽略了contentOffset和缩放中心不是原点的情况。解决:别自己算,用gesture.location(in: canvasView)让 UIKit 处理;同时检查SeatHitIndex的cellSize是不是比座位还小,太小时一个座位跨四五个格子,插入没问题,但索引构造里如果对coveredKeys去重不当会重复命中。
5.5 大一点的厅内存飙到两百兆,滑起来掉帧
现象:300 座以内流畅,换到 800 座的 IMAX 厅,内存翻倍,拖动有明显掉帧。原因:早期版本每个座位一个 UIView,加了阴影和圆角,还各自带一个 UILabel。解决:全部改成 path 合并绘制,文字只在缩放比例大于 1.5 时按需渲染(用一个 CATextLayer 承载可见区域的排号),阴影换成描边。改完之后 1000 座的内存占用和 100 座持平,因为图层数量没变。
6. 二次开发:换掉影厅数据之后怎么验证没改坏
接手这个工程,第一件事大概率是换成自己影院的座位数据。三个接入点必须一起改,漏一个就跑不起来。
第一个接入点是hall_sample.json,字段结构对齐HallLayout即可,canvasWidth别拍脑袋写,按最长一排的座位总宽加两侧过道留白算,否则初始缩放会溢出或留大片空白。第二个是SeatTheme,把 available、sold、locked 三种状态的填充和描边换掉,情侣座和普通座的差异如果你们有,就在SeatType里加 case 再扩 theme。第三个是LockService里的三个接口路径和字段名,如果后端返回的 token 字段叫lockId,改解码结构比改业务代码便宜。
// 换成自己的接口,只动解码层 struct LockResponse: Decodable { let lockId: String // 后端叫 lockId,本地仍叫 lockToken let expireIn: Int // 秒 let conflictSeats: [Int]? // 409 时才有 enum CodingKeys: String, CodingKey { case lockId, expireIn, conflictSeats } }改完之后按下面这张表过一遍,基本能把常见问题筛掉:
| 验证项 | 操作 | 预期 |
|---|---|---|
| 极限座位数 | 加载 800 座以上厅,快速拖动 | 无掉帧,内存平稳 |
| 缩放边界 | 缩到 0.6 倍、放到 2.5 倍反复 | 边缘清晰,命中不偏 |
| 并发冲突 | 两台设备同点一个座 | 后到者收到提示并保留其余选中 |
| 倒计时 | 锁座后拖动座位图 | 秒数持续递减,不卡顿 |
| 时间篡改 | 锁座后修改系统时间 | 倒计时不受影响 |
| 弱网 | 用网络限速工具把锁座延迟调到 3 秒 | 按钮置灰,不重复请求 |
还有一个改造技巧值得说:如果你们要做「影院全场座位热力图」这类分析功能,别在渲染层加逻辑,直接在SeatMapLayer外面套一个CALayer,把选中率映射到颜色,用同一套Seat.frame画半透明色块盖上去。渲染层只管画,业务指标从外面叠,这条边界守住,后面加什么可视化都不用动核心代码。
我最早做选座的时候,图省事把坐标换算写在手势回调里,用 zoomScale 手动乘除,自测全程没出问题,上线第二天收到一条反馈说「放大之后点哪都选不中」,一查是缩放中心不在原点。从那以后我每次接触任何带缩放的画布,都强制先写一个只有三个座位的小厅,缩放到极限值逐个点一遍,再上真实数据。这个习惯帮我省掉了至少三次线上回滚。希望帮到你。
本文还有配套的精品资源,点击获取