Music

这是一款音乐播放器,本地,网络音乐都可以播放,做这个主要原因:一个是为了毕业设计,一个是为了考验一下自己编码的能力。毕业设计题目是“基于iOS平台的网络音乐播放器的设计与实现”

项目要求

功能要求:

  • 基本的音乐播放功能(播放,暂停,继续等)。
  • 实现本地或者网络音乐的播放。
  • 实现基本的播放模式(顺序播放,随机播放等)。
  • 实现歌曲搜索(根据歌手进行搜索,根据歌名进行搜索)。
  • 收藏歌曲,将喜欢的歌曲进行收藏到指定的文件夹下。
  • 进行歌曲的下载和缓存,实现离线播放已经缓存好的或者已经下载好的音乐。
  • 实现歌词同步显示。
  • 清除缓存功能,删除已经下载的音乐。

后台:

总体设计

播放器设计

  • AVAudioPlayer只支持本地播放。

Q: Does the AVAudioPlayer provide support for streaming audio content?

A: The AVAudioPlayer class does not provide support for streaming audio based on HTTP URL’s. The URL used with initWithContentsOfURL: must be a File URL (file://). That is, a local path.

As mentioned in Core Audio Essentials, the AVAudioPlayer class is ideal for applications that require a simple Objective-C interface for audio playback, are not concerned about audio positioning or precise synchronization, and are not playing audio from a network stream.

  • ✅ 自己使用Audio File Stream ServicesAudio Queue Services来解析音频流。

To play streamed audio, connect to a network stream using the CFNetwork interfaces from Core Foundation, such as those in CFHTTPMessage. Parse the network packets into audio packets using Audio File Stream Services (AudioToolbox/AudioFileStream.h) and play the audio packets using Audio Queue Services (AudioToolbox/AudioQueue.h). You may also use Audio File Stream Services to parse audio packets from an on-disk file.

  • AVPlayer支持任何流媒体播放,包括视频,音频。

AVPlayer (available in iOS 4.0 and later) may also be used to stream audio content.

播放器-实现方案

  • 采用AVFoundation库:AVQueuePlayerAVPlayerItemAVURLAssetAVPlayer

    Apple 提供的高级API接口,但是坑比较多,实现变播放边缓存比较麻烦。

  • 采用第三方流媒体库:StreamingKitDOUAudioStreamerFreeStreamer

    第三方流媒体框架。比较老旧,很多已经几年没有更新,稳定性高。

  • 采用AudioToolbox库:AudioQueue, AudioFileStream

    接近于底层的API接口,提供更多的控制,需要自己实现音频流的解析。

播放器-实现方案测试

AudioToolbox > Audio Queue Services & Audio File Stream Services

Overview

Using Self In C Block

⚠️: Audio Queue ServicesAudio File Stream Services提供的回调都是C的回调,所以在Swift中使用这些回调有两个选择,一个是Block,一个是全局变量

如果在Block中使用self会出现:A C function pointer cannot be formed from a closure that captures context

一般这样的函数都会有一个参数是传递当前的实例,比如下面函数中的inClientData: UnsafeMutableRawPointer

传递参数的时候将自身实例转换成指针传递unsafeBitCast(self, to: UnsafeMutableRawPointer.self)

在回调中let mySelf = Unmanaged<YourClass>.fromOpaque(inClientData).takeUnretainedValue()再将指针转换成YourClass类型的指针。

下面给出这个播放器整体运行的流程图:

Thread in AudioQueue and AudioFileStream

这里主要说的是AudioQueue和AudioFileStream中线程的问题

AudioQueueNewOutput创建的时候有一个参数inCallbackRunLoop,官方是这样讲解这个参数的:

The event loop on which the callback function pointed to by the inCallbackProc parameter is to be called. If you specify NULL, the callback is invoked on one of the audio queue’s internal threads.

总结来说就是播放音频的线程需要开启Runloop,如果给nil,那么就会使用自己内部的线程。

由于主线程的Runloop是开启的,所以你的操作如果全部放在主线程,那么依旧可以播放,只不过性能不好。

如果你的音频播放在没有开启Runloop的线程中,那么就会出现播放不出来,或者播放一小段时间就自动停止的情况,例如DispatchQueue.global()创建的线程

是否可以播放 AudioQueueNewOutput AudioFileStreamParseBytes inCallbackRunLoop 详解
AnyThread AnyThread nil 推荐,AnyThread可以是自己创建的线程,也可以是非主线程,例如DispatchQueue.global()创建的线程
AnyThread AnyThread CFRunLoopGetMain() 不建议将解析数据和播放音频放在主队列中进行 ,虽然这是没有问题的

✨✨✨✨✨最佳方案: 在DispatchQueue串行队列(保证数据连续性)中调用AudioFileStreamParseBytes解析数据,inCallbackRunLoop传递nil。

Thanks for: Why might my AudioQueueOutputCallback not be called?

Memory leak in AudioQueue

在播放完成之后要调用AudioQueueDispose,否则会出现之前播放的数据不被释放,内存不断的上涨。

When you’re finished playing a file, dispose of the audio queue, close the audio file, and free any remaining resources.

The AudioQueueDispose function disposes of the audio queue and all of its resources, including its buffers.

AudioFileStream音频文件流

AudioFileStreamOpen打开一个音频文件流
1
func AudioFileStreamOpen(_ inClientData: UnsafeMutableRawPointer?, _ inPropertyListenerProc: @escaping AudioFileStream_PropertyListenerProc, _ inPacketsProc: @escaping AudioFileStream_PacketsProc, _ inFileTypeHint: AudioFileTypeID, _ outAudioFileStream: UnsafeMutablePointer<AudioFileStreamID?>) -> OSStatus
参数 意义
inPropertyListenerProc 歌曲信息解析的回调(一首歌曲只会回调一次)
inPacketsProc 分离帧的回调(每解析一部分数据就会回调一次)
inFileTypeHint MP3音频文件类型kAudioFileMP3Type , 不知道文件类型就传0
outAudioFileStream 成功返回音频流解析器ID: AudioFileStreamID
AudioFileStream_PropertyListenerProc音频属性解析回调
1
typealias AudioFileStream_PropertyListenerProc = (UnsafeMutableRawPointer, AudioFileStreamID, AudioFileStreamPropertyID, UnsafeMutablePointer<AudioFileStreamPropertyFlags>) -> Void
参数 意义
inAudioFileStream 音频流解析器ID
inPropertyID 属性ID,表示当前哪个属性被解析到。
ioFlags 表示这个property是否需要被缓存

当我们在这里解析到inPropertyIDkAudioFileStreamProperty_DataFormat的时候表示解析到了音频格式的数据,这个时候就可以建立输出队列,并开始解析音频数据,控制播放了。

AudioFileStream全局变量名
kAudioFileStreamProperty_ReadyToProducePackets 1919247481
kAudioFileStreamProperty_FileFormat 1717988724
kAudioFileStreamProperty_DataFormat 1684434292
kAudioFileStreamProperty_FormatList 1718383476
kAudioFileStreamProperty_MagicCookieData 1835493731
kAudioFileStreamProperty_AudioDataByteCount 1650683508
kAudioFileStreamProperty_AudioDataPacketCount 1885564532
kAudioFileStreamProperty_MaximumPacketSize 1886616165
kAudioFileStreamProperty_DataOffset 1685022310
kAudioFileStreamProperty_ChannelLayout 1668112752
kAudioFileStreamProperty_PacketToFrame 1886086770
kAudioFileStreamProperty_FrameToPacket 1718775915
kAudioFileStreamProperty_PacketToByte 1886085753
kAudioFileStreamProperty_ByteToPacket 1652125803
kAudioFileStreamProperty_PacketTableInfo 1886283375
kAudioFileStreamProperty_PacketSizeUpperBound 1886090594
kAudioFileStreamProperty_AverageBytesPerPacket 1633841264
kAudioFileStreamProperty_BitRate 1651663220
kAudioFileStreamProperty_InfoDictionary 1768842863

下面是我解析一个mp3文件得到的文件信息:

ID对应的实际值 ID名称 表示内容
1651663220 kAudioFileStreamProperty_BitRate 码率
1650683508 kAudioFileStreamProperty_AudioDataByteCount 音频数据总量
1885564532 kAudioFileStreamProperty_AudioDataPacketCount 音频帧总量
1717988724 kAudioFileStreamProperty_FileFormat 文件格式
1684434292 kAudioFileStreamProperty_DataFormat 数据格式
1685022310 kAudioFileStreamProperty_DataOffset 数据偏移量
1919247481 kAudioFileStreamProperty_ReadyToProducePackets 标志着接下来就是分离帧的步骤
AudioFileStream_PacketsProc帧分离回调
1
typealias AudioFileStream_PacketsProc = (UnsafeMutableRawPointer, UInt32, UInt32, UnsafeRawPointer, UnsafeMutablePointer<AudioStreamPacketDescription>) -> Void
参数 意义
inNumberBytes 音频的字节
inNumberPackets 音频中包含的帧数
inInputData 解析的音频数据
inPacketDescriptions 是一个AudioStreamPacketDescription类型的数组,通过mStartOffsetmDataByteSize,结合inInputData就可以将分离的帧保存起来
AudioFileStreamParseBytes音频解析
1
func AudioFileStreamParseBytes(_ inAudioFileStream: AudioFileStreamID, _ inDataByteSize: UInt32, _ inData: UnsafeRawPointer, _ inFlags: AudioFileStreamParseFlags) -> OSStatus
参数 意义
inAudioFileStream 音频流解析器ID
inDataByteSize 解析的数据总量
inData 解析的数据
inFlags 解析的数据是否和上一个有关联

在哪个线程中调用这个方法,就在哪个线程中解析数据,建议在非主线程中调用这个方法。

AudioQueue音频队列

This table lists result codes defined for Audio Queue Services.

AudioQueue全局变量
kAudioQueueErr_InvalidBuffer -66687
kAudioQueueErr_BufferEmpty -66686
kAudioQueueErr_DisposalPending -66685
kAudioQueueErr_InvalidProperty -66684
kAudioQueueErr_InvalidPropertySize -66683
kAudioQueueErr_InvalidParameter -66682
kAudioQueueErr_CannotStart -66681
kAudioQueueErr_InvalidDevice -66680
kAudioQueueErr_BufferInQueue -66679
kAudioQueueErr_InvalidRunState -66678
kAudioQueueErr_InvalidQueueType -66677
kAudioQueueErr_Permissions -66676
kAudioQueueErr_InvalidPropertyValue -66675
kAudioQueueErr_PrimeTimedOut -66674
kAudioQueueErr_CodecNotFound -66673
kAudioQueueErr_InvalidCodecAccess -66672
kAudioQueueErr_QueueInvalidated -66671
kAudioQueueErr_TooManyTaps -66670
kAudioQueueErr_InvalidTapContext -66669
kAudioQueueErr_RecordUnderrun -66668
kAudioQueueErr_InvalidTapType -66667
kAudioQueueErr_BufferEnqueuedTwice -66666
kAudioQueueErr_CannotStartYet -66665
kAudioQueueErr_EnqueueDuringReset -66632
kAudioQueueErr_InvalidOfflineMode -66626
AudioQueueNewOutput创建音频输出队列
1
func AudioQueueNewOutput(_ inFormat: UnsafePointer<AudioStreamBasicDescription>, _ inCallbackProc: @escaping AudioQueueOutputCallback, _ inUserData: UnsafeMutableRawPointer?, _ inCallbackRunLoop: CFRunLoop?, _ inCallbackRunLoopMode: CFString?, _ inFlags: UInt32, _ outAQ: UnsafeMutablePointer<AudioQueueRef?>) -> OSStatus
参数 意义
inFormat AudioStreamBasicDescription,要播放的音频格式
inCallbackProc 当音频队列中的的buffer已经播放完成并且能够重利用的时候
inCallbackRunLoop 回调的RunLoop,nil表示采用音频队列自己的RunLoop进行回调
inCallbackRunLoopMode RunLoopMode,nil表示采用kCFRunLoopCommonModes
inFlags 预留,必须传0
outAQ 成功返回音频队列实例: AudioQueueRef
AudioQueueOutputCallback播放音频完成回调
1
typealias AudioQueueOutputCallback = (UnsafeMutableRawPointer?, AudioQueueRef, AudioQueueBufferRef) -> Void
参数 意义
inAQ 音频回调队列
inBuffer Buffer已经播放完成,可以重新利用

下面是官方图解:(这个Buffer是可以重新利用的)

但是在代码中是直接销毁重新创建,由于时间关系,留在以后再优化。

下面来一个动图帮助理解(Thanks for: Streaming Audio to Multiple Listeners via iOS’ Multipeer Connectivity

AudioQueueAddPropertyListener添加音频队列属性监听
1
func AudioQueueAddPropertyListener(_ inAQ: AudioQueueRef, _ inID: AudioQueuePropertyID, _ inProc: @escaping AudioQueuePropertyListenerProc, _ inUserData: UnsafeMutableRawPointer?) -> OSStatus
参数 意义
inAQ 想要添加监听的音频队列
inID 想要监听的音频队列属性AudioQueuePropertyID
inProc 当监听的属性改变的时候的回调

kAudioQueueProperty_IsRunning 可以监听当前队列是否正在运行。

AudioQueueAllocateBuffer为音频队列分配空间
1
func AudioQueueAllocateBuffer(_ inAQ: AudioQueueRef, _ inBufferByteSize: UInt32, _ outBuffer: UnsafeMutablePointer<AudioQueueBufferRef?>) -> OSStatus
参数 意义
inAQ 想要为哪个音频队列分配空间
inBufferByteSize 分配空间大小
outBuffer 成功之后分配空间的地址 AudioQueueBufferRef

在这里我们向新的空间填充音频数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/// New buffer
var newAudioQueueBuffer: AudioQueueBufferRef? = nil
/// How many data need to enter queue
var inQueueDatas: ArraySlice<Data> = packets[currentOffset..<endOffset]
var inQueueDescriptions: [AudioStreamPacketDescription] = []
/// Total data size
let totalSize = inQueueDatas.reduce(0, { $0 + $1.count })

/// Allocate buffer
status = AudioQueueAllocateBuffer(audioQueue!,
UInt32(totalSize),
&newAudioQueueBuffer)

if !status.isSuccess { delegate?.streamAudioPlayer(self, anErrorOccur: .streamAudioPlayer(.audioQueue(.allocateBuffer(status)))) }


newAudioQueueBuffer?.pointee.mAudioDataByteSize = UInt32(totalSize)
newAudioQueueBuffer?.pointee.mUserData = selfInstance

var copiedSize = 0
for i in currentOffset..<endOffset {
let packetData = inQueueDatas[i]
/// Copy data to buffer
memcpy(newAudioQueueBuffer?.pointee.mAudioData.advanced(by: copiedSize),
(packetData as NSData).bytes,
packetData.count)
let description = AudioStreamPacketDescription(mStartOffset: Int64(copiedSize),
mVariableFramesInPacket: 0,
mDataByteSize: UInt32(packetData.count))
inQueueDescriptions.append(description)
copiedSize += packetData.count
}
AudioQueueEnqueueBuffer将Buffer加入到音频队列中
1
func AudioQueueEnqueueBuffer(_ inAQ: AudioQueueRef, _ inBuffer: AudioQueueBufferRef, _ inNumPacketDescs: UInt32, _ inPacketDescs: UnsafePointer<AudioStreamPacketDescription>?) -> OSStatus
参数 意义
inAQ 音频队列
inBuffer 分配新Buffer空间
inNumPacketDescs 帧总信息数量
inPacketDescs 帧信息AudioStreamPacketDescription数组

⚠️ 在短时间内多次调用AudioQueueStop会导致返回kAudioQueueErr_EnqueueDuringReset,但是依旧可以正常播放。常见于seek中,先AudioQueueStop再设置偏移量就会出现。暂时不知道如何更好的解决办法。

播放器测试结果:

✅ AudioToolbox

是否可以 内容 方法 详解
播放 AudioQueueStart 开始解析音频数据
暂停 AudioQueuePause 再次点击播放,继续从上次的地方进行解析。
停止 AudioQueueStop 再次点击播放,继续从上次的地方进行解析。
快进,快退 AudioQueueEnqueueBuffer 向队列中压入指定的数据来实现快进快退
当前时间 AudioQueueGetCurrentTime 获取播放时间后,通过时间偏移量来计算当前时间
总时间 AudioFileStream_PropertyListenerProc 回调中解析到kAudioFileStreamProperty_BitRatekAudioFileStreamProperty_AudioDataByteCount之后通过计算得到

API选择

  • ✅ 采用源网易云音乐API

    比较复杂,需要自己实现加密算法等,耗时比较长。

  • ✅ 采用网易云音乐NodeJS版API

    简单,不需要考虑加密等算法。

  • ❌ 自己实现音乐API

    没有音乐资源

实际项目开发

主要界面搭建

播放器界面

核心代码

后台音频播放及控制

后台音频播放

AppDelegate中我们要添加下面这个来设置音频行为:

1
2
3
4
5
6
7
8
9
10
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
...
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback)
} catch {
//ERROR
}

