最近一直在做音频播放器的改版重构,开发测试到最后发布用时差不多要一个月,功能上可能并不是很多,跟大多数音乐播放器一样,但是要注意的细节倒是不少。今天将播放器整理一下,做个储备。
播放器悬浮窗篇
播放器功能结构
- 从视图上看整个播放器从上往下分为六个功能结构
- 音频详情:当前音频的详情数据
- 专栏详情:当前专栏的详情
- 倍速定时控制:倍速和定时列表选项
- 控制条:进度控制
- 播放控制:音频播放控制
- 业务相关:音频列表、下载、点赞、进文章页
从功能结构上划分,可以对播放器的视图做一个结构拆分,这里是将播放控制和业务相关进行了一个自定义view,可以简单的降低了视图的复杂度。
播放器数据结构
开发模式采用了MVVM+RAC,无论是MVC还是MVVM、MVCS说白了都是数据流式架构,控制了数据的在代码结构中的走向,所以项目中只要能约定好一个固定的数据流的走向,分层结构明确,就能在开发中降低耦合度,提升代码质量。
我们这里的VM是拆分成了store 和 service,store层负责基础数据的管理,包括从网络层和数据库层上来的数据,service负责从store层拿数据,然后通过RAC提供给view层,所以播放器这里的view,只提供了一个入口函数,外边调用播放器,只需要传递专栏详情和列表数据以及播放位置,而播放器的当前播放数据都存放在service里。
//播放器的入口函数,viewcontroller提供
- (void)addPlayList:(NSArray *)playList playIndex:(NSInteger)index detail:(NSDictionary *)detailColumn;
// 播放器的当前播放数据,service持有
@property (nonatomic, strong) Model currentAudioModel; //当前播放的audio
@property (nonatomic, assign) NSInteger currentAudioIndex; //当前播放的位置
@property (nonatomic, strong) Model *detailRecord; //详情
@property (nonatomic, strong) NSArray *playList;//播放列表
@property (nonatomic, assign) PlayStatus currentAudioPlayStatus; //当前播放状态
@property (nonatomic, strong) NSString *playTime; //播放时间
@property (nonatomic, strong) NSNumber *hadLiked; //是否点了赞
@property (nonatomic, strong) NSString *audioSpeed;//当前播放速度
@property (nonatomic, strong) RACSubject *rac_shareIcon;
播放器核心控制
播放器开发通常采用的都是AVPlayer,功能丰富,自定制比较强。这里简单的比较一下系统提供的音频播放功能:
- AudioToolbox.framework提供的系统声音服务(System Sound Service),
通常用来播放铃声,点击声音,推送声
音效播放时间不能超过30s
数据必须是PCM或者IMA4格式
音频文件必须打包成.caf , .aif, .wav中的一种(这是官方的说法,实际测试一些.aac .mp3格式的也可以播放) - AVFoundation.framework中的AVAudioPlayer
通过音频的NSData或者本地音频文件的url,来创建一个AVAudioPlayer实例,如加载本地的music.mp3的音频 - AVQueuePlayer
AVPlayer的子类可以实现列表播放。因为是队列管理,所以没有提供上一首的方法,不满足需求。 - AVPlayer
支持播放本地、分步下载、或在线流媒体音视频,不仅可以播放音频,配合AVPlayerLayer类可实现视频播放。另外支持播放进度监听。
AVPlayer的使用
- 设置播放器URL
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:playerUrl options:nil];
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:asset];
self.player = [AVPlayer playerWithPlayerItem:item];
- 添加播放器监听
这里监听的播放器器属性包含了四个
- status:我在AVPlayerItemStatusReadyToPlay的状态下,取CMTimeGetSeconds(playerItem.duration)的值进行了播放总时间的更新,因为后台给的音频总时间不一定是很准确的,可能会丢失一点精度。
- loadedTimeRanges:网络音频的的缓冲进度,这里需要注意的是,本地数据不会调用,所以在设置的时候本地数据不能依赖这里
- playbackBufferEmpty:缓冲是否为空,这个值为YES,视频就会暂停,加载圈就会启动
- playbackLikelyToKeepUp:视频是否可以正常播放,这个值为YES,加载圈就会消失,这里不包含本地音频播放的情况
- name:AVPlayerItemDidPlayToEndTimeNotification:播放结束通知
- addPeriodicTimeObserverForInterval 播放进度监听
[self.player.currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
[self.player.currentItem addObserver:self forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
[self.player.currentItem addObserver:self forKeyPath:@"playbackBufferEmpty" options:NSKeyValueObservingOptionNew context:nil];
[self.player.currentItem addObserver:self forKeyPath:@"playbackLikelyToKeepUp" options:NSKeyValueObservingOptionNew context:nil];
//
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(playFinished:) name:AVPlayerItemDidPlayToEndTimeNotification object:self.player.currentItem];
- (id)addPeriodicTimeObserverForInterval:(CMTime)interval queue:(nullable dispatch_queue_t)queue usingBlock:(void (^)(CMTime time))block;
- 锁屏状态信息
//音乐锁屏信息展示
- (void)setupLockScreenInfo
{
// 1.获取锁屏中心
MPNowPlayingInfoCenter *playingInfoCenter = [MPNowPlayingInfoCenter defaultCenter];
//初始化一个存放音乐信息的字典
NSMutableDictionary *playingInfoDict = [NSMutableDictionary dictionary];
// 2、设置歌曲名
[playingInfoDict setObject:self.currentModel.title forKey:MPMediaItemPropertyTitle];
// 设置歌手名
[playingInfoDict setObject:self.currentModel.artist forKey:MPMediaItemPropertyArtist];
// 3设置封面的图片
MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] initWithImage:image];
[playingInfoDict setObject:artwork forKey:MPMediaItemPropertyArtwork];
// 4设置歌曲的总时长
[playingInfoDict setObject:self.currentModel.time?:@0 forKey:MPMediaItemPropertyPlaybackDuration];
//音乐信息赋值给获取锁屏中心的nowPlayingInfo属性
playingInfoCenter.nowPlayingInfo = playingInfoDict;
// 5.开启远程交互
[[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
}
设备控制之AVAudioSession
只要是app使用到了音频处理的相关硬件,就需要对AVAudioSession进行设置,来管理多个APP对音频硬件设备(麦克风,扬声器)的资源使用。
AUAvudioSession能做很多事:
- 设置自己的APP是否和其他APP音频同时存在,还是中断其他APP声音
- 在手机调到静音模式下,自己的APP音频是否可以播放出声音
- 电话或者其他APP中断自己APP的音频的事件处理
- 指定音频输入和输出的设备(比如是听筒输出声音,还是扬声器输出声音)
- 是否支持录音,录音同时是否支持音频播放
1. AVAudioSession的Categoary设置
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:&error]
上述代码是一般音乐播放器程序中的category,代表了只支持音频播放,通过设置可以不支持与其他app进行混音。iOS一共提供了七种category来进行开发中的相应配置,每种配置都提供了四种能力,
四种能力:
- 是否打断不支持混音播放的APP
- 是否会响应手机静音键开关
- 是否支持音频录制
- 是否支持音频播放
- AVAudioSessionCategoryAmbient,只支持音频播放。这个 Category,音频会被静音键和锁屏键静音。并且不会打断其他应用的音频播放。
- AVAudioSessionCategorySoloAmbient,这个是系统默认使用的 Category,只支持音频播放。音频会被静音键和锁屏键静音。和AVAudioSessionCategoryAmbient不同的是,这个会打断其他应用的音频播放
- AVAudioSessionCategoryPlayback,只支持音频播放。你的音频不会被静音键和锁屏键静音。适用于音频是主要功能的APP,像网易云这些音乐app,锁屏后依然可以播放。
需要注意一下,选择支持在静音键切到静音状态以及锁屏键切到锁屏状态下仍然可以播放音频 Category 时,必须在应用中开启支持后台音频功能,详见 UIBackgroundModes。
- AVAudioSessionCategoryRecord,只支持音频录制。不支持播放。
- AVAudioSessionCategoryPlayAndRecord,支持音频播放和录制。音频的输入和输出不需要同步进行,也可以同步进行。需要音频通话类应用,可以使用这个 Category。
- AVAudioSessionCategoryAudioProcessing,只支持本地音频编解码处理。不支持播放和录制。
- AVAudioSessionCategoryMultiRoute,支持音频播放和录制。允许多条音频流的同步输入和输出。(比如USB连接外部扬声器输出音频,蓝牙耳机同时播放另一路音频这种特殊需求)
我们也可以通过AVAudioSession的属性来读取当前设备支持的Category,这样可以保证设备兼容性。
@property(readonly) NSArray<NSString *> *availableCategories;
2. AVAudioSession的Mode和Options设置
上边的Category是七种大类别,Mode和Options是小类别调整,一些情况下我们可能并不需要调整。
- Mode
我们通过读取下面这条属性获取当前设备支持的Mode
@property(readonly) NSArray<NSString *> *availableModes;
iOS下有七种mode来定制我们的Category行为
- AVAudioSessionModeDefault,默认模式,与所有的 Category 兼容
- AVAudioSessionModeVoiceChat,适用于VoIP 类型的应用。只能是 AVAudioSessionCategoryPlayAndRecord Category下。在这个模式系统会自动配置AVAudioSessionCategoryOptionAllowBluetooth 这个选项。系统会自动选择最佳的内置麦克风组合支持语音聊天。
- AVAudioSessionModeVideoChat,用于视频聊天类型应用,只能是 AVAudioSessionCategoryPlayAndRecord Category下。适在这个模式系统会自动配置 AVAudioSessionCategoryOptionAllowBluetooth 和 AVAudioSessionCategoryOptionDefaultToSpeaker 选项。系统会自动选择最佳的内置麦克风组合支持视频聊天。
- AVAudioSessionModeGameChat,适用于游戏类应用。使用 GKVoiceChat 对象的应用会自动设置这个模式和AVAudioSessionCategoryPlayAndRecord Category。实际参数和AVAudioSessionModeVideoChat一致
- AVAudioSessionModeVideoRecording,适用于使用摄像头采集视频的应用。只能是 AVAudioSessionCategoryPlayAndRecord 和 - AVAudioSessionCategoryRecord 这两个 Category下。这个模式搭配 AVCaptureSession API 结合来用可以更好地控制音视频的输入输出路径。(例如,设置 automaticallyConfiguresApplicationAudioSession 属性,系统会自动选择最佳输出路径。
- AVAudioSessionModeMeasurement,最小化系统。只用于 AVAudioSessionCategoryPlayAndRecord、AVAudioSessionCategoryRecord、AVAudioSessionCategoryPlayback 这几种 Category。
- AVAudioSessionModeMoviePlayback,适用于播放视频的应用。只用于 AVAudioSessionCategoryPlayback 这个Category。
-
Options
使用options去微调Category行为,如下表
调优我们的Category
通过Category和合适的Mode和Options的搭配我们可以调优出我们的效果,下面举两个应用场景:
用过高德地图的都知道,在后台播放QQ音乐的时候,如果导航语音出来,QQ音乐不会停止,而是被智能压低和混音,等导航语音播报完后,QQ音乐正常播放,这里我们需要后台播放音乐,所以Category使用AVAudioSessionCategoryPlayback,需要混音和智能压低其他APP音量,所以Options选用 AVAudioSessionCategoryOptionMixWithOthers和AVAudioSessionCategoryOptionDuckOthers
代码示例如下
BOOL isSuccess = [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback
withOptions:AVAudioSessionCategoryOptionMixWithOthers | AVAudioSessionCategoryOptionDuckOthers
error:&setCategoryError];
又或者我希望AVAudioSessionCategoryPlayAndRecord这个Category默认的音频由扬声器播放,那么可以调用这个接口去调整Category
- (BOOL)setCategory:(NSString *)category
withOptions:(AVAudioSessionCategoryOptions)options
error:(NSError **)outError
通过选择合适和Category,mode和options,就可以调优音频的输入输出,来满足日常开发需求(需要注意的是Category,mode,option是搭配使用的,而不是简单组合,也就是说某种Category支持某些mode和option,从上面的表中也可以看出这一点)
3. 播放器中断处理
其他APP或者电话会中断我们的APP音频,所以相应的我们要做出处理。
我们可以通过监听AVAudioSessionInterruptionNotification这个key获取音频中断事件
回调回来Userinfo有键值
AVAudioSessionInterruptionTypeKey:
取值AVAudioSessionInterruptionTypeBegan表示中断开始
取值AVAudioSessionInterruptionTypeEnded表示中断结束
中断开始:我们需要做的是保存好播放状态,上下文,更新用户界面等
中断结束:我们要做的是恢复好状态和上下文,更新用户界面,根据需求准备好之后选择是否激活我们session。
选择不同的音频播放技术,处理中断方式也有差别,具体如下:
- System Sound Services:使用 System Sound Services 播发音频,系统会自动处理,不受APP控制,当中断发生时,音频播放会静音,当中断结束后,音频播放会恢复。
- AV Foundation framework:AVAudioPlayer 类和 AVAudioRecorder 类提供了中断开始和结束的 Delegate 回调方法来处理中断。中断发生,系统会自动停止播放,需要做的是记录播放时间等状态,更新用户界面,等中断结束后,再次调用播放方法,系统会自动激活session。
- Audio Queue Services, I/O audio unit:使用aduio unit这些技术需要处理中断,需要做的是记录播放或者录制的位置,中断结束后自己恢复audio session。
- OpenAL:使用 OpenAL 播放时,同样需要自己监听中断。管理 OpenAL上下文,用户中断结束后恢复audio session。
需要注意的是:
- 有中断开始事件,不一定对应有中断结束事件,所以需要在用户进入前台,点击UI操作的时候,需要保存好播放状态和对Audio Session管理,以便不影响APP的音频功能。
- 接受到中断结束时候,需要判断是否需要进行继续播放,未放置在之前没有播放时候也接受到了中断结束事件,所以需要增加之前是否播放的判断。
- 音频资源竞争上,一定是电话优先。
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleInterreption:)
name:AVAudioSessionInterruptionNotification
object:nil];
- (void)handleInterreption:(NSNotification *)notificaiton
{
DDLogDebug(@"handleInterreption:%@", notificaiton.userInfo);
AVAudioSessionInterruptionType interuptionType = [notificaiton.userInfo[AVAudioSessionInterruptionTypeKey] intValue];
switch (interuptionType) {
case AVAudioSessionInterruptionTypeBegan: {
DDLogDebug(@"收到中断,停止音频播放");
[self pause];
break;
}
case AVAudioSessionInterruptionTypeEnded: {
DDLogDebug(@"系统中断结束");
AVAudioSessionInterruptionOptions options = [notificaiton.userInfo[AVAudioSessionInterruptionOptionKey] intValue];
NSError *error = nil;
if (options == AVAudioSessionInterruptionOptionShouldResume) {
[self play:nil];
}
if (error) {
DDLogDebug(@"AVAudioSessionInterruptionOptionShouldResume失败:%@",[error localizedDescription]);
}
break;
}
}
}
3. AVAudioSession之 setActive
setActive方法有一下几个功能:
- 设置在静音模式下播放
- 设置之后可以使app拿到第一响应者,同时会取消别的音乐播放器播放状态
- 取消设置时候设置withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation,可以放弃相应,使对方收到中断通知,对方app可以继续播放。
这里有一个问题,在app刚启动时候,设置active为yes,然后立刻锁屏,app会有几率收到中断通知,所以我们上边提到,收到中断唤醒时候需要判断是否可以进行播放。
耳机监听
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(audioSessionRouteChange:)
name:AVAudioSessionRouteChangeNotification
object:nil];
- (void)audioSessionRouteChange:(NSNotification *)notify
{
NSDictionary *interuptionDict = notify.userInfo;
NSInteger routeChangeReason = [[interuptionDict valueForKey:AVAudioSessionRouteChangeReasonKey] integerValue];
switch (routeChangeReason) {
case AVAudioSessionRouteChangeReasonNewDeviceAvailable:
DDLogVerbose(@"耳机插入");
// 继续播放音频,什么也不用做
break;
case AVAudioSessionRouteChangeReasonOldDeviceUnavailable:
{
DDLogVerbose(@"耳机拔出");
// 拔出耳机时系统会自动暂停你正在播放的音频,因此只需要改变UI为暂停状态即可
if ([GKTAudioManager sharedManager].currentAudioPlayStatus == GKTPlayStatusPlaying) {
[[GKTAudioManager sharedManager] pause];
}
}
break;
default:
break;
}
}
远程控制,耳机和锁屏状态
//锁屏界面开启和监控远程控制事件
+ (void)configRemoteCommandCenter{ /**/
//远程控制命令中心 iOS 7.1 之后 详情看官方文档:https://developer.apple.com/documentation/mediaplayer/mpremotecommandcenter
MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
// MPFeedbackCommand对象反映了当前App所播放的反馈状态. MPRemoteCommandCenter对象提供feedback对象用于对媒体文件进行喜欢, 不喜欢, 标记的操作. 效果类似于网易云音乐锁屏时的效果
//添加喜欢按钮
// MPFeedbackCommand *likeCommand = commandCenter.likeCommand;
// likeCommand.enabled = YES;
// likeCommand.localizedTitle = @"喜欢";
// [likeCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { NSLog(@"喜欢"); return MPRemoteCommandHandlerStatusSuccess;
// }]; //添加不喜欢按钮,假装是“上一首”
// MPFeedbackCommand *dislikeCommand = commandCenter.dislikeCommand;
// dislikeCommand.enabled = YES;
// dislikeCommand.localizedTitle = @"上一首";
// [dislikeCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
// NSLog(@"上一首");
// [[GKTAudioManager sharedManager] previous:^(id error, id result) {
//
// }];
// return MPRemoteCommandHandlerStatusSuccess;
// }];
[commandCenter.previousTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
NSLog(@"上一首");
[[GKTAudioManager sharedManager] previous];
return MPRemoteCommandHandlerStatusSuccess;
}];
//标记
// MPFeedbackCommand *bookmarkCommand = commandCenter.bookmarkCommand;
// bookmarkCommand.enabled = YES;
// bookmarkCommand.localizedTitle = @"标记";
// [bookmarkCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { NSLog(@"标记"); return MPRemoteCommandHandlerStatusSuccess;
// }];
// commandCenter.togglePlayPauseCommand 耳机线控的暂停/播放
commandCenter.togglePlayPauseCommand.enabled = YES;
[commandCenter.togglePlayPauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
if ([GKTAudioManager sharedManager].currentAudioPlayStatus == GKTPlayStatusPlaying) {
[[GKTAudioManager sharedManager] pause];
} else {
[[GKTAudioManager sharedManager] play:^(id error, id result) {
}];
}
return MPRemoteCommandHandlerStatusSuccess;
}];
[commandCenter.pauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
[[GKTAudioManager sharedManager] pause];
return MPRemoteCommandHandlerStatusSuccess;
}];
[commandCenter.playCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
[[GKTAudioManager sharedManager] play:^(id error, id result) {
}];
// [self.player setRate:self.rateValue];
return MPRemoteCommandHandlerStatusSuccess;
}]; // [commandCenter.previousTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
// NSLog(@"上一首");
// return MPRemoteCommandHandlerStatusSuccess;
// }];
[commandCenter.nextTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { NSLog(@"下一首");
[[GKTAudioManager sharedManager] next:^(id error, id result) {
}];
// [self.player setRate:self.rateValue];
return MPRemoteCommandHandlerStatusSuccess;
}];
//快进
// MPSkipIntervalCommand *skipBackwardIntervalCommand = commandCenter.skipForwardCommand;
// skipBackwardIntervalCommand.preferredIntervals = @[@(54)];
// skipBackwardIntervalCommand.enabled = YES;
// [skipBackwardIntervalCommand addTarget:self action:@selector(skipBackwardEvent:)];
//在控制台拖动进度条调节进度(仿QQ音乐的效果)
[commandCenter.changePlaybackPositionCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { CMTime totlaTime = [GKTAudioManager sharedManager].player.currentItem.duration;
MPChangePlaybackPositionCommandEvent * playbackPositionEvent = (MPChangePlaybackPositionCommandEvent *)event;
@try {
[[GKTAudioManager sharedManager].player seekToTime:CMTimeMake(totlaTime.value*playbackPositionEvent.positionTime/CMTimeGetSeconds(totlaTime), totlaTime.timescale) completionHandler:^(BOOL finished) {
}];
} @catch (NSException *exception) {} @finally {}
return MPRemoteCommandHandlerStatusSuccess;
}];
}
-(void)skipBackwardEvent: (MPSkipIntervalCommandEvent *)skipEvent
{ NSLog(@"快进了 %f秒", skipEvent.interval);
}
总结:
播放器的总结大概就到这里,在开发的过程中,我们可能更关注一个基础功能的开发,但更要注重细节上东西,比如上边提到的AVAudioSession对 setActive的设置,大概率在发布之前,大家更注重的是功能上的完整性,而推到了线上,用户在使用时候,对细节的感知更明显。