因為目錄比較多,每次更新這裡比較麻煩,所以推薦點選到主頁,然後檢視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()
}
}
總體來說專案功能還是很全的,還有一些小功能,例如:快捷方式等就不在貼程式碼了,但肯定沒發和原版比,相信大家只要做過程式設計師就能理解,畢竟原版是一個商業級專案,幾十個人天天開發和維護,而且持續了幾年了;不過恕我直言,現在的常見的音樂軟體都太複雜了,各種功能,不過都要恰飯,好像又能理解了