return true
}

Setting the audio session category ensures that the application has the audio behavior expected of a media playback app.(设置Audio Session目的就是为了配置音频的行为)

An audio session acts as an intermediary between your app and the operating system.(Audio Session是作为App操作系统的中间人)

默认有下列行为配置:

  • Audio playback is supported, but audio recording is disallowed (audio recording applies only to iOS).(录音只适用于iOS)
  • In iOS, setting the Ring/Silent switch to silent mode silences any audio being played by the app.(静音按钮对所有App奏效)
  • In iOS, when the device is locked, the app’s audio is silenced.(默认没有后台播放)
  • When the app plays audio, any other background audio is silenced.(默认单个App播放)

而我们之前设置的AVAudioSessionCategoryPlayback就是其中的一个行为:

When using this category, your app audio continues with the Silent switch set to silent or when the screen locks.(按钮和锁屏都会使App不能后台播放)To continue playing audio when your app transitions to the background (for example, when the screen locks), add the audio value to the UIBackgroundModes key in your information property list file.(要在后台继续播放,需要配置Info.plist文件)

配置方式:

Select your app’s target in Xcode and select the Capabilities tab. Under the Capabilities tab, set the Background Modes switch to ON and select the “Audio, AirPlay, and Picture in Picture” option under the list of available modes.

