ios開發(fā):一個(gè)音樂播放器的設(shè)計(jì)與實(shí)現(xiàn)案例
這個(gè)Demo,關(guān)于歌曲播放的主要功能都實(shí)現(xiàn)了的。下一曲、上一曲,暫停,根據(jù)歌曲的播放進(jìn)度動(dòng)態(tài)滾動(dòng)歌詞,將當(dāng)前正在播放的歌詞放大顯示,拖動(dòng)進(jìn)度條,歌曲跟著變化,并且使用Time Profiler進(jìn)行了優(yōu)化,還使用XCTest對(duì)幾個(gè)主要的類進(jìn)行了單元測(cè)試。
已經(jīng)經(jīng)過真機(jī)調(diào)試,在真機(jī)上可以后臺(tái)播放音樂,并且鎖屏?xí)r,顯示一些主要的歌曲信息。
根據(jù)歌曲的播放來顯示對(duì)應(yīng)歌詞的。用UITableView來顯示歌詞,可以手動(dòng)滾動(dòng)界面查看后面或者前面的歌詞。
并且,當(dāng)拖動(dòng)進(jìn)度條,歌詞也會(huì)隨之變化,下一曲、上一曲依然是可以使用的。
代碼分析:
準(zhǔn)備階段,先是寫了一個(gè)音頻播放的單例,用這個(gè)單例來播放這個(gè)demo中的音樂文件,代碼如下:
#import <Foundation/Foundation.h> #import <AVFoundation/AVFoundation.h> @interface ZYAudioManager : NSObject + (instancetype)defaultManager; //播放音樂 - (AVAudioPlayer *)playingMusic:(NSString *)filename; - (void)pauseMusic:(NSString *)filename; - (void)stopMusic:(NSString *)filename; //播放音效 - (void)playSound:(NSString *)filename; - (void)disposeSound:(NSString *)filename; @end #import "ZYAudioManager.h" @interface ZYAudioManager () @property (nonatomic, strong) NSMutableDictionary *musicPlayers; @property (nonatomic, strong) NSMutableDictionary *soundIDs; @end static ZYAudioManager *_instance = nil; @implementation ZYAudioManager + (void)initialize { // 音頻會(huì)話 AVAudioSession *session = [AVAudioSession sharedInstance]; // 設(shè)置會(huì)話類型(播放類型、播放模式,會(huì)自動(dòng)停止其他音樂的播放) [session setCategory:AVAudioSessionCategoryPlayback error:nil]; // 激活會(huì)話 [session setActive:YES error:nil]; } + (instancetype)defaultManager { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _instance = [[self alloc] init]; }); return _instance; } - (instancetype)init { __block ZYAudioManager *temp = self; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ if ((temp = [super init]) != nil) { _musicPlayers = [NSMutableDictionary dictionary]; _soundIDs = [NSMutableDictionary dictionary]; } }); self = temp; return self; } + (instancetype)allocWithZone:(struct _NSZone *)zone { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _instance = [super allocWithZone:zone]; }); return _instance; } //播放音樂 - (AVAudioPlayer *)playingMusic:(NSString *)filename { if (filename == nil || filename.length == 0) return nil; AVAudioPlayer *player = self.musicPlayers[filename]; //先查詢對(duì)象是否緩存了 if (!player) { NSURL *url = [[NSBundle mainBundle] URLForResource:filename withExtension:nil]; if (!url) return nil; player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil]; if (![player prepareToPlay]) return nil; self.musicPlayers[filename] = player; //對(duì)象是最新創(chuàng)建的,那么對(duì)它進(jìn)行一次緩存 } if (![player isPlaying]) { //如果沒有正在播放,那么開始播放,如果正在播放,那么不需要改變什么 [player play]; } return player; } - (void)pauseMusic:(NSString *)filename { if (filename == nil || filename.length == 0) return; AVAudioPlayer *player = self.musicPlayers[filename]; if ([player isPlaying]) { [player pause]; } } - (void)stopMusic:(NSString *)filename { if (filename == nil || filename.length == 0) return; AVAudioPlayer *player = self.musicPlayers[filename]; [player stop]; [self.musicPlayers removeObjectForKey:filename]; } //播放音效 - (void)playSound:(NSString *)filename { if (!filename) return; //取出對(duì)應(yīng)的音效ID SystemSoundID soundID = (int)[self.soundIDs[filename] unsignedLongValue]; if (!soundID) { NSURL *url = [[NSBundle mainBundle] URLForResource:filename withExtension:nil]; if (!url) return; AudioServicesCreateSystemSoundID((__bridge CFURLRef)(url), &soundID); self.soundIDs[filename] = @(soundID); } // 播放 AudioServicesPlaySystemSound(soundID); } //摧毀音效 - (void)disposeSound:(NSString *)filename { if (!filename) return; SystemSoundID soundID = (int)[self.soundIDs[filename] unsignedLongValue]; if (soundID) { AudioServicesDisposeSystemSoundID(soundID); [self.soundIDs removeObjectForKey:filename]; //音效被摧毀,那么對(duì)應(yīng)的對(duì)象應(yīng)該從緩存中移除 } } @end
就是一個(gè)單例的設(shè)計(jì),并沒有多大難度。我是用了一個(gè)字典來裝播放過的歌曲了,這樣如果是暫停了,然后再開始播放,就直接在緩存中加載即可。但是如果不注意,在 stopMusic:(NSString *)fileName 這個(gè)方法里面,不從字典中移除掉已經(jīng)停止播放的歌曲,那么你下再播放這首歌的時(shí)候,就會(huì)在原先播放的進(jìn)度上繼續(xù)播放。在編碼過程中,我就遇到了這個(gè)Bug,然后發(fā)現(xiàn),在切換歌曲(上一曲、下一曲)的時(shí)候,我調(diào)用的是stopMusic方法,但由于我沒有從字典中將它移除,而導(dǎo)致它總是從上一次的進(jìn)度開始播放,而不是從頭開始播放。
如果在真機(jī)上想要后臺(tái)播放歌曲,除了在appDelegate以及plist里面做相應(yīng)操作之外,還得將播放模式設(shè)置為:AVAudioSessionCategoryPlayback。特別需要注意這里,我在模擬器上調(diào)試的時(shí)候,沒有設(shè)置這種模式也是可以進(jìn)行后臺(tái)播放的,但是在真機(jī)上卻不行了。后來在StackOverFlow上找到了對(duì)應(yīng)的答案,需要設(shè)置播放模式。
這個(gè)單例類,在整個(gè)demo中是至關(guān)重要的,要保證它是沒有錯(cuò)誤的,所以我寫了這個(gè)類的XCTest進(jìn)行單元測(cè)試,代碼如下:
#import <XCTest/XCTest.h> #import "ZYAudioManager.h" #import <AVFoundation/AVFoundation.h> @interface ZYAudioManagerTests : XCTestCase @property (nonatomic, strong) AVAudioPlayer *player; @end static NSString *_fileName = @"10405520.mp3"; @implementation ZYAudioManagerTests - (void)setUp { [super setUp]; // Put setup code here. This method is called before the invocation of each test method in the class. } - (void)tearDown { // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; } - (void)testExample { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } /** * 測(cè)試是否為單例,要在并發(fā)條件下測(cè)試 */ - (void)testAudioManagerSingle { NSMutableArray *managers = [NSMutableArray array]; dispatch_group_t group = dispatch_group_create(); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [[ZYAudioManager alloc] init]; [managers addObject:tempManager]; }); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [[ZYAudioManager alloc] init]; [managers addObject:tempManager]; }); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [[ZYAudioManager alloc] init]; [managers addObject:tempManager]; }); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [[ZYAudioManager alloc] init]; [managers addObject:tempManager]; }); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [[ZYAudioManager alloc] init]; [managers addObject:tempManager]; }); ZYAudioManager *managerOne = [ZYAudioManager defaultManager]; dispatch_group_notify(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [managers enumerateObjectsUsingBlock:^(ZYAudioManager *obj, NSUInteger idx, BOOL * _Nonnull stop) { XCTAssertEqual(managerOne, obj, @"ZYAudioManager is not single"); }]; }); } /** * 測(cè)試是否可以正常播放音樂 */ - (void)testPlayingMusic { self.player = [[ZYAudioManager defaultManager] playingMusic:_fileName]; XCTAssertTrue(self.player.playing, @"ZYAudioManager is not PlayingMusic"); } /** * 測(cè)試是否可以正常停止音樂 */ - (void)testStopMusic { if (self.player == nil) { self.player = [[ZYAudioManager defaultManager] playingMusic:_fileName]; } if (self.player.playing == NO) [self.player play]; [[ZYAudioManager defaultManager] stopMusic:_fileName]; XCTAssertFalse(self.player.playing, @"ZYAudioManager is not StopMusic"); } /** * 測(cè)試是否可以正常暫停音樂 */ - (void)testPauseMusic { if (self.player == nil) { self.player = [[ZYAudioManager defaultManager] playingMusic:_fileName]; } if (self.player.playing == NO) [self.player play]; [[ZYAudioManager defaultManager] pauseMusic:_fileName]; XCTAssertFalse(self.player.playing, @"ZYAudioManager is not pauseMusic"); } @end
需要注意的是,單例要在并發(fā)的條件下測(cè)試,我采用的是dispatch_group,主要是考慮到,必須要等待所有并發(fā)結(jié)束才能比較結(jié)果,否則可能會(huì)出錯(cuò)。比如說,并發(fā)條件下,x線程已經(jīng)執(zhí)行完畢了,它所對(duì)應(yīng)的a對(duì)象已有值;而y線程還沒開始初始化,它所對(duì)應(yīng)的b對(duì)象還是為nil,為了避免這種條件的產(chǎn)生,我采用dispatch_group來等待所有并發(fā)結(jié)束,再去做相應(yīng)的判斷。
首頁(yè)控制器的代碼:
#import "ZYMusicViewController.h" #import "ZYPlayingViewController.h" #import "ZYMusicTool.h" #import "ZYMusic.h" #import "ZYMusicCell.h" @interface ZYMusicViewController () @property (nonatomic, strong) ZYPlayingViewController *playingVc; @property (nonatomic, assign) int currentIndex; @end @implementation ZYMusicViewController - (ZYPlayingViewController *)playingVc { if (_playingVc == nil) { _playingVc = [[ZYPlayingViewController alloc] initWithNibName:@"ZYPlayingViewController" bundle:nil]; } return _playingVc; } - (void)viewDidLoad { [super viewDidLoad]; [self setupNavigation]; } - (void)setupNavigation { self.navigationItem.title = @"音樂播放器"; } #pragma mark ----TableViewDataSource - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 1; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return [ZYMusicTool musics].count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { ZYMusicCell *cell = [ZYMusicCell musicCellWithTableView:tableView]; cell.music = [ZYMusicTool musics][indexPath.row]; return cell; } #pragma mark ----TableViewDelegate - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return 70; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [tableView deselectRowAtIndexPath:indexPath animated:YES]; [ZYMusicTool setPlayingMusic:[ZYMusicTool musics][indexPath.row]]; ZYMusic *preMusic = [ZYMusicTool musics][self.currentIndex]; preMusic.playing = NO; ZYMusic *music = [ZYMusicTool musics][indexPath.row]; music.playing = YES; NSArray *indexPaths = @[ [NSIndexPath indexPathForItem:self.currentIndex inSection:0], indexPath ]; [self.tableView reloadRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; self.currentIndex = (int)indexPath.row; [self.playingVc show]; } @end
重點(diǎn)需要說說的是這個(gè)界面的實(shí)現(xiàn):
這里做了比較多的細(xì)節(jié)控制,具體在代碼里面有相應(yīng)的描述。主要是想說說,在實(shí)現(xiàn)播放進(jìn)度拖拽中遇到的問題。
控制進(jìn)度條的移動(dòng),我采用的是NSTimer,添加了一個(gè)定時(shí)器,并且在不需要它的地方都做了相應(yīng)的移除操作。
這里開發(fā)的時(shí)候,遇到了一個(gè)問題是,我拖動(dòng)滑塊的時(shí)候,發(fā)現(xiàn)歌曲播放的進(jìn)度是不正確的。代碼中可以看到:
//得到挪動(dòng)距離 CGPoint point = [sender translationInView:sender.view]; //將translation清空,免得重復(fù)疊加 [sender setTranslation:CGPointZero inView:sender.view];
在使用translation的時(shí)候,一定要記住,每次處理過后,一定要將translation清空,以免它不斷疊加。
我使用的是ZYLrcView來展示歌詞界面的,需要注意的是,它繼承自UIImageView,所以要將userInteractionEnabled屬性設(shè)置為Yes。
代碼:
#import <UIKit/UIKit.h> @interface ZYLrcView : UIImageView @property (nonatomic, assign) NSTimeInterval currentTime; @property (nonatomic, copy) NSString *fileName; @end #import "ZYLrcView.h" #import "ZYLrcLine.h" #import "ZYLrcCell.h" #import "UIView+AutoLayout.h" @interface ZYLrcView () <UITableViewDataSource, UITableViewDelegate> @property (nonatomic, weak) UITableView *tableView; @property (nonatomic, strong) NSMutableArray *lrcLines; /** * 記錄當(dāng)前顯示歌詞在數(shù)組里面的index */ @property (nonatomic, assign) int currentIndex; @end @implementation ZYLrcView #pragma mark ----setter\geter方法 - (NSMutableArray *)lrcLines { if (_lrcLines == nil) { _lrcLines = [ZYLrcLine lrcLinesWithFileName:self.fileName]; } return _lrcLines; } - (void)setFileName:(NSString *)fileName { if ([_fileName isEqualToString:fileName]) { return; } _fileName = [fileName copy]; [_lrcLines removeAllObjects]; _lrcLines = nil; [self.tableView reloadData]; } - (void)setCurrentTime:(NSTimeInterval)currentTime { if (_currentTime > currentTime) { self.currentIndex = 0; } _currentTime = currentTime; int minute = currentTime / 60; int second = (int)currentTime % 60; int msecond = (currentTime - (int)currentTime) * 100; NSString *currentTimeStr = [NSString stringWithFormat:@"%02d:%02d.%02d", minute, second, msecond]; for (int i = self.currentIndex; i < self.lrcLines.count; i++) { ZYLrcLine *currentLine = self.lrcLines[i]; NSString *currentLineTime = currentLine.time; NSString *nextLineTime = nil; if (i + 1 < self.lrcLines.count) { ZYLrcLine *nextLine = self.lrcLines[i + 1]; nextLineTime = nextLine.time; } if (([currentTimeStr compare:currentLineTime] != NSOrderedAscending) && ([currentTimeStr compare:nextLineTime] == NSOrderedAscending) && (self.currentIndex != i)) { NSArray *reloadLines = @[ [NSIndexPath indexPathForItem:self.currentIndex inSection:0], [NSIndexPath indexPathForItem:i inSection:0] ]; self.currentIndex = i; [self.tableView reloadRowsAtIndexPaths:reloadLines withRowAnimation:UITableViewRowAnimationNone]; [self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForItem:self.currentIndex inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:YES]; } } } #pragma mark ----初始化方法 - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { [self commitInit]; } return self; } - (instancetype)initWithCoder:(NSCoder *)aDecoder { if (self = [super initWithCoder:aDecoder]) { [self commitInit]; } return self; } - (void)commitInit { self.userInteractionEnabled = YES; self.image = [UIImage imageNamed:@"28131977_1383101943208"]; self.contentMode = UIViewContentModeScaleToFill; self.clipsToBounds = YES; UITableView *tableView = [[UITableView alloc] init]; tableView.delegate = self; tableView.dataSource = self; tableView.separatorStyle = UITableViewCellSeparatorStyleNone; tableView.backgroundColor = [UIColor clearColor]; self.tableView = tableView; [self addSubview:tableView]; [self.tableView autoPinEdgesToSuperviewEdgesWithInsets:UIEdgeInsetsMake(0, 0, 0, 0)]; } #pragma mark ----UITableViewDataSource - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 1; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.lrcLines.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { ZYLrcCell *cell = [ZYLrcCell lrcCellWithTableView:tableView]; cell.lrcLine = self.lrcLines[indexPath.row]; if (indexPath.row == self.currentIndex) { cell.textLabel.font = [UIFont boldSystemFontOfSize:16]; } else{ cell.textLabel.font = [UIFont systemFontOfSize:13]; } return cell; } - (void)layoutSubviews { [super layoutSubviews]; // NSLog(@"++++++++++%@",NSStringFromCGRect(self.tableView.frame)); self.tableView.contentInset = UIEdgeInsetsMake(self.frame.size.height / 2, 0, self.frame.size.height / 2, 0); } @end
也沒有什么好說的,整體思路就是,解析歌詞,將歌詞對(duì)應(yīng)的播放時(shí)間、在當(dāng)前播放時(shí)間的那句歌詞一一對(duì)應(yīng),然后持有一個(gè)歌詞播放的定時(shí)器,每次給ZYLrcView傳入歌曲播放的當(dāng)前時(shí)間,如果,歌曲的currentTime > 當(dāng)前歌詞的播放,并且小于下一句歌詞的播放時(shí)間,那么就是播放當(dāng)前的這一句歌詞了。
我這里做了相應(yīng)的優(yōu)化,CADisplayLink生成的定時(shí)器,是每毫秒調(diào)用觸發(fā)一次,1s等于1000ms,如果不做一定的優(yōu)化,性能是非常差的,畢竟一首歌怎么也有四五分鐘。在這里,我記錄了上一句歌詞的index,那么如果正常播放的話,它去查找歌詞應(yīng)該是從上一句播放的歌詞在數(shù)組里面的索引開始查找,這樣就優(yōu)化了很多。
這是鎖屏下的界面展示:
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
- ios 流媒體播放器實(shí)現(xiàn)流程及FreeStreamer的使用的示例
- 實(shí)例講解iOS音樂播放器DOUAudioStreamer用法
- iOS之基于FreeStreamer的簡(jiǎn)單音樂播放器示例
- 運(yùn)用iOS教你輕松制作音樂播放器
- iOS中視頻播放器的簡(jiǎn)單封裝詳解
- iOS中的音頻服務(wù)和音頻AVAudioPlayer音頻播放器使用指南
- 實(shí)例解析iOS中音樂播放器應(yīng)用開發(fā)的基本要點(diǎn)
- iOS開發(fā)中音頻工具類的封裝以及音樂播放器的細(xì)節(jié)控制
- iOS音樂播放器實(shí)現(xiàn)代碼完整版
相關(guān)文章
iOS開發(fā)實(shí)現(xiàn)簡(jiǎn)單抽屜效果
這篇文章主要為大家詳細(xì)介紹了iOS開發(fā)實(shí)現(xiàn)簡(jiǎn)單抽屜效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-08-08iOS11 下載之?dāng)帱c(diǎn)續(xù)傳的bug的解決方法
本篇文章主要介紹了iOS11 下載之?dāng)帱c(diǎn)續(xù)傳的bug的解決方法,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-11-11iOS 動(dòng)畫實(shí)戰(zhàn)之釣魚小游戲?qū)嵗a
最近小編做了一個(gè)釣魚小游戲,平時(shí)沒有做過,所以上手有點(diǎn)急躁,不過,最終還是實(shí)現(xiàn)了,下面小編給大家分享iOS 動(dòng)畫實(shí)戰(zhàn)之釣魚小游戲的實(shí)現(xiàn)思路,感興趣的朋友一起看看吧2018-02-02iOS實(shí)現(xiàn)選項(xiàng)卡效果的方法
選項(xiàng)卡在我們?nèi)粘i_發(fā)的時(shí)候經(jīng)常要用到,所以這篇文章給大家分享一種iOS實(shí)現(xiàn)的簡(jiǎn)單選項(xiàng)卡效果,很適合大家學(xué)習(xí)和使用,有需要的可以參考借鑒,下面來一起看看吧。2016-09-09總結(jié)iOS開發(fā)中的斷點(diǎn)續(xù)傳與實(shí)踐
本文先從斷點(diǎn)續(xù)傳問題開始,介紹斷點(diǎn)續(xù)傳概述和原理。接著結(jié)合筆者調(diào)研中嘗試的 AFHTTPRequestOpeartion,簡(jiǎn)單分析源碼。最后分別基于 NSURLConnection,NSURLSessionDataTask 和 NSURLSessionDownloadTask 去實(shí)現(xiàn)應(yīng)用重啟情況下的斷點(diǎn)續(xù)傳。下面一起來看看。2016-07-07iOS輸入框的字?jǐn)?shù)統(tǒng)計(jì)/最大長(zhǎng)度限制詳解
在開發(fā)中經(jīng)常會(huì)遇到鍵盤輸入的字符長(zhǎng)度的限制,下面這篇文章主要給大家介紹了關(guān)于iOS輸入框的字?jǐn)?shù)統(tǒng)計(jì)/最大長(zhǎng)度限制的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-06-06iOS實(shí)現(xiàn)按鈕點(diǎn)擊選中與被選中切換功能
這篇文章主要介紹了iOS實(shí)現(xiàn)按鈕點(diǎn)擊選中與被選中切換功能,需要的朋友可以參考下2017-07-07