音视频二、AVPlayer播放网络音视频

最近在项目中使用了AVPlayer自定义视频播放器,具体包括播放、暂停、进度拖动、监听缓存状态(缓存慢的时候转菊花)、全屏播放等功能,在这里总结一下基本实现方法。

什么是AVPlayer

AVPlayer是AVFoundation框架里面提供的类,专门用于播放音视频(支持本地和流媒体)。详情参考AVFoundation Programming Guide.

基本使用

播放音视频

NSURL * url  = [NSURL URLWithString:self.currentSong.url];
AVPlayerItem * videoItem = [[AVPlayerItem alloc]initWithURL:url];
AVPlayer * player = [[AVPlayer alloc]initWithPlayerItem:songItem];

播放/暂停

[player play];
[player pause];

注意:由于视频刚开始播放的时候要先下载资源缓存下来,然后再进行解封装、解码、渲染播放等操作,所以立即调用play方法要等待一段时间才开始播放,更好的方法是在播放器转态变为AVPlayerStatusReadyToPlay时调用play方法。立即调用play方法有可能会黑屏一下子,就是这个原因。

KVO监听播放器播放当前资源时的状态

[self.player.currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];

然后可以在KVO方法中获取其status的改变

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"status"]) {
        switch (self.player.status) {            
            case AVPlayerStatusUnknown:                
                NSLog(@"未知状态,此时不能播放");                
                break;            
            case AVPlayerStatusReadyToPlay:                
                NSLog(@"准备完毕,可以播放"); 
                [self.player play];               
                break;
            case AVPlayerStatusFailed:
                NSLog(@"加载失败,网络或者服务器出现问题");
                break;            
            default:                
                break;        
        }
    }
}

注意:AVPlayer一共有3种状态AVPlayerStatusUnknown、AVPlayerStatusReadyToPlay、AVPlayerStatusFailed。一般初始化player到播放都会经历
Unknown到ReadyToPlay这个过程,网络情况良好时可能不会出现Unknown状态的提示,网络情况差的时候Unknown的状态可能会持续比较久甚至可能不进入ReadyToPlay状态,针对这种情况我们要做特殊的处理。

巨坑:播放有些在线音频文件的时候,在 currentItem 进入AVPlayerStatusReadyToPlay状态时, 获取资源的总时长时item.duration 仍有可能等于 CMTime.indefinite,CMTimeGetSeconds(item.duration)方法获取总时长为nan, 计算播放进度的时候老是crash。后来干脆在addPeriodicTimeObserverForInterval:queue:usingBlock:方法中获取总时长。

另外,不要忘记在合适的时机移除观察者:

[self.player.currentItem removeObserver:self forKeyPath:@"status"];

如何实现播放视频的上一个/下一个切换

这里我们有两种方式可以实现,一种是替换当前的item,这种方式据说是有性能问题。

[player replaceCurrentItemWithPlayerItem:songItem];

另一种是使用AVPlayer的子类AVQueuePlayer来播放多个item,调用advanceToNextItem来播放下一个视频

NSArray * items = @[item1, item2, item3 ....];
AVQueuePlayer * queuePlayer = [[AVQueuePlayer alloc]initWithItems:items];
[queuePlayer advanceToNextItem];

巨坑:AVQueuePlayer默认的actionAtItemEnd会删掉当前播放的item。

监听播放进度更新UI

使用player的addPeriodicTimeObserverForInterval:queue:usingBlock:方法来监听播放器的进度

添加观察者:

id timeObserve = [self.player addPeriodicTimeObserverForInterval:CMTimeMake(1.0, 1.0) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
        float current = CMTimeGetSeconds(time);        
        float total = CMTimeGetSeconds(songItem.duration);        
        if (current) {            
              weakSelf.progress = current / total;            
              weakSelf.playTime = [NSString stringWithFormat:@"%.f",current];            
              weakSelf.playDuration = [NSString stringWithFormat:@"%.2f",total];        }
}];

移除观察者:

if (timeObserve) {
        [player removeTimeObserver:_timeObserve];
        timeObserve = nil;
}

注意点:

  1. 方法传入一个CMTime结构体,每到一定时间都会回调一次,包括开始和结束播放

  2. 如果block里面的操作耗时太长,下次不一定会收到回调,所以尽量减少block的操作耗时

  3. 方法会返回一个观察者对象,当播放完毕时需要移除这个观察者

KVO监听数据缓冲进度

[self.player.currentItem addObserver:self forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];

