iOS电影选座座位图开发:Swift+UIKit+MVVM 完整实现拆解 简介面向iOS开发者的“类似猫眼电影选座”功能完整实现涵盖从座位图展示、手势交互到数据同步的整套流程适合有UIKit基础、希望快速复刻选座场景或用于课程设计与项目原型的移动端工程师。压缩包共110个文件约906KB除28个png界面素材、13个m与9个h源文件外还包含12个xcscheme工程配置、plist与json等配置文件以及2个pbxproj工程文件便于直接导入Xcode查看运行效果。已有555人学习下载是CSDN上较受关注的选座类实现之一。源码中详细实现了SMScrollView座位布局、Seat模型与状态管理、自定义SeatView点击反馈并通过UIScrollView滚动适配屏幕与手势切换座位状态同时考虑多场次座位占用、网络请求与错误提示等实际业务问题。工程目录结构清晰并附有可运行的Xcode工程配置帮助读者快速理解从界面到数据的落地思路可直接作为二次开发或毕业设计参考。1. 对标猫眼电影选座的 iOS 工程一张座位图难倒了多少开发者第一次在电影院柜台前买票看到售票员手里那张印满小方格的座位图你可能觉得「在手机上做选座」只是把这张图搬上去而已。真动手写代码才发现整个工程里最烧脑的从来不是订单流程也不是网络请求而是那张座位图本身数据模型怎么建、格子怎么渲染、手指缩放平移之后点击事件怎么不穿帮每一个问题都能让人加班到深夜。我拆过的这个对标猫眼电影选座iOS体验的工程是 Swift 写的一套完整 Demo覆盖影片列表、影院场次、座位选择和订单确认适合打算在 iOS 上复刻这类选座交互、或者第一次接手座位图需求的同学拿来当底稿。2. 技术选型与整体架构纯代码 SnapKit MVVM 的组织方式2.1 为什么选 UIKit 纯代码而不是 SwiftUI这个工程的目标是像素级复刻市面上常见电影购票 App 的选座交互涉及大量自定义视图、多层嵌套、滚动容器套自绘内容还有精细的手势控制。我第一版用 SwiftUI 试过界面搭起来确实快但一旦进入座位点击的命中检测、缩放状态下让滚动视图保持稳定SwiftUI 的抽象层会让调试变得非常绕很多时候你得借助UIViewRepresentable跳回 UIKit。所以最终整体栈选回 UIKit 纯代码布局用 SnapKit代码可读性和可控性都更好。选型上的对比可以从这几个维度看维度UIKit SnapKitSwiftUI手势与命中检测hitTest 链路透明可直接干预封装深排查事件穿透成本高复杂布局约束代码直观支持运行时动态调整声明式语法简洁但动态刷新有额外心智负担最低部署版本要求iOS 12 起就很稳复杂交互特性有版本下限团队上手门槛iOS 开发者普遍熟悉需要额外补 SwiftUI 思维当然这不是说 SwiftUI 不行而是这个需求里 UIKit 能让我把精力花在座位图本身而不是框架适配。纯代码还有一个好处合并没有 storyboard 冲突代码审查时一眼能看出约束和逻辑。2.2 目录结构MVVM 分层怎么落到文件夹里工程采用 MVVM 分层目录结构是我平时接项目习惯的一套按职责分得比较干净换人接手也容易定位问题SeatDemo/ ├── App/ │ ├── AppDelegate.swift │ └── SceneDelegate.swift ├── Models/ │ ├── CinemaModel.swift │ ├── HallModel.swift │ └── Seat.swift ├── ViewModels/ │ ├── MovieListViewModel.swift │ ├── HallSelectionViewModel.swift │ └── SeatSelectionViewModel.swift ├── Views/ │ ├── SeatMapView.swift │ ├── SeatRowView.swift │ ├── SeatButton.swift │ └── OrderConfirmView.swift ├── ViewControllers/ │ ├── MovieListViewController.swift │ ├── HallViewController.swift │ ├── SeatSelectionViewController.swift │ └── OrderConfirmViewController.swift ├── Services/ │ ├── SeatDataService.swift │ └── MockSeatDataService.swift ├── Utils/ │ ├── Constants.swift │ └── PriceCalculator.swift └── Resources/ ├── mock_halls.json └── Assets.xcassetsModels 层只放数据结构不掺业务ViewModels 层负责状态管理和业务计算比如选座数量上限、总价计算Views 层只做渲染和事件上报ViewControllers 层负责页面生命周期和导航。Services 层是容易被新手忽略的一块这里我单独拎出SeatDataService目的就是为了以后把本地模拟数据替换成真实后端接口时能不碰页面代码只改服务实现。2.3 数据是怎么来的本地 JSON 模拟场次与座位工程还没接后端所以数据源用本地 JSON 模拟。这个 JSON 的字段设计不是随手写的而是照着真实接口的常见返回结构来定的字段名和类型都按后续联调习惯预留{ cinemaName: 示例影城, hallName: 3号厅, movieStartTime: 2025-06-10 19:30, maxSelectCount: 4, rows: [ { row: 1, price: 45, seats: [ { column: 1, status: 0 }, { column: 2, status: 3 }, { column: 3, status: 0 } ] }, { row: 2, price: 55, seats: [ { column: 1, status: 1 }, { column: 2, status: 0 }, { column: 3, status: 0 } ] } ] }status 字段是座位状态标记0 代表可售1 代表已被锁座2 代表已售出3 代表过道或者无障碍通道占位。这个设计跟真实后端通常返回的枚举是一致的。读 JSON 时我直接用 Codable 解码MockSeatDataService从 bundle 里加载文件返回一个HallModel对象以后换成网络请求只要把服务内部实现替换ViewModel 完全不用改。3. 核心模块拆解座位图模型、渲染与选座交互的完整链路3.1 座位数据模型三种状态 一个选中标记撑起整场逻辑座位模型是整个选座页面最基础的地基。写模型的时候我盯着猫眼这类 App 的交互想了一个问题一个座位从加载到出票到底要经历几个状态变化于是模型就顺着这个生命周期设计// Seat.swift enum SeatStatus: Int { case available 0 // 可售 case locked 1 // 已被其它渠道锁定 case sold 2 // 已售出 case aisle 3 // 过道/通道占位 } struct Seat: Identifiable, Codable { var id: String { \(section)-\(row)-\(column) } let section: Int // 影厅分区IMAX厅通常分 A/B/C 区 let row: Int // 排号从 1 开始 let column: Int // 列号 var status: SeatStatus var isSelected: Bool false var price: Int { ... } // 价格通常由所属排的 price 决定可在解码后二次计算 }这里有两个容易被忽略的设计细节。第一个是id字段用计算属性生成格式是「分区-排-列」保证同一场次内座位 ID 唯一后续做选座集合去重、提交订单时的座位编码都能直接用这串字符串。第二个是保留section分区字段哪怕 Demo 只做了单区这个字段也能支撑以后扩展大影厅的多分区票价差异。isSelected单独放模型里而不是放在 ViewModel 的字典里是为了让选中状态跟着座位走回显到订单页时不用再做一次映射。3.2 座位图渲染为什么不用 UICollectionView座位图渲染方案我在两个选择之间犹豫过UICollectionView还是UIScrollView 自定义行视图。直接用UICollectionView也有可行性它的 cell 复用在大量座位时性能占优但问题在于选座需求里每一排的宽度不固定靠左靠右的情况很常见要用sectionInset和alignmentLayout去凑这些排布代码很快就会绕进去。更关键的是需要把整个座位图包进一个可缩放滚动的容器里UICollectionView本身对缩放的支持并不顺手还要处理 cell 复用带来的闪烁问题。这个工程用的是UIScrollView包一个SeatMapView座位图内部按行创建SeatRowView// SeatMapView.swift final class SeatMapView: UIView { private var rowViews: [SeatRowView] [] func configure(with rows: [SeatRowModel]) { rowViews.forEach { $0.removeFromSuperview() } rowViews.removeAll() for (index, rowModel) in rows.enumerated() { let rowView SeatRowView(rowModel: rowModel) rowView.translatesAutoresizingMaskIntoConstraints false addSubview(rowView) rowView.snp.makeConstraints { make in make.top.equalToSuperview().offset(index * rowHeight) make.leading.trailing.equalToSuperview() make.height.equalTo(rowHeight) } } } }每一行用水平方向的UIStackView排列座位按钮按钮宽度根据屏幕宽度动态算大约在 28 到 36 之间过道位用透明占位视图撑住间距。这样做的开销比UICollectionView稍大但一百多个座位对这个体量的工程完全不是压力换来的是行内排布和座位宽度控制的绝对自由。3.3 座位点击逻辑选中的那一秒 ViewModel 里发生了什么座位按钮点击后事件会通过 delegate 回调到SeatSelectionViewController再由控制器把座位的行号列号交给SeatSelectionViewModel。之所以不直接在按钮 action 里改状态是为了把业务规则收敛到 ViewModel 里方便后续做单元测试。核心逻辑是选座数量上限和已售状态的过滤// SeatSelectionViewModel.swift final class SeatSelectionViewModel { private(set) var hall: HallModel private(set) var selectedSeats: [Seat] [] var maxSelectableCount: Int 4 var totalPrice: Int { selectedSeats.reduce(0) { $0 $1.price } } discardableResult func toggleSeat(at row: Int, column: Int) - SeatChangeResult { guard let seatIndex hall.index(of: row, column: column) else { return .noSuchSeat } let seat hall.seats[seatIndex] // 已售、过道、被锁定的座位直接忽略 guard seat.status .available || seat.isSelected else { return .notAvailable } if let selectedIndex selectedSeats.firstIndex(where: { $0.id seat.id }) { selectedSeats.remove(at: selectedIndex) hall.seats[seatIndex].isSelected false return .deselected } guard selectedSeats.count maxSelectableCount else { return .reachLimit } selectedSeats.append(seat) hall.seats[seatIndex].isSelected true return .selected } }这个方法返回一个SeatChangeResult枚举控制器拿到结果后决定是否刷新座位按钮状态、是否更新底部价格栏。maxSelectableCount是票档限制有的影院限制单人最多选 4 张实测中这个数值由场次数据里的maxSelectCount字段注入。价格计算不依赖座位按钮 title而是从 ViewModel 的selectedSeats汇总因为按钮上的价格文本是展示层的东西不参与逻辑计算。3.4 缩放与平移UIScrollView 让整张图跟着手指走座位图的内容宽度往往超过屏幕宽度放大之后还需要平移查看靠边的座位。这里的容器是UIScrollView需要设置缩放相关的关键参数// SeatSelectionViewController.swift private func setupScrollView() { let scrollView UIScrollView() scrollView.delegate self scrollView.maximumZoomScale 3.0 scrollView.minimumZoomScale 1.0 scrollView.bouncesZoom true scrollView.showsVerticalScrollIndicator false scrollView.showsHorizontalScrollIndicator false scrollView.backgroundColor .black view.addSubview(scrollView) } func viewForZooming(in scrollView: UIScrollView) - UIView? { return seatMapView }参数含义可以用一张表看明白参数取值作用maximumZoomScale3.0允许放大到 3 倍够看清座位编号minimumZoomScale1.0最小回到原始尺寸避免缩太小bouncesZoomtrue缩放拉到边界时允许橡皮筋回弹反馈showsHorizontalScrollIndicatorfalse隐藏滚动条保证界面干净缩放时还有一个不能漏的点seatMapView内部约束的 constant 不能写死。行高、座位间距都要在缩放发生后重新读取zoomScale对应的真实 frame或者使用transform方案统一缩放内容视图。Demo 里更稳妥的做法是让seatMapView的尺寸是固定物理尺寸交给UIScrollView的zoomScale去做缩放约束只保证初始尺寸正确后续缩放全部由滚动视图接管这样点击坐标不会乱套。4. 业务链路打通影片列表、场次选择与订单确认页4.1 从影片列表到选座页参数怎么传、页面怎么推很多入门写法是直接在prepareForSegue里塞一堆模型对象但这个工程的页面传递做了收敛列表页只持有轻量级的MovieSummary点到具体场次时才从数据服务加载完整HallModel。跳转时通过一个路由枚举传参// SceneRouter.swift enum SceneTarget { case hall(movieId: String) case seatSelection(hallId: String, showTime: String) case orderConfirm(hall: HallModel, selectedSeats: [Seat]) } extension SceneTarget { func viewController() - UIViewController { switch self { case .seatSelection(let hallId, let showTime): let vm SeatSelectionViewModel(hallId: hallId, showTime: showTime) return SeatSelectionViewController(viewModel: vm) case .orderConfirm(let hall, let selectedSeats): let vm OrderConfirmViewModel(hall: hall, selectedSeats: selectedSeats) return OrderConfirmViewController(viewModel: vm) default: return UIViewController() } } }hallId和showTime是字符串参数好处是以后接真实接口时依赖的只是 ID 而不是对象引用网络层拿到 ID 就能发请求。页面控制器统一由路由创建职责单一页面多了也不至于在 AppDelegate 里堆跳转代码。4.2 订单确认页座位编号、票价与张数如何回显从选座页跳转到订单确认传递的数据包含hall、selectedSeats和totalPrice。确认页不再请求网络直接用传入数据渲染。这里的核心问题是座位编号的展示格式要跟真实取票码的惯用格式对齐排和列从 1 开始展示成「3排5座、3排6座」。代码里的做法是在确认页的 ViewModel 里生成展示描述// OrderConfirmViewModel.swift final class OrderConfirmViewModel { let hall: HallModel let selectedSeats: [Seat] let totalPrice: Int init(hall: HallModel, selectedSeats: [Seat]) { self.hall hall self.selectedSeats selectedSeats self.totalPrice selectedSeats.reduce(0) { $0 $1.price } } var seatDescriptions: [String] { selectedSeats .sorted { ($0.row, $0.column) ($1.row, $1.column) } .map { \($0.row)排\($0.column)座 } } }注意这里对座位做了排序再拼接描述。如果不排序按用户点击顺序展示「5座、3座」会给核对带来麻烦按排和列排序后再展示和前端的视觉座位图是一致的用户一眼能对上。4.3 从选座返回后列表状态怎么保持用户选择常见的翻车点是从选座页返回列表页时选座状态被清空。这个工程的解决方案是把SeatSelectionViewModel的实例持有在导航栈的上一级页面返回时不销毁下次进入同一个场次还能看到已选座位的回显。具体做法是列表控制器维护一个字典存储每个场次对应的选择记录只有当提交订单成功后清除对应 key。这个设计模拟真实购票流程里「重新进入场次仍保留待支付座位」的体验也为后续接支付流程留好了状态位。5. 避坑指南座位图开发里最容易翻车的五个问题5.1 座位按钮点击错位点 A 座却选中了 C 座现象在放大 2 倍后点击某个座位命中的是旁边座位。原因把座位按钮的点击区域绑定在frame上而滚动视图缩放后按钮的展示位置与contentView的坐标发生了偏移按钮的touch响应区域还是缩放前的逻辑坐标。这是UIScrollView缩放场景标准坑。解决在SeatMapView里重写hitTest把点击坐标换算到座位内容视图的坐标系再按列宽和行高取整定位座位索引override func hitTest(_ point: CGPoint, with event: UIEvent?) - UIView? { let localPoint convert(point, from: superview) for rowView in rowViews { if rowView.frame.contains(localPoint) { let rowPoint rowView.convert(localPoint, from: self) let column Int(rowPoint.x / seatWidth) return rowView.seatButton(at: column) } } return super.hitTest(point, with: event) }这样命中的是实际渲染位置的视图而不是滚动视图未缩放时的逻辑位置。5.2 缩放后座位之间有缝隙放大一看像被撕裂的网现象放大到 3 倍时座位格子之间的间距不均匀出现细线缝隙。原因座位视图使用(column - 1) * seatWidth计算 frame缩放到 3 倍时浮点坐标产生小数iOS 的像素对齐机制在部分机型上会丢弃亚像素导致相邻视图之间露出背景色。解决缩放到非整数倍时在scrollViewDidZoom里对seatMapView的宽高做一次四舍五入修正并保证座位按钮的 frame 用ceil取整。另外相邻座位用同一个父容器绘制不要单独给每个座位加 border缝隙问题会明显改善。5.3 滑动浏览座位图时误触选座手一抖就锁了一个座现象用户本意是滑动查看右半区手指一抬起却选中了座位。原因touchUpInside事件判断不了是否有位移滑动结束后松手也会触发按钮点击。解决在SeatSelectionViewController里给滚动视图加一个panGestureRecognizer记录手势起始点在按钮的 touch 回调里判断手势位移是否小于某个阈值比如 8pt位移超标就视为滚动操作忽略点击事件。5.4 底部确认栏被 Home Indicator 遮挡现象带刘海屏的机型和老机型上底部“确认选座”按钮的间距表现不一致老机型没问题新机型按钮被横条挡住。原因确认栏使用了固定 bottom 约束没有考虑安全区。iOS 11 以后的 safe area 逻辑必须在按钮约束上显式处理。解决约束改用view.safeAreaLayoutGuideconfirmBar.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide) }这样在带 Home Indicator 的机型上自动避让老机型底部高度为 0 也不会多出空白。5.5 锁座回调时状态对不上本地选中了后台已经锁给别人现象两个客户端同时打开同一场次A 用户选座时显示可售提交后被后台拒绝。原因Demo 的座位状态是本地写死的真实场景下座位被其他人锁定时后端会返回状态变更。解决在做数据服务层时预留updateSeatStatus通道在选座页每次加载、点选座位后都从服务端同步一次场次锁座状态。工程里我用一个模拟延迟来演示这个过程点选后 500ms 内服务层会返回座位状态变化页面根据响应刷新按钮样式。真实接入时把这个服务替换成 WebSocket 推送或轮询接口即可。6. 二次开发实战从本地 JSON 到真实接口的改造路径6.1 把数据服务抽成协议页面不感知数据来源如果要把这个 Demo 接到真实后端第一个动作是定义数据服务协议。我在最初写服务层时已经把接口抽象好了// SeatDataService.swift protocol SeatDataServicing { func fetchMovieList(completion: escaping (Result[MovieSummary], Error) - Void) func fetchHall(movieId: String, showTime: String, completion: escaping (ResultHallModel, Error) - Void) func lockSeats(hallId: String, seats: [String], completion: escaping (ResultVoid, Error) - Void) }本地实现MockSeatDataService读 JSON、模拟锁座新增RemoteSeatDataService走 URLSession 请求。改造时只需在控制器初始化的位置替换服务实例页面和 ViewModel 完全不用动。这种依赖注入的做法是整个工程里我认为最值得保留的设计。锁座接口需要注意幂等性用户选好座、点了确认请求因为网络抖动发出去了两次后端不能给用户锁两张票。常见的做法是客户端生成一个请求批次号服务端用批次号做去重返回的锁座结果才能保证一致。6.2 选座页的边界超时、刷新和异常恢复接真实接口后选座页还要处理三类边界锁座超时、状态轮询、断网恢复。我一般习惯在 ViewModel 里放一个loadState枚举用loading / success / failure三个状态驱动页面显示。锁座请求发出后启动一个计时器超时未返回就提示用户重新确认如果用户切后台再回前台重新拉取一次场次状态。每次刷新后只更新状态变化的座位而不是整张图重建这样可以避免闪烁也减少无谓的动画。另外真实环境中不同票档有不同颜色和价格「票价分区」一定要在设计模型时就留好字段。我这次拆这个工程最大的收获是把座位图当做一个有状态的前端组件来设计而不是一堆按钮拼起来的死页面。从那以后我每次接这类选座页面都强制自己先按需求把所有座位状态逐一点一遍再谈后续联调。希望这篇拆解能让你少踩几个我踩过的坑选座页面的坑真的是踩一次记一辈子。本文还有配套的精品资源点击获取