后台音频播放控制

这个时候我们需要用到MediaPlayer库中的MPRemoteCommandCenterMPNowPlayingInfoCenter,两个类都是单例

添加播放,暂停,下一首,上一首控制。

1
2
3
4
MPRemoteCommandCenter.shared().playCommand.addTarget(musicPlayerViewController, action: #selector(musicPlayerViewController.playCommand))
MPRemoteCommandCenter.shared().pauseCommand.addTarget(musicPlayerViewController, action: #selector(musicPlayerViewController.pauseCommand))
MPRemoteCommandCenter.shared().nextTrackCommand.addTarget(musicPlayerViewController, action: #selector(musicPlayerViewController.nextTrack))
MPRemoteCommandCenter.shared().previousTrackCommand.addTarget(musicPlayerViewController, action: #selector(musicPlayerViewController.lastTrack))

设置音乐名称,封面图,当前时间,总共时间,需要自己设置Timer来刷新。

1
2
3
4
5
6
7
8
9
private func updateRemoteControl(_ time: TimeInterval) {
var info: [String: Any] = [:]
info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(image: backgroundImageView.image ?? #imageLiteral(resourceName: "background_default_dark"))
info[MPMediaItemPropertyTitle] = resource?.name ?? ""
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
info[MPMediaItemPropertyPlaybackDuration] = (resource?.duration ?? 0) / 1000

MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}

多线程

DispatchGroup

在缓存音乐的时候需要保证音乐数据已经全部请求完成,而且音乐信息已经全部请求完成,这就涉及到多个异步线程完成之后统一回调的问题,所以采用DispatchGroup的两个方法enter(), leave()来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/// 请求前enter
cacheGroup.enter()
self.musicUrl(originResource.id, block: { (model) in
originResource.info = model
guard let url = model?.url else { failedBlock?(MusicError.resourcesError(.invalidURL)); return }

ConsoleLog.verbose("Request Music Data")
self.playResources[originResource.id] =
MusicNetwork.send(url)
.receive(queue: self.threadManager.audioParseQueue, data: responseBlock)
.receive(queue: self.threadManager.resourceQueue, response: { cacheGroup.leave() })/// 请求完成leave
.receive(queue: self.threadManager.resourceQueue, success: { data = $0 })
})

if originResource.lyric?.lyric == nil {
cacheGroup.enter()/// 请求前enter
self.lyric(originResource.id, block: { (model) in
originResource.lyric = model
cacheGroup.leave()/// 请求完成leave
})
}

/// Completed request data, and now it can be cache to file
cacheGroup.notify(queue: self.threadManager.resourceQueue, execute: {
guard let validData = data else { return }
self.cache(originResource, data: validData)
})

DispatchQoS

A quality of service (QoS) class categorizes work to be performed on a DispatchQueue. By specifying a QoS to work, you indicate its importance, and the system prioritizes it and schedules it accordingly.

Because higher priority work is performed more quickly and with more resources than lower priority work, it typically requires more energy than lower priority work. Accurately specifying appropriate QoS classes for the work your app performs ensures that your app is responsive and energy efficient.

描述了服务质量,高优先级的执行越快。

Manager

MusicResourceManager

MusicFileManager

MusicDataBaseManager

数据库设计

数据库采用SQLite。

内容
Cache 存储已经缓存的文件ID
Download 存储已经下载的文件ID
Resources 存储音乐信息
CollectionList 保存文件夹信息,主要用于离线使用
ListDetail 保存文件夹下具体信息,主要用于离线使用
LeastResources 程序退出前保存最后播放的信息(暂时未使用)

Cache:

1
2
3
CREATE TABLE IF NOT EXISTS Cache (
id text PRIMARY KEY NOT NULL
);

Download:

1
2
3
CREATE TABLE IF NOT EXISTS Download (
id text PRIMARY KEY NOT NULL
);

Resources:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE IF NOT EXISTS Resources (
id text PRIMARY KEY NOT NULL,
name text NOT NULL,
duration real NOT NULL,
lyric blob NOT NULL,
artist blob NOT NULL,
album blob NOT NULL,
info blob NOT NULL,
status integer NOT NULL
);

CollectionList:

1
2
3
4
CREATE TABLE IF NOT EXISTS CollectionList (
id text PRIMARY KEY NOT NULL,
playList blob NOT NULL
);

ListDetail:

1
2
3
4
CREATE TABLE IF NOT EXISTS ListDetail (
id text PRIMARY KEY NOT NULL,
playList blob NOT NULL
);

LeastResources:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE IF NOT EXISTS LeastResources (
id text PRIMARY KEY NOT NULL,
name text NOT NULL,
duration real NOT NULL,
lyric blob NOT NULL,
artist blob NOT NULL,
album blob NOT NULL,
info blob NOT NULL,
status integer NOT NULL
);

在这里并没有直接存储各个字段,而是存储成blob类型,实际就是获取的JSON,这样从网络获取的数据和从数据库获取的数据可以采用同一个解析方式。

总结

  • 字节计算,用于缓存计算,缺点是只能是整数。
1
ByteCountFormatter.string(fromByteCount: 1024, countStyle: .binary)
  • 图片旋转
1
UIImage(cgImage: <#T##CGImage#>, scale: UIScreen.main.scale, orientation: <#T##UIImageOrientation#>)
  • StatusBar

UINavigationController或者其子类中重写下面属性。

1
override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent }

Thanks for: How to change Status Bar text color in iOS 7

  • 第三方库的使用

由于Swift是以文件为单位进行管理,所以每次使用第三方库的时候都需要import,非常的麻烦。

所以如果这个库需要在很多文件中使用,那么使用下面的方式好很多。

如果这个库需要再次进行封装,或者只是在一个文件中使用,那么在单个文件中import比较好。

在这里只需要重命名类型(class, struct, tyealias, enum….)就可以。类似于SnapKit,由于是extension则不需要,只要在一个地方import过就可以在整个项目中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//MARK: - Alamofire
import Alamofire

//MARK: - Kingfisher
import Kingfisher

//MARK: - SwiftyJSON
import SwiftyJSON
public typealias JSON = SwiftyJSON.JSON

//MARK: - Log
import Log
typealias ConsoleLog = Log.ConsoleLog

//MARK: - Wave
import Wave

//MARK: - PageKit
import PageKit

//MARK: - Music API

import MusicAPI
let API = MusicAPI.default

//MARK: - Lotus
import Lotus
let MusicNetwork = Lotus.Session.default
typealias Client = Lotus.Client
...
  • 关于UIApplicationDelegate

基本的流程大概是这样的。

项目踩坑

StoryBoard

  • 约束相对于的对象写错

push页面的时候因为TabBar被动态隐藏,所以页面会跳动一下。

内容 描述
Top Layout Guide 设置的约束相对于StatusBarNavigationBar
Bottom Layout Guide 设置的约束相对于TabBar
Super View 设置的约束相对于父视图。

  • 之前采用StoryBoard写播放器界面

在下面代码中,第一次初始化播放器的时候,并且页面还没有push出来。这个如果使用里面的控件,都会是nil。所以只能放弃,使用代码进行布局。

1
2
3
4
5
6
7
extension UIViewController {
static func instanseFromStoryboard<T: UIViewController>() -> T? {
return UIStoryboard(name: self.reuseIdentifier, bundle: nil).instantiateViewController(withIdentifier: self.reuseIdentifier) as? T
}
}

let musicPlayerViewController: MusicPlayerViewController = MusicPlayerViewController.instanseFromStoryboard()! as! MusicPlayerViewController

UITabBar

是否解决成功 问题描述 如何解决
透明背景 设置tabBar.backgroundImage为一张透明背景图。
文字颜色,图片颜色修改

全局设定TabBar的字体大小

1
2
3
4
let attributesTabBarNormal = [NSForegroundColorAttributeName: UIColor.white, NSFontAttributeName: UIFont.font10]
let attributesTabBarSelected = [NSForegroundColorAttributeName: UIColor.white, NSFontAttributeName: UIFont.font10]
UITabBarItem.appearance().setTitleTextAttributes(attributesTabBarNormal, for: .normal)
UITabBarItem.appearance().setTitleTextAttributes(attributesTabBarSelected, for: .selected)

修改选中图片颜色

1
tabBar.tintColor = .white

是否解决成功 问题描述 如何解决
push的时候出现跳动问题 约束应该依照当前的View进行设置。而不是Bottom Layout Guide设置,否则push时候隐藏TabBar会导致视图跳动。

JSON

JSON序列化
⚠️ 序列化的条件是JSONSerialization.isValidJSONObject(<#T##obj: Any##Any#>)返回true

1
2
3
4
5
6
7
8
/*
Returns YES if the given object can be converted to JSON data, NO otherwise. The object must have the following properties:
- Top level object is an NSArray or NSDictionary
- All objects are NSString, NSNumber, NSArray, NSDictionary, or NSNull
- All dictionary keys are NSStrings
- NSNumbers are not NaN or infinity
Other rules may apply. Calling this method or attempting a conversion are the definitive ways to tell if a given object can be converted to JSON data.
*/

文档中这样描述这个方法的意义,明确指出了能够序列化的类型

  1. Key必须是NSString类型
  2. 最高层必须是NSArray或者NSDictionary,意味着在Swift中元组,闭包等等都不可以。
  3. 对象必须是NSString, NSNumber, NSArray, NSDictionary, 或者NSNull
  4. NSNumber必须有意义

在Swift中Bool也是可以的,但是URL却不符合,只能使用absoluteString转换成String进行存储

由于这种持久化方式过于麻烦,所以采用SQLite数据库。

播放器资源设计

在进行编码的时候一直都存在着一些难点

  1. 播放器播放的时候需要哪些资源。
  2. 其他模块如何给定这些资源。
  3. 音乐请求之后需要缓存,如何实现。
  4. 点击下载之后怎们办。

ResourceManager

其他模块在传递的时候只告诉播放器音乐ID,播放去请求ResourceManagerResourceManager决定资源从哪来。

其他模块需要先reset资源,ResourceManager从资源中找到对应的ID并判断资源来源,有没有缓存或者下载,之后再请求网络,或者从本地读取数据给播放器。

缺点很明显:需要兼顾网络和本地的管理,接口变得复杂,内部变的臃肿,资源包含的主体内容不明确。

暂时无法解决的Bug

赞赏功能无法使用

使用URLSessiondataTask,包括使用Alamofire返回的code都是301。

但是使用浏览器或者Postman请求同一个URL没有任何问题。

Swift好像暂时没有修复这个Bug。URLSession dataTask doesn’t follow redirects

参考

⚠️ 未明确说明,英文说明都来自于Apple官方文档

参考博客

参考博客地址 参考内容
码农人生 音频流解析,播放
Play And Record With CoreAudio on iOS 音频流解析,播放

参考代码

第三方库

第三方库地址 使用内容
SnapKit 约束布局
Kingfisher 图片缓存
Alamofire 网络请求(使用了其中的编码功能)
SwiftyJSON JSON解析
MJRefresh 刷新组件(OC)
SQLite.swift SQLite数据库工具
Toast-Swift Toast提示组件

自定义库

自定义库地址 使用内容
PageKit 简易的分页库
Wave 基于AudioToolBox简易流媒体播放器
Log 简易Log工具
MusicAPI 封装网易云音乐NodeJS版API 提供的API
Lotus 简易网络请求库