音频播放一直是一个最常用的功能,不管是否是以该功能为主业务的app,音频播放都可以作为一个模块存在于其中。
一般来说,在普通的业务需求中,很少会遇到直接播放本地资源文件的情况,基本都是给一个资源链接再播放。
那么,在一个以音频播放为核心功能的app中,相关延伸的功能也就必须实现:
- 边缓冲边播放。
- 播放进度控制。
- 缓存机制(即播放过一遍的链接再次播放时无需再次请求资源)
- 后台播放以及远程控制响应(从锁屏和控制中心进行的播放控制)
那么为何使用AVPlayer作为播放框架,而不使用市面上其他的第三方封装的流媒体播放框架(AudloStreamer, freeStreamer等)呢?这里说明一下,我个人在工作中遇到的坑。
AudloStreamer是一款历史久远的流媒体播放框架,基于苹果的AudioToolbox封装,功能强大,不仅实现了播放,缓冲,缓存等必备机制,还可提供一些音频数据处理的相关功能,可谓是十分强大。而在我负责的项目中原本使用的也是该框架。此框架作为核心音频播放框架使用,一直表现稳定,并无任何bug出现。但是,iOS13的出现,彻底动摇了此框架的地位。
众所周知,iOS13出现的初期,可谓是烽烟四起,各大app频频出现被无故杀后台的现象,但知道真相的用户毕竟是少数,多数用户会把现象归结于程序本身,认为是程序自身出了bug,才导致的后台无故退出。当然苹果后来也对这种疯狂杀后台的机制做了调整(估计是收到了大量开发者的抗议),但某些机制并未作出调整。
比如,我负责的这个项目也出现了崩溃率暴涨的现象(因iOS杀后台是被系统级的watchDog强行结束进程,因此会被崩溃监听的框架认为是异常退出),而用户并不知道退出的真正原因,只从现象上进行反馈,比如:“为什么我播着播着就停了?”,“为什么从后台打开就重启了?”,“为什么后台播放停止了我在锁屏上点什么都没反应?”等等等等。这一系列反馈的根本原因都是因为我这个项目在后台播放时,莫名其妙的被系统看门狗干掉了。
在这个现象出现的初期,我实在一头雾水,因为崩溃的线程很奇怪,那是一个网络请求使用的线程,而这个网络请求使用的框架更是一个远古的框架,著名的ASIHTTPRequset。而在此之前,整个后台播放的流程运行到此处时并未出现问题,而且崩溃位置被记录到了框架代码内部。由于该框架早已无人维护,因此也无法向框架作者寻求帮助,于是只能自己开始研究。
过程就不多说了,直接说结果。经过一系列的尝试,最终问题锁定在了播放框架上,也就是AudloStreamer。我们都知道,如果想让你的app在后台播放音频,那必须要先启动系统的音频播放通道AVAudioSession。当然,这在之前肯定也是做了的,否则早就出问题了。但是,在iOS13中,当我这个项目退到后台播放时,只要播放结束,下一个音频开始播放之后程序就会被杀死。然而,我尝试使用苹果自己封装的AVAudioPlayer循环播放一个本地音频文件时,却可以正常播放。
这也就解释了为什么之前的崩溃会发生在网络层。请求发出了,等待响应的时候app直接被杀死了,当然是网络请求的进程会报错。所以我得出结论,如果使用苹果封装的音频播放框架,则程序可以正常存活在后台,如果使用自己封装的播放框架,则程序就不能正常在后台存活。
这就是我选择了AVPlayer的主要原因。一个以音频播放为核心功能的app,如果不能正常在后台持续播放那还玩个锤子。而且,AVPlayer的封装度很高,使用方便,可提供一切播放器所需的数据支持(除了缓存文件)。
好了,说了这么多,也该上代码了。
在使用之前,我们需要知道几个关键类:
- AVPlayer(播放器类) :播放器主体,控制播放状态,播放进度,以及播放源
- AVPlayerItem(播放源):可播放的资源,包含各种资源信息,以及资源的状态
- CMTime(播放源中使用的时间相关结构体) :AVPlayer中所有时间相关的属性都是该结构体
- AVAudioSession(系统的音频通道):这个都知道
- MPRemoteCommandCenter(远程控制命令中心):这个理论上也都知道
在实际工作中,会因为业务需求不同,我们所用的数据结构也不同,但最终,我们都会用到的一定有资源链接。所以在demo中,我定义的也只有链接,其他属性可根据自己需要再补充。
那么,我们从我们得到了资源链接之后开始。
有了资源链接,我们需要先创建播放源。
/// 创建播放源
/// @param url 链接
- (AVPlayerItem *)getAVPlayItemWithURL:(NSURL *)url{
AVPlayerItem *playerItem = [[AVPlayerItem alloc] initWithURL:url];
if (@available(iOS 10.0, *)) {
playerItem.preferredForwardBufferDuration = 1;
}
return playerItem;
}
然后我们可以选择播放器的创建思路,如果你只需要播放一次音频,可直接用播放源创建播放器,当然也可直接用资源链接直接创建,但如果你需要监听播放时的各种属性,就需要播放源了。
AVPlayer *player = [AVPlayer playerWithPlayerItem:playerItem];
如果你需要的是播放音频列表,那懒加载创建一个播放器是更优选择。然后使用创建的播放源替换当前播放源
/// 懒加载生成player
- (AVPlayer *)player{
if(!_player){
_player = [[AVPlayer alloc] init];
//添加音频播放结束时的通知监听
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_playEOF) name:AVPlayerItemDidPlayToEndTimeNotification object:nil];
}
return _player;
}
/// 替换播放源
/// @param url 链接
- (void)resetPlayItemWithURL:(NSURL *)url{
//移除正在当前播放源的监听
[self removeObserversFromPlayerItem];
//创建新播放源
AVPlayerItem *playerItem = [self getAVPlayItemWithURL:url];
//替换播放源
[self.player replaceCurrentItemWithPlayerItem:playerItem];
if (@available(iOS 10.0, *)) {
self.player.automaticallyWaitsToMinimizeStalling = NO;
}
//给新播放源加监听
[self addObserverToPlayerItem:playerItem];
}
给当前播放源添加监听事件,在添加之前,需移除上一个播放源的监听。
/// 给播放源添加监听
/// @param playerItem 播放源
- (void)addObserverToPlayerItem:(AVPlayerItem *)playerItem{
//监听字段@"status":播放源状态
[playerItem addObserver:self
forKeyPath:@"status"
options:NSKeyValueObservingOptionNew
context:nil];
//监听字段@"playbackBufferEmpty":缓冲区为空
[playerItem addObserver:self
forKeyPath:@"playbackBufferEmpty"
options:NSKeyValueObservingOptionNew
context:nil];
//监听字段@"playbackLikelyToKeepUp":播放似乎可继续进行
[playerItem addObserver:self
forKeyPath:@"playbackLikelyToKeepUp"
options:NSKeyValueObservingOptionNew
context:nil];
//监听字段@"loadedTimeRanges":已加载的时间区间
[playerItem addObserver:self
forKeyPath:@"loadedTimeRanges"
options:NSKeyValueObservingOptionNew
context:nil];
}
/// 移除播放源监听
- (void)removeObserversFromPlayerItem
{
if (self.player.currentItem){
[self.player.currentItem removeObserver:self forKeyPath:@"status"];
[self.player.currentItem removeObserver:self forKeyPath:@"playbackBufferEmpty"];
[self.player.currentItem removeObserver:self forKeyPath:@"playbackLikelyToKeepUp"];
[self.player.currentItem removeObserver:self forKeyPath:@"loadedTimeRanges"];
}
}
完成这些之后,其实已经可以播放音频了,其实如果只想播放,直接用链接创建播放器,然后play就可以了,所以说AVPlayer的封装度真的很高。
然后,我们需要实现监听方法。
#pragma mark- -KVO 监听实现
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
{
if (object == [self.player currentItem]) {
//播放源状态
if ([keyPath isEqualToString:@"status"]) {
AVPlayerStatus status = [[change objectForKey:NSKeyValueChangeNewKey] integerValue];
switch (status) {
case AVPlayerStatusUnknown:{
//未知
}
break;
case AVPlayerStatusReadyToPlay:{
[self.player play];
}
break;
//失败时,停止
case AVPlayerStatusFailed:{
NSLog(@"播放失败:%@",self.player.currentItem.error);
[self.player stop];
}
break;
default:
break;
}
} else if ([keyPath isEqualToString:@"playbackBufferEmpty"] && self.player.currentItem.playbackBufferEmpty) {
//缓冲区为空时,播放状态为加载中,并暂停播放器
[self.player pause];
} else if ([keyPath isEqualToString:@"playbackLikelyToKeepUp"]) {
//播放源可持续播放,按需进行操作
} else if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
//当前缓冲区间发生变化时,读取已缓冲的进度,并通知进度条更新UI
CMTime playerDuration = [[self.player currentItem] duration];
//判断当前播放源的整体播放区间是否合法
if (!CMTIME_IS_INVALID(playerDuration)) {
//获取已缓存的时间
NSTimeInterval *bufferTime = (NSTimeInterval)[self availableDuration];
//发送通知给UI层更新缓冲进度。
}
}
}
}
// 返回当前已缓存的时长
- (float)availableDuration
{
NSArray *loadedTimeRanges = [[self.player currentItem] loadedTimeRanges];
if ([loadedTimeRanges count] > 0) {
CMTimeRange timeRange = [[loadedTimeRanges objectAtIndex:0] CMTimeRangeValue];
float startSeconds = CMTimeGetSeconds(timeRange.start);
float durationSeconds = CMTimeGetSeconds(timeRange.duration);
return (startSeconds + durationSeconds);
} else {
return 0.0f;
}
}
还有一些播放器需要的属性需要单独获取,并通过通知的方式通知UI层刷新UI,具体可通过demo查看
到此,一个可以边缓冲边播放的播放器就完成了。
如果还想实现缓存机制,那还需要做如下操作。
先说明一点,其实有很多第三方库可以进行边缓存边播放,并且将player本身的缓冲进度替换为缓存进度,实现的核心思想是以AVURLAsset创建播放源,设置它的resourceLoader的delegate,然后通过代练方法重写AVPlayer自己封装的缓冲机制。方法固然好,我也进行了尝试,但播放源总是报错,因为我这个项目使用的链接并非正常的链接,通过AVAssetResourceLoaderDelegate的代理方法返回的链接均不能再次使用,因此,这个办法被废弃。
之后,我便又生一计,既然AVPlayer可以自己边缓冲边播放,那我只需要单独实现一个缓存机制,然后在播放前判断本地是否有缓存文件不就得了吗。于是,AFNetworking便派上用场。
在设置播放链接之前,我先对本地是否有完整的缓存文件进行判断,如没有或文件不完整,则一遍开始播放,一遍去下载文件进行缓存。下载方式如下:
//缓存音频原始数据
- (void)requestRawAudioDataWithURL:(NSURL *)url
{
//先取消上一次的下载任务
if (_cacheDownloadTask && _cacheDownloadTask.state == NSURLSessionTaskStateRunning) {
[_cacheDownloadTask cancel];
_cacheDownloadTask = nil;
}
//创建任务
_cacheDownloadTask = [self.cacheDownloadManager downloadTaskWithRequest:[NSURLRequest requestWithURL:url] progress:^(NSProgress * _Nonnull downloadProgress) {
//下载进度,可按需求对UI层进行刷新
NSLog(@"下载进度:%.0f%", downloadProgress.fractionCompleted * 100);
} destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
//保存完整文件的长度
if (response.expectedContentLength > 0) {
//TODO: 需要自己创建本地缓存,记录对应音频文件的总长度,已用于判断文件是否完整。
}
NSURL *path = //TODO: 需要自己定义本地存储路径
return path;
} completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
NSLog(@"下载完成");
}];
[_cacheDownloadTask resume];
}
对应下载路径的操作,具体可以参考demo。
缓存之前先取消上次的缓存是为了节约内存资源,否则一旦用户快速切歌,就会导致所有的音频都要进行缓存。
当然,有缓存,就得有清空缓存,否则你的app会越来越大,最终被用户放弃。
先这样吧。