Swift仿寫喜馬拉雅FM
- 最近抽空面了幾家公司,大部分都是從基礎開始慢慢深入專案和原理。面試內容還是以
OC
為主,但是多數也都會問一下Swift
技術情況,也有例外全程問Swift
的公司(做區塊鏈專案),感覺現在雖然大多數公司任然以OC
做為主開發語言,但是Swift
發展很強勢,估計明年Swift5
以後使用會更加廣泛。 - 另外,如果準備跳槽的話,可以提前投簡歷抽空面試幾家公司,一方面可以通過投遞反饋檢驗簡歷,另外可以總結面試的大致問題方向有利於做針對性複習,畢竟會用也要會說才行,會說也要能說到重點才行,還有就是心儀的公司一定要留到最後面試。希望都能進一個心儀不坑的公司,當然也應努力提升自己的技術,不坑公司不坑團隊, 好像跑題了!!!
目錄:
- 上一個仿寫專案
GitHub
: ofollow,noindex">github.com/daomoer/YYS… 專案分析地址:Swift仿寫有妖氣漫畫 - 本專案開始前準備階段: Swift高仿喜馬拉雅APP之一Charles抓包、圖片資源獲取等
- 本專案
GitHub
: XMLYFM" rel="nofollow,noindex">github.com/daomoer/XML…
關於專案:
該專案採用 MVC
+ MVVM
設計模式, Moya
+ SwiftyJSON
+ HandyJSON
網路框架和資料解析。資料來源抓包及部分本地 json
檔案。 使用 Xcode9.4
基於 Swift4.1
進行開發。 專案中使用到的一些開源庫以下列表,在這裡感謝作者的開源。
pod 'SnapKit' pod 'Kingfisher' #tabbar樣式 pod 'ESTabBarController-swift' #banner滾動圖片 pod 'FSPagerView' pod 'Moya' pod 'HandyJSON' pod 'SwiftyJSON' # 分頁 pod 'DNSPageView' #跑馬燈 pod 'JXMarqueeView' #滾動頁 pod 'LTScrollView' #重新整理 pod 'MJRefresh' #訊息提示 pod 'SwiftMessages' pod 'SVProgressHUD' #播放網路音訊 pod 'StreamingKit' 複製程式碼
效果圖:






