Swift高仿iOS網易雲音樂Moya+RxSwift+Kingfisher+MVC+MVVM

2022-07-31 18:01:12

效果

列文章目錄

因為目錄比較多,每次更新這裡比較麻煩,所以推薦點選到主頁,然後檢視iOS Swift雲音樂專欄。

目簡介

這是一個使用Swift(還有OC版本)語言,從0開發一個iOS平臺,接近企業級的專案(我的雲音樂),包含了基礎內容,高階內容,專案封裝,專案重構等知識;主要是使用系統功能,流行的第三方框架,第三方服務,完成接近企業級商業級專案。

目功能點

隱私協定對話方塊
啟動介面和動態處理許可權
引導介面和廣告
輪播圖和側滑選單
首頁複雜列表和列表排序
音樂播放和音樂列表管理
全域性音樂控制條
桌面歌詞和自定義樣式
全域性媒體控制中心
評論和回覆評論
評論富文字點選
評論提醒人和話題
朋友圈動態列表和釋出
高德地圖定位和路徑規劃
阿里雲OSS上傳
視訊播放和控制
QQ/微信登入和分享
商城/購物車\微信\支付寶支付
文字和圖片聊天
訊息離線推播
自動和手動檢查更新
記憶體漏失和優化
...

發環境概述

2022年7月開發完成的,所以全部都是最新的,平均每3年會重新制作,現在已經是第三版了。

Xcode 13.4
iOS 15

譯和執行

先安裝pod,用最新Xcode開啟MyCloudMusic.xcworkspace,然後執行,如果要執行到真機,先登陸自己的開發者賬戶,如果不是付費賬戶,請刪除推播等付費功能,更改BundleId,然後執行。

目目錄結構

├── MyCloudMusic
│   ├── AppDelegate.swift
│   ├── Assets.xcassets #資源目錄
│   ├── Base.lproj
│   ├── Cell #通用cell
│   ├── Component #每個功能模組
│   │   ├── Ad #廣告相關
│   │   ├── Address #收穫地址相關
│   ├── Config #設定目錄,例如:網路地址設定
│   ├── Controller #通用控制器
│   ├── Extension #擴充套件,例如:字串擴充套件
│   ├── Info.plist
│   ├── Manager #管理器,例如:音樂播放管理器
│   ├── Model #通用模型
│   ├── MyCloudMusic-Bridging-Header.h
│   ├── MyCloudMusic.entitlements
│   ├── Repository #資料倉儲,例如:網路請求封裝
│   ├── Service #資料服務,例如:網路api
│   ├── UI #通用UI模型
│   ├── Util #工具類
│   ├── Vender #通過原始碼方式依賴的第三方框架
│   ├── View #通用View
├── MyCloudMusic.xcodeproj
├── MyCloudMusic.xcworkspace
├── MyCloudMusicTests #測試相關
├── MyCloudMusicUITests #UI測試相關
├── Podfile
├── Podfile.lock
└── R.generated.swift #R.swfit框架生成的檔案

賴框架

內容太多,只列出部分。

target 'MyCloudMusic' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for MyCloudMusic
  #提供類似Android中更高層級的佈局框架
  #https://github.com/youngsoft/TangramKit
  pod 'TangramKit'
  
  #將資源(圖片,檔案等)生成類,方便到程式碼中方法
  #例如:let icon = R.image.settingsIcon()
  #let font = R.font.sanFrancisco(size: 42)
  #let color = R.color.indicatorHighlight()
  #let viewController = CustomViewController(nib: R.nib.customView)
  #let string = R.string.localizable.welcomeWithName("Arthur Dent")
  #https://github.com/mac-cain13/R.swift
  pod 'R.swift'
  
  #騰訊開源的UI框架,提供了很多功能,例如:圓角按鈕,空心按鈕,TextView支援placeholder
  #https://github.com/QMUI/QMUIDemo_iOS
  #https://qmuiteam.com/ios/get-started
  pod "QMUIKit"
  
  #圖片載入
  #https://github.com/SDWebImage/SDWebImage
  pod 'SDWebImage'
  
  # 網路請求框架
  # https://github.com/Moya/Moya
  pod 'Moya/RxSwift'

  #避免每個介面定義disposeBag
  #https://github.com/RxSwiftCommunity/NSObject-Rx
  pod "NSObject+Rx"
  
  #提示框架
  #https://github.com/jdg/MBProgressHUD
  pod 'MBProgressHUD'
  
  #Swift圖片載入
  #https://github.com/onevcat/Kingfisher
  pod "Kingfisher"
  
  #Swift擴充套件,像字串,陣列等
  #https://github.com/SwifterSwift/SwifterSwift
  pod 'SwifterSwift'
  
  #下拉重新整理
  #https://github.com/CoderMJLee/MJRefresh
  pod 'MJRefresh'
  
  #富文字方塊架
  #https://github.com/a1049145827/BSText
  #OC版本:https://github.com/ibireme/YYText
  pod "BSText"
  
  #騰訊開源的偏好儲存框架
  #https://github.com/Tencent/MMKV
  pod 'MMKV'
  
  #騰訊WCDB是一個高效、完整、易用的行動資料庫框架,基於SQLCipher,支援iOS, macOS和Android
  #https://github.com/Tencent/wcdb
  pod 'WCDB.swift'
  
  #面向泛前端產品研發全生命週期的效率平臺,檢視資料庫,網路請求,記憶體漏失
  #https://xingyun.xiaojukeji.com/docs/dokit/#/iosGuide
    pod 'DoraemonKit/Core', :configurations => ['Debug'] #必選
  #  pod 'DoraemonKit/WithGPS', '~> 3.0.4', :configurations => ['Debug'] #可選
  #  pod 'DoraemonKit/WithLoad', '~> 3.0.4', :configurations => ['Debug'] #可選
  #  pod 'DoraemonKit/WithLogger', '~> 3.0.4', :configurations => ['Debug'] #可選
    pod 'DoraemonKit/WithDatabase',  :configurations => ['Debug'] #可選
  #  pod 'DoraemonKit/WithMLeaksFinder',  :configurations => ['Debug'] #可選
  #  pod 'DoraemonKit/WithWeex', '~> 3.0.4', :configurations => ['Debug'] #可選
  
  #騰訊雲開源的一款播放器元件,簡單幾行程式碼即可擁有類似騰訊視訊強大的播放功能,包括橫豎屏切換、清晰度選擇、手勢和小窗等基礎功能,還支援視訊快取,軟硬解切換和倍速播放等特殊功能,相比系統播放器,支援格式更多,相容性更好,功能更強大,同時還具備首屏秒開、低延遲的優點,以及視訊縮圖等高階能力。
  #https://cloud.tencent.com/document/product/881/20208
  pod 'SuperPlayer'
  
  #圖片選擇框架,預覽框架
  #https://github.com/longitachi/ZLPhotoBrowser
  pod 'ZLPhotoBrowser'
  
  # 阿里雲OSS
  # 用來上傳發布帶圖片動態
  # https://help.aliyun.com/document_detail/32055.html
  pod 'AliyunOSSiOS'
  
  #高德地圖
  #https://lbs.amap.com/api/ios-sdk/guide/create-project/cocoapods
  #這裡用的是沒有IDFA的sdk,更多說明:https://lbs.amap.com/api/ios-sdk/guide/create-project/idfa-guide
  pod 'AMap3DMap-NO-IDFA'

  #使用者詳情頭部檢視
  # https://github.com/pujiaxin33/JXPagingView
  pod 'JXPagingView/Paging'

  #指示器
  #https://github.com/pujiaxin33/JXSegmentedView
  pod 'JXSegmentedView'
  
  #支付寶支付
  #https://docs.open.alipay.com/204/105295/
  pod 'AlipaySDK-iOS'
  
  #融雲聊天
  #https://doc.rongcloud.cn/im/IOS/5.X/noui/import
  pod 'RongCloudIM/IMLib'
  
  # share sdk
  #https://mob.com/wiki/detailed?wiki=4&id=14
  # 主模組(必須)
  pod 'mob_sharesdk'

  # UI模組(非必須,需要用到ShareSDK提供的分享選單欄和分享編輯頁面需要以下1行)
  pod 'mob_sharesdk/ShareSDKUI'

  # 平臺SDK模組(對照一下平臺,需要的加上。如果只需要QQ、微信、新浪微博,只需要以下3行)
  pod 'mob_sharesdk/ShareSDKPlatforms/QQ'
  pod 'mob_sharesdk/ShareSDKPlatforms/SinaWeibo'

  #(微信sdk不帶支付的命令)
  #  pod 'mob_sharesdk/ShareSDKPlatforms/WeChat'

  #(微信sdk帶支付的命令,和上面不帶支付的不能共存,只能選擇一個)
  pod 'mob_sharesdk/ShareSDKPlatforms/WeChatFull'

  #需要精簡版QQ,微信,微博,Facebook的可以加這3個命令(精簡版去掉了這4個平臺的原生SDK)
  #  pod 'mob_sharesdk/ShareSDKPlatforms/QQ_Lite'
  #  pod 'mob_sharesdk/ShareSDKPlatforms/SinaWeibo_Lite'
  #  pod 'mob_sharesdk/ShareSDKPlatforms/WeChat_Lite'
  #  pod 'mob_sharesdk/ShareSDKPlatforms/Facebook_Lite'
  #  pod 'mob_sharesdk/ShareSDKPlatforms/KuaiShou_Lite'

  # ShareSDKPlatforms模組其他平臺,按需新增

  #  pod 'mob_sharesdk/ShareSDKPlatforms/TikTok'
  #  pod 'mob_sharesdk/ShareSDKPlatforms/SnapChat'
  #  pod 'mob_sharesdk/ShareSDKPlatforms/Oasis'

  # 使用組態檔分享模組(非必須)
  #  pod 'mob_sharesdk/ShareSDKConfigFile'

  # 閉環分享依賴(非必須)
  #  pod 'mob_sharesdk/ShareSDKRestoreScene'

  # 擴充套件模組(在呼叫可以彈出我們UI分享方法的時候是必需的)
  pod 'mob_sharesdk/ShareSDKExtension'
  #end share sdk

  target 'MyCloudMusicTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'MyCloudMusicUITests' do
    # Pods for testing
  end

