【iOS】AVPlayer 播放音视频

1、常见的音视频播放器

iOS开发中不可避免地会遇到音视频播放方面的需求。

常用的音频播放器有 AVAudioPlayer、AVPlayer 等。不同的是,AVAudioPlayer 只支持本地音频的播放,而 AVPlayer 既支持本地音频播放,也支持网络音频播放。

常用的视频播放器有 MPMoviePlayerController、AVPlayer 等。不同的是,MPMoviePlayerController 内部做了高度封装,包含了播放控件,几乎不用写几行代码就能完成一个播放器,但是正是由于它的高度封装使得要自定义这个播放器变得很复杂,甚至是不可能完成。而 AVPlayer 更加接近于底层,所以灵活性也更强,更加方便自定义。

今天我们要介绍的主角就是强大的 AVPlayer。

2、AVPlayer

AVPlayer 存在于 AVFoundation 框架中,所以要使用 AVPlayer,要先在工程中导入 AVFoundation 框架。

AVPlayer 播放界面中不带播放控件,想要播放视频,必须要加入 AVPlayerLayer 中,并添加到其他能显示的 layer 当中。

AVPlayer 中音视频的播放、暂停功能对应着两个方法 playpause 来实现。

大多播放器都是通过通知来获取播放器的播放状态、加载状态等,而 AVPlayer 中对于获得播放状态和加载状态有用的通知只有一个:AVPlayerItemDidPlayToEndTimeNotification(播放完成通知) 。播放器的播放状态判断可以通过播放器的播放速度 rate 来获得,如果 rate 为0说明是停止状态,为1时则是正常播放状态。想要获取视频播放情况、缓冲情况等的实时变化,可以通过 KVO 监控 AVPlayerItem 的 statusloadedTimeRanges 等属性来获得。当 AVPlayerItem 的 status 属性为 AVPlayerStatusReadyToPlay 时说明可以开始播放,只有处于这个状态时才能获得视频时长等信息;当 loadedTimeRanges 改变时(每缓冲一部分数据就会更新此属性),可以获得本次缓冲加载的视频范围(包含起始时间、本次加载时长),这样一来就可以实时获得缓冲情况。

AVPlayer 中播放进度的获取通常是通过:- (id)addPeriodicTimeObserverForInterval:(CMTime)interval queue:(dispatch_queue_t)queue usingBlock:(void (^)(CMTime time))block 方法。这个方法会在设定的时间间隔内定时更新播放进度,通过 time 参数通知客户端。至于播放进度的跳转则是依靠 - (void)seekToTime:(CMTime)time 方法。

AVPlayer 还提供了 - (void)replaceCurrentItemWithPlayerItem:(AVPlayerItem *)item 方法用于在不同视频之间的切换(事实上在AVFoundation内部还有一个AVQueuePlayer专门处理播放列表切换,有兴趣的朋友可以自行研究,这里不再赘述)。

3、自定义AVPlayer

下面是我自己在项目中封装的音视频播放器,贴上代码,大家可以参考一下。

#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>

/**
 播放器开始播放的通知
 当存在多个播放器,可使用该通知在其他播放器播放时暂停当前播放器
 */
extern NSString * const YDPlayerDidStartPlayNotification;

/**
 enum 播放器状态

 - YDPlayerStatusUnknown: 未知
 - YDPlayerStatusPlaying: 播放中
 - YDPlayerStatusLoading: 加载中
 - YDPlayerStatusPausing: 暂停中
 - YDPlayerStatusFailed: 播放失败
 - YDPlayerStatusFinished: 播放完成
 */
typedef NS_ENUM(NSInteger, YDPlayerStatus) {
    YDPlayerStatusUnknown,
    YDPlayerStatusPlaying,
    YDPlayerStatusLoading,
    YDPlayerStatusPausing,
    YDPlayerStatusFailed,
    YDPlayerStatusFinished
};


@interface YDPlayerMananger : NSObject

/**
 播放器
 */
@property (nonatomic, strong) AVPlayer *player;

/**
 播放器layer层
 */
@property (nonatomic, strong) AVPlayerLayer *playerLayer;

/**
 当前PlayerItem
 */
@property (nonatomic, strong) AVPlayerItem *currentItem;

/**
 播放器状态
 */
@property (nonatomic, assign) YDPlayerStatus playStatus;

/**
 Item总时长回调
 */
@property (nonatomic, copy) void(^currentItemDurationCallBack)(AVPlayer *player, CGFloat duration);

/**
 Item播放进度回调
 */
@property (nonatomic, copy) void(^currentPlayTimeCallBack)(AVPlayer *player, CGFloat time);

/**
 Item缓冲进度回调
 */
@property (nonatomic, copy) void(^currentLoadedTimeCallBack)(AVPlayer *player, CGFloat time);

