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對幾個(gè)主要的類進(jìn)行了單元測試。
已經(jīng)經(jīng)過真機(jī)調(diào)試,在真機(jī)上可以后臺播放音樂,并且鎖屏?xí)r,顯示一些主要的歌曲信息。
根據(jù)歌曲的播放來顯示對應(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]; //先查詢對象是否緩存了
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; //對象是最新創(chuàng)建的,那么對它進(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;
//取出對應(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]; //音效被摧毀,那么對應(yīng)的對象應(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ī)上想要后臺播放歌曲,除了在appDelegate以及plist里面做相應(yīng)操作之外,還得將播放模式設(shè)置為:AVAudioSessionCategoryPlayback。特別需要注意這里,我在模擬器上調(diào)試的時(shí)候,沒有設(shè)置這種模式也是可以進(jìn)行后臺播放的,但是在真機(jī)上卻不行了。后來在StackOverFlow上找到了對應(yīng)的答案,需要設(shè)置播放模式。
這個(gè)單例類,在整個(gè)demo中是至關(guān)重要的,要保證它是沒有錯(cuò)誤的,所以我寫了這個(gè)類的XCTest進(jìn)行單元測試,代碼如下:
#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.
}
/**
* 測試是否為單例,要在并發(fā)條件下測試
*/
- (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");
}];
});
}
/**
* 測試是否可以正常播放音樂
*/
- (void)testPlayingMusic
{
self.player = [[ZYAudioManager defaultManager] playingMusic:_fileName];
XCTAssertTrue(self.player.playing, @"ZYAudioManager is not PlayingMusic");
}
/**
* 測試是否可以正常停止音樂
*/
- (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");
}
/**
* 測試是否可以正常暫停音樂
*/
- (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ā)的條件下測試,我采用的是dispatch_group,主要是考慮到,必須要等待所有并發(fā)結(jié)束才能比較結(jié)果,否則可能會(huì)出錯(cuò)。比如說,并發(fā)條件下,x線程已經(jīng)執(zhí)行完畢了,它所對應(yīng)的a對象已有值;而y線程還沒開始初始化,它所對應(yīng)的b對象還是為nil,為了避免這種條件的產(chǎn)生,我采用dispatch_group來等待所有并發(fā)結(jié)束,再去做相應(yīng)的判斷。
首頁控制器的代碼:
#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
也沒有什么好說的,整體思路就是,解析歌詞,將歌詞對應(yīng)的播放時(shí)間、在當(dāng)前播放時(shí)間的那句歌詞一一對應(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)化了很多。
這是鎖屏下的界面展示:

以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
iOS11 下載之?dāng)帱c(diǎn)續(xù)傳的bug的解決方法
本篇文章主要介紹了iOS11 下載之?dāng)帱c(diǎn)續(xù)傳的bug的解決方法,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-11-11
iOS 動(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-02
iOS實(shí)現(xiàn)選項(xiàng)卡效果的方法
選項(xiàng)卡在我們?nèi)粘i_發(fā)的時(shí)候經(jīng)常要用到,所以這篇文章給大家分享一種iOS實(shí)現(xià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,簡單分析源碼。最后分別基于 NSURLConnection,NSURLSessionDataTask 和 NSURLSessionDownloadTask 去實(shí)現(xiàn)應(yīng)用重啟情況下的斷點(diǎn)續(xù)傳。下面一起來看看。2016-07-07
iOS輸入框的字?jǐn)?shù)統(tǒng)計(jì)/最大長度限制詳解
在開發(fā)中經(jīng)常會(huì)遇到鍵盤輸入的字符長度的限制,下面這篇文章主要給大家介紹了關(guān)于iOS輸入框的字?jǐn)?shù)統(tǒng)計(jì)/最大長度限制的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-06-06
iOS實(shí)現(xiàn)按鈕點(diǎn)擊選中與被選中切換功能
這篇文章主要介紹了iOS實(shí)現(xiàn)按鈕點(diǎn)擊選中與被選中切換功能,需要的朋友可以參考下2017-07-07