end

戶協定對話方塊

使用自定義Dialog實現。

class TermServiceDialogController: BaseController, QMUIModalPresentationContentViewControllerProtocol {
    var contentContainer:TGBaseLayout!
    var modalController:QMUIModalPresentationViewController!
    var textView:UITextView!
    var disagreeButton:QMUIButton!
    
    override func initViews() {
        super.initViews()
        view.layer.cornerRadius = SMALL_RADIUS
        view.clipsToBounds = true
        view.backgroundColor = .colorDivider
        view.tg_width.equal(.fill)
        view.tg_height.equal(.wrap)
        
        //內容容器
        contentContainer = TGLinearLayout(.vert)
        contentContainer.tg_width.equal(.fill)
        contentContainer.tg_height.equal(.wrap)
        contentContainer.tg_space = 25
        contentContainer.backgroundColor = .colorBackground
        contentContainer.tg_padding = UIEdgeInsets(top: PADDING_OUTER, left: PADDING_OUTER, bottom: PADDING_OUTER, right: PADDING_OUTER)
        contentContainer.tg_gravity = TGGravity.horz.center
        view.addSubview(contentContainer)
        
        //標題
        contentContainer.addSubview(titleView)
        
        textView = UITextView()
        textView.tg_width.equal(.fill)
        
        //超出的內容,自動支援捲動
        textView.tg_height.equal(230)
        textView.text="公司CFO David Wehner..."
        
        textView.backgroundColor = .clear
        
        //禁用編輯
        textView.isEditable = false
        
        contentContainer.addSubview(textView)
        
        contentContainer.addSubview(primaryButton)
        
        //不同意按鈕按鈕
        disagreeButton=ViewFactoryUtil.linkButton()
        disagreeButton.setTitle(R.string.localizable.disagree(), for: .normal)
        disagreeButton.setTitleColor(.black80, for: .normal)
        disagreeButton.addTarget(self, action: #selector(disagreeClick(_:)), for: .touchUpInside)
        disagreeButton.sizeToFit()
        contentContainer.addSubview(disagreeButton)
    }
    
    @objc func disagreeClick(_ sender:QMUIButton) {
        hide()
        
        //退出應用
        exit(0)
    }
    
    func show() {
        modalController = QMUIModalPresentationViewController()
        modalController.animationStyle = .fade
        
        //邊距
        modalController.contentViewMargins = UIEdgeInsets(top: PADDING_LARGE2, left: PADDING_LARGE2, bottom: PADDING_LARGE2, right: PADDING_LARGE2)
        
        //點選外部不隱藏
        modalController.isModal = true
        
        //設定要顯示的內容控制元件
        modalController.contentViewController = self
        
        modalController.showWith(animated: true)
    }
    
    lazy var titleView: UILabel = {
        let r = UILabel()
        r.tg_width.equal(.fill)
        r.tg_height.equal(.wrap)
        r.text = "標題"
        r.textColor = .colorOnSurface
        r.font = UIFont.boldSystemFont(ofSize: TEXT_LARGE2)
        r.textAlignment = .center
        return r
    }()
    
    lazy var primaryButton: QMUIButton = {
        let r = ViewFactoryUtil.primaryHalfFilletButton()
        r.setTitle(R.string.localizable.agree(), for: .normal)
        return r
    }()
}

導介面

引導介面比較簡單,就是多個圖片可以左右捲動。

class GuideController: BaseLogicController {
    var bannerView:YJBannerView!