/**
 Player状态改变回调
 */
@property (nonatomic, copy) void(^playStatusChangeCallBack)(AVPlayer *player, YDPlayerStatus status);


/**
 初始化方法
 
 @param url 播放链接
 @return YDPlayerMananger对象
 */
- (instancetype)initWithURL:(NSURL *)url;


/**
 创建单例对象

 @return YDPlayerMananger单例对象
 */
+ (instancetype)shareManager;


/**
 将播放器展示在某个View
 
 @param view 展示播放器的View
 */
- (void)showPlayerInView:(UIView *)view withFrame:(CGRect)frame;


/**
 替换PlayerItem

 @param url 需要播放的链接
 */
- (void)replaceCurrentItemWithURL:(NSURL *)url;


/**
 播放某个链接

 @param urlStr 需要播放的链接
 */
- (void)playWithUrl:(NSString *)urlStr;


/**
 开始播放
 */
- (void)play;


/**
 暂停播放
 */
- (void)pause;


/**
 停止播放
 */
- (void)stop;


/**
 跳转到指定时间

 @param time 指定的时间
 */
- (void)seekToTime:(CGFloat)time;

@end
#import "YDPlayerMananger.h"

NSString * const YDPlayerDidStartPlayNotification = @"YDPlayerDidStartPlayNotification";

@interface YDPlayerMananger ()
@property (nonatomic, strong) id timeObserver; // 监控播放进度的观察者

@end

@implementation YDPlayerMananger

#pragma mark - 生命周期

- (instancetype)init
{
    if (self = [super init]) {
        AVAudioSession *audioSession = [AVAudioSession sharedInstance];
        [audioSession setCategory:AVAudioSessionCategoryPlayback error:nil];
        [audioSession setActive:YES error:nil];
        self.player = [[AVPlayer alloc] init];
        [self addNotificationAndObserver];
    }
    return self;
}

- (instancetype)initWithURL:(NSURL *)url
{
    if (self = [self init]) {
        [self replaceCurrentItemWithURL:url];
    }
    return self;
}

+ (instancetype)shareManager
{
    static YDPlayerMananger *manager = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        manager = [[self alloc] init];
    });
    return manager;
}

- (void)dealloc
{
    [self removeNotificationAndObserver];
}

#pragma mark - 公开方法

- (void)showPlayerInView:(UIView *)view withFrame:(CGRect)frame
{
    self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player];
    _playerLayer.frame = frame;
    _playerLayer.backgroundColor = [UIColor blackColor].CGColor;
    _playerLayer.videoGravity = AVLayerVideoGravityResizeAspect;
    [view.layer addSublayer:_playerLayer];
}

