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