    override func initViews() {
        super.initViews()
        initLinearLayoutSafeArea()
        
        container.tg_space = PADDING_OUTER
        
        bannerView = YJBannerView()
        bannerView.backgroundColor = .clear
        bannerView.dataSource = self
        bannerView.delegate = self
        bannerView.tg_width.equal(.fill)
        bannerView.tg_height.equal(.fill)
        
        //設定如果找不到圖片顯示的圖片
        bannerView.emptyImage = R.image.placeholderError()
        
        //設定佔點陣圖
        bannerView.placeholderImage = R.image.placeholder()
        
        //設定輪播圖內部顯示圖片的時候呼叫什麼方法
        bannerView.bannerViewSelectorString = "sd_setImageWithURL:placeholderImage:"
        
        //設定指示器預設顏色
        bannerView.pageControlNormalColor = .black80
        
        //高亮的顏色
        bannerView.pageControlHighlightColor = .colorPrimary
        
        //重新載入資料
        bannerView.reloadData()
        
        container.addSubview(bannerView)
        
        //按鈕容器
        let controlContainer = TGLinearLayout(.horz)
        controlContainer.tg_bottom.equal(PADDING_OUTER)
        controlContainer.tg_width ~= .fill
        controlContainer.tg_height.equal(.wrap)
        
        //水平拉昇,左,中,右間距一樣
        controlContainer.tg_gravity = TGGravity.horz.among
        container.addSubview(controlContainer)
        
        //登入註冊按鈕
        let primaryButton = ViewFactoryUtil.primaryButton()
        primaryButton.setTitle(R.string.localizable.loginOrRegister(), for: .normal)
        primaryButton.addTarget(self, action: #selector(primaryClick(_:)), for: .touchUpInside)
        primaryButton.tg_width.equal(BUTTON_WIDTH_MEDDLE)
        controlContainer.addSubview(primaryButton)
        
        //立即體驗按鈕
        let enterButton = ViewFactoryUtil.primaryOutlineButton()
        enterButton.setTitle(R.string.localizable.experienceNow(), for: .normal)
        enterButton.addTarget(self, action: #selector(enterClick(_:)), for: .touchUpInside)
        enterButton.tg_width.equal(BUTTON_WIDTH_MEDDLE)
        controlContainer.addSubview(enterButton)
        
    }
    
    ///登入註冊按鈕點選
    /// - Parameter sender: <#sender description#>
    @objc func primaryClick(_ sender:QMUIButton) {
        AppDelegate.shared.toLogin()
    }
    
    ///立即體驗按鈕點選
    /// - Parameter sender: <#sender description#>
    @objc func enterClick(_ sender:QMUIButton) {
        AppDelegate.shared.toMain()
    }

}

// MARK: - YJBannerViewDataSource
extension GuideController:YJBannerViewDataSource{
    /// banner資料來源
    ///
    /// - Parameter bannerView: <#bannerView description#>
    /// - Returns: <#return value description#>
    func bannerViewImages(_ bannerView: YJBannerView!) -> [Any]! {
        return ["guide1","guide2","guide3","guide4","guide5"]
    }
    
    /// 自定義Cell
    /// 複寫該方法的目的是
    /// 設定圖片的縮放模式
    ///
    /// - Parameters:
    ///   - bannerView: <#bannerView description#>
    ///   - customCell: <#customCell description#>
    ///   - index: <#index description#>
    /// - Returns: <#return value description#>
    func bannerView(_ bannerView: YJBannerView!, customCell: UICollectionViewCell!, index: Int) -> UICollectionViewCell! {
        //將cell型別轉為YJBannerViewCell
        let cell = customCell as! YJBannerViewCell

        //設定圖片的縮放模式為
        //從中心填充
        //多餘的裁剪掉
        cell.showImageViewContentMode = .scaleAspectFit

        return cell
    }
}

// MARK: - YJBannerViewDelegate
extension GuideController:YJBannerViewDelegate{
    
}

廣告介面

實現圖片廣告和視訊廣告,廣告資料是在首頁是快取到本地,目的是在啟動介面載入更快,因為真實專案中,大部分專案啟動頁面廣告時間一共就5秒,如果太長了使用者體驗不好,如果是從網路請求,那麼網路可能就耗時2秒左右,所以導致就美喲多少時間顯示廣告了。

廣告

func downloadAd(_ data:Ad,_ path:URL) {
    let destination: DownloadRequest.Destination = { _, _ in
        return (path, [.removePreviousFile, .createIntermediateDirectories])
    }

    AF.download(data.icon.absoluteUri(), to: destination).response { response in
        if response.error == nil, let filePath = response.fileURL?.path {
            print("ad downloaded success \(filePath)")
        }
    }
}

廣告

func showVideoAd(_ data:URL) {
    //播放應用內嵌入視訊,放根目錄中
    //同樣其他的檔案,也可以通過這種方式讀取
	//var data=Bundle.main.url(forResource: "ixueaeduTestVideo", withExtension: ".mp4")!
    player = AVPlayer(url: data)
    
    //靜音
    player!.isMuted = true
    
    /// 新增進度監聽
    player!.addPeriodicTimeObserver(forInterval: CMTime(value: CMTimeValue(1.0), timescale: 60), queue: DispatchQueue.main, using: {time in
        if self.player == nil {
            return
        }
        
        //播放時間
        let current = Float(CMTimeGetSeconds(time))
        
        //總時間
        let duration = Float(CMTimeGetSeconds(self.player!.currentItem!.duration))
        
        if current==duration {
            //視訊播放結束
            self.next()
        } else {
            self.skipView.setTitle(R.string.localizable.skipAdCount(Int(duration-current)), for: .normal)
            self.skipView.tg_width.equal(.wrap)
            self.skipView.setNeedsLayout()
        }
    })
    
    //顯示影象
    playerLayer = AVPlayerLayer(player: player)
    
    //從中心等比縮放,完全顯示控制元件
    playerLayer?.videoGravity = .resizeAspectFill
    
    view.layer.insertSublayer(playerLayer!, at: 0)
}

顯示圖片就是顯示本地圖片了,沒什麼難點,就不貼程式碼了。

首頁/歌單詳情/黑膠唱片介面

首頁沒有頂部是輪播圖,然後是可以左右的選單,接下來是熱門歌單,推薦單曲,最後是首頁排序模組;整體上使用RecycerView實現,輪播圖:

//取出一個Cell
let cell = tableView.dequeueReusableCell(withIdentifier: Constant.CELL, for: indexPath) as! BannerCell

//繫結資料
cell.bind(data as! BannerData)

cell.bannerClick = {[weak self] data in
    self?.processAdClick(data)
}

推薦歌單

/// 協定
protocol SheetGroupDelegate:NSObjectProtocol {
    /// 歌單點選回撥
    /// - Parameter data: 點選的歌單物件
    func sheetClick(data:Sheet)
}

class SheetGroupCell: BaseTableViewCell {
    static let NAME = "SheetGroupCell"
    var datum:Array<Sheet> = []
    var cellWidth:CGFloat!
    var cellHeight:CGFloat!
    var spanCount:CGFloat = 3
    weak open var delegate: SheetGroupDelegate?
    
    override func initViews() {
        super.initViews()
        //分割線
        container.addSubview(ViewFactoryUtil.smallDivider())
        
        //標題
        container.addSubview(titleView)
        
        container.addSubview(collectionView)
        
        collectionView.register(SheetCell.self, forCellWithReuseIdentifier: Constant.CELL)
    }
    
    override func getContainerOrientation() -> TGOrientation {
        return .vert
    }
    
    func bind(_ data:SheetData) {
        //計算每個cell寬度
        
        //螢幕寬度-外邊距16*2-(self.spanCount-1)*5
        cellWidth = (SCREEN_WIDTH-PADDING_OUTER*CGFloat(2) - (spanCount - CGFloat(1))*PADDING_SMALL)/spanCount
        
        //cell高度,5:圖片和標題邊距,40:2行文字高度
        cellHeight = cellWidth + PADDING_SMALL + 40
        
        //計算可以顯示幾行
        let rows = ceil(CGFloat(data.datum.count) / spanCount)
        
        //CollectionView高度等於,行數*行高,10:垂直方向每個cell間距
        let viewHeight = rows * (cellHeight + PADDING_MEDDLE)
        
        collectionView.tg_height.equal(viewHeight)
        
        datum.removeAll()
        
        datum += data.datum
        collectionView.reloadData()
    }
    
    /// 標題控制元件
    lazy var titleView: ItemTitleView = {
        let r = ItemTitleView()
        r.titleView.text = R.string.localizable.recommendSheet()
        return r
    }()
    
    lazy var collectionView: UICollectionView = {
        let r = ViewFactoryUtil.collectionView()
        r.delegate = self
        r.dataSource = self
        r.isScrollEnabled = false
        
        return r
    }()
}

/// CollectionView資料來源和代理
extension SheetGroupCell:UICollectionViewDataSource,UICollectionViewDelegate {
    
    /// 有多少個
    /// - Parameters:
    ///   - collectionView: <#collectionView description#>
    ///   - section: <#section description#>
    /// - Returns: <#description#>
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return datum.count
    }
    
    /// 返回cell
    /// - Parameters:
    ///   - collectionView: <#collectionView description#>
    ///   - indexPath: <#indexPath description#>
    /// - Returns: <#description#>
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let data = datum[indexPath.row]
        
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constant.CELL, for: indexPath) as! SheetCell
        
        cell.bind(data)
        
        return cell
    }
    
    /// item點選
    /// - Parameters:
    ///   - collectionView: <#collectionView description#>
    ///   - indexPath: <#indexPath description#>
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        if let d = delegate {
            d.sheetClick(data:datum[indexPath.row])
        }
    }
}

/// UICollectionViewDelegateFlowLayout
extension SheetGroupCell:UICollectionViewDelegateFlowLayout{
    /// 返回CollectionView裡面的Cell到CollectionView的間距
    /// - Parameters:
    ///   - collectionView: <#collectionView description#>
    ///   - collectionViewLayout: <#collectionViewLayout description#>
    ///   - section: <#section description#>
    /// - Returns: <#description#>
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 0, left: PADDING_OUTER, bottom: PADDING_OUTER, right: PADDING_OUTER)
    }
    
    /// 返回每個Cell的行間距
    /// - Parameters:
    ///   - collectionView: <#collectionView description#>
    ///   - collectionViewLayout: <#collectionViewLayout description#>
    ///   - section: <#section description#>
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return PADDING_MEDDLE
    }
    
    /// 返回每個Cell的列間距
    /// - Parameters:
    ///   - collectionView: <#collectionView description#>
    ///   - collectionViewLayout: <#collectionViewLayout description#>
    ///   - section: <#section description#>
    /// - Returns: <#description#>
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return PADDING_SMALL
    }
    
    /// cell尺寸
    /// - Parameters:
    ///   - collectionView: <#collectionView description#>
    ///   - collectionViewLayout: <#collectionViewLayout description#>
    ///   - indexPath: <#indexPath description#>
    /// - Returns: <#description#>
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: cellWidth, height: cellHeight)
    }
}

詳情

頂部是歌單資訊,通過Cell實現,底部是列表,顯示歌單內容的音樂,點選音樂進入黑膠唱片播放介面。

class SheetDetailController: BaseMusicPlayerController {
    /// 資料id
    var id:String!
    var data:Sheet!
    
    //背景
    var backgroundImageView: UIImageView!
    
    //背景模糊
    var backgroundVisual: UIVisualEffectView!
    
    override func initViews() {
        super.initViews()
        
        //新增背景圖片控制元件
        backgroundImageView = UIImageView()
        backgroundImageView.clipsToBounds = true
        backgroundImageView.alpha = 0
        backgroundImageView.contentMode = .scaleAspectFill
        view.addSubview(backgroundImageView)
        
        //背景模糊效果
        let blur = UIBlurEffect(style: .dark)
        backgroundVisual = UIVisualEffectView(effect: blur)
        backgroundImageView.addSubview(backgroundVisual)
        
        //初始化TableView結構
        initTableViewSafeArea()
        
        //設定狀態列為亮色(文字是白色)
        setStatusBarLight()
        
        setToolbarLight()
        
        title = R.string.localizable.sheet()
        
        //註冊單曲
        tableView.register(SongCell.self, forCellReuseIdentifier: Constant.CELL)
        tableView.register(SheetInfoCell.self, forCellReuseIdentifier: SheetInfoCell.NAME)
        
        //註冊section
        tableView.register(SongGroupHeaderView.self, forHeaderFooterViewReuseIdentifier: SongGroupHeaderView.NAME)
        tableView.bounces = false
    }
    
    override func initDatum() {
        super.initDatum()
        loadData()
    }
    
