高仿網易雲音樂播放器
網易雲音樂憑著良好的互動體驗,優質豐富的資源在終端一直有著不錯的市場。相比較市面上的主流音樂播放器(QQ音樂、蝦米音樂),筆者更傾向於雲音樂的UED。
單從播放器頁來說,雲音樂的介面非常簡潔,只保留了主要的操作功能,避免過多的資訊造成視覺上的疲倦。主檢視上不斷旋轉的黑膠唱片非常有帶入感,左滑右滑切換唱片的設計也非常經典。
本文主要介紹了播放器實現的一些核心程式碼和思路,完整程式碼請通過文末連結自行clone
必要知識儲備:
- 基礎資料結構知識(佇列、棧、連結串列)
- AVFoundation/AVPlayer
- AVAudioSession
- CoreAnimation
- Autolayout(Masonry)
- KVO、Notification
實現需求:
- 播放器播放、暫停,上一首、下一首
- 播放模式切換,支援單曲迴圈、順序播放、隨機播放
- 黑膠唱片模擬動畫
- 後臺播放及中斷控制
- 支援iOS10.0包括及以上系統
播放資源控制
音樂播放器的核心功能是針對播放資源的控制,其核心內容包括媒體資源狀態控制、播放器狀態控制。
值得慶幸的是我們並不用自己去實現複雜的底層功能,如線上資源拉取、資源快取(如果有必要實現)、資源解碼播放等,此處我們可以選用源生AVFoundation框架下的AVPlayer工具幫助我們完成這些功能,我們要做的是針對AVPlayer提供的介面根據業務進行封裝。
本文不介紹AVPlayer的詳細使用,只針對具體使用業務場景提供封裝思路及核心程式碼
AVPlayerItem封裝
AVPlayerItem
是 AVPlayer
播放資源的基本單位,得益於OC語言良好的命名習慣,我們非常容易理解AVPlayerItem類的定義,它管理了媒體資源的 地址
、 時長
、 快取
,並提供相關狀態屬性用於監聽。下面程式碼塊展示的是被封裝的屬性:
// AVPlayerItem.h /** 訂閱這個通知,當資源完成播放時該通知會被髮出 */ AVF_EXPORT NSString *const AVPlayerItemDidPlayToEndTimeNotificationNS_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特性,很像一個棧建構。那麼我們將之前的播放佇列做一點改造,使用一個棧結構將下一曲切換時丟棄的曲目儲存好,當需要上一曲切換時使用棧中的內容補充佇列空缺。
playing.png
有了思路,我們將上一曲切換的流程轉化為資料結構圖並實現核心程式碼。首先播放佇列丟棄index=2的曲目,並從播放棧中獲取棧頂曲目插入播放佇列index=0。當播放棧內容為空時,根據不同的播放模式從播放列表直接獲取資源插入播放佇列
prev.png
核心程式碼如下:
- (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位置
next.png
核心程式碼如下:
- (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
參考文獻:《Enabling Background Audio》
設定AVAudioSession
可以使用 AVAudioSession
告訴作業系統如何處理你的App音訊流,而不需要和音訊硬體產生直接的互動或者操作
Audio Session
設定 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》
關於
本文為博主原創文章,專案資源均只供學習請勿商用,轉載請附上博文連結!