成果
列文章目录
因为目录比拟多,每次更新这里比拟麻烦,所以举荐点击到主页,而后查看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
}()
}
````
## 导界面

疏导界面比较简单,就是多个图片能够左右滚动。
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())
}
## 歌词
<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
}
}
}
## 登录/注册/验证码登录

登录注册没有多大难度,用户名和明码登录,就是把信息传递到服务端,能够加密后在传输,服务端判断登录胜利,返回一个标记,客户端保留,其余须要的登录的接口带上;验证码登录就是用验证码代替明码,发送验证码都是服务端发送,客户端只须要调用接口。
## 评论

评论列表包含下拉刷新,上拉加载更多,点赞,公布评论,回复评论,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…
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()
}
}
## 我的项目总结
总体来说我的项目性能还是很全的,还有一些小性能,例如:快捷方式等就不在贴代码了,但必定没发和原版比,置信大家只有做过程序员就能了解,毕竟原版是一个商业级我的项目,几十个人天天开发和保护,而且继续了几年了;不过恕我直言,当初的常见的音乐软件都太简单了,各种性能,不过都要恰饭,如同又能了解了😄。
发表回复