    func loadData() {
        DefaultRepository.shared
            .sheetDetail(id)
            .subscribeSuccess {[weak self] data in
                self?.show(data.data!)
            }.disposed(by: rx.disposeBag)
    }
    
    func show(_ data:Sheet) {
        self.data=data
        
        backgroundImageView.show(data.icon)
        
        //使用動畫顯示背景圖片
        UIView.animate(withDuration: 0.3) {
            //透明度設定為1
            self.backgroundImageView.alpha = 1
        }
        
        //第一組
        var groupData=SongGroupData()
        groupData.datum = [data]
        datum.append(groupData)
        
        //第二組
        if let r = data.songs {
            if !r.isEmpty {
                //有音樂才設定

                //設定資料
                groupData=SongGroupData()
                groupData.datum = r
                datum.append(groupData)
                superFooterContainer.backgroundColor = .colorLightWhite
            }
        }
    
        tableView.reloadData()
    }
    
    /// 獲取列表型別
    ///
    /// - Parameter data: <#data description#>
    /// - Returns: <#return value description#>
    func typeForItemAtData(_ data:Any) -> MyStyle {
        if data is Sheet {
            return .sheet
        }
        
        return .song
    }
    
    /// 播放音樂
    /// - Parameter data: <#data description#>
    func play(_ data:Song) {
        //把當前歌單所有音樂設定到播放列表
        //有些應用
        //可能會實現新增到已經播放列表功能
        MusicListManager.shared().setDatum(self.data.songs!)
        
        //播放當前音樂
        MusicListManager.shared().play(data)
        
        startMusicPlayerController()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        backgroundImageView.frame = view.bounds
        backgroundVisual.frame = backgroundImageView.bounds
    }
    
    @objc func commentClick() {
        CommentController.start(navigationController!)
    }
}

extension SheetDetailController{
    /// 有多少組
    /// - Parameter tableView: <#tableView description#>
    /// - Returns: <#description#>
    func numberOfSections(in tableView: UITableView) -> Int {
        return datum.count
    }
    
    /// 當前組有多少個
    /// - Parameters:
    ///   - tableView: <#tableView description#>
    ///   - section: <#section description#>
    /// - Returns: <#description#>
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        let data = datum[section] as! SongGroupData
        return data.datum.count
    }
    
    /// 返回section view
    /// - Parameters:
    ///   - tableView: <#tableView description#>
    ///   - section: <#section description#>
    /// - Returns: <#description#>
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        //取出組資料
        let groupData=datum[section] as! SongGroupData
        
        //獲取header
        let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: SongGroupHeaderView.NAME) as! SongGroupHeaderView
        
        header.bind(groupData)
        
        header.playAllClick = {[weak self] in
            let groupData = self?.datum[1] as! SongGroupData
            self?.play(groupData.datum[0] as! Song)
        }
        
        return header
    }
    
    /// 返回當前位置的cell
    /// - Parameters:
    ///   - tableView: <#tableView description#>
    ///   - indexPath: <#indexPath description#>
    /// - Returns: <#description#>
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let groupData = datum[indexPath.section] as! SongGroupData
        let data = groupData.datum[indexPath.row]
        
        let type = typeForItemAtData(data)
        
        switch type {
        case .sheet:
            let cell = tableView.dequeueReusableCell(withIdentifier: SheetInfoCell.NAME, for: indexPath) as! SheetInfoCell
            cell.bind(data as! Sheet)
            
            cell.commentCountView.addTarget(self, action: #selector(commentClick), for: .touchUpInside)
            
            return cell
        default:
            let cell = tableView.dequeueReusableCell(withIdentifier: Constant.CELL, for: indexPath) as! SongCell
            cell.bind(data as! Song)
            cell.indexView.text = "\(indexPath.row + 1)"
            
            return cell
        }
        
        
    }
    
    /// header高度
    /// - Parameters:
    ///   - tableView: <#tableView description#>
    ///   - section: <#section description#>
    /// - Returns: <#description#>
    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        if section == 1 {
            return 50
        }
        
        //其他組不顯示section
        return 0
    }
    
    /// cell點選
    /// - Parameters:
    ///   - tableView: <#tableView description#>
    ///   - indexPath: <#indexPath description#>
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let groupData = datum[indexPath.section] as! SongGroupData
        let data = groupData.datum[indexPath.row]
        
        let type = typeForItemAtData(data)
        
        if type == .song {
            play(data as! Song)
        }
    }
}

extension SheetDetailController{
    /// 啟動方法
    /// - Parameters:
    ///   - controller: <#controller description#>
    ///   - id: <#id description#>
    static func start(_ controller:UINavigationController,_ id:String) {
        let target = SheetDetailController()
        target.id=id
        controller.pushViewController(target, animated: true)
    }
}

唱片

上面是黑膠唱片,和網易雲音樂差不多,隨著音樂捲動或暫停,頂部是控制相關,音樂播放邏輯是封裝到MusicPlayerManager中:

class MusicPlayerManager : NSObject{
    /// 儲存音樂播放進度的間隔
    private static let SAVE_PROGRESS_TIME_INTERVAL:TimeInterval = 2
    
    private static var instance:MusicPlayerManager?
    
    /// 當前播放的音樂
    var data:Song?
    
    /// 播放器
    private var player:AVPlayer!
    
    /// 播放狀態
    var status:PlayStatus = .none
    
    /// 定時器返回的物件
    private var playTimeObserve:Any?
    
    ///播放完畢回撥
    var complete:((_ data:Song)->Void)!
    
    private var lastSaveProgressTime:TimeInterval = 0
    
    /// 代理物件,目的是將不同的狀態分發出去
    weak open var delegate:MusicPlayerManagerDelegate?{
        didSet{
            if let _ = self.delegate {
                //有代理
                
                //判斷是否有音樂在播放
                if self.isPlaying() {
                    //有音樂在播放
                    
                    //啟動定時器
                    startPublishProgress()
                }
            }else {
                //沒有代理
                
                //停止定時器
                stopPublishProgress()
            }
        }
    }
    
    /// 獲取單例的播放管理器
    ///
    /// - Returns: <#return value description#>
    static func shared() -> MusicPlayerManager {
        if instance == nil {
            instance = MusicPlayerManager()
        }
        
        return instance!
    }
    
    private override init() {
        super.init()
        player = AVPlayer()
    }
    
    /// 播放
    /// - Parameters:
    ///   - uri: 絕對音樂地址
    ///   - data: 音樂物件
    func play(uri:String,data:Song) {
        //請求獲取音訊對談焦點
        SuperAudioSessionManager.requestAudioFocus()
        
        //儲存音樂物件
        self.data = data
        
        status = .playing
        
        var url:URL?=nil
        if uri.starts(with: "http") {
            //網路地址
            url = URL(string: uri)
        } else {
            //本地地址
            url = URL(fileURLWithPath: uri)
        }
        
        //建立一個播放Item
        let item = AVPlayerItem(url: url!)
        
        //替換掉原來的播放Item
        player.replaceCurrentItem(with: item)
        
        //播放
        player.play()
        
        //回撥代理
        if let r = delegate {
            r.onPlaying(data: data)
        }
        
        //設定監聽器
        //因為監聽器是針對PlayerItem的
        //所以說播放了音樂在這裡設定
        initListeners()
        
        //啟動進度分發定時器
        startPublishProgress()
        
        prepareLyric()
    }
    
    /// 暫停
    func pause() {
        //更改狀態
        status = .pause
        
        //暫停
        player.pause()
        
        //回撥代理
        if let r = delegate {
            r.onPaused(data: data!)
        }
        
        //移除監聽器
        removeListeners()
        
        //停止進度分發定時器
        stopPublishProgress()
    }
    
    /// 繼續播放
    func resume() {
        //請求獲取音訊對談焦點
        SuperAudioSessionManager.requestAudioFocus()
        
        status = .playing
        
        player.play()
        
        //回撥代理
        if let r = delegate {
            r.onPlaying(data: data!)
        }
        
        //設定監聽器
        initListeners()
        
        //啟動進度分發定時器
        startPublishProgress()
    }
    
    /// 是否在播放
    /// - Returns: <#description#>
    func isPlaying() -> Bool {
        return status == .playing
    }
    
    /// 移動到指定位置播放
    func seekTo(data:Float) {
        let positionTime = CMTime(seconds: Double(data), preferredTimescale: 1)
        player.seek(to: positionTime)
    }
    
    ...
    
    private func stopPublishProgress() {
        if let playTimeObserve = playTimeObserve {
            player.removeTimeObserver(playTimeObserve)
            self.playTimeObserve = nil
        }
    }
    
