SZAVPlayer

基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出

2019-12-31

TOC

前言

最近正好在开发音频播放功能,所以基于AVPlayer实现了SZAVPlayer这个库,完整实现了音频播放功能,同时实现了缓存。在实现缓存的时候遇到了不少坑,所以找个地方记录一下。

在iOS系统里实现音视频播放有很多种方式,这里贴一下苹果WWDC上的一个架构图: iOS音视频架构图

图里很清晰的讲述了各个库之间的依赖关系。AudioToolbox是较底层的音视频处理库,一般专业的音视频播放器和编辑器都会基于这个库进行开发,例如这个AudioKit。AVFoundation是更上一层的封装好的库,一般常规的音视频播放需求都会使用这个库进行开发,本文要讲的AVPlayer就属于AVFoundation。

如何实现播放

需要理解的几个类

  • AVPlayer

    AVPlayer 需要 AVPlayerItemAVAssetAVPlayerLayer 等功能类配对使用,支持本地和远程音视频的播放。可以通过添加observer的方式获取到当前播放时间、总播放时间等信息。

  • AVPlayerItem

    封装了 AVPlayer 播放用的 AVAsset 的时间以及播放相关的状态变化。可以添加observer进行状态变化的监听,比如是否可以播放、播放失败、加载进度、开始缓冲、缓冲结束等等。

  • AVAsset

    是个抽象类,一般会使用它的子类AVURLAsset,主要是封装了播放资源相关的各类属性。其中传递进来的URL可以是远程的地址也可以是本地地址。

  • AVPlayerLayer

    继承自CALayer,主要用于展示AVPlayer输出的视频画面,如果只是播放音频的话不需要此类。

更细致的描述建议参考苹果官方Document。

音视频初始化过程

  1. 使用URL初始化AVURLAsset,并提前进行异步加载,下方的“问题整理”部分有更细致的说明。
  2. 使用上一步初始化好的Asset创建AVPlayerItem,配置好需要的各类属性。
  3. 添加各类监听。

    AVPlayer监听(player.addPeriodicTimeObserver)

    AVPlayerItem监听(status / loadedTimeRanges / playbackBufferEmpty / playbackLikelyToKeepUp等)

    NotificationCenter监听(AVPlayerItemDidPlayToEndTime / AVPlayerItemPlaybackStalled等)。

  4. 如果是视频播放就创建AVPlayerLayer,如果是音频播放可跳过此步。

  5. 等待状态变化的通知。在第2步添加好监听的话可以接收到readyToPlay的变化通知,此时可以调用AVPlayer的play和pause方法控制播放和暂停。

  6. 播放过程中可能会出现缓冲开始、缓冲结束、播放挂起(playbackStalled)等变化,需要正确识别并处理。

在一些版本的模拟器上AVPlayer无法正常工作,据我测试iOS10系统的模拟器里面直接报错。在其他版本的模拟器中如果遇到无法播放问题可以尝试完整退出模拟器再重新开启的方式。

如果播放音视频时需要在设备静音模式下也要出声音或者进入后台也要持续播放的话可设置AVAudioSession,参考代码如下:

     public static func activeAudioSession() {
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playback)
            try session.setActive(true, options: [])
        } catch {
            SZLogError("ActiveAudioSession failed.")
        }
    }

    public static func deactiveAudioSession() {
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.ambient)
            try session.setActive(false, options: [.notifyOthersOnDeactivation])
        } catch {
            SZLogError("DeactiveAudioSession failed.")
        }
    }

音视频切换过程

  1. 移除初始化时添加到AVPlayerItem的所有监听并释放该item。因为AVPlayer和NotificationCenter是复用的,不用重新创建,所以监听不用移除,只有AVPlayer不再使用的时候进行移除就可以。

    这里需要注意的是添加监听和移除必须是配对的,如果未正常移除就会出现AVPlayerItem和AVPlayer无法正常释放,严重时就会出现崩溃现象。如果你遇到播放一定量音视频以后AVPlayer开始报无法播放错误时多半也是这个原因。

  2. 使用URL初始化AVURLAsset,并提前进行异步加载。

  3. 使用上一步初始化好的Asset创建AVPlayerItem,配置好需要的各类属性。

  4. 添加AVPlayerItem监听。

  5. 等待状态变化的通知,并开始正常使用。

