<output id="qn6qe"></output>

    1. <output id="qn6qe"><tt id="qn6qe"></tt></output>
    2. <strike id="qn6qe"></strike>

      亚洲 日本 欧洲 欧美 视频,日韩中文字幕有码av,一本一道av中文字幕无码,国产线播放免费人成视频播放,人妻少妇偷人无码视频,日夜啪啪一区二区三区,国产尤物精品自在拍视频首页,久热这里只有精品12

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

      效果

      在這里插入圖片描述

      列文章目錄

      因為目錄比較多,每次更新這里比較麻煩,所以推薦點擊到主頁,然后查看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()
          }
          
      }
      

      項目總結

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

      posted on 2022-07-31 16:05  愛學啊  閱讀(861)  評論(2)    收藏  舉報

      導航

      主站蜘蛛池模板: 成人性生交片无码免费看| 无码内射中文字幕岛国片 | 色狠狠综合天天综合综合| 阜宁县| 人妻教师痴汉电车波多野结衣| 欧美精欧美乱码一二三四区| 日本喷奶水中文字幕视频| 日韩一区二区a片免费观看| 国产精品香港三级国产av| 丰满人妻一区二区三区色| 无码国产欧美一区二区三区不卡| 国产精品午夜福利资源| 北条麻妃42部无码电影| 亚洲精品中文字幕一二三| 福利无遮挡喷水高潮| 国产av不卡一区二区| 实拍女处破www免费看| 亚洲电影天堂在线国语对白| 色婷婷综合久久久久中文一区二区 | 久久婷婷五月综合色和啪| 人妻蜜臀久久av不卡| 天天摸天天碰天天添| 国产啪视频免费观看视频| 国内精品综合九九久久精品| 国产亚洲精品久久久久久大师| 奇米四色7777中文字幕| 综合色天天久久| 成在线人永久免费视频播放| 131mm少妇做爰视频| 亚洲另类欧美在线电影| 99久久无码私人网站| 好硬好湿好爽好深视频| 久热久热中文字幕综合激情| 中文字幕亚洲人妻系列| 亚洲精品国产成人| 国产福利微视频一区二区| 另类 专区 欧美 制服| 日本亚洲色大成网站www久久 | 女人扒开腿让男人桶到爽| 国产中文字幕久久黄色片| 亚洲国产精品午夜福利|