    private func initListeners() {
        //KVO方式監聽播放狀態
        //KVC:Key-Value Coding,另一種獲取物件欄位的值,類似字典
        //KVO:Key-Value Observing,建立在KVC基礎上,能夠觀察一個欄位值的改變
        player.currentItem?.addObserver(self, forKeyPath: MusicPlayerManager.STATUS, options: .new, context: nil)
        
        //監聽音樂緩衝狀態
        player.currentItem?.addObserver(self, forKeyPath: "loadedTimeRanges", options: .new, context: nil)
        
        //播放結束事件
        NotificationCenter.default.addObserver(self, selector: #selector(onComplete(_:)), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: player.currentItem)
    }
    
    /// 移除監聽器
    private func removeListeners() {
        player.currentItem?.removeObserver(self, forKeyPath: MusicPlayerManager.STATUS)
        player.currentItem?.removeObserver(self, forKeyPath: "loadedTimeRanges")
    }
    
    /// 播放完畢了回撥
    @objc func onComplete(_ sender:Notification) {
        complete(data!)
    }
    
    /// KVO監聽回撥方法
    ///
    /// - Parameters:
    ///   - keyPath: <#keyPath description#>
    ///   - object: <#object description#>
    ///   - change: <#change description#>
    ///   - context: <#context description#>
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        //判斷監聽的欄位
        if MusicPlayerManager.STATUS == keyPath {
            //播放狀態
            switch player.status {
            case .readyToPlay:
                //準備播放完成了
                
                //音樂的總時間
                self.data!.duration = Float(CMTimeGetSeconds(player.currentItem!.asset.duration))
                
                //回撥代理
                delegate?.onPrepared(data:data!)
                
                updateMediaInfo()
            case .failed:
                //播放失敗了
                status = .error
                
                delegate?.onError(data: data!)
            default:
                //未知狀態
                status = .none
            }
        }
        
        
    }
    
    /// 更新系統媒體控制中心資訊
    /// 不需要更新進度到控制中心
    /// 他那邊會自動倒計時
    /// 這部分可以重構到公共類,因為像播放視訊也可以更新到系統媒體中心
    private func updateMediaInfo() {
        //下載圖片
        //這部分可以封裝
        //因為其他介面可能也會用
        let manager = SDWebImageManager.shared

        if data?.icon == nil {
            self.setMediaInfo(R.image.placeholder()!)
        } else {
            let url = URL(string: data!.icon!.absoluteUri())

            //下載圖片
            manager.loadImage(with: url, options: .progressiveLoad) { receivedSize, expectedSize, targetURL in

            } completed: { image, data, error, cacheType, finished, imageURL in
                print("load song image success \(url)")
                if let r = image {
                    self.setMediaInfo(r)
                }
            }
        }

    }

    func prepareLyric() {
        //歌詞處理
        //真實專案可能會
        //將歌詞這個部分拆分到其他元件中
        if data!.parsedLyric != nil && data!.parsedLyric!.datum.count > 0 {
            //解析好了
            onLyricReady()
        } else if SuperStringUtil.isNotBlank(data!.lyric){
            //有歌詞,但是沒有解析
            parseLyric()
        } else {
            //沒有歌詞,並且不是本地音樂才請求

            //真實專案中可以會快取歌詞
            //獲取歌詞資料
            DefaultRepository.shared
                .songDetail(data!.id)
                .subscribeSuccess { data in
                    //請求成功
                    self.data!.style = data.data!.style
                    self.data!.lyric = data.data!.lyric
                    
                    self.parseLyric()
                }
        }
    }
    
    func parseLyric() {
        if SuperStringUtil.isNotBlank(data?.lyric) {
            //有歌詞
            
            //在這裡解析的好處是
            //外面不用管,直接使用
            data?.parsedLyric = LyricParser.parse(data!.style,data!.lyric!)
        }
        
        //通知歌詞準備好了
        onLyricReady()
    }
    
    func onLyricReady() {
        if let r = delegate {
            r.onLyricReady(data: data!)
        }
    }
    
    static let STATUS = "status"
}


/// 播放狀態列舉
enum PlayStatus {
    case none //未知
    case pause //暫停了
    case playing //播放中
    case prepared //準備中
    case completion //當前這一首音樂播放完成
    case error
}

/// 播放管理器代理
protocol MusicPlayerManagerDelegate:NSObjectProtocol{
    /// 播放器準備完畢了
    /// 可以獲取到音樂總時長
    func onPrepared(data:Song)
    
    /// 暫停了
    func onPaused(data:Song)
    
    /// 正在播放
    func onPlaying(data:Song)
    
    /// 進度回撥
    func onProgress(data:Song)
    
    /// 歌詞資料準備好了
    func onLyricReady(data:Song)
    
    /// 出錯了
    func onError(data:Song)
}

音樂列表邏輯封裝到MusicListManager:

class MusicListManager {
    private static var instance:MusicListManager?
    
    /// 當前音樂物件
    var data:Song?
    
    //播放列表
    var datum:[Song] = []
    
    /// 播放管理器
    var musicPlayerManager:MusicPlayerManager!
    
    /// 是否播放了
    var isPlay = false
    
    /// 迴圈模式,預設列表迴圈
    var model:MusicPlayRepeatModel = .list
    
    /// 獲取單例的播放列表管理器
    ///
    /// - Returns: <#return value description#>
    static func shared() -> MusicListManager {
        if instance == nil {
            instance = MusicListManager()
        }
        
        return instance!
    }
    
    private init() {
        //初始化音樂播放管理器
        musicPlayerManager = MusicPlayerManager.shared()
        
        //設定播放完畢回撥
        musicPlayerManager.complete = {d in
            //判斷播放回圈模式
            if self.model == .one {
                //單曲迴圈
                self.play(d)
            }else{
                //其他模式
                self.play(self.next())
            }
        }
        
        initPlayList()
    }
    
    func initPlayList() {
        datum.removeAll()
        
        //查詢播放列表
        let datum=SuperDatabaseManager.shared.findPlayList()
        if datum.count > 0 {
            //新增到現在的播放列表
            self.datum += datum
            
            //獲取最後播放音樂id
            let id = PreferenceUtil.getLastPlaySongId()
            if SuperStringUtil.isNotBlank(id) {
                //有最後播放音樂的id

                //在播放列表中找到該音樂
                for it in datum {
                    if it.id == id {
                        data = it
                    }
                }
                
                if data == nil {
                    //表示沒找到
                    //可能各種原因
                    defaultPlaySong()
                } else {
                    //找到了
                }
            }else{
                //如果沒有最後播放音樂
                //預設就是第一首
                defaultPlaySong()
            }
            
            musicPlayerManager.data = data
            musicPlayerManager.prepareLyric()
        }
        
        
//        sendMusicListChanged()
    }
    
    func defaultPlaySong() {
        data = datum[0]
    }
    
    /// 設定音樂列表
    /// - Parameter datum: <#datum description#>
    func setDatum(_ datum:[Song]) {
        //將原來資料list標誌設定為false
       DataUtil.changePlayListFlag(self.datum, false)

       //儲存到資料庫
       saveAll()
        
        //清空原來的資料
        self.datum.removeAll()
        
        //新增新的資料
        self.datum += datum
        
        //更改播放列表標誌
        DataUtil.changePlayListFlag(self.datum, true)

        //儲存到資料庫
        saveAll()

        sendMusicListChanged()
    }
    
    /// 播放
    /// - Parameter data: <#data description#>
    func play(_ data:Song) {
        self.data = data
        
        //標記為播放了
        isPlay = true
        
        var path:String!
        
        //查詢是否有下載任務
        let downloadInfo = AppDelegate.shared.getDownloadManager().findDownloadInfo(data.id)
        if downloadInfo != nil && downloadInfo.status == .completed {
            //下載完成了

           //播放本地音樂
            path = StorageUtil.documentUrl().appendingPathComponent(downloadInfo.path).path
            print("MusicListManager play offline \(path!) \(data.uri!)")
        } else {
            //播放線上音樂
            path = data.uri.absoluteUri()
            print("MusicListManager play online \(path!) \(data.uri!)")
        }
        
        musicPlayerManager.play(uri: path, data: data)
        
        //設定最後播放音樂的Id
        PreferenceUtil.setLastPlaySongId(data.id)

    }
    
    /// 暫停
    func pause() {
        musicPlayerManager.pause()
    }
    
    /// 繼續播放
    func resume() {
        if isPlay {
            //原來已經播放過
            //也就說播放器已經初始化了
            musicPlayerManager.resume()
        } else {
            //到這裡,是應用開啟後,第一次點繼續播放
            //而這時內部其實還沒有準備播放,所以應該呼叫播放
            play(data!)
            
            //判斷是否需要繼續播放
            if data!.progress>0 {
                //有播放進度

                //就從上一次位置開始播放
                musicPlayerManager.seekTo(data: data!.progress)
            }
        }
    }
    