捕获视频画面的输出(比如想要同时绘制到多个View上时)

  1. 创建 AVPlayerItemVideoOutput ,设定输出的相关参数。

    private let videoOutputQueue: DispatchQueue = DispatchQueue(label: "com.SZAVPlayer.videoOutput")
        
    let settings: [String: Any] = [String(kCVPixelBufferPixelFormatTypeKey): NSNumber(value: kCVPixelFormatType_32BGRA)]
    let videoOutput = AVPlayerItemVideoOutput(outputSettings: settings)
    videoOutput.setDelegate(self, queue: videoOutputQueue)
    
  2. 创建 CADisplayLink ,用来后续实时的捕获视频画面。

    let link = CADisplayLink(target: self, selector: #selector(handleDisplayLinkCallback))
    link.add(to: RunLoop.current, forMode: .default)
    // 需要先暂停,在确定到视频已开始播放以后再取消暂停
    link.isPaused = true
    
  3. AVPlayerItem 创建好以后使用 add(_ output: AVPlayerItemOutput) 方法添加output到playerItem上。

  4. 调用play方法播放之前需要调用一下 requestNotificationOfMediaDataChange 方法,此方法会在视频开始播放时触发 AVPlayerItemOutputPullDelegate 的方法。

    videoOutput.requestNotificationOfMediaDataChange(withAdvanceInterval: 0.03)
    
  5. 监听 AVPlayerItemOutputPullDelegate 的方法并开始捕获。

    public func outputMediaDataWillChange(_ sender: AVPlayerItemOutput) {
        // 取消暂停,开始捕获
        displayLink?.isPaused = false
    }
    
  6. 捕获方法 handleDisplayLinkCallback 参考代码

    @objc private func handleDisplayLinkCallback(sender: CADisplayLink) {
        let nextVSync = sender.timestamp + sender.duration
        let outputItemTime = videoOutput.itemTime(forHostTime: nextVSync)
        guard videoOutput.hasNewPixelBuffer(forItemTime: outputItemTime) else { return }
    
        if let pixelBuffer = videoOutput.copyPixelBuffer(forItemTime: outputItemTime, itemTimeForDisplay: nil) {
            CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
            let bitmapInfo = CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.noneSkipFirst.rawValue)
            let cgContext = CGContext(data: CVPixelBufferGetBaseAddress(pixelBuffer),
                                      width: CVPixelBufferGetWidth(pixelBuffer),
                                      height: CVPixelBufferGetHeight(pixelBuffer),
                                      bitsPerComponent: 8,
                                      bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
                                      space: CGColorSpaceCreateDeviceRGB(),
                                      bitmapInfo: bitmapInfo.rawValue)
            if let cgContext = cgContext,
                let cgImage = cgContext.makeImage()
            {
                delegate?.avplayer(self, didOutput: cgImage)
            }
    
            CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
        }
    }
    

以上所有过程可以参考 SZAVPlayer 库Example上的实现例子

AVPlayer缓存原理及问题整理

AVPlayer的缓存原理以及实现思路讲解文章已经有很多现成的,而且讲解的也比较细,可以直接查阅,地址如下:

中文文章地址

英文文章地址

往下阅读之前确保已经仔细阅读上面列举文章之一,或者已经足够熟悉AVAssetResourceLoaderDelegate。

问题整理

AVAsset的异步加载

当设置AVPlayerItem时需要设置AVAsset,如果直接进行设置会发现经常卡住主线程,如果你使用AVPlayer时切换播放内容导致UI卡顿的话基本就是这个问题了。苹果针对AVAsset提供一个loadValuesAsynchronously方法,目的就是先异步加载好以后再设置进去,如下是代码示例:

    asset.loadValuesAsynchronously(forKeys: ["playable"]) {
        DispatchQueue.main.async {
            completion(asset)
        }
    }

需要注意的是AVAsset在load完以后传递到需要用的地方进行使用,如果重新创建一个新的Asset等于是做了无用功。

AVAssetResourceLoaderDelegate设置

AVAssetResourceLoaderDelegate 设置时必须配对非常规schema,就是非http/https的自定义schema,否则这个delegate里的方法是不会有机会被调用。通常会采取如下做法:

  • 把自定义schema先拼接到最前面进行Asset的初始化,然后需要使用url时再从最前面剔除自定义schema获取原始链接
  • 把原始链接和自定义schema的url分别存储,后续场景中需要哪个就使用哪个

我自己是使用第二种方式实现的。

AVAssetResourceLoader适配

