网易云音乐凭着良好的交互体验,优质丰富的资源在终端一直有着不错的市场。相比较市面上的主流音乐播放器(QQ音乐、虾米音乐),笔者更倾向于云音乐的UED。
单从播放器页来说,云音乐的界面非常简洁,只保留了主要的操作功能,避免过多的信息造成视觉上的疲倦。主视图上不断旋转的黑胶唱片非常有带入感,左滑右滑切换唱片的设计也非常经典。
本文主要介绍了播放器实现的一些核心代码和思路,完整代码请通过文末链接自行clone
必要知识储备:
- 基础数据结构知识(队列、栈、链表)
- AVFoundation/AVPlayer
- AVAudioSession
- CoreAnimation
- Autolayout(Masonry)
- KVO、Notification
实现需求:
- 播放器播放、暂停,上一首、下一首
- 播放模式切换,支持单曲循环、顺序播放、随机播放
- 黑胶唱片仿真动画
- 后台播放及中断控制
- 支持iOS10.0包括及以上系统
播放资源控制
音乐播放器的核心功能是针对播放资源的控制,其核心内容包括媒体资源状态控制、播放器状态控制(播放、暂停、切换)。
值得庆幸的是我们并不用自己去实现如在线资源拉取、资源缓存(如果有必要实现)、资源解码播放等复杂的底层功能,AVFoundation框架可以帮助我们完成这些事情,我们要做的是针对AVPlayer、AVPlayerItem提供的接口根据业务进行封装。
本文不介绍框架的详细使用,只针对具体使用业务场景提供封装思路及核心代码
AVPlayerItem封装
AVPlayerItem
是AVPlayer
播放资源的基本单位,得益于OC语言良好的命名习惯,我们非常容易理解AVPlayerItem类的定义,它管理了媒体资源的地址
、时长
、缓存
,并提供相关状态属性用于监听。下面代码块展示的是被封装的属性:
// AVPlayerItem.h
/**
订阅这个通知,当资源完成播放时该通知会被发出
*/
AVF_EXPORT NSString *const AVPlayerItemDidPlayToEndTimeNotification NS_AVAILABLE(10_7, 4_0); // item has played to its end time
/*!
@property status
@abstract
The ability of the receiver to be used for playback.
@discussion
The value of this property is an AVPlayerItemStatus that indicates whether the receiver can be used for playback.
When the value of this property is AVPlayerItemStatusFailed, the receiver can no longer be used for playback and a new instance needs to be created in its place. When this happens, clients can check the value of the error
property to determine the nature of the failure. This property is key value observable.
@translation
监听这个属性可以帮助我们获取当前资源的状态是否可以用于播放
*/
@property (nonatomic, readonly) AVPlayerItemStatus status;
/*!
@property loadedTimeRanges
@abstract This property provides a collection of time ranges for which the player has the media data readily available. The ranges provided might be discontinuous.
@discussion Returns an NSArray of NSValues containing CMTimeRanges.
@translation
这个属性将会返回当前资源的缓存进度
*/
@property (nonatomic, readonly) NSArray<NSValue *> *loadedTimeRanges;
值得关注的是,该类的多种状态响应使用了KVO、通知等方式发出,在接口阅读和使用上给我们带来了一定的困难,所以要将状态的监控再封装成我们熟悉的形式(delegate、block),并增加媒体播放的自定义参数:
// CMPlayerItem.h
@class CMPlayerItem;
@protocol CMPlayItemDelegate <NSObject>
@optional
/** 缓存进度 */
- (void)musicPlayerItem:(CMPlayerItem *)item bufferSeconds:(NSTimeInterval)seconds rate:(CGFloat)rate;
/** 资源状态 */
- (void)musicPlayerItem:(CMPlayerItem *)item playItemStatus:(AVPlayerItemStatus)status;
@end
@interface CMPlayerItem : AVPlayerItem
/** 名称 */
@property (nonatomic, copy) NSString *musicName;
/** 作者 */
@property (nonatomic, copy) NSString *musicAuthor;
/** 封面 */
@property (nonatomic, strong) NSURL *musicCoverURL;
/** 工厂方法 */
+ (instancetype)musicPlayItemWithURL:(NSURL *)URL name:(NSString *)name author:(NSString *)author coverURL:(NSURL *)coverURL;
/** 缓存时长(s) */
@property (nonatomic, assign, readonly) NSTimeInterval bufferSeconds;
/** 总时长(s) */
@property (nonatomic, assign, readonly) NSTimeInterval durationSeconds;
/** 资源状态代理 */
@property (nonatomic, weak) id<CMPlayItemDelegate> delegate;
@end
// CMPlayerItem.m
@implementation CMPlayerItem
+ (instancetype)musicPlayItemWithURL:(NSURL *)URL name:(NSString *)name author:(NSString *)author coverURL:(NSURL *)coverURL {
CMPlayerItem *item = [self playerItemWithURL:URL];
item.musicName = name;
item.musicAuthor = author;
item.musicCoverURL = coverURL;
[item addObserver:item forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
[item addObserver:item forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
return item;
}
#pragma mark -
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
if (!self.delegate) return;
// 监听资源状态
if ([keyPath isEqualToString:@"status"]) {
if ([self.delegate respondsToSelector:@selector(musicPlayerItem:playItemStatus:)]) {
[self.delegate musicPlayerItem:self playItemStatus:self.status];
}
// 监听资源下载进度
} else if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
if ([self.delegate respondsToSelector:@selector(musicPlayerItem:bufferSeconds:rate:)]) {
[self.delegate musicPlayerItem:self bufferSeconds:self.bufferSeconds rate:self.bufferSeconds / self.durationSeconds];
}
}
}
...
@end
这里我们利用了继承的特性,创建了一个符合业务需求的类,并使用了工厂方法提供了一个新的初始化器,用于初始化必要资源并添加监听。对比父类的接口,经过封装以后的API阅读起来是不是容易理解多了~
数据结构分析
在封装AVPlayer
之前,先来分析一下播放器需求。
播放器有三种播放模式,顺序、乱序、单曲循环。点击下一曲按钮要根据不同的播放模式获取曲目
点击上一曲按钮能够播放最近已经播放过上一首的曲目
滑动黑胶唱片能够切换上一曲或下一曲
通过对需求的分析,我们将业务转换为数据结构,播放器的播放资源组织可以通过一个特殊的队列结构进行管理,队列的长度为3,index=1指向播放中的数据,因为存在一个滑动唱片的操作设计,我们必须保证播放队列的内容长度永远为3,否则在滑动时再加载必然会造成页面卡顿。当切换下一曲时丢弃index=0的数据,从资源列表获取下一曲内容。但是这样的数据结构存在一个缺陷,当切换上一曲时,我们永远只能拿到上一次播放的资源。
聪明的同学会发现上一曲功能具有LIFO特性,很像一个栈建构。那么我们将之前的播放队列做一点改造,使用一个栈结构将下一曲切换时丢弃的曲目保存好,当需要上一曲切换时使用栈中的内容补充队列空缺。
有了思路,我们将上一曲切换的流程转化为数据结构图并实现核心代码。首先播放队列丢弃index=2的曲目,并从播放栈中获取栈顶曲目插入播放队列index=0。当播放栈内容为空时,根据不同的播放模式从播放列表直接获取资源插入播放队列
核心代码如下:
- (void)prev {
...
// 丢弃播放队列最后一个元素
[self.playQueue removeLastObject];
CMPlayerItem *prevItem = [self.playedStack pop];
// 当栈中内容为空
if (!prevItem) {
// 直接获取上一首资源
prevItem = [self prevResourceWithPlayingItem:self.playQueue[CM_PLAYQUEUE_PREV_SOURCE]];
}
// 将资源插入播放队列
[self.playQueue insertObject:prevItem atIndex:CM_PLAYQUEUE_PREV_SOURCE];
// 更新播放器资源
[self replaceCurrentItemWithPlayerItem:self.playQueue[CM_PLAYQUEUE_PLAYING_SOURCE]];
[self replay];
...
}
下一曲切换操作和上一曲类似,只不过我们要改变一下数据流方向,将播放队列index=0内容丢至播放栈中,并从播放列表中根据当前模式插入播放队列index=2位置
核心代码如下:
- (void)next {
...
// 将index=0内容放入播放栈
[self.playedStack push:self.playQueue[CM_PLAYQUEUE_PREV_SOURCE]];
[self.playQueue removeObjectAtIndex:CM_PLAYQUEUE_PREV_SOURCE];
// 从播放列表补充下一首数据
[self.playQueue addObject:[self nextResourceWithPlayingItem:self.playQueue[CM_PLAYQUEUE_PLAYING_SOURCE]]];
// 更新播放器资源
[self replaceCurrentItemWithPlayerItem:self.playQueue[CM_PLAYQUEUE_PLAYING_SOURCE]];
[self replay];
...
}
AVPlayer封装
和封装AVPlayerItem
思路一样,通过继承的方式我们创建一个新类CMPlayer
,整合父类接口并用我们熟悉的方式暴露新的API,下面是.h文件代码
// CMPlayer.h
@class CMPlayer;
@class CMPlayerItem;
/**
播放模式选择
- CMPlayerModeLoop: 顺序
- CMPlayerModeOne: 单曲循环
- CMPlayerModeShuffle: 乱序
*/
typedef NS_ENUM(NSUInteger, CMPlayerMode) {
CMPlayerModeLoop,
CMPlayerModeOne,
CMPlayerModeShuffle,
};
@protocol CMPlayerDelegate <NSObject>
@optional
/** 播放 */
- (void)musicPlayerStatusPlaying:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 暂停 */
- (void)musicPlayerStatusPaused:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 加载中 */
- (void)musicPlayerStatusLoading:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 完成 */
- (void)musicPlayerStatusComplete:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 下一首 */
- (void)musicPlayerStatusNext:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 上一首 */
- (void)musicPlayerStatusPrev:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 重播 */
- (void)musicPlayerStatusReplay:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 进度监听,间隔1s */
- (void)musicPlayerPlayingProgressCurrenSeconds:(NSTimeInterval)currentSec duration:(NSTimeInterval)durationSec buffer:(NSTimeInterval)bufferSec;
@end
@interface CMPlayer : AVPlayer
/**
初始化播放器
@param playList 播放列表
@return 实例
*/
- (instancetype)initWithPlayList:(NSArray<CMPlayerItem *> *)playList;
/** 播放模式 */
@property (nonatomic, assign) CMPlayerMode playerMode;
/** 播放器代理 */
@property (nonatomic, weak) id<CMPlayerDelegate> delegate;
#pragma mark -
/** 播放列表 */
@property (nonatomic, strong) NSArray<CMPlayerItem *> *playList;
/** 播放队列 */
@property (nonatomic, readonly) NSArray *currentPlayingQueue;
/** 当前播放资源 */
@property (nonatomic, readonly) CMPlayerItem *currentMusicItem;
#pragma mark -
/** 当前播放时长 */
@property (nonatomic, readonly) NSTimeInterval currentSeconds;
/** 放回当前播放item.duration */
@property (nonatomic, readonly) NSTimeInterval durationSeconds;
#pragma mark -
/** 下一首 */
- (void)next;
/** 上一首 */
- (void)prev;
@end
.m文件具体实现代码请通过文末github链接自行clone,此处不再赘述
后台播放控制
允许后台播放
播放器需要在应用切换到后台时,保持音频播放能力。允许后台播放最简单的方式,是在Target - Capability - Background Modes
中进行配置
参考文献:《Enabling Background Audio》
设置AVAudioSession
可以使用AVAudioSession
告诉操作系统如何处理你的App音频流,而不需要和音频硬件产生直接的交互或者操作
设置AVAudioSession
的Category
确定基本事件行为,再使用Mode
Options
两个属性对行为进行微调,下面是播放器设置源码:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
...
// 设置并激活音频会话类别
AVAudioSession *session = [AVAudioSession sharedInstance];
// 设置Category只支持音频播放,打断其他不支持混音App
[session setCategory:AVAudioSessionCategoryPlayback error:nil];
// 启用session
[session setActive:YES error:nil];
...
return YES;
}
参考文献:《Audio Session Programming Guide》
远程控制媒体播放
我们还需支持在锁屏的媒体资源信息展示以及控制中心中对音乐的控制。我们可以使用Media Player框架的MPRemoteCommandCenter
和MPNowPlayingInfoCenter
实现。
MPRemoteCommandCenter
使用事件绑定的方式实现对远程事件的监听处理,每个控制事件都封装了一个MPRemoteCommand
对象,为具体的事件添加响应方法:
// CMPlayer.m
- (void)handleRemoteControlEvent {
MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
// 播放
[commandCenter.playCommand addTarget:self action:@selector(play)];
// 暂停
[commandCenter.pauseCommand addTarget:self action:@selector(pause)];
// 上一首
[commandCenter.previousTrackCommand addTarget:self action:@selector(prev)];
// 下一首
[commandCenter.nextTrackCommand addTarget:self action:@selector(next)];
// 为耳机的按钮操作添加相关的响应事件
[commandCenter.togglePlayPauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
if (self.timeControlStatus == AVPlayerTimeControlStatusPlaying) {
[self pause];
} else {
[self play];
}
return MPRemoteCommandHandlerStatusSuccess;
}];
}
MPNowPlayingInfoCenter
是一个可以设置当前播放媒体需要展示信息的对象,这些信息会被展示到锁屏界面、控制中心等处,以下是简单用法:
// CMPlayer.m
- (void)configNowPlayingInfoCenter {
NSMutableDictionary *nowPlayInfo = [[NSMutableDictionary alloc] init];
// 歌曲名称
[nowPlayInfo setObject:self.currentMusicItem.musicName forKey:MPMediaItemPropertyTitle];
// 演唱者
[nowPlayInfo setObject:self.currentMusicItem.musicAuthor forKey:MPMediaItemPropertyArtist];
// 音乐剩余时长
[nowPlayInfo setObject:@(self.currentMusicItem.durationSeconds) forKey:MPMediaItemPropertyPlaybackDuration];
// 音乐当前播放时间
[nowPlayInfo setObject:@(self.currentSeconds) forKey:MPNowPlayingInfoPropertyElapsedPlaybackTime];
[[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:nowPlayInfo];
}
参考文献:《Controlling Background Audio》
关于
本文为博主原创文章,项目资源均只供学习请勿商用,转载请附上博文链接!