ios開發:一個音樂播放器的設計與實現

github地址:https://github.com/wzpziyi1/MusicPlauergit

這個Demo,關於歌曲播放的主要功能都實現了的。下一曲、上一曲,暫停,根據歌曲的播放進度動態滾動歌詞,將當前正在播放的歌詞放大顯示,拖動進度條,歌曲跟着變化,而且使用Time Profiler進行了優化,還使用XCTest對幾個主要的類進行了單元測試。github

已經通過真機調試,在真機上能夠後臺播放音樂,而且鎖屏時,顯示一些主要的歌曲信息。數組

首頁:緩存

歌曲內部播放:session

當拖動小的進度條的時候,歌曲也會隨之變化。

併發

顯示歌詞界面:app

這是根據歌曲的播放來顯示對應歌詞的。用UITableView來顯示歌詞,能夠手動滾動界面查看後面或者前面的歌詞。
而且,當拖動進度條,歌詞也會隨之變化,下一曲、上一曲依然是可使用的。async

 

代碼分析:性能

準備階段,先是寫了一個音頻播放的單例,用這個單例來播放這個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
{
    // 音頻會話
    AVAudioSession *session = [AVAudioSession sharedInstance];
    
    // 設置會話類型(播放類型、播放模式,會自動中止其餘音樂的播放)
    [session setCategory:AVAudioSessionCategoryPlayback error:nil];
    
    // 激活會話
    [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;            //對象是最新建立的,那麼對它進行一次緩存
    }
    
    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;
    
    //取出對應的音效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];    //音效被摧毀,那麼對應的對象應該從緩存中移除
    }
}
@end

 就是一個單例的設計,並無多大難度。我是用了一個字典來裝播放過的歌曲了,這樣若是是暫停了,而後再開始播放,就直接在緩存中加載便可。可是若是不注意,在 stopMusic:(NSString *)fileName  這個方法裏面,不從字典中移除掉已經中止播放的歌曲,那麼你下再播放這首歌的時候,就會在原先播放的進度上繼續播放。在編碼過程當中,我就遇到了這個Bug,而後發現,在切換歌曲(上一曲、下一曲)的時候,我調用的是stopMusic方法,但因爲我沒有從字典中將它移除,而致使它老是從上一次的進度開始播放,而不是從頭開始播放。

若是在真機上想要後臺播放歌曲,除了在appDelegate以及plist裏面作相應操做以外,還得將播放模式設置爲:AVAudioSessionCategoryPlayback。特別須要注意這裏,我在模擬器上調試的時候,沒有設置這種模式也是能夠進行後臺播放的,可是在真機上卻不行了。後來在StackOverFlow上找到了對應的答案,須要設置播放模式。

這個單例類,在整個demo中是相當重要的,要保證它是沒有錯誤的,因此我寫了這個類的XCTest進行單元測試,代碼以下:

#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.
}

/**
 *  測試是否爲單例,要在併發條件下測試
 */
- (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

 須要注意的是,單例要在併發的條件下測試,我採用的是dispatch_group,主要是考慮到,必需要等待全部併發結束才能比較結果,不然可能會出錯。好比說,併發條件下,x線程已經執行完畢了,它所對應的a對象已有值;而y線程還沒開始初始化,它所對應的b對象仍是爲nil,爲了不這種條件的產生,我採用dispatch_group來等待全部併發結束,再去作相應的判斷。

 

首頁控制器的代碼:

#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 

 

 

重點須要說說的是這個界面的實現:

 

 這裏作了比較多的細節控制,具體在代碼裏面有相應的描述。主要是想說說,在實現播放進度拖拽中遇到的問題。

控制進度條的移動,我採用的是NSTimer,添加了一個定時器,而且在不須要它的地方都作了相應的移除操做。
這裏開發的時候,遇到了一個問題是,我拖動滑塊的時候,發現歌曲播放的進度是不正確的。代碼中能夠看到:

//獲得挪動距離
    CGPoint point = [sender translationInView:sender.view];
    //將translation清空,省得重複疊加
    [sender setTranslation:CGPointZero inView:sender.view];

 在使用translation的時候,必定要記住,每次處理事後,必定要將translation清空,以避免它不斷疊加。

我使用的是ZYLrcView來展現歌詞界面的,須要注意的是,它繼承自UIImageView,因此要將userInteractionEnabled屬性設置爲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;
/**
 *  記錄當前顯示歌詞在數組裏面的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

 也沒有什麼好說的,總體思路就是,解析歌詞,將歌詞對應的播放時間、在當前播放時間的那句歌詞一一對應,而後持有一個歌詞播放的定時器,每次給ZYLrcView傳入歌曲播放的當前時間,若是,歌曲的currentTime > 當前歌詞的播放,而且小於下一句歌詞的播放時間,那麼就是播放當前的這一句歌詞了。

我這裏作了相應的優化,CADisplayLink生成的定時器,是每毫秒調用觸發一次,1s等於1000ms,若是不作必定的優化,性能是很是差的,畢竟一首歌怎麼也有四五分鐘。在這裏,我記錄了上一句歌詞的index,那麼若是正常播放的話,它去查找歌詞應該是從上一句播放的歌詞在數組裏面的索引開始查找,這樣就優化了不少。

 

這是鎖屏下的界面展現:

 

這是使用Instruments的Time Profiler時的情景:

 

還有其餘許多細節,就不一一例舉了......

github地址:https://github.com/wzpziyi1/MusicPlauer

相關文章
相關標籤/搜索