然后可以在KVO方法中获取其数据缓冲状态的改变

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {   
    AVPlayerItem * songItem = object;
    if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
        NSArray * array = songItem.loadedTimeRanges;        
        CMTimeRange timeRange = [array.firstObject CMTimeRangeValue]; //本次缓冲的时间范围
        NSTimeInterval totalBuffer = CMTimeGetSeconds(timeRange.start) + CMTimeGetSeconds(timeRange.duration); //缓冲总长度
        NSTimeInterval videoLength = CMTimeGetSeconds(songItem.duration);
       CGFloat ratio = totalBuffer/videoLength;
       NSLog(@"共缓冲%.2f",totalBuffer);
       NSLog(@"缓冲比例%.2f", ratio);
    }
}

如果你需要在进度条展示缓冲的进度,可以增加这个观察者。另外缓存的数据也可能是离散的。

同样也要做合适的时机移除观察者:

[songItem removeObserver:self forKeyPath:@"loadedTimeRanges"];

KVO监听缓冲状态

item.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil)
item.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil)

playbackBufferEmpty 一般与 playbackLikelyToKeepUp 同时使用,二者均为Bool类型值。 playbackBufferEmpty 表示缓冲是否为空,为 true 时可以转菊花。playbackLikelyToKeepUp 表示缓冲是否可以足够播放, 为 true 时可以继续播放。

if path.elementsEqual("playbackBufferEmpty") {
    if let playbackBufferEmpty = dict[NSKeyValueChangeKey.newKey] as? Bool {
        PlayerLog("playbackBufferEmpty = \(playbackBufferEmpty)")
        if playbackBufferEmpty {
            playerStatus = .buffering
        }
    }
} else if path.elementsEqual("playbackLikelyToKeepUp") {
    if let playbackLikelyToKeepUp = dict[NSKeyValueChangeKey.newKey] as? Bool {
        PlayerLog("playbackLikelyToKeepUp = \(playbackLikelyToKeepUp)")
        if playbackLikelyToKeepUp {
            if queuePlayer?.rate == 0 {
                queuePlayer?.play()
                playerStatus = .prepareToPlay
            }
        }
    }
}

定位播放点

要定位到指定时间点进行播放, 可以使用 seekToTime:如下:

CMTime fiveSecondsIn = CMTimeMake(5, 1);
[player seekToTime:fiveSecondsIn];

seekToTime:方法牺牲了精确度来优化性能, 如果需要精确定位, 使用 seekToTime:toleranceBefore:toleranceAfter:方法.

CMTime fiveSecondsIn = CMTimeMake(5, 1);
[player seekToTime:fiveSecondsIn toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];    

将容忍值设置为0意味着框架会解码大量的数据来保证精确度. 确保只有在真正需要的时候才会如此设置.

播放完毕之后, 播放点会被设置为item的结束点. 要将播放点重新设置到item开头, 可以注册接收AVPlayerItemDidPlayToEndTimeNotification通知, 在处理通知的方法中, 调用seekToTime:并传入参数kCMTimeZero.

监听播放完成

// 监听播放完成
NotificationCenter.default.addObserver(self, selector: #selector(itemFinished(_:)), name: .AVPlayerItemDidPlayToEndTime, object: queuePlayer?.currentItem ?? nil)    

这里有个巨坑: 该通知会发送多次,尽管已经切换到下一首,Notification.object为播放的item,需要与当前item进行比较,以判断是否是当前正在播放的歌曲播放结束。另外,播放某些网络音频文件时,一开始播放就接收到AVPlayerItemDidPlayToEndTime通知,尴尬!遇到这种情况根本无法确定是否真正播放完成,以完成切换下一首的需求。目前用isPlaybackLikelyToKeepUp简单判断,亦可以用当前播放时间跟总时长比较,差值在一定范围类判断为播放完成。

最近发现使用原生AVPlayer进行资源加载后又没出现这个问题了, 推测使用AVPlayer+CacheSupport进行资源加载会导致这个问题。另外,AVPlayer+CacheSupport还会偶现有网情况下无法播放资源的情况。

// MARK: 播放完成

@objc private func itemFinished(_ notif: Notification) {
    PlayerLog("播放完成通知!")
    guard let currentItem = queuePlayer?.currentItem else { return }
    if let item = notif.object as? AVPlayerItem, item == currentItem {
        PlayerLog("\(currentItem.mc_URL) 播放完成!")
        // 播放完成回到开始位置
        updateProgress(0) { [weak self](ret) in
            guard let strongSelf = self else {
                self?.playerStatus = .finished
                return
            }
            strongSelf.playerStatus = .finished
        }
    }
}