- (void)replaceCurrentItemWithURL:(NSURL *)url
{
    // 移除当前观察者
    if (_currentItem) {
        [_currentItem removeObserver:self forKeyPath:@"status"];
        [_currentItem removeObserver:self forKeyPath:@"loadedTimeRanges"];
    }
    _currentItem = [[AVPlayerItem alloc] initWithURL:url];
    [self.player replaceCurrentItemWithPlayerItem:_currentItem];
    
    // 重新添加观察者
    [_currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
    [_currentItem addObserver:self forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
}

- (void)playWithUrl:(NSString *)urlStr
{
    [self replaceCurrentItemWithURL:[NSURL URLWithString:urlStr]];
    [self play];
}

- (void)play
{
    [self.player play];
    self.playStatus = YDPlayerStatusPlaying;
    // 发起开始播放的通知
    [[NSNotificationCenter defaultCenter] postNotificationName:YDPlayerDidStartPlayNotification object:_player];
}

- (void)pause
{
    [self.player pause];
    self.playStatus = YDPlayerStatusPausing;
}

- (void)stop
{
    [self.player pause];
    [_currentItem cancelPendingSeeks];
    self.playStatus = YDPlayerStatusFinished;
}

- (void)seekToTime:(CGFloat)time
{
    [_currentItem seekToTime:CMTimeMakeWithSeconds(time, 1.0) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
}

#pragma mark - 私有方法

// 添加通知、观察者
- (void)addNotificationAndObserver
{
    // 添加播放完成通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(playbackFinished:) name:AVPlayerItemDidPlayToEndTimeNotification object:nil];
    // 添加打断播放的通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(interruptionComing:) name:AVAudioSessionInterruptionNotification object:nil];
    // 添加插拔耳机的通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(routeChanged:) name:AVAudioSessionRouteChangeNotification object:nil];
    // 添加观察者监控播放器状态
    [self addObserver:self forKeyPath:@"playStatus" options:NSKeyValueObservingOptionNew context:nil];
    // 添加观察者监控进度
    __weak typeof(self) weakSelf = self;
    _timeObserver = [_player addPeriodicTimeObserverForInterval:CMTimeMake(1, 1.0) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
        __strong typeof(self) strongSelf = weakSelf;
        
        if (strongSelf.currentPlayTimeCallBack) {
            float currentPlayTime = (double)strongSelf.currentItem.currentTime.value / strongSelf.currentItem.currentTime.timescale;
            strongSelf.currentPlayTimeCallBack(strongSelf.player, currentPlayTime);
        }
    }];
}

// 移除通知、观察者
- (void)removeNotificationAndObserver
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [self removeObserver:self forKeyPath:@"playStatus"];
    [_player removeTimeObserver:_timeObserver];
    if (_currentItem) {
        [_currentItem removeObserver:self forKeyPath:@"status"];
        [_currentItem removeObserver:self forKeyPath:@"loadedTimeRanges"];
    }
}

#pragma mark - 观察者

// 观察者
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if ([keyPath isEqualToString:@"status"]) {
        
        AVPlayerStatus status = [[change objectForKey:@"new"] intValue];
        
        if (status == AVPlayerStatusReadyToPlay) {
            // 获取视频长度
            if (self.currentItemDurationCallBack) {
                CGFloat duration = CMTimeGetSeconds(_currentItem.duration);
                self.currentItemDurationCallBack(_player, duration);
            }
            
        } else if (status == AVPlayerStatusFailed) {
            self.playStatus = YDPlayerStatusFailed;
        } else {
            self.playStatus = YDPlayerStatusUnknown;
        }
        
    } else if ([keyPath isEqualToString:@"playStatus"]) {
        
        if (self.playStatusChangeCallBack) {
            self.playStatusChangeCallBack(_player, _playStatus);
        }
    } else if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
        
        // 计算缓冲总进度
        NSArray *loadedTimeRanges = [_currentItem loadedTimeRanges];
        CMTimeRange timeRange = [loadedTimeRanges.firstObject CMTimeRangeValue];
        float startSeconds = CMTimeGetSeconds(timeRange.start);
        float durationSeconds = CMTimeGetSeconds(timeRange.duration);
        NSTimeInterval loadedTime = startSeconds + durationSeconds;

        if (self.playStatus == YDPlayerStatusPlaying && self.player.rate <= 0) {
            self.playStatus = YDPlayerStatusLoading;
        }
        
        // 卡顿时缓冲完成后自动播放
        if (self.playStatus == YDPlayerStatusLoading) {
            NSTimeInterval currentTime = self.player.currentTime.value / self.player.currentTime.timescale;
            if (loadedTime > currentTime + 5) {
                [self play];
            }
        }
        
        if (self.currentLoadedTimeCallBack) {
            self.currentLoadedTimeCallBack(_player, loadedTime);
        }
    }
}

#pragma mark - 通知

// 播放完成通知
- (void)playbackFinished:(NSNotification *)notification
{
    AVPlayerItem *playerItem = (AVPlayerItem *)notification.object;
    if (playerItem == _currentItem) {
        self.playStatus = YDPlayerStatusFinished;
    }
}

// 插拔耳机通知
- (void)routeChanged:(NSNotification *)notification
{
    NSDictionary *dic = notification.userInfo;
    int changeReason = [dic[AVAudioSessionRouteChangeReasonKey] intValue];
    // 旧输出不可用
    if (changeReason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
        AVAudioSessionRouteDescription *routeDescription = dic[AVAudioSessionRouteChangePreviousRouteKey];
        AVAudioSessionPortDescription *portDescription = [routeDescription.outputs firstObject];
        // 原设备为耳机则暂停
        if ([portDescription.portType isEqualToString:@"Headphones"]) {
            [self pause];
        }
    }
}

// 来电、闹铃打断播放通知
- (void)interruptionComing:(NSNotification *)notification
{
    NSDictionary *userInfo = notification.userInfo;
    AVAudioSessionInterruptionType type = [userInfo[AVAudioSessionInterruptionTypeKey] intValue];
    if (type == AVAudioSessionInterruptionTypeBegan) {
        [self pause];
    }
}

@end

4、注意点

在使用 AVPlayer 时需要注意的是,由于播放状态、缓冲状态等是通过 KVO 监控 AVPlayerItem 的 status、loadedTimeRanges 等属性来获得的,在使用 - (void)replaceCurrentItemWithPlayerItem:(AVPlayerItem *)item 切换视频后,当前的 AVPlayerItem 实际上已经被释放掉了,所以一定要及时移除观察者并重新添加,否则会引起崩溃。

如果有大神发现文章中的错误,欢迎指正。有兴趣下载文中 Demo 的朋友,可以前往我的GitHub:GitHud地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容