ios开发:一个音乐播放器的设计与实现案例

这个Demo,关于歌曲播放的主要功能都实现了的。下一曲、上一曲,暂停,根据歌曲的播放进度动态滚动歌词,将当前正在播放的歌词放大显示,拖动进度条,歌曲跟着变化,并且使用Time Profiler进行了优化,还使用XCTest对几个主要的类进行了单元测试。

已经经过真机调试,在真机上可以后台播放音乐,并且锁屏时,显示一些主要的歌曲信息。

根据歌曲的播放来显示对应歌词的。用UITableView来显示歌词,可以手动滚动界面查看后面或者前面的歌词。

并且,当拖动进度条,歌词也会随之变化,下一曲、上一曲依然是可以使用的。

代码分析:

准备阶段,先是写了一个音频播放的单例,用这个单例来播放这个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,那么如果正常播放的话,它去查找歌词应该是从上一句播放的歌词在数组里面的索引开始查找,这样就优化了很多。

这是锁屏下的界面展示:

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 运用iOS教你轻松制作音乐播放器

    本文实例为大家分享了iOS音乐播放器制作的具体代码,供大家参考,具体内容如下 效果图 目录结构 代码 // // ViewController.m // 播放音乐 // // Created by xubh on 2017/3/24. // Copyright © 2017年 xubh. All rights reserved. // #import "ViewController.h" #import <AVFoundation/AVFoundation.h> @inte

  • iOS实现播放远程网络音乐的核心技术点总结

    一.前言 这两天做了个小项目涉及到了远程音乐播放,因为第一次做这种音乐项目,边查资料边做,其中涉及到主要技术点有: 如何播放远程网络音乐 如何切换当前正在播放中的音乐资源 如何监听音乐播放的各种状态(播放器状态.播放的进度.缓冲的进度,播放完成) 如何手动操控播放进度 如何在后台模式或者锁屏情况下正常播放音乐 如何在锁屏模式下显示音乐播放信息和远程操控音乐 如果您对一块技术点有兴趣或者正在寻找相关资料,那么本篇或许能提供一些参考或启发. 二. 网络音乐播放的核心技术点 根据自己的经验和查了一些音

  • iOS视频添加背景音乐同时保留原音

    话不多说,请看代码: //抽取原视频的音频与需要的音乐混合 -(void)addmusic:(id)sender { [MBProgressHUDshowHUDAddedTo:self.viewanimated:YES]; AVMutableComposition *composition =[AVMutableCompositioncomposition]; audioMixParams =[[NSMutableArrayalloc]initWithObjects:nil]; //录制的视频

  • iOS中关于音乐锁屏控制音乐(锁屏信息设置)的实例代码

    废话不多说了,直接给大家贴代码了,具体代码如下所示: <pre name="code" class="objc">appDelegate里面加入如下代码获取后台播放权限</pre><pre name="code" class="objc">- (void)setAudioBackstagePlay{ AVAudioSession *audioSession = [AVAudioSession

  • 讲解iOS开发中对音效和音乐播放的简单实现

    音效的播放 一.简单介绍 简单来说,音频可以分为2种 (1)音效 又称"短音频",通常在程序中的播放时长为1~2秒 在应用程序中起到点缀效果,提升整体用户体验 (2)音乐 比如游戏中的"背景音乐",一般播放时间较长 框架:播放音频需要用到AVFoundation.framework框架 二.音效的播放 1.获得音效文件的路径 复制代码 代码如下: NSURL *url = [[NSBundle mainBundle] URLForResource:@"m_

  • iOS App中实现播放音效和音乐功能的简单示例

    播放音效 iOS开发过程中可能会遇到播放音效的功能 其实很简单,iOS已经提供了一个框架直接负责播放音效 AudioToolbox.framework 新建项目  TestWeChatSounds 给新建的项目导入AudioToolbox.framework 导入成功之后如下图 项目目录如下 接下来我们给项目中添加几个caf格式的音效文件 接下来 我们打开 项目默认生成的ViewController中添加代码 导入 AudioToolbox 复制代码 代码如下: #import <AudioTo

  • iOS开发中音频工具类的封装以及音乐播放器的细节控制

    一.控制器间数据传递 两个控制器之间数据的传递 第一种方法: 复制代码 代码如下: self.parentViewController.music=self.music[indexPath.row]; 不能满足 第二种做法:把整个数组传递给它 第三种做法:设置一个数据源,设置播放控制器的数据源是这个控制器.self.parentViewController.dataSource=self;好处:没有耦合性,任何实现了协议的可以作为数据源. 第四种做法:把整个项目会使用到的音频资源交给一个工具类去

  • iOS实现获取系统iTunes音乐的方法示例

    播放音乐库中的音乐 音乐是iOS的重要组成播放,无论是iPod.iTouch.iPhone还是iPad都可以在iTunes购买音乐或添加本地音乐到音乐库中同步到你的iOS设备. 本文将给大家详细介绍关于iOS获取系统iTunes音乐的相关内容,分享出来供大家参考学习,下面话不多说了,来一起看看详细的介绍吧. 首先来看看效果图 简介 获取类型iTune音乐非常类似于UIKit框架中UIImagePickerController图片选取器的用法,既可以直接使用系统自带的媒体选择器也可以只获取系统的数

  • iOS利用AVPlayer播放网络音乐的方法教程

    前言 假如你现在打算做一个类似百度音乐.豆瓣电台的在线音乐类APP,你会怎样做? 首先了解一下音频播放的实现级别: (1) 离线播放:这里并不是指应用不联网,而是指播放本地音频文件,包括先下完完成音频文件再进行播放的情况,这种使用AVFoundation里的AVAudioPlayer可以满足 (2) 在线播放:使用AVFoundation的AVPlayer可以满足 (3) 在线播放同时存储文件:使用AudioFileStreamer + AudioQueue 可以满足 (4) 在线播放且带有音效

  • 实例解析iOS中音乐播放器应用开发的基本要点

    一.调整项目的结构,导入必要的素材 调整后的项目结构如下: 二.新建两个控制器 (1)新建一个控制器,用于展示音乐文件列表界面,其继承自UITableViewController (2)新建一个控制器,用于展示播放界面,其继承自UIViewController (3)在storyboard中,把之前的控制器删除,换上一个导航控制器,设置tableViewController与之前新建的控制器类进行关联 三.音乐文件列表控制器中基本界面的搭建 (1)新建一个音乐文件的模型 根据plist文件建立模

随机推荐