    @discardableResult
    /// 更改回圈模式
    func changeLoopModel() -> MusicPlayRepeatModel {
        //將當前迴圈模式轉為int
        var model = self.model.rawValue
        
        //迴圈模式+1
        model += 1
        
        //判斷邊界
        if model > MusicPlayRepeatModel.random.rawValue {
            //超出了範圍
            model = 0
        }
        
        self.model = MusicPlayRepeatModel(rawValue: model)!
        
        return self.model
    }
    
    /// 獲取上一個
    func previous() -> Song {
        var index = 0
        switch model {
        case .random:
            //隨機迴圈
            
            //在0~datum.size-1範圍中
            //產生一個亂數
            index = Int(arc4random()) % datum.count
        default:
            //列表迴圈
            let datumOC = datum as NSArray
            index = datumOC.index(of: data!)
            
            //如果當前播放的音樂是最後一首音樂
            if index == 0 {
                //當前播放的是第一首音樂
                index = datum.count - 1
            } else {
                index -= 1
            }
        }
        
        return datum[index]
    }
    
    ...
}

//音樂迴圈狀態
enum MusicPlayRepeatModel:Int {
    case list=0 //列表迴圈
    case one //單曲迴圈
    case random //列表隨機
}

外界統一使用播放列表管理器播放音樂,上一曲下一曲:

@objc func previousClick(_ sender:QMUIButton) {
    MusicListManager.shared().play(MusicListManager.shared().previous())
}

@objc func playClick(_ sender:QMUIButton) {
    playOrPause()
}

@objc func nextClick(_ sender:QMUIButton) {
    MusicListManager.shared().play(MusicListManager.shared().next())
}

歌詞

歌詞實現了LRC,KSC兩種歌詞,封裝到LyricListView,單個歌詞行封裝到LyricView中,外界直接使用LyricListView就行:

/// 顯示歌詞資料
func showLyricData() {
    lyricView.setData(MusicListManager.shared().data!.parsedLyric)
}

歌詞控制元件封裝:

class LyricListView: BaseRelativeLayout {
    var data:Lyric?
    var tableView:UITableView!
    var datum:[Any] = []
    
    /// 當前時間歌詞行數
    var lyricLineNumber:Int = 0
    
    /// 歌詞填充多個佔位資料
    var lyricPlaceholderSize = 0
    
    /// 是否已經呼叫了reloadData
    var isReloadData:Bool = false
    
    /// 歌詞拖拽效果容器
    var lyricDragContainer:TGLinearLayout!
    
    /// 拖拽位置歌詞時間
    var timeView:UILabel!
    
    /// 是否在拖拽狀態
    var isDrag:Bool = false
    
    /// 捲動時,當前這行歌詞
    var scrollSelectedLyricLine:LyricLine?
    
    override func initViews() {
        super.initViews()
        //設定約束
        tg_width.equal(.fill)
        tg_height.equal(.fill)
        
        //tableView
        tableView = ViewFactoryUtil.tableView()
        tableView.delegate = self
        tableView.dataSource = self
        addSubview(tableView)
        
        //註冊歌詞cell
        tableView.register(LyricCell.self, forCellReuseIdentifier: Constant.CELL)
        
        //建立一個水平方向容器
        lyricDragContainer = TGLinearLayout(.horz)
        lyricDragContainer.hide()
        lyricDragContainer.tg_horzMargin(PADDING_OUTER)
        lyricDragContainer.tg_width.equal(.fill)
        lyricDragContainer.tg_height.equal(.wrap)

        //控制元件之間間距
        lyricDragContainer.tg_space = PADDING_MEDDLE

        //內容垂直居中
        lyricDragContainer.tg_gravity = TGGravity.vert.center

        //居中
        lyricDragContainer.tg_centerY.equal(0)
        addSubview(lyricDragContainer)
        
        //播放按鈕
        let playView = QMUIButton()
        playView.tg_width.equal(15)
        playView.tg_height.equal(15)
        playView.setImage(R.image.play()!.withTintColor(), for: .normal)
        playView.tintColor = .colorLightWhite
        //圖片完全顯示到控制元件裡面
        playView.contentMode = .scaleAspectFit
        playView.addTarget(self, action: #selector(playClick(_:)), for: .touchUpInside)
        lyricDragContainer.addSubview(playView)
        
        //分割線
        let dividerView = ViewFactoryUtil.smallDivider()
        dividerView.backgroundColor = .colorLightWhite
        lyricDragContainer.addSubview(dividerView)
        
        //時間
        timeView = UILabel()
        timeView.tg_width.equal(.wrap)
        timeView.tg_height.equal(.wrap)
        timeView.text = "00:00"
        timeView.textColor = .colorLightWhite
        lyricDragContainer.addSubview(timeView)
    }
    
    /// 這個方法會呼叫多次計算,最後一次才是最準確的值
    override func layoutSubviews() {
        super.layoutSubviews()
        if lyricPlaceholderSize > 0 {
            return
        }
        
        lyricPlaceholderSize = Int(ceil( Double(tableView.frame.height)/2.0/44.0))
    }
    
    func setData(_ data:Lyric?) {
        self.data=data
        
        if lyricPlaceholderSize>0 {
           //已經計算了填充數量
           next()
       }
    }
    
    func next() {
        //清空原來的歌詞
        datum.removeAll()
        
        if let r = data {
            //新增佔位資料
            addLyricFillData()
            
            datum += r.datum
            
            //新增佔位資料
            addLyricFillData()
        }

        isReloadData=true
        tableView.reloadData()
    }
    
    //顯示拖拽效果
    func showDragView() {
        if isLyricEmpty() {
            //沒有歌詞不能拖拽
            return
        }
        
        isDrag=true

        lyricDragContainer.show()
    }
    
    func prepareScrollLyricView() {
        //取消原來的任務
        NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(hideDragView), object: nil)

        //4秒後隱藏拖拽控制元件
        perform(#selector(hideDragView), with: nil, afterDelay: 4.0)
    }
    
    @objc func hideDragView() {
        isDrag=false
        
        //取消原來的任務
        NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(hideDragView), object: nil)
        
        lyricDragContainer.hide()
    }
    
    @objc func playClick(_ sender:QMUIButton) {
        if let r = scrollSelectedLyricLine {
            //回撥回來是毫秒,要轉為秒
            MusicListManager.shared().seekTo(Float(r.startTime/1000))

            //馬上顯示歌詞捲動
            hideDragView()
        }
    }

    ...
}

extension LyricListView:QMUITableViewDelegate,QMUITableViewDataSource{
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return datum.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let data = datum[indexPath.row]
        
        let cell = tableView.dequeueReusableCell(withIdentifier: Constant.CELL, for: indexPath) as! LyricCell
        cell.bind(data, self.data!.isAccurate)
        
        return cell
    }
    
    /// 開始拖拽
    /// - Parameter scrollView: <#scrollView description#>
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        showDragView()
    }
    
    /// 拖拽結束
    /// - Parameters:
    ///   - scrollView: <#scrollView description#>
    ///   - decelerate: <#decelerate description#>
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if !decelerate {
            //如果不需要減速,就延時後,顯示歌詞
            prepareScrollLyricView()
        }
    }
    
    /// 慣性拖拽結束
    /// - Parameter scrollView: <#scrollView description#>
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        prepareScrollLyricView()
    }
    
    /// 滑動中
    /// - Parameter scrollView: <#scrollView description#>
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if isDrag {
            //只有手動拖拽的時候才處理
            
            let offsetY  = scrollView.contentOffset.y
            
            //根據捲動距離計算出index
            let index = Int((offsetY+tableView.frame.height/2)/44)
            
            //獲取歌詞物件
            var lyric:Any!
            if (index < 0) {
                //如果計算出的index小於0
                //就預設第一個歌詞物件
                lyric = datum.first
            }else if (index > datum.count - 1) {
                //大於最後一個歌詞物件(包含填充資料)
                //就是最後一行資料
                lyric = datum.last
            }else {
                //如果在列表範圍內
                //就直接去對應位置的資料
                lyric = datum[index]
            }
            
            //設定捲動時間

            //判斷是否是填充資料
            if lyric is String {
                //填充資料
                timeView.text = ""
            } else {
                //真實歌詞資料
                //儲存到一個欄位上
                scrollSelectedLyricLine = lyric as! LyricLine
                
                //將開始時間轉為秒
                let startTime = Float( scrollSelectedLyricLine!.startTime / 1000)
                
                timeView.text = SuperDateUtil.second2MinuteSecond(startTime)
            }
            
        }
    }
}

控制器

使用了可以通過系統媒體控制器,通知欄,鎖屏介面,耳機,藍芽耳機等裝置控制媒體播放暫停,只需要把媒體資訊更新到系統:

