ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS电影选座座位图开发:Swift+UIKit+MVVM 完整实现拆解

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.xcassets

Models 层只放数据结构,不掺业务;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.safeAreaLayoutGuide:

confirmBar.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 (Result<HallModel, Error>) -> Void) func lockSeats(hallId: String, seats: [String], completion: @escaping (Result<Void, Error>) -> Void) }

本地实现MockSeatDataService读 JSON、模拟锁座;新增RemoteSeatDataService走 URLSession 请求。改造时只需在控制器初始化的位置替换服务实例,页面和 ViewModel 完全不用动。这种依赖注入的做法,是整个工程里我认为最值得保留的设计。

锁座接口需要注意幂等性:用户选好座、点了确认,请求因为网络抖动发出去了两次,后端不能给用户锁两张票。常见的做法是客户端生成一个请求批次号,服务端用批次号做去重,返回的锁座结果才能保证一致。

6.2 选座页的边界:超时、刷新和异常恢复

接真实接口后,选座页还要处理三类边界:锁座超时、状态轮询、断网恢复。我一般习惯在 ViewModel 里放一个loadState枚举,用loading / success / failure三个状态驱动页面显示。锁座请求发出后启动一个计时器,超时未返回就提示用户重新确认;如果用户切后台再回前台,重新拉取一次场次状态。每次刷新后只更新状态变化的座位,而不是整张图重建,这样可以避免闪烁,也减少无谓的动画。

另外,真实环境中不同票档有不同颜色和价格,「票价分区」一定要在设计模型时就留好字段。我这次拆这个工程最大的收获是把座位图当做一个有状态的前端组件来设计,而不是一堆按钮拼起来的死页面。从那以后,我每次接这类选座页面,都强制自己先按需求把所有座位状态逐一点一遍,再谈后续联调。希望这篇拆解能让你少踩几个我踩过的坑,选座页面的坑,真的是踩一次记一辈子。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进