尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
iOS电影选座:UIKit自绘座位图与锁座状态机实战
简介面向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 为什么座位图不用 UICollectionViewUICollectionView 在座位图场景里有三个硬伤。第一是复用池座位图需要整张可见cell 滑出屏幕被回收再复用时你精心做的选中动画会重置用户缩放到边缘再缩回来选中态闪一下。第二是缩放collectionView 的 layout 是按 bounds 排的你在外面套一层 transform 缩放cell 的文字和圆角会跟着糊改用zoomScale重排 layout则每次缩放都要重新计算 800 个 attribute卡。第三是绘制成本每个 cell 至少一个 UIView、一个 layer800 个座位就是 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 选中不是布尔数组是一个状态机很多人把选中状态写成SetInt加一个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: SetInt [] 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用SetInt存座位 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 手动乘除自测全程没出问题上线第二天收到一条反馈说「放大之后点哪都选不中」一查是缩放中心不在原点。从那以后我每次接触任何带缩放的画布都强制先写一个只有三个座位的小厅缩放到极限值逐个点一遍再上真实数据。这个习惯帮我省掉了至少三次线上回滚。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

基于51单片机的车辆出入光电传感管理系统设计与实现

基于51单片机的车辆出入光电传感管理系统设计与实现

1. 项目概述与总体方案设计1.1 我们到底要做一个什么样的系统车辆出入光电传感管理系统,说白了就是用51单片机配合光电传感器,实现车辆的进出检测、方向判断、数量统计和道闸联动控制。这类系统在小区停车场、校园园区、公司门岗、仓储物流入口都很常见&…

📅 2026/10/24 4:40:56
医疗大模型诊断推理战平人类医生?拆解100%一致背后的真相与落地实践

医疗大模型诊断推理战平人类医生?拆解100%一致背后的真相与落地实践

AI诊断首度战平人类医生(100%一致)——这条消息在热搜上挂了一整天。作为这几年一直在跟医疗AI项目打交道的人,我第一反应不是欢呼,而是条件反射地想去找原始论文:到底怎么测的?评测了多少病例?…

📅 2026/10/24 4:35:56
电容原理与选型指南:从“微型充电宝”到电路实战

电容原理与选型指南:从“微型充电宝”到电路实战

平时画板子、调电源、写单片机程序,我接触最多的元件除了电阻就是电容。很多人对电容的理解停留在“滤波”“去耦”这个层面,觉得它只是芯片旁边一颗贴着的安静小豆子。但如果换个视角:电容本质上就是一个“快充快放”的微型充电宝&#xff0…

📅 2026/10/24 4:35:56
MORE NEWS

更多资讯

📰

大模型如何决定“读不读”?从注意力机制到长上下文应用实践

模型在长上下文里是不是真的把每个字都“读”进去了?这个问题我琢磨了很久。正好NIPS 2025上有篇标题很直接的研究,题目叫“Language Models (Mostly) Know When to Stop Reading”,大意是“大语言模型大多数时候知道自己该停在哪”。这篇博文…

📰

DirectShow 虚拟摄像头:.ax 源滤镜与 FillBuffer 实现

简介:DirectShow 实现的 VCAM 虚拟摄像头工程项目,面向 Windows 多媒体开发者,适合需要在不依赖物理摄像头的情况下提供连续视频流的测试、演示与软件集成场景,也可作为学习 DirectShow 滤镜体系的实战样本。压缩包共 154 个文件&…

📰

Vue事件冒泡导致文件上传弹窗两次?用@click.stop彻底解决

1. 事件冒泡为什么会让文件选择框弹两次先说个我实际踩过的场景。某后台管理项目里,我需要做一个卡片式的上传入口:整张卡片可以点击进入详情页,卡片右上角有一个上传按钮。结果上线后测试同事反馈,点一次上传按钮会弹两次文件选择…

📰

基于Arduino与ESP8266的温湿度及PM2.5环境监测系统设计

拿到这个标题,第一反应就是典型的“毕业设计/课程设计热门款”加“个人DIY环境监测入门标配”。但说真的,把它做好做稳并不简单,很多照着网上教程接线的人最后都卡在数据飘、连不上网、读数对不齐这几个地方。我这篇不打算只给一份能用就行的…

📰

Qt节点式视觉工具链:QGraphicsScene流程编辑器源码解析

做视觉项目这些年,我越来越觉得“工具链搭建”和“流程编排”是个绕不开的坎。尤其是做工业检测类的上位机软件,常常需要把相机采集、图像预处理、特征提取、缺陷判定、结果输出这些步骤串起来。最朴素的实现是写死一段流程函数,今天要加一个…

📰

龙软地质建模开源复现:从钻孔数据到三维地层的全流程闭环

简介:龙软地质软件是一款面向地质勘查、矿山设计与采矿工程技术人员的专业工具,聚焦三维地质建模、矿体可视化分析与开采方案辅助设计,有效解决地层结构解析、钻孔数据整合、地质图件绘制及稳定性模拟等核心问题。资源包共121个文件&#xff…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