:Swift+UIKit+MVVM 完整實現(xiàn)拆解)
簡介面向iOS開發(fā)者的“類似貓眼電影選座”功能完整實現(xiàn)涵蓋從座位圖展示、手勢交互到數(shù)據(jù)同步的整套流程適合有UIKit基礎(chǔ)、希望快速復(fù)刻選座場景或用于課程設(shè)計與項目原型的移動端工程師。壓縮包共110個文件約906KB除28個png界面素材、13個m與9個h源文件外還包含12個xcscheme工程配置、plist與json等配置文件以及2個pbxproj工程文件便于直接導(dǎo)入Xcode查看運行效果。已有555人學(xué)習(xí)下載是CSDN上較受關(guān)注的選座類實現(xiàn)之一。源碼中詳細實現(xiàn)了SMScrollView座位布局、Seat模型與狀態(tài)管理、自定義SeatView點擊反饋并通過UIScrollView滾動適配屏幕與手勢切換座位狀態(tài)同時考慮多場次座位占用、網(wǎng)絡(luò)請求與錯誤提示等實際業(yè)務(wù)問題。工程目錄結(jié)構(gòu)清晰并附有可運行的Xcode工程配置幫助讀者快速理解從界面到數(shù)據(jù)的落地思路可直接作為二次開發(fā)或畢業(yè)設(shè)計參考。1. 對標貓眼電影選座的 iOS 工程一張座位圖難倒了多少開發(fā)者第一次在電影院柜臺前買票看到售票員手里那張印滿小方格的座位圖你可能覺得「在手機上做選座」只是把這張圖搬上去而已。真動手寫代碼才發(fā)現(xiàn)整個工程里最燒腦的從來不是訂單流程也不是網(wǎng)絡(luò)請求而是那張座位圖本身數(shù)據(jù)模型怎么建、格子怎么渲染、手指縮放平移之后點擊事件怎么不穿幫每一個問題都能讓人加班到深夜。我拆過的這個對標貓眼電影選座iOS體驗的工程是 Swift 寫的一套完整 Demo覆蓋影片列表、影院場次、座位選擇和訂單確認適合打算在 iOS 上復(fù)刻這類選座交互、或者第一次接手座位圖需求的同學(xué)拿來當?shù)赘濉?. 技術(shù)選型與整體架構(gòu)純代碼 SnapKit MVVM 的組織方式2.1 為什么選 UIKit 純代碼而不是 SwiftUI這個工程的目標是像素級復(fù)刻市面上常見電影購票 App 的選座交互涉及大量自定義視圖、多層嵌套、滾動容器套自繪內(nèi)容還有精細的手勢控制。我第一版用 SwiftUI 試過界面搭起來確實快但一旦進入座位點擊的命中檢測、縮放狀態(tài)下讓滾動視圖保持穩(wěn)定SwiftUI 的抽象層會讓調(diào)試變得非常繞很多時候你得借助UIViewRepresentable跳回 UIKit。所以最終整體棧選回 UIKit 純代碼布局用 SnapKit代碼可讀性和可控性都更好。選型上的對比可以從這幾個維度看維度UIKit SnapKitSwiftUI手勢與命中檢測hitTest 鏈路透明可直接干預(yù)封裝深排查事件穿透成本高復(fù)雜布局約束代碼直觀支持運行時動態(tài)調(diào)整聲明式語法簡潔但動態(tài)刷新有額外心智負擔最低部署版本要求iOS 12 起就很穩(wěn)復(fù)雜交互特性有版本下限團隊上手門檻iOS 開發(fā)者普遍熟悉需要額外補 SwiftUI 思維當然這不是說 SwiftUI 不行而是這個需求里 UIKit 能讓我把精力花在座位圖本身而不是框架適配。純代碼還有一個好處合并沒有 storyboard 沖突代碼審查時一眼能看出約束和邏輯。2.2 目錄結(jié)構(gòu)MVVM 分層怎么落到文件夾里工程采用 MVVM 分層目錄結(jié)構(gòu)是我平時接項目習(xí)慣的一套按職責(zé)分得比較干凈換人接手也容易定位問題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 層只放數(shù)據(jù)結(jié)構(gòu)不摻業(yè)務(wù)ViewModels 層負責(zé)狀態(tài)管理和業(yè)務(wù)計算比如選座數(shù)量上限、總價計算Views 層只做渲染和事件上報ViewControllers 層負責(zé)頁面生命周期和導(dǎo)航。Services 層是容易被新手忽略的一塊這里我單獨拎出SeatDataService目的就是為了以后把本地模擬數(shù)據(jù)替換成真實后端接口時能不碰頁面代碼只改服務(wù)實現(xiàn)。2.3 數(shù)據(jù)是怎么來的本地 JSON 模擬場次與座位工程還沒接后端所以數(shù)據(jù)源用本地 JSON 模擬。這個 JSON 的字段設(shè)計不是隨手寫的而是照著真實接口的常見返回結(jié)構(gòu)來定的字段名和類型都按后續(xù)聯(lián)調(diào)習(xí)慣預(yù)留{ 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 字段是座位狀態(tài)標記0 代表可售1 代表已被鎖座2 代表已售出3 代表過道或者無障礙通道占位。這個設(shè)計跟真實后端通常返回的枚舉是一致的。讀 JSON 時我直接用 Codable 解碼MockSeatDataService從 bundle 里加載文件返回一個HallModel對象以后換成網(wǎng)絡(luò)請求只要把服務(wù)內(nèi)部實現(xiàn)替換ViewModel 完全不用改。3. 核心模塊拆解座位圖模型、渲染與選座交互的完整鏈路3.1 座位數(shù)據(jù)模型三種狀態(tài) 一個選中標記撐起整場邏輯座位模型是整個選座頁面最基礎(chǔ)的地基。寫模型的時候我盯著貓眼這類 App 的交互想了一個問題一個座位從加載到出票到底要經(jīng)歷幾個狀態(tài)變化于是模型就順著這個生命周期設(shè)計// 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 // 影廳分區(qū)IMAX廳通常分 A/B/C 區(qū) let row: Int // 排號從 1 開始 let column: Int // 列號 var status: SeatStatus var isSelected: Bool false var price: Int { ... } // 價格通常由所屬排的 price 決定可在解碼后二次計算 }這里有兩個容易被忽略的設(shè)計細節(jié)。第一個是id字段用計算屬性生成格式是「分區(qū)-排-列」保證同一場次內(nèi)座位 ID 唯一后續(xù)做選座集合去重、提交訂單時的座位編碼都能直接用這串字符串。第二個是保留section分區(qū)字段哪怕 Demo 只做了單區(qū)這個字段也能支撐以后擴展大影廳的多分區(qū)票價差異。isSelected單獨放模型里而不是放在 ViewModel 的字典里是為了讓選中狀態(tài)跟著座位走回顯到訂單頁時不用再做一次映射。3.2 座位圖渲染為什么不用 UICollectionView座位圖渲染方案我在兩個選擇之間猶豫過UICollectionView還是UIScrollView 自定義行視圖。直接用UICollectionView也有可行性它的 cell 復(fù)用在大量座位時性能占優(yōu)但問題在于選座需求里每一排的寬度不固定靠左靠右的情況很常見要用sectionInset和alignmentLayout去湊這些排布代碼很快就會繞進去。更關(guān)鍵的是需要把整個座位圖包進一個可縮放滾動的容器里UICollectionView本身對縮放的支持并不順手還要處理 cell 復(fù)用帶來的閃爍問題。這個工程用的是UIScrollView包一個SeatMapView座位圖內(nèi)部按行創(chuàng)建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排列座位按鈕按鈕寬度根據(jù)屏幕寬度動態(tài)算大約在 28 到 36 之間過道位用透明占位視圖撐住間距。這樣做的開銷比UICollectionView稍大但一百多個座位對這個體量的工程完全不是壓力換來的是行內(nèi)排布和座位寬度控制的絕對自由。3.3 座位點擊邏輯選中的那一秒 ViewModel 里發(fā)生了什么座位按鈕點擊后事件會通過 delegate 回調(diào)到SeatSelectionViewController再由控制器把座位的行號列號交給SeatSelectionViewModel。之所以不直接在按鈕 action 里改狀態(tài)是為了把業(yè)務(wù)規(guī)則收斂到 ViewModel 里方便后續(xù)做單元測試。核心邏輯是選座數(shù)量上限和已售狀態(tài)的過濾// 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枚舉控制器拿到結(jié)果后決定是否刷新座位按鈕狀態(tài)、是否更新底部價格欄。maxSelectableCount是票檔限制有的影院限制單人最多選 4 張實測中這個數(shù)值由場次數(shù)據(jù)里的maxSelectCount字段注入。價格計算不依賴座位按鈕 title而是從 ViewModel 的selectedSeats匯總因為按鈕上的價格文本是展示層的東西不參與邏輯計算。3.4 縮放與平移UIScrollView 讓整張圖跟著手指走座位圖的內(nèi)容寬度往往超過屏幕寬度放大之后還需要平移查看靠邊的座位。這里的容器是UIScrollView需要設(shè)置縮放相關(guān)的關(guān)鍵參數(shù)// 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 }參數(shù)含義可以用一張表看明白參數(shù)取值作用maximumZoomScale3.0允許放大到 3 倍夠看清座位編號minimumZoomScale1.0最小回到原始尺寸避免縮太小bouncesZoomtrue縮放拉到邊界時允許橡皮筋回彈反饋showsHorizontalScrollIndicatorfalse隱藏滾動條保證界面干凈縮放時還有一個不能漏的點seatMapView內(nèi)部約束的 constant 不能寫死。行高、座位間距都要在縮放發(fā)生后重新讀取zoomScale對應(yīng)的真實 frame或者使用transform方案統(tǒng)一縮放內(nèi)容視圖。Demo 里更穩(wěn)妥的做法是讓seatMapView的尺寸是固定物理尺寸交給UIScrollView的zoomScale去做縮放約束只保證初始尺寸正確后續(xù)縮放全部由滾動視圖接管這樣點擊坐標不會亂套。4. 業(yè)務(wù)鏈路打通影片列表、場次選擇與訂單確認頁4.1 從影片列表到選座頁參數(shù)怎么傳、頁面怎么推很多入門寫法是直接在prepareForSegue里塞一堆模型對象但這個工程的頁面?zhèn)鬟f做了收斂列表頁只持有輕量級的MovieSummary點到具體場次時才從數(shù)據(jù)服務(wù)加載完整HallModel。跳轉(zhuǎn)時通過一個路由枚舉傳參// 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是字符串參數(shù)好處是以后接真實接口時依賴的只是 ID 而不是對象引用網(wǎng)絡(luò)層拿到 ID 就能發(fā)請求。頁面控制器統(tǒng)一由路由創(chuàng)建職責(zé)單一頁面多了也不至于在 AppDelegate 里堆跳轉(zhuǎn)代碼。4.2 訂單確認頁座位編號、票價與張數(shù)如何回顯從選座頁跳轉(zhuǎn)到訂單確認傳遞的數(shù)據(jù)包含hall、selectedSeats和totalPrice。確認頁不再請求網(wǎng)絡(luò)直接用傳入數(shù)據(jù)渲染。這里的核心問題是座位編號的展示格式要跟真實取票碼的慣用格式對齊排和列從 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 從選座返回后列表狀態(tài)怎么保持用戶選擇常見的翻車點是從選座頁返回列表頁時選座狀態(tài)被清空。這個工程的解決方案是把SeatSelectionViewModel的實例持有在導(dǎo)航棧的上一級頁面返回時不銷毀下次進入同一個場次還能看到已選座位的回顯。具體做法是列表控制器維護一個字典存儲每個場次對應(yīng)的選擇記錄只有當提交訂單成功后清除對應(yīng) key。這個設(shè)計模擬真實購票流程里「重新進入場次仍保留待支付座位」的體驗也為后續(xù)接支付流程留好了狀態(tài)位。5. 避坑指南座位圖開發(fā)里最容易翻車的五個問題5.1 座位按鈕點擊錯位點 A 座卻選中了 C 座現(xiàn)象在放大 2 倍后點擊某個座位命中的是旁邊座位。原因把座位按鈕的點擊區(qū)域綁定在frame上而滾動視圖縮放后按鈕的展示位置與contentView的坐標發(fā)生了偏移按鈕的touch響應(yīng)區(qū)域還是縮放前的邏輯坐標。這是UIScrollView縮放場景標準坑。解決在SeatMapView里重寫hitTest把點擊坐標換算到座位內(nèi)容視圖的坐標系再按列寬和行高取整定位座位索引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 縮放后座位之間有縫隙放大一看像被撕裂的網(wǎng)現(xiàn)象放大到 3 倍時座位格子之間的間距不均勻出現(xiàn)細線縫隙。原因座位視圖使用(column - 1) * seatWidth計算 frame縮放到 3 倍時浮點坐標產(chǎn)生小數(shù)iOS 的像素對齊機制在部分機型上會丟棄亞像素導(dǎo)致相鄰視圖之間露出背景色。解決縮放到非整數(shù)倍時在scrollViewDidZoom里對seatMapView的寬高做一次四舍五入修正并保證座位按鈕的 frame 用ceil取整。另外相鄰座位用同一個父容器繪制不要單獨給每個座位加 border縫隙問題會明顯改善。5.3 滑動瀏覽座位圖時誤觸選座手一抖就鎖了一個座現(xiàn)象用戶本意是滑動查看右半?yún)^(qū)手指一抬起卻選中了座位。原因touchUpInside事件判斷不了是否有位移滑動結(jié)束后松手也會觸發(fā)按鈕點擊。解決在SeatSelectionViewController里給滾動視圖加一個panGestureRecognizer記錄手勢起始點在按鈕的 touch 回調(diào)里判斷手勢位移是否小于某個閾值比如 8pt位移超標就視為滾動操作忽略點擊事件。5.4 底部確認欄被 Home Indicator 遮擋現(xiàn)象帶劉海屏的機型和老機型上底部“確認選座”按鈕的間距表現(xiàn)不一致老機型沒問題新機型按鈕被橫條擋住。原因確認欄使用了固定 bottom 約束沒有考慮安全區(qū)。iOS 11 以后的 safe area 邏輯必須在按鈕約束上顯式處理。解決約束改用view.safeAreaLayoutGuideconfirmBar.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide) }這樣在帶 Home Indicator 的機型上自動避讓老機型底部高度為 0 也不會多出空白。5.5 鎖座回調(diào)時狀態(tài)對不上本地選中了后臺已經(jīng)鎖給別人現(xiàn)象兩個客戶端同時打開同一場次A 用戶選座時顯示可售提交后被后臺拒絕。原因Demo 的座位狀態(tài)是本地寫死的真實場景下座位被其他人鎖定時后端會返回狀態(tài)變更。解決在做數(shù)據(jù)服務(wù)層時預(yù)留updateSeatStatus通道在選座頁每次加載、點選座位后都從服務(wù)端同步一次場次鎖座狀態(tài)。工程里我用一個模擬延遲來演示這個過程點選后 500ms 內(nèi)服務(wù)層會返回座位狀態(tài)變化頁面根據(jù)響應(yīng)刷新按鈕樣式。真實接入時把這個服務(wù)替換成 WebSocket 推送或輪詢接口即可。6. 二次開發(fā)實戰(zhàn)從本地 JSON 到真實接口的改造路徑6.1 把數(shù)據(jù)服務(wù)抽成協(xié)議頁面不感知數(shù)據(jù)來源如果要把這個 Demo 接到真實后端第一個動作是定義數(shù)據(jù)服務(wù)協(xié)議。我在最初寫服務(wù)層時已經(jīng)把接口抽象好了// 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) }本地實現(xiàn)MockSeatDataService讀 JSON、模擬鎖座新增RemoteSeatDataService走 URLSession 請求。改造時只需在控制器初始化的位置替換服務(wù)實例頁面和 ViewModel 完全不用動。這種依賴注入的做法是整個工程里我認為最值得保留的設(shè)計。鎖座接口需要注意冪等性用戶選好座、點了確認請求因為網(wǎng)絡(luò)抖動發(fā)出去了兩次后端不能給用戶鎖兩張票。常見的做法是客戶端生成一個請求批次號服務(wù)端用批次號做去重返回的鎖座結(jié)果才能保證一致。6.2 選座頁的邊界超時、刷新和異?;謴?fù)接真實接口后選座頁還要處理三類邊界鎖座超時、狀態(tài)輪詢、斷網(wǎng)恢復(fù)。我一般習(xí)慣在 ViewModel 里放一個loadState枚舉用loading / success / failure三個狀態(tài)驅(qū)動頁面顯示。鎖座請求發(fā)出后啟動一個計時器超時未返回就提示用戶重新確認如果用戶切后臺再回前臺重新拉取一次場次狀態(tài)。每次刷新后只更新狀態(tài)變化的座位而不是整張圖重建這樣可以避免閃爍也減少無謂的動畫。另外真實環(huán)境中不同票檔有不同顏色和價格「票價分區(qū)」一定要在設(shè)計模型時就留好字段。我這次拆這個工程最大的收獲是把座位圖當做一個有狀態(tài)的前端組件來設(shè)計而不是一堆按鈕拼起來的死頁面。從那以后我每次接這類選座頁面都強制自己先按需求把所有座位狀態(tài)逐一點一遍再談后續(xù)聯(lián)調(diào)。希望這篇拆解能讓你少踩幾個我踩過的坑選座頁面的坑真的是踩一次記一輩子。本文還有配套的精品資源點擊獲取