private func setMediaInfo(_ image:UIImage)  {
    //初始化一個可變字典
    var songInfo:[String:Any] = [:]

    //封面
    let albumArt = MPMediaItemArtwork(boundsSize: CGSize(width: 100, height: 100)) { size -> UIImage in
        return image
    }

    //封面
    songInfo[MPMediaItemPropertyArtwork]=albumArt

    //歌曲名稱
    songInfo[MPMediaItemPropertyTitle]=data!.title

    //歌手
    songInfo[MPMediaItemPropertyArtist]=data!.singer.nickname

    //專輯名稱
    //由於伺服器端沒有返回專輯的資料
    //所以這裡就寫死資料就行了
    songInfo[MPMediaItemPropertyAlbumTitle]="這是專輯名稱"

    //流派
    //songInfo[MPMediaItemPropertyGenre]="這是流派"

    //總時長
    songInfo[MPMediaItemPropertyPlaybackDuration]=data!.duration

    //已經播放的時長
    songInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime]=data!.progress

    //歌詞
    songInfo[MPMediaItemPropertyLyrics]="這是歌詞"

    //設定到系統
    MPNowPlayingInfoCenter.default().nowPlayingInfo = songInfo
}

媒體控制

/// 接收遠端控制事件
/// 可以接收到媒體控制中心的事件
///
/// - Parameter event: <#event description#>
override func remoteControlReceived(with event: UIEvent?) {
    print("AppDelegate remoteControlReceived:\(event?.type),\(event?.subtype)")

    //判斷是不是遠端控制事件
    if event?.type == UIEvent.EventType.remoteControl {
        //是遠端控制事件

        //是否有音樂
        if MusicListManager.shared().data == nil {
            //當前播放列表中沒有音樂
            return
        }

        //判斷事件型別
        switch event!.subtype {
        case .remoteControlPlay:
            //點選了播放按鈕
            print("AppDelegate play")

            MusicListManager.shared().resume()
        case .remoteControlPause:
            //點選了暫停
            print("AppDelegate pause")

            MusicListManager.shared().pause()
        case .remoteControlNextTrack:
            //下一首
            //雙擊iPhone有線耳機上的控制按鈕
            print("AppDelegate next")

            let song = MusicListManager.shared().next()
            MusicListManager.shared().play(song)
        case .remoteControlPreviousTrack:
            //上一首
            //三擊iPhone有線耳機上的控制按鈕
            print("AppDelegate previouse")

            let song = MusicListManager.shared().previous()
            MusicListManager.shared().play(song)
        case .remoteControlTogglePlayPause:
            //單擊iPhone有線耳機上的控制按鈕
            print("AppDelegate toggle play pause")

            //播放或者暫停
            if MusicPlayerManager.shared().isPlaying() {
                MusicListManager.shared().pause()
            } else {
                MusicListManager.shared().resume()
            }
        default:
            break
        }
    }
}

登入/註冊/驗證碼登入

登入註冊沒有多大難度,使用者名稱和密碼登入,就是把資訊傳遞到伺服器端,可以加密後在傳輸,伺服器端判斷登入成功,返回一個標記,使用者端儲存,其他需要的登入的介面帶上;驗證碼登入就是用驗證碼代替密碼,傳送驗證碼都是伺服器端傳送,使用者端只需要呼叫介面。

評論

評論列表包括下拉重新整理,上拉載入更多,點贊,釋出評論,回覆評論,Emoji,話題和提醒人點選,選擇好友,選擇話題等。

重新整理和下拉載入更多

核心邏輯就只需要更改page就行了

//下拉重新整理
let header=MJRefreshNormalHeader {
    [weak self] in
    self?.loadData()
}

//隱藏標題
header.stateLabel?.isHidden = true

// 隱藏時間
header.lastUpdatedTimeLabel?.isHidden = true
tableView.mj_header=header

//上拉載入更多
let footer = MJRefreshAutoNormalFooter {
    [weak self] in
    self?.loadMore()
}

// 設定空閒時文字
footer.setTitle("", for: .idle)

tableView.mj_footer = footer

人和話題點選

通過正規表示式,找到特殊文字,然後使用富文字實現點選。

/// 處理文字點選事件
func processContent(_ data:String) -> NSAttributedString {
    return RichUtil.processContent(data) { containerView, text, range, rect in
        let result = RichUtil.processClickText(data, range)
        if let r = self.nicknameClickBlock{
            r(result)
        }
    } _: { containerView, text, range, rect in
        let result = RichUtil.processClickText(data, range)
        print(result)
    }

}

好友

class UserController: BaseTitleController {
    var style:MyStyle!
    
    override func initViews() {
        super.initViews()
        initTableViewSafeArea()
        
        tableView.register(TopicCell.self, forCellReuseIdentifier: Constant.CELL)
    }
    
    override func initDatum() {
        super.initDatum()
        
        
        if style == .friend || style == .select {
            //好友
            title = R.string.localizable.myFriend()
        } else {
            //粉絲
            title = R.string.localizable.myFans()
        }
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        loadData()
    }
    
    func loadData() {
        var api:Observable<ListResponse<User>>!
        
        if style == .friend || style == .select  {
            api = DefaultRepository.shared
                .friends(PreferenceUtil.getUserId())
        } else {
            api = DefaultRepository.shared
                .fans(PreferenceUtil.getUserId())
        }
        
        api.subscribeSuccess {[weak self] data in
            self?.show(data.data?.data ?? [])
        }.disposed(by: rx.disposeBag)
    }
    
    func show(_ data:[User]) {
        datum.removeAll()
        
        datum += data
        
        tableView.reloadData()
    }
    
    static func start(_ controller:UINavigationController,_ style:MyStyle) {
        let target = UserController()
        target.style=style
        controller.pushViewController(target, animated: true)
    }
}

//列表資料來源
extension UserController{
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let data = datum[indexPath.row] as! User
        
        let cell = tableView.dequeueReusableCell(withIdentifier: Constant.CELL, for: indexPath) as! TopicCell

        cell.bind(data)
        
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let data = datum[indexPath.row] as! User
        
        if style == .select {
            //選擇
            SwiftEventBus.post(Constant.EVENT_USER_SELECTED, sender: data)
            
            finish()
        } else {
            UserDetailController.start(navigationController!, id: data.id)
        }
    }
}

視訊和播放

真實專案中視訊播放大部分都是用第三方服務,例如:阿里雲視訊服務,騰訊視訊服務,因為他們提供一條龍服務,包括稽核,轉碼,CDN,安全,播放器等,這裡用不到這麼多功能,所以使用了第三方播放器播放普通mp4,這使用餃子播放器框架。

func play(_ data:Video) {
    //不開防盜鏈
    let model = SuperPlayerModel()

    //播放騰訊雲視訊
    // 設定 AppId
//    model.appId = 0;
//
//    model.videoId = [[SuperPlayerVideoId alloc] init];
//    model.videoId.fileId = "5285890799710670616"; // 設定 FileId

    //停止播放
    playerView.removeVideo()

    //直接使用url播放
    model.videoURL = data.uri.absoluteUri()

    playerView.play(with: model)

    //設定標題
    playerView.controlView.title = data.title
}

使用者詳情/更改資料

使用者詳情頂部顯示使用者資訊,好友數量,下面分別顯示建立的歌單,收藏的歌單,釋出的動態,類似微信朋友圈,右上角可以更改使用者資料;使用第三方框架裡面的kJXPagingListRefreshView控制元件實現。

