简介:一份iOS电影选座功能实现源码包,面向需要开发票务类或影院App选座模块的开发者,以类似猫眼App的选座流程为蓝本,完整覆盖界面布局、数据管理、座位交互与网络同步等环节。项目使用自定义滚动视图承载座位图,通过座位数据模型管理可选、已选、禁选状态;自定义座位视图负责点选手势、连坐限制和选中高亮等反馈。资源共110个文件,压缩包仅约906KB,包含28张界面图片、13个m源码、9个头文件、12个工程配置方案、9个属性列表及5个json数据文件,另有依赖管理、编译配置、脚本等辅助内容。已有555人前来学习。深入阅读这套代码,可重点理解滚动座位图的绘制与适配、多场次座位占用冲突处理,以及通过Core Graphics自绘座位样式的实现技巧,同时学习工程结构组织方式,对提升自定义控件与交互设计能力很有帮助。1. 类似猫眼电影选座(iOS):把一张座位图做成完整的闭环做过票务类 App 的人都知道,选座页是全链路里最容易翻车、也最影响转化的页面。用户从场次点进来,真正关心的其实只有三件事:哪些座位能选、能不能连座、价格怎么算,任何一个环节卡住都可能丢单。这个项目就是一套参考猫眼选座交互的 iOS 完整实现,从座位图渲染、点击选座、价格联动、限选规则到生成下单参数,整条链路都真实跑得通。它适合两类人:一类是想搞懂自定义视图绘制、手势命中与状态管理的 iOS 开发者;另一类是产品原型期需要先交出一个可点可选选座 Demo、不等后端接口就能给业务方演示的开发者。工程不依赖任何第三方库,纯 Swift + UIKit 手写,拿到后直接在 Xcode 里编译运行,没有任何中间环节。2. 技术选型与工程结构:为什么是纯 UIKit,而不是 SwiftUI2.1 Swift + UIKit:选座场景对视图层的要求先回答一个大概率会被问的问题:都做到这个年头了,为什么不用 SwiftUI?我的看法是,选座页本质上是一张「可以缩放、可以平移、上面散落着几百个可独立变化状态的小部件」的画布。SwiftUI 做列表和表单确实顺手,但一旦涉及 UIScrollView 缩放、精确命中区域、以及高频的选中态切换,UIKit 的显式视图树反而更好控制——每个座位就是一个独立的 UIControl,点谁谁响应,系统帮你把命中测试都处理完了。这个 Demo 的架构是轻量 MVVM,没有引入 RxSwift 之类的响应式框架,而是用「ViewModel 暴露方法、View 通过闭包回调事件」的经典写法。这样做的原因有三个:第一,座位状态变化是典型的多视图联动——点一个座位,顶部提示文案、底部价格条、座位图本身要同时刷新,状态集中到 ViewModel 里可以避免控制器里塞满 if;第二,后续接真实接口时,只需要把 SeatService 的 mock 数据源换掉,View 层一行都不用动;第三,ViewModel 不依赖 UIKit,可以直接写单元测试验证选座规则,这在票务这种规则三天两头变的业务里非常值钱。2.2 工程目录:每个文件负责一件事工程按功能拆分,不是按层拆分,找代码的时候顺着页面走就行:SeatDemo/ ├── App/ │ ├── AppDelegate.swift # 启动入口,创建窗口 │ └── SceneDelegate.swift ├── Models/ │ ├── Movie.swift # 影片信息 │ ├── Showtime.swift # 场次与影厅维度 │ ├── Hall.swift # 影厅座位矩阵描述 │ ├── Seat.swift # 座位模型与状态枚举 │ └── Order.swift # 订单草稿与下单参数 ├── ViewModels/ │ └── SeatSelectionViewModel.swift # 选座规则、选中集合、金额计算 ├── Views/ │ ├── SeatMapView.swift # 座位图画布,负责铺座与缩放 │ ├── SeatButton.swift # 单个座位按钮,渲染与命中 │ ├── SeatLegendView.swift # 图例:可选/已售/选中/过道 │ ├── PriceBarView.swift # 底部价格条与确认按钮 │ └── FilmStripView.swift # 顶部影片与场次切换条 ├── ViewControllers/ │ └── SeatSelectionViewController.swift # 页面装配与回调绑定 ├── Services/ │ └── SeatService.swift # mock 数据源,模拟接口延迟 └── Resources/ └── seat_theme.json # 配色与价格分区配置简单过一遍职责:SeatService 是数据源的黑匣子边界,内部用 DispatchQueue.asyncAfter 模拟 300ms 网络延迟,返回场次、影厅和座位数组;SeatMapView 只做两件事——根据座位矩阵生成按钮、把点击事件通过闭包抛出去,自己不保存任何业务状态;SeatSelectionViewModel 持有座位矩阵和选中集合,所有规则都在这里;ViewController 只负责把三者串起来,同时处理弹窗、loading 这类 UI 细节。这种分层方式最直接的好处是出问题时能快速定位:按钮显示不对查 View,规则不对查 ViewModel,数据不对查 Service。关键类职责对外接口SeatMapView渲染座位矩阵、缩放平移容器configure(with:)、onSeatTap 闭包SeatButton单座按钮皮肤与命中外抛seat 属性、isSelected 刷新SeatSelectionViewModel限选、取消、金额、快照toggle(_:)、selectedSeatIds、totalAmountSeatServicemock 影厅与座位数据fetchSeats(showtimeId:completion:)SeatSelectionViewController视图装配、提交订单confirmTapped()、showOrderConfirm(_:)这个表基本就是二次开发的索引:想改选座规则,直接看 ViewModel;想改座位长什么样,进 Views 目录;想接真实数据,只动 SeatService。3. 核心模块拆解:座位模型、渲染与命中测试3.1 座位数据模型:状态为什么不用一个 Bool座位模型是整个页面的地基,先看它长什么样:enum SeatStatus: Int, Codable { case sold = 0 // 已售出,灰色不可点 case available = 1 // 可售,可点击 case aisle = 2 // 过道,排内占位,不渲染 case locked = 3 // 他人锁定,几分钟内未支付 } struct Seat: Codable, Equatable { let seatId: String // 唯一标识,规则:排号_列号,如 "A_12" let rowNo: Int // 排号索引,从 1 开始 let colNo: Int // 列号索引,从 1 开始 let zone: Int // 票价分区:1 普通区 / 2 黄金区 / 3 情侣座区 let price: Int // 单座价格,单位是分,避免浮点误差 let status: SeatStatus // 服务端返回的实时状态 }逻辑说明:状态用枚举而不是 Bool 的原因很实际——「不能选」至少分三种情况:已售完、过道、别人正在锁定。如果只用一个 isAvailable: Bool,过道座和已售座就没法区分,渲染时要么错位,要么会多画出一排空洞。价格用 Int 分而不是 Double 元,是票务项目最常规的做法,金额计算只做整数加减,不会出现 0.1 + 0.2 这种经典型的精度坑。参数说明:seatId 用排号加列号拼接,主要是为了排查问题方便——用户反馈「第 6 排最左边点不动」,看日志里打印的 seatId 立刻能对上位置。zone 字段是给后续做分区配色预留的,当前版本只在图例和价格条里体现,后面要做「点击某分区高亮所有同价座位」,这个字段就是现成的索引。3.2 渲染:375 设计稿铺座,用比例缩放适配机型SeatMapView 用 UIScrollView 包一个容器视图,所有座位都铺在容器上,布局核心就一段:/// 座位尺寸与间距,按 375pt 宽设计稿定稿 private let seatSize = CGSize(width: 36, height: 36) private let hGap: CGFloat = 8 private let vGap: CGFloat = 14 private let designBaseWidth: CGFloat = 375 private func layoutSeats(on container: UIView) { var contentHeight: CGFloat = 0 for (rowIndex, row) in matrix.enumerated() { let y = CGFloat(rowIndex) * (seatSize.height + vGap) + topPadding for (colIndex, seat) in row.enumerated() { guard seat.status != .aisle else { continue } // 过道不落按钮 let zoneOffset = CGFloat(seat.zone - 1) * 6 // 分区错位 6pt let x = CGFloat(colIndex) * (seatSize.width + hGap) + zoneOffset let button = SeatButton(type: .custom) button.frame = CGRect(x: x, y: y, width: seatSize.width, height: seatSize.height) button.seat = seat button.addTarget(self, action: #selector(seatTapped(_:)), for: .touchUpInside) container.addSubview(button) } contentHeight = y + seatSize.height } container.frame.size = CGSize(width: designBaseWidth, height: contentHeight) } /// 不同屏幕用等比缩放,避免小屏重叠、大屏被拉伸成椭圆 func applyScreenScale(to container: UIView) { let scale = UIScreen.main.bounds.width / designBaseWidth container.transform = CGAffineTransform(scaleX: scale, y: scale) scrollView.contentSize = CGSize(width: designBaseWidth * scale, height: container.frame.height * scale) }逻辑说明:布局用「行索引 ×(座位高 + 行距)」算 y,用「列索引 ×(座位宽 + 列距)」算 x,是最朴素的网格铺法。zoneOffset 让不同价位的分区在视觉上错开一点,模拟真实影厅里分区之间有走道的感觉。按钮统一用 .custom 类型,因为系统默认按钮自带高亮背景和点击态,会影响自定义座位皮肤。参数说明:36pt 的座位在 375pt 宽屏幕上大约可以放 8 个带间距,竖屏场景下影厅宽度刚好够;vGap 14pt 比水平间距大一圈,是为了在视觉上明确区分「排」和「列」。整体 scale 用的是「屏幕宽 / 设计稿宽」,等比缩放而不是逐点拉伸,避免在 6.7 寸大屏上座位被拉成椭圆,这是选座页最常见的变形翻车原因。3.3 命中测试:点到的到底是哪个座位每个座位都是独立的 UIControl,命中测试直接交给系统,这是 UIKit 做选座页一个隐性好处。按钮实现:final class SeatButton: UIButton { var seat: Seat? // 绑定的座位数据,点击时回抛给外部 override var isSelected: Bool { didSet { refreshAppearance() } } private func refreshAppearance() { guard let seat else { return } switch (seat.status, isSelected) { case (.available, false): setBackgroundImage(UIImage(named: "seat_normal"), for: .normal) case (.available, true), (.locked, true): setBackgroundImage(UIImage(named: "seat_selected"), for: .normal) case (.sold, _): setBackgroundImage(UIImage(named: "seat_sold"), for: .normal) isUserInteractionEnabled = false default: break } accessibilityLabel = "\(seat.rowNo)排\(seat.colNo)号座位" } }逻辑说明:SeatButton 持有 seat 数据,外观由「服务端状态 + 本地选中态」共同决定。isSelected 是 UIButton 自带的属性,选中变化时触发 didSet 刷新图片,ViewModel 只要切换选中集合,按钮外观自动跟着变,不需要手动 setImage,这也是把状态收敛到 ViewModel 带来的连锁收益。参数说明:isUserInteractionEnabled 置 false 的座位会直接从响应链里摘出去,连高亮动画都不会触发,这是处理已售座位的正确姿势,而不是在回调里加 if 判断。顺手把 accessibilityLabel 做了,VoiceOver 用户能听到「第几排第几号」,这类辅助功能字段看起来不起眼,等 App 走审核或合规检查时就是硬指标。4. 选座状态流转:ViewModel 怎么管住几百个座位4.1 选座规则:限选、重复点击与金额联动选座交互的规则不多,但每一条都直接影响用户体感。核心逻辑全部在 ViewModel 里:struct SeatSelectionViewModel { let matrix: [[Seat]] // 服务端返回的原始座位矩阵 let maxSeats: Int // 单笔订单最多可选座位数 private(set) var selected: [Seat] = [] // 本地选中集合,按点击顺序 mutating func toggle(_ seat: Seat) -> Result<[Seat], SeatError> { guard seat.status == .available else { return .failure(.notAvailable) } if let index = selected.firstIndex(where: { $0.seatId == seat.seatId }) { selected.remove(at: index) // 再点一次取消选中 return .success(selected) } guard selected.count < maxSeats else { return .failure(.exceedLimit) } selected.append(seat) return .success(selected) } var totalAmount: Int { selected.reduce(0) { $0 + $1.price } } var selectedSeatIds: [String] { selected.map { $0.seatId } } } enum SeatError: Error { case notAvailable // 已售或锁定,不可选 case exceedLimit // 超过限选数量 }逻辑说明:toggle 是整套选座逻辑的唯一入口,成功返回新的选中数组,失败返回错误类型。注意「取消选中」的判断在「限选数量」之前,所以当用户已经点了 6 个座位后想换一个,必须先点掉旧座再点新的——实际产品里也可以改成「超限时自动顶掉最早选的那个」,两种策略见仁见智,Demo 保留了最直观、用户最能理解的写法。参数说明:selected 用数组而不是 Set,是为了保留点击顺序,后续做连坐判定和自动推荐都要依赖这个顺序。totalAmount 每次调用都重新 reduce,而不是维护一个增量变量,是刻意为之——选座操作虽然高频,但座位最多也就几十个,全量重算的开销可以忽略,却换来「永远不可能出现金额状态不同步」的确定性。4.2 下单:座位快照与重复提交防护点击底部「确认选座」后,页面要把当前选中座位打包成下单参数:struct OrderDraft: Encodable { let showtimeId: String // 场次 ID let hallId: String // 影厅 ID let seatIds: [String] // 座位 ID 快照,如 ["A_5", "A_6"] let totalAmount: Int // 总金额,单位分 let orderSource: String // 渠道标识,用于后端埋点 } final class SeatSelectionViewController: UIViewController { private var isSubmitting = false // 提交锁,防止连点 @objc private func confirmTapped() { guard !isSubmitting else { return } isSubmitting = true let draft = OrderDraft( showtimeId: viewModel.showtime.id, hallId: viewModel.showtime.hall.id, seatIds: viewModel.selectedSeatIds, totalAmount: viewModel.totalAmount, orderSource: "ios_seat_demo" ) orderService.submit(draft) { [weak self] result in self?.isSubmitting = false // 成功跳转支付占位页 / 失败弹出重试 } } }逻辑说明:提交时做三件事——用 isSubmitting 锁住入口、把选中座位拍成快照、走 orderService 提交。快照的意义在于,提交过程中用户仍然可以操作页面,但传给后端的永远是点击确认那一瞬间的座位列表,不会出现「界面显示选了 A5、实际提交了 A6」的错位。参数说明:isSubmitting 是防重提交最简单的手段,比「按钮 disable 再恢复」更稳,因为异步回调回来时页面状态可能已经变了。orderSource 这种字段看起来不起眼,但线上排查订单来源、区分 App 端和后续的 H5 渠道全靠它,属于一开始就该留好的字段,后面补会非常痛。5. 跑起来与避坑指南:从 Xcode 到真机调试5.1 环境要求与启动步骤工程是纯 Swift 工程,没有任何第三方依赖,因此省掉了 CocoaPods 和 SwiftPM 这一层,解压就能编。环境要求按当前主流 Xcode 版本即可:Xcode 14 以上、iOS 15 以上模拟器、Swift 5.7 以上。启动流程分四步:解压工程后,双击 SeatDemo.xcodeproj 打开 Xcode。如果提示签名问题,在 Signing 里选一个自己的开发团队即可,模拟器运行不要求真机签名。在 Xcode 顶部 Scheme 里把运行目标选成一个 iPhone 模拟器,然后 Command + R 编译运行。进 App 后先出现场次列表,这是 SeatService 用 mock 数据生成的,点击任意场次进入选座页,座位图会模拟 300ms 网络延迟后渲染出来。想从 mock 切换到真机联调时,只需要替换 SeatService 内部实现,接口路径和参数都写在文件注释里,照着改就行。模拟器上有一个小技巧:座位图支持双指缩放,模拟器里按住 Option 键就能模拟双指手势,调试大影厅座席时非常方便,不用非得找真机。5.2 避坑排查:五个真实踩过的坑下面五条是从开发到联调阶段反复遇到的问题,每一条都按「现象 → 原因 → 解决」整理。坑一:点击座位,选中的却是旁边一个现象:用户点第 5 排 3 号,高亮却落在 4 号上,小屏机型尤其明显。原因:座位容器包在 UIScrollView 里,容器又设置了 transform 缩放。点击坐标要经过 scrollView 的内容偏移和 transform 两层折算,任何一层没处理对,命中就偏。解决:把命中交给按钮自身,不要自己用坐标换算。现在每个座位是独立的 UIControl,事件由系统分发。如果遇到必须手写命中测试的场景,记得先把 point 用convert(_:from:)转换到按钮父视图坐标系,再判断frame.contains(point)。从那以后我检查所有带缩放的容器,只要涉及触摸一律先做坐标转换,这类错位问题就基本绝迹了。坑二:快速连点两次,产生了两个订单现象:提交后页面跳转有延迟,用户手快又点了一次,后端收到两笔相同订单。原因:按钮 disable 的生效是异步的,第一次点击的回调还没回来,第二次点击已经进来了。解决:用 isSubmitting 布尔锁而不是 disable 按钮,它跟 UI 状态无关,天然防重。另外可以在 OrderDraft 里补一个 clientRequestId(UUID 字符串),后端拿它做幂等,同一个请求号只创建一笔订单,这是从服务端层面兜底的做法,前后端各守一道。坑三:6.7 寸大屏上,座位被拉成了椭圆现象:大屏真机上座位横向间距明显比设计稿大,整排座位看起来被横向抻开。原因:页面用了 Auto Layout 约束,把座位宽度跟着屏幕宽度撑开了,相当于每个座位横向等比变大,视觉上就成了椭圆。解决:固定 375pt 设计稿宽度,整体 transform 等比缩放,也就是 3.2 节 applyScreenScale 的思路。缩放之后要记得同步更新 scrollView 的 contentSize,否则滚动范围算错,最边上的座位滚不到。坑四:刚进页面时已售座位没问题,过一会儿却能选中现象:进入页面后已售座位是灰的,但停留十几秒后再点,有的座又能选中了。原因:前端只在首次加载时拉了一次座位状态,这段时间别人买走或被锁定的座位,本地完全没有感知。解决:选座页通常不主动刷新——刷新会把用户正在选的座位状态打掉,体验更差。正确做法是进页面时拉一次全量状态,提交订单时后端做最终校验,前端收到座位冲突错误码就提示「座位已售出」并刷新本地状态。把最终一致性放在提交那一跳,是所有票务项目的通用妥协方案。坑五:VoiceOver 读不出座位排号现象:开启旁白后,座位只朗读「按钮」,用户根本分不清自己在选第几排。原因:自定义按钮没有设置 accessibilityLabel,系统只能读出控件类型。解决:在 SeatButton 里给每个座位设置 accessibilityLabel,内容就是「第几排第几号」,顺手把已售座位的 accessibilityTraits 标记为 .notEnabled,旁白用户就不会点到已售座位上。6. 改造思路:加「智能推荐选座」和分区高亮6.1 改造一:一键选中间排连座产品上有个高频需求:帮用户「选最好的位置」。实现思路是定位影厅中线排,从中线向两侧扩展,找连续可售座位:func suggestSeats(in matrix: [[Seat]], count: Int) -> [Seat] { guard !matrix.isEmpty, count > 0 else { return [] } let centerRow = matrix.count / 2 for rowOffset in 0...3 { // 从中间排向外扩散最多 3 排 for rowIndex in [centerRow + rowOffset, centerRow - rowOffset] { guard matrix.indices.contains(rowIndex) else { continue } if let seats = findContinuous(in: matrix[rowIndex], count: count) { return seats } } } return [] } private func findContinuous(in row: [Seat], count: Int) -> [Seat]? { var streak = 0 var startIndex = 0 for (index, seat) in row.enumerated() { if seat.status == .available { if streak == 0 { startIndex = index } streak += 1 if streak == count { return Array(row[startIndex...index]) } } else { streak = 0 } } return nil }逻辑说明:找连座本质是一个滑动窗口问题,在单排里数连续 available 的座位,攒够 count 个就返回。外层从中间排向上下扩展,优先满足「位置居中」的直觉。这个函数是纯计算,不碰 UI,可以直接塞进 SeatSelectionViewModel 里,配合单测覆盖各种边界。参数说明:rowOffset 上限 3 表示最多向外找 3 排,再远就失去「推荐好位置」的意义了。返回数组按排内顺序排列,调用方拿来替换 selected 集合即可,再让座位图刷新。改造时确认一个边界:如果用户已经手动选了 2 个座,再点推荐是追加还是替换?建议做成替换,并弹确认提示,不然会把用户已有的选择静默冲掉。6.2 改造二:按价格分区高亮整片座位另一个常见交互是「点击价格条某档位,同价座位全部高亮」。这个改动不需要碰渲染层,只需在 SeatSelectionViewModel 里加一个highlightZone: Int?属性,SeatButton 的 refreshAppearance 在读图之前判断当前座位的 zone 是否匹配,匹配就叠加一层半透明遮罩。业务状态与视觉状态彻底解耦,分区高亮和选中态互不覆盖。验证方法分两层:逻辑层用单元测试直接调 ViewModel 的 toggle 和 suggestSeats,断言选中集合、限选次数、金额分项;渲染层用 XCUITest 跑一条完整用例——进选座页、点两个座位、断言底部价格条文本等于两个座位价格之和、点确认、断言跳转到支付占位页。选座这种页面,UI 自动化前期投入不高,但后续每改一次规则都能自动回归,非常值钱。最后说一个我自己的习惯:凡是带手势和状态联动的页面,每次改动后我都会强制走一遍完整流程——小屏、大屏、横竖屏各点一遍,再开着 VoiceOver 过一遍选座和取消。因为选座页的 bug 从来不藏在代码编译错误里,而是藏在坐标换算、状态时序和辅助功能这些最容易忽略的角落。这个工程把这些典型坑都提前趟了一遍,拿到手先按第 5 章的步骤跑通,再动手改,希望帮到你。本文还有配套的精品资源,点击获取
简介:一份iOS电影选座功能实现源码包,面向需要开发票务类或影院App选座模块的开发者,以类似猫眼App的选座流程为蓝本,完整覆盖界面布局、数据管理、座位交互与网络同步等环节。项目使用自定义滚动视图承载座位图,通过座位数据模型管理可选、已选、禁选状态;自定义座位视图负责点选手势、连坐限制和选中高亮等反馈。资源共110个文件,压缩包仅约906KB,包含28张界面图片、13个m源码、9个头文件、12个工程配置方案、9个属性列表及5个json数据文件,另有依赖管理、编译配置、脚本等辅助内容。已有555人前来学习。深入阅读这套代码,可重点理解滚动座位图的绘制与适配、多场次座位占用冲突处理,以及通过Core Graphics自绘座位样式的实现技巧,同时学习工程结构组织方式,对提升自定义控件与交互设计能力很有帮助。1. 类似猫眼电影选座(iOS):把一张座位图做成完整的闭环做过票务类 App 的人都知道,选座页是全链路里最容易翻车、也最影响转化的页面。用户从场次点进来,真正关心的其实只有三件事:哪些座位能选、能不能连座、价格怎么算,任何一个环节卡住都可能丢单。这个项目就是一套参考猫眼选座交互的 iOS 完整实现,从座位图渲染、点击选座、价格联动、限选规则到生成下单参数,整条链路都真实跑得通。它适合两类人:一类是想搞懂自定义视图绘制、手势命中与状态管理的 iOS 开发者;另一类是产品原型期需要先交出一个可点可选选座 Demo、不等后端接口就能给业务方演示的开发者。工程不依赖任何第三方库,纯 Swift + UIKit 手写,拿到后直接在 Xcode 里编译运行,没有任何中间环节。2. 技术选型与工程结构:为什么是纯 UIKit,而不是 SwiftUI2.1 Swift + UIKit:选座场景对视图层的要求先回答一个大概率会被问的问题:都做到这个年头了,为什么不用 SwiftUI?我的看法是,选座页本质上是一张「可以缩放、可以平移、上面散落着几百个可独立变化状态的小部件」的画布。SwiftUI 做列表和表单确实顺手,但一旦涉及 UIScrollView 缩放、精确命中区域、以及高频的选中态切换,UIKit 的显式视图树反而更好控制——每个座位就是一个独立的 UIControl,点谁谁响应,系统帮你把命中测试都处理完了。这个 Demo 的架构是轻量 MVVM,没有引入 RxSwift 之类的响应式框架,而是用「ViewModel 暴露方法、View 通过闭包回调事件」的经典写法。这样做的原因有三个:第一,座位状态变化是典型的多视图联动——点一个座位,顶部提示文案、底部价格条、座位图本身要同时刷新,状态集中到 ViewModel 里可以避免控制器里塞满 if;第二,后续接真实接口时,只需要把 SeatService 的 mock 数据源换掉,View 层一行都不用动;第三,ViewModel 不依赖 UIKit,可以直接写单元测试验证选座规则,这在票务这种规则三天两头变的业务里非常值钱。2.2 工程目录:每个文件负责一件事工程按功能拆分,不是按层拆分,找代码的时候顺着页面走就行:SeatDemo/ ├── App/ │ ├── AppDelegate.swift # 启动入口,创建窗口 │ └── SceneDelegate.swift ├── Models/ │ ├── Movie.swift # 影片信息 │ ├── Showtime.swift # 场次与影厅维度 │ ├── Hall.swift # 影厅座位矩阵描述 │ ├── Seat.swift # 座位模型与状态枚举 │ └── Order.swift # 订单草稿与下单参数 ├── ViewModels/ │ └── SeatSelectionViewModel.swift # 选座规则、选中集合、金额计算 ├── Views/ │ ├── SeatMapView.swift # 座位图画布,负责铺座与缩放 │ ├── SeatButton.swift # 单个座位按钮,渲染与命中 │ ├── SeatLegendView.swift # 图例:可选/已售/选中/过道 │ ├── PriceBarView.swift # 底部价格条与确认按钮 │ └── FilmStripView.swift # 顶部影片与场次切换条 ├── ViewControllers/ │ └── SeatSelectionViewController.swift # 页面装配与回调绑定 ├── Services/ │ └── SeatService.swift # mock 数据源,模拟接口延迟 └── Resources/ └── seat_theme.json # 配色与价格分区配置简单过一遍职责:SeatService 是数据源的黑匣子边界,内部用 DispatchQueue.asyncAfter 模拟 300ms 网络延迟,返回场次、影厅和座位数组;SeatMapView 只做两件事——根据座位矩阵生成按钮、把点击事件通过闭包抛出去,自己不保存任何业务状态;SeatSelectionViewModel 持有座位矩阵和选中集合,所有规则都在这里;ViewController 只负责把三者串起来,同时处理弹窗、loading 这类 UI 细节。这种分层方式最直接的好处是出问题时能快速定位:按钮显示不对查 View,规则不对查 ViewModel,数据不对查 Service。关键类职责对外接口SeatMapView渲染座位矩阵、缩放平移容器configure(with:)、onSeatTap 闭包SeatButton单座按钮皮肤与命中外抛seat 属性、isSelected 刷新SeatSelectionViewModel限选、取消、金额、快照toggle(_:)、selectedSeatIds、totalAmountSeatServicemock 影厅与座位数据fetchSeats(showtimeId:completion:)SeatSelectionViewController视图装配、提交订单confirmTapped()、showOrderConfirm(_:)这个表基本就是二次开发的索引:想改选座规则,直接看 ViewModel;想改座位长什么样,进 Views 目录;想接真实数据,只动 SeatService。3. 核心模块拆解:座位模型、渲染与命中测试3.1 座位数据模型:状态为什么不用一个 Bool座位模型是整个页面的地基,先看它长什么样:enum SeatStatus: Int, Codable { case sold = 0 // 已售出,灰色不可点 case available = 1 // 可售,可点击 case aisle = 2 // 过道,排内占位,不渲染 case locked = 3 // 他人锁定,几分钟内未支付 } struct Seat: Codable, Equatable { let seatId: String // 唯一标识,规则:排号_列号,如 "A_12" let rowNo: Int // 排号索引,从 1 开始 let colNo: Int // 列号索引,从 1 开始 let zone: Int // 票价分区:1 普通区 / 2 黄金区 / 3 情侣座区 let price: Int // 单座价格,单位是分,避免浮点误差 let status: SeatStatus // 服务端返回的实时状态 }逻辑说明:状态用枚举而不是 Bool 的原因很实际——「不能选」至少分三种情况:已售完、过道、别人正在锁定。如果只用一个 isAvailable: Bool,过道座和已售座就没法区分,渲染时要么错位,要么会多画出一排空洞。价格用 Int 分而不是 Double 元,是票务项目最常规的做法,金额计算只做整数加减,不会出现 0.1 + 0.2 这种经典型的精度坑。参数说明:seatId 用排号加列号拼接,主要是为了排查问题方便——用户反馈「第 6 排最左边点不动」,看日志里打印的 seatId 立刻能对上位置。zone 字段是给后续做分区配色预留的,当前版本只在图例和价格条里体现,后面要做「点击某分区高亮所有同价座位」,这个字段就是现成的索引。3.2 渲染:375 设计稿铺座,用比例缩放适配机型SeatMapView 用 UIScrollView 包一个容器视图,所有座位都铺在容器上,布局核心就一段:/// 座位尺寸与间距,按 375pt 宽设计稿定稿 private let seatSize = CGSize(width: 36, height: 36) private let hGap: CGFloat = 8 private let vGap: CGFloat = 14 private let designBaseWidth: CGFloat = 375 private func layoutSeats(on container: UIView) { var contentHeight: CGFloat = 0 for (rowIndex, row) in matrix.enumerated() { let y = CGFloat(rowIndex) * (seatSize.height + vGap) + topPadding for (colIndex, seat) in row.enumerated() { guard seat.status != .aisle else { continue } // 过道不落按钮 let zoneOffset = CGFloat(seat.zone - 1) * 6 // 分区错位 6pt let x = CGFloat(colIndex) * (seatSize.width + hGap) + zoneOffset let button = SeatButton(type: .custom) button.frame = CGRect(x: x, y: y, width: seatSize.width, height: seatSize.height) button.seat = seat button.addTarget(self, action: #selector(seatTapped(_:)), for: .touchUpInside) container.addSubview(button) } contentHeight = y + seatSize.height } container.frame.size = CGSize(width: designBaseWidth, height: contentHeight) } /// 不同屏幕用等比缩放,避免小屏重叠、大屏被拉伸成椭圆 func applyScreenScale(to container: UIView) { let scale = UIScreen.main.bounds.width / designBaseWidth container.transform = CGAffineTransform(scaleX: scale, y: scale) scrollView.contentSize = CGSize(width: designBaseWidth * scale, height: container.frame.height * scale) }逻辑说明:布局用「行索引 ×(座位高 + 行距)」算 y,用「列索引 ×(座位宽 + 列距)」算 x,是最朴素的网格铺法。zoneOffset 让不同价位的分区在视觉上错开一点,模拟真实影厅里分区之间有走道的感觉。按钮统一用 .custom 类型,因为系统默认按钮自带高亮背景和点击态,会影响自定义座位皮肤。参数说明:36pt 的座位在 375pt 宽屏幕上大约可以放 8 个带间距,竖屏场景下影厅宽度刚好够;vGap 14pt 比水平间距大一圈,是为了在视觉上明确区分「排」和「列」。整体 scale 用的是「屏幕宽 / 设计稿宽」,等比缩放而不是逐点拉伸,避免在 6.7 寸大屏上座位被拉成椭圆,这是选座页最常见的变形翻车原因。3.3 命中测试:点到的到底是哪个座位每个座位都是独立的 UIControl,命中测试直接交给系统,这是 UIKit 做选座页一个隐性好处。按钮实现:final class SeatButton: UIButton { var seat: Seat? // 绑定的座位数据,点击时回抛给外部 override var isSelected: Bool { didSet { refreshAppearance() } } private func refreshAppearance() { guard let seat else { return } switch (seat.status, isSelected) { case (.available, false): setBackgroundImage(UIImage(named: "seat_normal"), for: .normal) case (.available, true), (.locked, true): setBackgroundImage(UIImage(named: "seat_selected"), for: .normal) case (.sold, _): setBackgroundImage(UIImage(named: "seat_sold"), for: .normal) isUserInteractionEnabled = false default: break } accessibilityLabel = "\(seat.rowNo)排\(seat.colNo)号座位" } }逻辑说明:SeatButton 持有 seat 数据,外观由「服务端状态 + 本地选中态」共同决定。isSelected 是 UIButton 自带的属性,选中变化时触发 didSet 刷新图片,ViewModel 只要切换选中集合,按钮外观自动跟着变,不需要手动 setImage,这也是把状态收敛到 ViewModel 带来的连锁收益。参数说明:isUserInteractionEnabled 置 false 的座位会直接从响应链里摘出去,连高亮动画都不会触发,这是处理已售座位的正确姿势,而不是在回调里加 if 判断。顺手把 accessibilityLabel 做了,VoiceOver 用户能听到「第几排第几号」,这类辅助功能字段看起来不起眼,等 App 走审核或合规检查时就是硬指标。4. 选座状态流转:ViewModel 怎么管住几百个座位4.1 选座规则:限选、重复点击与金额联动选座交互的规则不多,但每一条都直接影响用户体感。核心逻辑全部在 ViewModel 里:struct SeatSelectionViewModel { let matrix: [[Seat]] // 服务端返回的原始座位矩阵 let maxSeats: Int // 单笔订单最多可选座位数 private(set) var selected: [Seat] = [] // 本地选中集合,按点击顺序 mutating func toggle(_ seat: Seat) -> Result<[Seat], SeatError> { guard seat.status == .available else { return .failure(.notAvailable) } if let index = selected.firstIndex(where: { $0.seatId == seat.seatId }) { selected.remove(at: index) // 再点一次取消选中 return .success(selected) } guard selected.count < maxSeats else { return .failure(.exceedLimit) } selected.append(seat) return .success(selected) } var totalAmount: Int { selected.reduce(0) { $0 + $1.price } } var selectedSeatIds: [String] { selected.map { $0.seatId } } } enum SeatError: Error { case notAvailable // 已售或锁定,不可选 case exceedLimit // 超过限选数量 }逻辑说明:toggle 是整套选座逻辑的唯一入口,成功返回新的选中数组,失败返回错误类型。注意「取消选中」的判断在「限选数量」之前,所以当用户已经点了 6 个座位后想换一个,必须先点掉旧座再点新的——实际产品里也可以改成「超限时自动顶掉最早选的那个」,两种策略见仁见智,Demo 保留了最直观、用户最能理解的写法。参数说明:selected 用数组而不是 Set,是为了保留点击顺序,后续做连坐判定和自动推荐都要依赖这个顺序。totalAmount 每次调用都重新 reduce,而不是维护一个增量变量,是刻意为之——选座操作虽然高频,但座位最多也就几十个,全量重算的开销可以忽略,却换来「永远不可能出现金额状态不同步」的确定性。4.2 下单:座位快照与重复提交防护点击底部「确认选座」后,页面要把当前选中座位打包成下单参数:struct OrderDraft: Encodable { let showtimeId: String // 场次 ID let hallId: String // 影厅 ID let seatIds: [String] // 座位 ID 快照,如 ["A_5", "A_6"] let totalAmount: Int // 总金额,单位分 let orderSource: String // 渠道标识,用于后端埋点 } final class SeatSelectionViewController: UIViewController { private var isSubmitting = false // 提交锁,防止连点 @objc private func confirmTapped() { guard !isSubmitting else { return } isSubmitting = true let draft = OrderDraft( showtimeId: viewModel.showtime.id, hallId: viewModel.showtime.hall.id, seatIds: viewModel.selectedSeatIds, totalAmount: viewModel.totalAmount, orderSource: "ios_seat_demo" ) orderService.submit(draft) { [weak self] result in self?.isSubmitting = false // 成功跳转支付占位页 / 失败弹出重试 } } }逻辑说明:提交时做三件事——用 isSubmitting 锁住入口、把选中座位拍成快照、走 orderService 提交。快照的意义在于,提交过程中用户仍然可以操作页面,但传给后端的永远是点击确认那一瞬间的座位列表,不会出现「界面显示选了 A5、实际提交了 A6」的错位。参数说明:isSubmitting 是防重提交最简单的手段,比「按钮 disable 再恢复」更稳,因为异步回调回来时页面状态可能已经变了。orderSource 这种字段看起来不起眼,但线上排查订单来源、区分 App 端和后续的 H5 渠道全靠它,属于一开始就该留好的字段,后面补会非常痛。5. 跑起来与避坑指南:从 Xcode 到真机调试5.1 环境要求与启动步骤工程是纯 Swift 工程,没有任何第三方依赖,因此省掉了 CocoaPods 和 SwiftPM 这一层,解压就能编。环境要求按当前主流 Xcode 版本即可:Xcode 14 以上、iOS 15 以上模拟器、Swift 5.7 以上。启动流程分四步:解压工程后,双击 SeatDemo.xcodeproj 打开 Xcode。如果提示签名问题,在 Signing 里选一个自己的开发团队即可,模拟器运行不要求真机签名。在 Xcode 顶部 Scheme 里把运行目标选成一个 iPhone 模拟器,然后 Command + R 编译运行。进 App 后先出现场次列表,这是 SeatService 用 mock 数据生成的,点击任意场次进入选座页,座位图会模拟 300ms 网络延迟后渲染出来。想从 mock 切换到真机联调时,只需要替换 SeatService 内部实现,接口路径和参数都写在文件注释里,照着改就行。模拟器上有一个小技巧:座位图支持双指缩放,模拟器里按住 Option 键就能模拟双指手势,调试大影厅座席时非常方便,不用非得找真机。5.2 避坑排查:五个真实踩过的坑下面五条是从开发到联调阶段反复遇到的问题,每一条都按「现象 → 原因 → 解决」整理。坑一:点击座位,选中的却是旁边一个现象:用户点第 5 排 3 号,高亮却落在 4 号上,小屏机型尤其明显。原因:座位容器包在 UIScrollView 里,容器又设置了 transform 缩放。点击坐标要经过 scrollView 的内容偏移和 transform 两层折算,任何一层没处理对,命中就偏。解决:把命中交给按钮自身,不要自己用坐标换算。现在每个座位是独立的 UIControl,事件由系统分发。如果遇到必须手写命中测试的场景,记得先把 point 用convert(_:from:)转换到按钮父视图坐标系,再判断frame.contains(point)。从那以后我检查所有带缩放的容器,只要涉及触摸一律先做坐标转换,这类错位问题就基本绝迹了。坑二:快速连点两次,产生了两个订单现象:提交后页面跳转有延迟,用户手快又点了一次,后端收到两笔相同订单。原因:按钮 disable 的生效是异步的,第一次点击的回调还没回来,第二次点击已经进来了。解决:用 isSubmitting 布尔锁而不是 disable 按钮,它跟 UI 状态无关,天然防重。另外可以在 OrderDraft 里补一个 clientRequestId(UUID 字符串),后端拿它做幂等,同一个请求号只创建一笔订单,这是从服务端层面兜底的做法,前后端各守一道。坑三:6.7 寸大屏上,座位被拉成了椭圆现象:大屏真机上座位横向间距明显比设计稿大,整排座位看起来被横向抻开。原因:页面用了 Auto Layout 约束,把座位宽度跟着屏幕宽度撑开了,相当于每个座位横向等比变大,视觉上就成了椭圆。解决:固定 375pt 设计稿宽度,整体 transform 等比缩放,也就是 3.2 节 applyScreenScale 的思路。缩放之后要记得同步更新 scrollView 的 contentSize,否则滚动范围算错,最边上的座位滚不到。坑四:刚进页面时已售座位没问题,过一会儿却能选中现象:进入页面后已售座位是灰的,但停留十几秒后再点,有的座又能选中了。原因:前端只在首次加载时拉了一次座位状态,这段时间别人买走或被锁定的座位,本地完全没有感知。解决:选座页通常不主动刷新——刷新会把用户正在选的座位状态打掉,体验更差。正确做法是进页面时拉一次全量状态,提交订单时后端做最终校验,前端收到座位冲突错误码就提示「座位已售出」并刷新本地状态。把最终一致性放在提交那一跳,是所有票务项目的通用妥协方案。坑五:VoiceOver 读不出座位排号现象:开启旁白后,座位只朗读「按钮」,用户根本分不清自己在选第几排。原因:自定义按钮没有设置 accessibilityLabel,系统只能读出控件类型。解决:在 SeatButton 里给每个座位设置 accessibilityLabel,内容就是「第几排第几号」,顺手把已售座位的 accessibilityTraits 标记为 .notEnabled,旁白用户就不会点到已售座位上。6. 改造思路:加「智能推荐选座」和分区高亮6.1 改造一:一键选中间排连座产品上有个高频需求:帮用户「选最好的位置」。实现思路是定位影厅中线排,从中线向两侧扩展,找连续可售座位:func suggestSeats(in matrix: [[Seat]], count: Int) -> [Seat] { guard !matrix.isEmpty, count > 0 else { return [] } let centerRow = matrix.count / 2 for rowOffset in 0...3 { // 从中间排向外扩散最多 3 排 for rowIndex in [centerRow + rowOffset, centerRow - rowOffset] { guard matrix.indices.contains(rowIndex) else { continue } if let seats = findContinuous(in: matrix[rowIndex], count: count) { return seats } } } return [] } private func findContinuous(in row: [Seat], count: Int) -> [Seat]? { var streak = 0 var startIndex = 0 for (index, seat) in row.enumerated() { if seat.status == .available { if streak == 0 { startIndex = index } streak += 1 if streak == count { return Array(row[startIndex...index]) } } else { streak = 0 } } return nil }逻辑说明:找连座本质是一个滑动窗口问题,在单排里数连续 available 的座位,攒够 count 个就返回。外层从中间排向上下扩展,优先满足「位置居中」的直觉。这个函数是纯计算,不碰 UI,可以直接塞进 SeatSelectionViewModel 里,配合单测覆盖各种边界。参数说明:rowOffset 上限 3 表示最多向外找 3 排,再远就失去「推荐好位置」的意义了。返回数组按排内顺序排列,调用方拿来替换 selected 集合即可,再让座位图刷新。改造时确认一个边界:如果用户已经手动选了 2 个座,再点推荐是追加还是替换?建议做成替换,并弹确认提示,不然会把用户已有的选择静默冲掉。6.2 改造二:按价格分区高亮整片座位另一个常见交互是「点击价格条某档位,同价座位全部高亮」。这个改动不需要碰渲染层,只需在 SeatSelectionViewModel 里加一个highlightZone: Int?属性,SeatButton 的 refreshAppearance 在读图之前判断当前座位的 zone 是否匹配,匹配就叠加一层半透明遮罩。业务状态与视觉状态彻底解耦,分区高亮和选中态互不覆盖。验证方法分两层:逻辑层用单元测试直接调 ViewModel 的 toggle 和 suggestSeats,断言选中集合、限选次数、金额分项;渲染层用 XCUITest 跑一条完整用例——进选座页、点两个座位、断言底部价格条文本等于两个座位价格之和、点确认、断言跳转到支付占位页。选座这种页面,UI 自动化前期投入不高,但后续每改一次规则都能自动回归,非常值钱。最后说一个我自己的习惯:凡是带手势和状态联动的页面,每次改动后我都会强制走一遍完整流程——小屏、大屏、横竖屏各点一遍,再开着 VoiceOver 过一遍选座和取消。因为选座页的 bug 从来不藏在代码编译错误里,而是藏在坐标换算、状态时序和辅助功能这些最容易忽略的角落。这个工程把这些典型坑都提前趟了一遍,拿到手先按第 5 章的步骤跑通,再动手改,希望帮到你。本文还有配套的精品资源,点击获取