AVAssetResourceLoader的执行过程可以简单理解为ContentInfoRequest和DataRequest两部分。

AVAssetResourceLoader发的请求都是AVAssetResourceLoadingRequest类型,里面包含 contentInformationRequestdataRequest 两个属性。

ContentInfoRequest

AVPlayer会先发一个ContentInfoRequest请求,通常是2字节大小。ResourceLoader的Delegate实现方接收到此请求以后需要自行发起一个request获取response,然后从response里获取mimeType、contentLength、isByteRangeAccessSupported等信息反馈回去。

需要特别注意的是这里的contentType接收的是特殊的字符描述,iOS有专门的转换方法,示例代码如下:

    if let mimeType = response.mimeType,
        let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)
    {
        request.contentType = contentType.takeRetainedValue() as String
    }

代码可参考SZAVPlayerAssetLoaderhandleContentInfoRequest方法

DataRequest

如果上一步反馈的信息符合AVPlayer的播放要求,就会开始发起DataRequest,也就是真正的播放数据请求。

需要特别注意的是DataRequest请求在各个iOS系统下表现不一。比如长度为10000的音频,在最新的iOS13下默认会发一个完整range(0-10000)的请求,而在iOS12下就有可能拆成range为0-3000和3000-10000的2个或粒度更小的N个请求,如果要自己处理DataRequest就要考虑清楚此类情况。

接收到DataRequest请求以后就需要自行发起真实的数据请求了,可以有如下两种实现方式:

  • 忽略DataRequest的RequestedRange,直接发起完整range的数据请求,可以简单的把range请求头去掉。

    优点:

    1. 实现起来简单。

    缺点:

    1. 出现进度条跳跃请求时无法及时响应,必须等到目标点的数据加载到了才能进行继续播放。数据是从0的位置开始加载的,所以只能等待前面的数据加载完。
    2. 无法做到片段缓存,因为请求是整段的,所以在整段请求成功结束时才有可能进行缓存。
  • 根据DataRequest的RequestedRange发起特定range的数据请求。

    优点:

    1. 可以及时响应进度条跳跃请求,因为发起的请求是从特定位置开始请求的。
    2. 可以做到片段缓存,在下一次的请求中可以充分利用本地已缓存数据,可以组合使用远程片段请求和本地片段缓存快速响应。

    缺点:

    1. 实现复杂,需要考虑如何存储、如何拼接、如何清理等等一系列功能。

当发起的数据请求有数据返回时需要实时反馈给AVAssetResourceLoader的AVAssetResourceLoadingRequest,然后AVAssetResourceLoader会自行判断Player是否可以播放、是否超时、是否出错等等状态,并通知到各个监听者。

此部分实现代码可参考 SZAVPlayerAssetLoaderhandleDataRequest 方法。其中由 SZAVPlayerDataLoader 负责管理和维护数据请求相关操作。

片段请求异常结束时是否缓存已加载数据

我在一开始的时候尝试过不完整的片段请求数据也进行缓存。

比如某个请求的RequestedRange是100-2000,数据加载到1000的时候请求被取消了,这时候是可以缓存100-1000的数据。但是后续使用过程中发现这种中断的数据拼接出来以后会有几率出现明显的播放不连贯现象,应该是数据被异常截断导致的,也有可能是我处理的方式有问题。

最后改成只缓存那种正常结束的片段请求,然后一切都恢复了正常。

无网环境下是否可以播放

如果实现了完整的 AVAssetResourceLoaderDelegate 流程,在调用AVAsset的 loadValuesAsynchronously 方法时不管有网还是无网都会走上面讲的AVAssetResourceLoader适配流程,所以在无网状态下也能正常播放已缓存的部分。

如果考虑无网模式下也要正常播放的话除了数据缓存以外ContentInfoRequest部分也要持久化到本地,否则ResourceLoader过程的第一步就走不通。

总结

基于AVPlayer实现音视频播放相对来说是较理想的实现方式,能够满足常规的音视频播放需求。不过在调研过程中发现市面上的文章和现有库都很零散或者处于不在维护的状态,同时AVPlayer的AVAssetResourceLoader这部分除了基本的描述信息以外基本属于黑盒状态,需要自己在各个系统下摸索出来规律,而且不排除在后续的新系统中这些规律还会不断变化。

基于以上这些原因决定开始编写这个库,是使用Swift编写的。目前支持音视频播放,以经过完整的测试接入到自己的音频项目。

欢迎大家提issue以及共同参与后续维护。