func initUI() {
    container.removeSubviews()
    
    //頭部控制元件
    userHeaderView = UserDetailHeaderView()
    
    userHeaderView.followView.addTarget(self, action: #selector(followClick), for: .touchUpInside)
    userHeaderView.sendMessageView.addTarget(self, action: #selector(sendClick), for: .touchUpInside)
    
    //指示器
    indicatorView = JXSegmentedView(frame: CGRect(x: 0, y: 0, width: SCREEN_WIDTH, height: UserDetailController.SIZE_INDICATOR_HEIGHT))
    
    segmentedDataSource = JXSegmentedTitleDataSource()
    
    //標題
    segmentedDataSource.titles = [R.string.localizable.sheet(), R.string.localizable.feed()]
    
    //選擇的顏色
    segmentedDataSource.titleSelectedColor = .colorPrimary
    
    //預設顏色
    segmentedDataSource.titleNormalColor = .colorOnSurface
    
    //選中是否放大
    segmentedDataSource.isTitleZoomEnabled = false
    
    indicatorView.dataSource=segmentedDataSource
    
    indicatorView.backgroundColor = .clear
    indicatorView.delegate = self

    //指示器下面那條線
    let lineView = JXSegmentedIndicatorLineView()
    
    //選中顏色
    lineView.indicatorColor = .colorPrimary
    lineView.indicatorWidth = 30
    indicatorView.indicators = [lineView]
    
    pagerView = JXPagingListRefreshView(delegate: self)
    pagerView.mainTableView.gestureDelegate = self
    pagerView.tg_width.equal(.fill)
    pagerView.tg_height.equal(.fill)
    container.addSubview(pagerView)

    indicatorView.listContainer = pagerView.listContainerView
    
    //扣邊返回處理,下面的程式碼要加上
    pagerView.listContainerView.scrollView.panGestureRecognizer.require(toFail: self.navigationController!.interactivePopGestureRecognizer!)
    pagerView.mainTableView.panGestureRecognizer.require(toFail: self.navigationController!.interactivePopGestureRecognizer!)
}

然後就是把每個子介面放到單獨View中,並在代理方法返回就行了。

釋出動態/選擇位置/路徑規劃

釋出效果和微信朋友圈類似,可以選擇圖片,和地理位置;地理位置使用高德地圖實現選擇,路徑規劃是呼叫系統中安裝的地圖,類似微信。

位置

/// 搜尋該位置的poi,方便使用者選擇,也方便其他人找
func searchPOI() {
    if keyword != nil {
        //關鍵字搜尋
        let request = AMapPOIKeywordsSearchRequest()
        
        //關鍵字
        request.keywords=keyword

        //距離排序
        request.sortrule = 0

        //是否返回擴充套件資訊
        request.requireExtension=true

        search.aMapPOIKeywordsSearch(request)
    } else {
        //搜尋位置附近
        let request = AMapPOIAroundSearchRequest()
        request.location = AMapGeoPoint.location(withLatitude: CGFloat(coordinate!.latitude), longitude: CGFloat(coordinate!.longitude))
        
        //距離排序
        request.sortrule=0
        
        //是否返回擴充套件資訊
        request.requireExtension=true
        
        search.aMapPOIAroundSearch(request)
    }
}

地圖路徑規劃

/// 高德地圖路徑規劃
/// 官方檔案:https://lbs.amap.com/api/amap-mobile/guide/ios/route
static func amapPathPlan(title:String,latitude:Double,longitude:Double) {
    let urlString = "iosamap://path?sourceApplication=雲音樂&backScheme=weichat&dlat=\(latitude)&dlon=\(longitude)&dname=\(title)"
    
    SuperApplicationUtil.open(urlString)
}

聊天/離線推播

大部分真實專案中聊天都會選擇第三方商業級付費聊天服務,常用的有騰訊雲聊天,融雲聊天,網易雲聊天等,這裡選擇融雲聊天服務,使用步驟是先在伺服器端生成聊天Token,這裡是登入後返回,然後使用者端登入聊天伺服器,然後設定訊息監聽,傳送訊息等。

聊天伺服器

/// 連線聊天伺服器
func connectChat(_ data:Session) {
    RCIMClient.shared()
        .connect(withToken: data.chatToken) { code in
            //訊息資料庫開啟,可以進入到主頁面

            //因為我們應用不是純微信這樣的應用,所以就不再這裡才跳轉到主介面
        } success: { userId in
            //連線成功
        } error: { status in
            if (status == .RC_CONN_TOKEN_INCORRECT) {
                //從 APP 服務獲取新 token,並重連
            } else {
                //無法連線到 IM 伺服器,請根據相應的錯誤碼作出對應處理
            }

            //因為我們這個應用,不是類似微信那樣純聊天應用,所以聊天伺服器連線失敗,也讓進入應用
            //真實專案中按照需求實現就行了
            SuperToast.show(title: R.string.localizable.errorMessageLogin())
        }

}

訊息監聽

func onReceived(_ message: RCMessage!, left nLeft: Int32, object: Any!, offline: Bool, hasPackage: Bool) {
    DispatchQueue.main.async {
        if message.targetId == self.currentChatUserId || offline {
            //正在和這個人聊天,或者離線訊息
        } else {
            //其他訊息顯示到通知欄
            NotificationUtil.showMessage(message)
        }

        //傳送訊息未讀數改變了通知
        NotificationCenter.default.post(name: NSNotification.Name(rawValue: ON_MESSAGE_COUNT_CHANGED), object: nil, userInfo: nil)

        //傳送訊息到通知(這個通知是,跨介面通訊,不是顯示到通知欄)
        NotificationCenter.default.post(name: NSNotification.Name(rawValue: ON_MESSAGE), object: nil, userInfo: [Constant.DATA:message])
    }
}

文字訊息

傳送圖片等其他訊息也是差不多。

/// 傳送文字訊息
func sendTextMessage()  {
    let result=contentInputView.text.trimmed
    
    if SuperStringUtil.isBlank(result) {
        SuperToast.show(title: R.string.localizable.hintEnterMessage())
        return
    }

    //1.構造文字訊息
    let param = RCTextMessage(content: result)!

    //2.將文字訊息傳送出去
    RCIMClient.shared().sendMessage(.ConversationType_PRIVATE, targetId: id, content: param, pushContent: nil, pushData: MessageUtil.createPushData(MessageUtil.getContent(param), PreferenceUtil.getUserId())) { messageId in
        print("message send success \(messageId)")

        DispatchQueue.main.async {
            //清空輸入框
            self.clearInput()
        }

        self.addMessage(RCIMClient.shared().getMessage(messageId))
    } error: { code, messageId in
        print("message send fail \(messageId) \(code)")
    }
}

離線推播

需要付費蘋果開發者賬戶,先開啟SDK離線推播,然後在蘋果開發者後臺建立推播證書,設定到融雲,最後在程式碼中處理通知點選等。

@objc func notificationClick(_ notification:Notification) {
    processPushClick()
}

/// 處理推播點選
func processPushClick()  {
    let data = Push.deserialize(from: AppDelegate.shared.notificationData!)!

    switch data.style {
    case Push.PUSH_STYLE_CHAT:
        processChatMessageClick(data.message!)
    default:
        break
    }

    AppDelegate.shared.notificationData = nil
}

/// 聊天訊息通知點選
func processChatMessageClick(_ data:PushMessage) {
    ChatController.start(navigationController!, data.userId)
}

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    //延時的目的是讓當前介面顯示出來以後,在檢查
    //檢查是否需要處理通知點選
    DispatchQueue.main.asyncAfter(deadline: .now()+0.5) {
        if let _ = AppDelegate.shared.notificationData {
            self.processPushClick()
        }
    }
}

商城/訂單/支付/購物車


學到這裡,大家不能說熟悉,那麼看到上面的介面,那麼大體要能實現出來。

詳情富文字

//詳情
self.detailView = QMUITextView()
self.detailView.tg_width.equal(.fill)
self.detailView.tg_height.equal(.wrap)
self.detailView.delegate=self
self.detailView.isScrollEnabled=false
self.detailView.isEditable=false

//去除左右邊距
self.detailView.textContainer.lineFragmentPadding = 0

//去除上下邊距
self.detailView.textContainerInset = .zero
contentContainer.addSubview(detailView)

寶/微信支付

使用者端先整合微信,支付寶SDK,然後請求伺服器端獲取支付資訊,設定到SDK,最後就是處理支付結果。

/// 處理支付寶支付
func processAlipay(_ data:String) {
    //支付寶官方開發檔案:https://docs.open.alipay.com/204/105295/
    AlipaySDK.defaultService()
        .payOrder(data, fromScheme: Config.ALIPAY_CALLBACK_SCHEME) { data in
            //如果手機中沒有安裝支付寶使用者端
            //會跳轉H5支付頁面
            //支付相關的資訊會通過這個方法回撥

            //處理支付寶支付結果
            self.processAlipayResult(data as! [String:Any])
        }
}

/// 處理微信支付
func processWechat(_ data:WechatPay) {
    //把伺服器端返回的引數
    //設定到對應的欄位
    let request = PayReq()
    request.partnerId = data.partnerid
    request.prepayId = data.prepayid
    request.nonceStr = data.noncestr
    request.timeStamp = UInt32(data.timestamp)!
    request.package = data.package
    request.sign = data.sign

    WXApi.send(request) { data in
        print("PayController processWechat \(data)")
    }
}

支付結果

/// 處理支付寶支付結果
func processAlipayResult(_ data:[String:Any]) {
    let resultStatus = data["resultStatus"] as! String
    if "9000" == resultStatus {
        //本地支付成功

        //不能依賴本地支付結果
        //一定要以伺服器端為準
        SuperToast.showLoading(title: R.string.localizable.hintPayWait())

        checkPayStatus()

        //這裡就不根據伺服器端判斷了
        //購買成功統計
    } else if "6001" == resultStatus {
        //取消了
        showCancel()
    } else {
        //支付失敗
        showPayFailedTip()
    }
    
}

專案總結

總體來說專案功能還是很全的,還有一些小功能,例如:快捷方式等就不在貼程式碼了,但肯定沒發和原版比,相信大家只要做過程式設計師就能理解,畢竟原版是一個商業級專案,幾十個人天天開發和維護,而且持續了幾年了;不過恕我直言,現在的常見的音樂軟體都太複雜了,各種功能,不過都要恰飯,好像又能理解了