MVVM
模式進行設計,下面貼一下
ViewModel
中介面請求和佈局設定方法程式碼。
import UIKit import SwiftyJSON import HandyJSON class HomeRecommendViewModel: NSObject { // MARK - 資料模型 var fmhomeRecommendModel:FMHomeRecommendModel? var homeRecommendList:[HomeRecommendModel]? var recommendList : [RecommendListModel]? // Mark: -資料來源更新 typealias AddDataBlock = () ->Void var updataBlock:AddDataBlock? // Mark:-請求資料 extension HomeRecommendViewModel { func refreshDataSource() { //首頁推薦介面請求 FMRecommendProvider.request(.recommendList) { result in if case let .success(response) = result { //解析資料 let data = try? response.mapJSON() let json = JSON(data!) if let mappedObject = JSONDeserializer<FMHomeRecommendModel>.deserializeFrom(json: json.description) { // 從字串轉換為物件例項 self.fmhomeRecommendModel = mappedObject self.homeRecommendList = mappedObject.list if let recommendList = JSONDeserializer<RecommendListModel>.deserializeModelArrayFrom(json: json["list"].description) { self.recommendList = recommendList as? [RecommendListModel] } } } } // Mark:-collectionview資料 extension HomeRecommendViewModel { func numberOfSections(collectionView:UICollectionView) ->Int { return (self.homeRecommendList?.count) ?? 0 } // 每個分割槽顯示item數量 func numberOfItemsIn(section: NSInteger) -> NSInteger { return 1 } //每個分割槽的內邊距 func insetForSectionAt(section: Int) -> UIEdgeInsets { return UIEdgeInsetsMake(0, 0, 0, 0) } //最小 item 間距 func minimumInteritemSpacingForSectionAt(section:Int) ->CGFloat { return 0 } //最小行間距 func minimumLineSpacingForSectionAt(section:Int) ->CGFloat { return 0 } // 分割槽頭檢視size func referenceSizeForHeaderInSection(section: Int) -> CGSize { let moduleType = self.homeRecommendList?[section].moduleType if moduleType == "focus" || moduleType == "square" || moduleType == "topBuzz" || moduleType == "ad" || section == 18 { return CGSize.zero }else { return CGSize.init(width: YYScreenHeigth, height:40) } } // 分割槽尾檢視size func referenceSizeForFooterInSection(section: Int) -> CGSize { let moduleType = self.homeRecommendList?[section].moduleType if moduleType == "focus" || moduleType == "square" { return CGSize.zero }else { return CGSize.init(width: YYScreenWidth, height: 10.0) } } } 複製程式碼
與 ViewModel
相對應的是控制器 Controller.m
檔案中的使用,使用 MVVM
可以梳理 Controller
看起來更整潔一點,避免滿眼的邏輯判斷。
lazy var viewModel: HomeRecommendViewModel = { return HomeRecommendViewModel() }() override func viewDidLoad() { super.viewDidLoad() self.view.addSubview(self.collectionView) self.collectionView.snp.makeConstraints { (make) in make.width.height.equalToSuperview() make.center.equalToSuperview() } self.collectionView.uHead.beginRefreshing() loadData() loadRecommendAdData() } func loadData(){ // 載入資料 viewModel.updataBlock = { [unowned self] in self.collectionView.uHead.endRefreshing() // 更新列表資料 self.collectionView.reloadData() } viewModel.refreshDataSource() } // MARK - collectionDelegate extension HomeRecommendController: UICollectionViewDelegateFlowLayout, UICollectionViewDataSource, UICollectionViewDelegate { func numberOfSections(in collectionView: UICollectionView) -> Int { return viewModel.numberOfSections(collectionView:collectionView) } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return viewModel.numberOfItemsIn(section: section) } //每個分割槽的內邊距 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { return viewModel.insetForSectionAt(section: section) } //最小 item 間距 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { return viewModel.minimumInteritemSpacingForSectionAt(section: section) } //最小行間距 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { return viewModel.minimumLineSpacingForSectionAt(section: section) } //item 的尺寸 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return viewModel.sizeForItemAt(indexPath: indexPath) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { return viewModel.referenceSizeForHeaderInSection(section: section) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { return viewModel.referenceSizeForFooterInSection(section: section) } 複製程式碼
首頁模組分析:
專案首頁推薦模組,根據介面請求資料進行處理,頂部的 Banner
滾動圖片和分類按鈕以及下面的聽頭條統一劃分為 HeaderCell
,在這個 HeaderCell
中繼續劃分,頂部 Banner
單獨處理,下面建立 CollectionView
,並把分類按鈕和聽頭條作為兩個 Section
,其中聽頭條的實現思路為 CollectionCell
,通過定時器控制器自動上下滾動。


moduleType
進行
Section
初始化並返回不同樣式的
Cell
,另外在該模組中還穿插有廣告,廣告為單獨介面,根據介面返回資料穿插到對應的
Section
。
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let moduleType = viewModel.homeRecommendList?[indexPath.section].moduleType if moduleType == "focus" || moduleType == "square" || moduleType == "topBuzz" { let cell:FMRecommendHeaderCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMRecommendHeaderCellID, for: indexPath) as! FMRecommendHeaderCell cell.focusModel = viewModel.focus cell.squareList = viewModel.squareList cell.topBuzzListData = viewModel.topBuzzList cell.delegate = self return cell }else if moduleType == "guessYouLike" || moduleType == "paidCategory" || moduleType == "categoriesForLong" || moduleType == "cityCategory"{ ///橫式排列布局cell let cell:FMRecommendGuessLikeCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMRecommendGuessLikeCellID, for: indexPath) as! FMRecommendGuessLikeCell cell.delegate = self cell.recommendListData = viewModel.homeRecommendList?[indexPath.section].list return cell }else if moduleType == "categoriesForShort" || moduleType == "playlist" || moduleType == "categoriesForExplore"{ // 豎式排列布局cell let cell:FMHotAudiobookCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMHotAudiobookCellID, for: indexPath) as! FMHotAudiobookCell cell.delegate = self cell.recommendListData = viewModel.homeRecommendList?[indexPath.section].list return cell }else if moduleType == "ad" { let cell:FMAdvertCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMAdvertCellID, for: indexPath) as! FMAdvertCell if indexPath.section == 7 { cell.adModel = self.recommnedAdvertList?[0] }else if indexPath.section == 13 { cell.adModel = self.recommnedAdvertList?[1] } return cell }else if moduleType == "oneKeyListen" { let cell:FMOneKeyListenCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMOneKeyListenCellID, for: indexPath) as! FMOneKeyListenCell cell.oneKeyListenList = viewModel.oneKeyListenList return cell }else if moduleType == "live" { let cell:HomeRecommendLiveCell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeRecommendLiveCellID, for: indexPath) as! HomeRecommendLiveCell cell.liveList = viewModel.liveList return cell } else { let cell:FMRecommendForYouCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMRecommendForYouCellID, for: indexPath) as! FMRecommendForYouCell return cell } } 複製程式碼
專案中分割槽尺寸高度是根據返回資料的 Count
進行計算的,其他各模組基本思路相同這裡只貼一下首頁模組分割槽的尺寸高度計算。
// item 尺寸 func sizeForItemAt(indexPath: IndexPath) -> CGSize { let HeaderAndFooterHeight:Int = 90 let itemNums = (self.homeRecommendList?[indexPath.section].list?.count)!/3 let count = self.homeRecommendList?[indexPath.section].list?.count let moduleType = self.homeRecommendList?[indexPath.section].moduleType if moduleType == "focus" { return CGSize.init(width:YYScreenWidth,height:360) }else if moduleType == "square" || moduleType == "topBuzz" { return CGSize.zero }else if moduleType == "guessYouLike" || moduleType == "paidCategory" || moduleType == "categoriesForLong" || moduleType == "cityCategory" || moduleType == "live"{ return CGSize.init(width:YYScreenWidth,height:CGFloat(HeaderAndFooterHeight+180*itemNums)) }else if moduleType == "categoriesForShort" || moduleType == "playlist" || moduleType == "categoriesForExplore"{ return CGSize.init(width:YYScreenWidth,height:CGFloat(HeaderAndFooterHeight+120*count!)) }else if moduleType == "ad" { return CGSize.init(width:YYScreenWidth,height:240) }else if moduleType == "oneKeyListen" { return CGSize.init(width:YYScreenWidth,height:180) }else { return .zero } } 複製程式碼
首頁分類模組分析:
首頁分類採用的是 CollectionView
展示分類列表,點選每個分類 Item
進入對應的分類介面,根據 categoryId
請求頂部滾動 title
資料,另外該資料不包含推薦模組,所以分類整體為兩個 Controller
,一個為推薦模組,一個為其他分類介面根據不同 categoryId
顯示不同資料列表(因為該介面資料樣式一樣都是列表),然後推薦部分按照首頁的同等思路根據不同的 moduleType
顯示不同型別 Cell
。


首頁Vip模組分析:
首頁 Vip
模組與推薦模組較為相似,頂部 Banner
滾動圖片和分類按鈕作為頂部 Cell
,然後其他 Cell
橫向顯示或者是豎向顯示以及顯示的 Item
數量根據介面而定,分割槽的標題同樣來自於介面資料,點選分割槽 headerVeiw
的更多按鈕跳轉到該分割槽模組的更多頁面。

首頁直播模組分析:
首頁直播介面的排版主要分四個部分也就是自定義四個 CollectionCell
,頂部分類按鈕,接著是 Banner
滾動圖片 Cell
內部使用 FSPagerView
實現滾動圖片效果,滾動排行榜為 Cell
內部巢狀 CollectionView
,通過定時器控制 CollectionCell
實現自動滾動,接下來就是播放列表了,通過自定義 HeaderView
上面的按鈕切換,重新整理不同型別的播放列表。


首頁廣播模組分析:
首頁廣播模組主要分三個部分,頂部分類按鈕 Cell
,中間可展開收起分類 Item
,因為介面中返回的是 14
個電臺分類,收起狀態顯示 7
個電臺和展開按鈕,展開狀態顯示 14
個電臺和收起按鈕中間空一格 Item
,在 ViewModel
中獲取到資料之後進行插入圖片按鈕並根據當前展開或是收起狀態返回不同 Item
資料來實現這部分功能,剩下的是根據資料介面中的分割槽顯示列表和 HeaderView
內容。
點選廣播頂部分類 Item
跳轉到對應介面,但是介面返回的該 Item
引數為 Url
中拼接的欄位例如:url:"iting://open?msg_type=70&api=http://live.ximalaya.com/live-web/v2/radio/national&title=國家臺&type=national",所以我們要解析 Url
拼接引數為字典,拿到我們所需的跳轉下一介面請求介面用到的欄位。下面為程式碼部分:
func getUrlAPI(url:String) -> String { // 判斷是否有引數 if !url.contains("?") { return "" } var params = [String: Any]() // 擷取引數 let split = url.split(separator: "?") let string = split[1] // 判斷引數是單個引數還是多個引數 if string.contains("&") { // 多個引數,分割引數 let urlComponents = string.split(separator: "&") // 遍歷引數 for keyValuePair in urlComponents { // 生成Key/Value let pairComponents = keyValuePair.split(separator: "=") let key:String = String(pairComponents[0]) let value:String = String(pairComponents[1]) params[key] = value } } else { // 單個引數 let pairComponents = string.split(separator: "=") // 判斷是否有值 if pairComponents.count == 1 { return "nil" } let key:String = String(pairComponents[0]) let value:String = String(pairComponents[1]) params[key] = value as AnyObject } guard let api = params["api"] else{return ""} return api as! String } 複製程式碼

我聽模組分析:
我聽模組主頁面頂部為自定義 HeaderView
,內部迴圈建立按鈕,下面為使用 LTScrollView
管理三個子模組的滾動檢視,訂閱和推薦為固定列表顯示介面資料,一鍵聽模組也是現實列表資料,其中有個跑馬燈滾動顯示重要內容的效果,點選新增頻道,跳轉更多頻道介面,該介面為雙 TableView
實現聯動效果,點選左邊分類 LeftTableView
對應右邊 RightTableView
滾動到指定分割槽,滾動右邊 RightTableView
對應的左邊 LeftTableView
滾動到對應分類。

發現模組分析:
發現模組主頁面頂部為自定義 HeaderView
,內部巢狀 CollectionView
建立分類按鈕 Item
,下面為使用 LTScrollView
管理三個子模組的滾動檢視,關注和推薦動態類似都是顯示圖片加文字形式顯示動態,這裡需要注意的是根據文字內容和圖片的張數計算當前 Cell
的高度,趣配音就是正常的列表顯示。
下面貼一個計算動態釋出距當前時間的程式碼 複製程式碼
//MARK: -根據後臺時間戳返回幾分鐘前,幾小時前,幾天前 func updateTimeToCurrennTime(timeStamp: Double) -> String { //獲取當前的時間戳 let currentTime = Date().timeIntervalSince1970 //時間戳為毫秒級要 / 1000, 秒就不用除1000,引數帶沒帶000 let timeSta:TimeInterval = TimeInterval(timeStamp / 1000) //時間差 let reduceTime : TimeInterval = currentTime - timeSta //時間差小於60秒 if reduceTime < 60 { return "剛剛" } //時間差大於一分鐘小於60分鐘內 let mins = Int(reduceTime / 60) if mins < 60 { return "\(mins)分鐘前" } //時間差大於一小時小於24小時內 let hours = Int(reduceTime / 3600) if hours < 24 { return "\(hours)小時前" } //時間差大於一天小於30天內 let days = Int(reduceTime / 3600 / 24) if days < 30 { return "\(days)天前" } //不滿足上述條件---或者是未來日期-----直接返回日期 let date = NSDate(timeIntervalSince1970: timeSta) let dfmatter = DateFormatter() //yyyy-MM-dd HH:mm:ss dfmatter.dateFormat="yyyy年MM月dd日 HH:mm:ss" return dfmatter.string(from: date as Date) } 複製程式碼

我的模組分析:
我的介面在這裡被劃分為了三個模組,頂部的頭像、名稱、粉絲等一類個人資訊作為 TableView
的 HeaderView
,並且在該 HeaderView
中迴圈建立了已購、優惠券等按鈕,然後是 Section0
迴圈建立錄音、直播等按鈕,下面的 Cell
根據 dataSource
進行分割槽顯示及每個分割槽的 count
。在我的介面中使用了兩個小動畫,一個是上下滾動的優惠券引導領取動畫,另一個是我要錄音一個波狀擴散提示錄音動畫。
下面貼一下波紋擴散動畫的程式碼 複製程式碼
import UIKit class CVLayerView: UIView { var pulseLayer : CAShapeLayer!//定義圖層 override init(frame: CGRect) { super.init(frame: frame) let width = self.bounds.size.width // 動畫圖層 pulseLayer = CAShapeLayer() pulseLayer.bounds = CGRect(x: 0, y: 0, width: width, height: width) pulseLayer.position = CGPoint(x: width/2, y: width/2) pulseLayer.backgroundColor = UIColor.clear.cgColor // 用BezierPath畫一個原型 pulseLayer.path = UIBezierPath(ovalIn: pulseLayer.bounds).cgPath // 脈衝效果的顏色(註釋*1) pulseLayer.fillColor = UIColor.init(r: 213, g: 54, b: 13).cgColor pulseLayer.opacity = 0.0 // 關鍵程式碼 let replicatorLayer = CAReplicatorLayer() replicatorLayer.bounds = CGRect(x: 0, y: 0, width: width, height: width) replicatorLayer.position = CGPoint(x: width/2, y: width/2) replicatorLayer.instanceCount = 3// 三個複製圖層 replicatorLayer.instanceDelay = 1// 頻率 replicatorLayer.addSublayer(pulseLayer) self.layer.addSublayer(replicatorLayer) self.layer.insertSublayer(replicatorLayer, at: 0) } func starAnimation() { // 透明 let opacityAnimation = CABasicAnimation(keyPath: "opacity") opacityAnimation.fromValue = 1.0// 起始值 opacityAnimation.toValue = 0// 結束值 // 擴散動畫 let scaleAnimation = CABasicAnimation(keyPath: "transform") let t = CATransform3DIdentity scaleAnimation.fromValue = NSValue(caTransform3D: CATransform3DScale(t, 0.0, 0.0, 0.0)) scaleAnimation.toValue = NSValue(caTransform3D: CATransform3DScale(t, 1.0, 1.0, 0.0)) // 給CAShapeLayer新增組合動畫 let groupAnimation = CAAnimationGroup() groupAnimation.animations = [opacityAnimation,scaleAnimation] groupAnimation.duration = 3//持續時間 groupAnimation.autoreverses = false //迴圈效果 groupAnimation.repeatCount = HUGE groupAnimation.isRemovedOnCompletion = false pulseLayer.add(groupAnimation, forKey: nil) } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } } 複製程式碼


播放模組分析:
播放模組可以說是整個專案主線的終點,前面模組點選跳轉進入具體節目介面,主頁面頂部為自定義 HeaderView
,主要顯示該有聲讀物的一些介紹,背景為毛玻璃虛化,下面為使用 LTScrollView
管理三個子模組的滾動檢視,簡介為對讀物和作者的介紹,節目列表為該讀物分章節顯示,找相似為與此相似的讀物,圈子為讀者分享圈幾個子模組都是簡單的列表顯示,子模組非固定是根據介面返回資料決定有哪些子模組。
點選節目列表任一 Cell
就跳轉到播放詳情介面,該介面採用分割槽 CollectionCell
,頂部 Cell
為整體的音訊播放及控制,因為要實時播放音訊所以沒有使用 AVFoudtion
,該框架需要先快取本地在進行播放,而是使用的三方開源的 Streaming
庫來線上播放音訊,剩下的為作者發言和評論等。

總結:
目前專案中主要模組的介面和功能基本完成,寫法也都是比較簡單的寫法,專案用時很短,目前一些功能模組使用了第三方。接下來 1、準備替換為自己封裝的控制元件 2、把專案中可以複用的部分抽離出來封裝為靈活多用的公共元件 3、對當前模組進行一些 Bug
修改和當前功能完善。 在這件事情完成之後準備對整體程式碼進行 Review
,之後進行接下來功能模組的仿寫。
最後:
感興趣的朋友可以到 GitHub
: github.com/daomoer/XML…
下載原始碼看看,也請多提意見,喜歡的朋友動動小手給點個 Star
:sparkles::sparkles: