关于ios:Swift高仿iOS网易云音乐MoyaRxSwiftKingfisherMVCMVVM

31次阅读

共计 45874 个字符,预计需要花费 115 分钟才能阅读完成。

成果

列文章目录

因为目录比拟多,每次更新这里比拟麻烦,所以举荐点击到主页,而后查看 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

户协定对话框

<img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/13eaa319d44e45c9b64205f5a6b2966a~tplv-k3u1fbpfcp-watermark.image” width=”300″ >

应用自定义 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
    }()}
````

## 导界面
![在这里插入图片形容](https://img-blog.csdnimg.cn/3e22061d7ea84ba695eeffd06024cd3f.png)

疏导界面比较简单,就是多个图片能够左右滚动。

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{

}



## 广告界面
![在这里插入图片形容](https://img-blog.csdnimg.cn/d2bc980948f74aaca024e09f9c7924cc.png)


实现图片广告和视频广告,广告数据是在首页是缓存到本地,目标是在启动界面加载更快,因为实在我的项目中,大部分我的项目启动页面广告工夫一共就 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)

}


显示图片就是显示本地图片了,没什么难点,就不贴代码了。## 首页 / 歌单详情 / 黑胶唱片界面
![在这里插入图片形容](https://img-blog.csdnimg.cn/4f0846d0571d45fca4a8d7d84841f939.png)


首页没有顶部是轮播图,而后是能够左右的菜单,接下来是热门歌单,举荐单曲,最初是首页排序模块;整体上应用 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())

}


## 歌词

<img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3f7cb8ed4d71459aabd5f2600e84ece8~tplv-k3u1fbpfcp-watermark.image" width="300" >

歌词实现了 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
    }
}

}


## 登录 / 注册 / 验证码登录
![在这里插入图片形容](https://img-blog.csdnimg.cn/8f97044067314f7aa1c13e65b3bc7ed2.png)

登录注册没有多大难度,用户名和明码登录,就是把信息传递到服务端,能够加密后在传输,服务端判断登录胜利,返回一个标记,客户端保留,其余须要的登录的接口带上;验证码登录就是用验证码代替明码,发送验证码都是服务端发送,客户端只须要调用接口。## 评论
![在这里插入图片形容](https://img-blog.csdnimg.cn/13e04cfb79994224aa5a3d1fb3a11d9b.png)

评论列表包含下拉刷新,上拉加载更多,点赞,公布评论,回复评论,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)
    }
}

}


## 视频和播放
![在这里插入图片形容](https://img-blog.csdnimg.cn/a163b94f958d4e6d86867cbf1d223823.png)


实在我的项目中视频播放大部分都是用第三方服务,例如:阿里云视频服务,腾讯视频服务,因为他们提供一条龙服务,包含审核,转码,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

}


## 用户详情 / 更改材料
![在这里插入图片形容](https://img-blog.csdnimg.cn/132a10deb54344368dc78aa13700829d.png)


用户详情顶部显示用户信息,好友数量,上面别离显示创立的歌单,珍藏的歌单,公布的动静,相似微信朋友圈,右上角能够更改用户材料;应用第三方框架外面的 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 中,并在代理办法返回就行了。## 公布动静 / 抉择地位 / 门路布局
![在这里插入图片形容](https://img-blog.csdnimg.cn/f7ad8f5367c64daf87281e11354317c3.png)

公布成果和微信朋友圈相似,能够抉择图片,和地理位置;地理位置应用高德地图实现抉择,门路布局是调用零碎中装置的地图,相似微信。### 地位

/// 搜寻该地位的 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…
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)

}


## 聊天 / 离线推送
![在这里插入图片形容](https://img-blog.csdnimg.cn/22e188ada9d843729a84077cd4bf7702.png)

大部分实在我的项目中聊天都会抉择第三方商业级付费聊天服务,罕用的有腾讯云聊天,融云聊天,网易云聊天等,这里抉择融云聊天服务,应用步骤是先在服务端生成聊天 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()
    }
}

}


## 商城 / 订单 / 领取 / 购物车
![在这里插入图片形容](https://img-blog.csdnimg.cn/d35de5953765419c93cb0a4b5532a7fd.png)
![在这里插入图片形容](https://img-blog.csdnimg.cn/86ebe1174149491aa58425c1d1393b6e.png)


学到这里,大家不能说相熟,那么看到下面的界面,那么大体要能实现进去。### 详情富文本

// 详情
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()}

}


## 我的项目总结

总体来说我的项目性能还是很全的,还有一些小性能,例如:快捷方式等就不在贴代码了,但必定没发和原版比,置信大家只有做过程序员就能了解,毕竟原版是一个商业级我的项目,几十个人天天开发和保护,而且继续了几年了;不过恕我直言,当初的常见的音乐软件都太简单了,各种性能,不过都要恰饭,如同又能了解了😄。

正文完
 0