AVPlayer初体验之边下边播与视频缓存

上篇文章介绍了AVPlayer的基本播放和解码纹理,本文主要利用AVAssetResourceLoaderDelegate实现AVPlayer的边下边播和缓存机制。

基本原理

AVUrlAsset在请求自定义的URLScheme资源的时候会通过AVAssetResourceLoader实例来进行资源请求。它是AVUrlAsset的属性,声明如下:

var resourceLoader: AVAssetResourceLoader { get }

AVAssetResourceLoader请求的时候会把相关请求(AVAssetResourceLoadingRequest)传递给AVAssetResourceLoaderDelegate(如果有实现的话),我们可以保存这些请求,然后构造自己的NSUrlRequset来发送请求,当收到响应的时候,把响应的数据设置给AVAssetResourceLoadingRequest,并且对数据进行缓存,就完成了边下边播,整个流程大体如下图。



其中最为复杂的部分是数据偏移处理,因为数据是分块下载和分块填充的,我们的需要填充的对象是AVAssetResourceLoadingDataRequest,需要控制好currentOffset

实现

必要的配置

手动实现AVAssetResourceLoaderDelegate协议需要URL是自定义的URLScheme,只需要把源URL的http://或者https://替换成xxxx://,然后再实现AVAssetResourceLoaderDelegate协议函数才可以生效,否则不会生效。

//首先判断是否有缓存,如果没有缓存才走下面的步骤,有缓存直接从`file://`读取
let asset = AVURLAsset(url: urlWithCustomScheme)
//urlWithCustomScheme = "xxxx://xxxx.mp4"
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: self.queue)

AVAssetResourceLoaderDelegate协议

AVAssetResourceLoaderDelegateAVPlayer在向媒体服务器请求数据时的代理,为了实现边下边播,需要实现自定义请求,需要实现的两个方法如下:

  • optional func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool

    该函数表示代理类是否可以处理该请求,这里需要返回True表示可以处理该请求,然后在这里保存所有发出的请求,然后发出我们自己构造的NSUrlRequest
  • optional func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)

    该函数表示AVAssetResourceLoader放弃了本次请求,需要把该请求从我们保存的原始请求列表里移除。

以上两个是必须要实现的方法,其他的函数依照具体的场景(比如需要鉴权则需要实现两个鉴权函数来处理URLAuthenticationChallenge)具体看是否需要实现。

一个最简单的实例

下面实现一个不带分块下载功能的最简单的边下边播代理,帮助理解AVAssetResourceLoaderDelegate协议

注意,以下代码不带分块功能,是因为只发送一个请求,利用NSUrlSession直接请求视频资源,针对元信息在视频文件头部的视频可以实现边下边播,而元信息在视频尾部的视频则会下载完才播放,关于这个视频元信息(moov)接下来会再讨论,以下代码缓存也是放在下载完整个视频做,而不是分块写入文件。

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
    if session == nil {
        //由于使用了自定义UrlScheme,需要构造出原始的URL
        guard let interceptedUrl = loadingRequest.request.url,
            let initialUrl = interceptedUrl.withScheme(self.initialScheme) else {
                fatalError("internal inconsistency")
        }
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
        configuration.networkServiceType = .video
        configuration.allowsCellularAccess = true
        var urlRequst = URLRequest.init(url: initialUrl, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) // 20s超时
        urlRequst.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
        urlRequst.httpMethod = "GET"
        session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        session?.dataTask(with: urlRequst).resume()
    }
    //保存原始请求
    self.pendingRequests.insert(loadingRequest)
    //每次发送请求都遍历处理一遍原始请求数组
    self.processPendingRequests()
    return true
}
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
    //移除原始请求
    self.pendingRequests.remove(loadingRequest)
}

NSUrlRequest响应回调处理

// MARK: URLSession delegate
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
    self.mediaData?.append(data)
    self.processPendingRequests()
    //print("数据下载成功 已下载\( mediaData!.count) 总数据\(Int(dataTask.countOfBytesExpectedToReceive))")
}

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
    //只会调用一次,在这里构造下载完成的数据
    //这里传allow告知session持续下载而不是当做下载任务
    completionHandler(Foundation.URLSession.ResponseDisposition.allow)
    self.mediaData = Data()
    self.response = response
    self.processPendingRequests()
}

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    if let errorUnwrapped = error {
        print("下载失败\(errorUnwrapped)")
        return
    }
    self.processPendingRequests()
    //下载完成,保存文件
    let fileName = self.fileCachePath
    if let data = self.mediaData{
        VideoCacheManager.share.saveData(data:data,url:self.url)
    }else{
        print("数据为空")
    }
}

填充响应以及判断请求是否完成

func processPendingRequests() {
    self.queue.async {
        let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(self.pendingRequests.flatMap {
            if let res = self.response{
                $0.response = res
            }
            self.fillInContentInformationRequest($0.contentInformationRequest)
            if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
                if(!$0.isFinished){
                    $0.finishLoading()
                }
                //print("请求填充完成 结束本次请求")
                return $0
            }
            return nil
        })
        _ = requestsFulfilled.map { self.pendingRequests.remove($0) }
    }
}
//填充请求
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
    self.queue.async {
        guard let responseUnwrapped = self.response else {
            return
        }
        contentInformationRequest?.contentType = responseUnwrapped.mimeType
        contentInformationRequest?.contentLength = responseUnwrapped.expectedContentLength
        contentInformationRequest?.isByteRangeAccessSupported = true
    }
}
//判断是否完整
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
    
    let requestedOffset = Int(dataRequest.requestedOffset)
    let requestedLength = dataRequest.requestedLength
    let currentOffset = Int(dataRequest.currentOffset)
    //print("下载数据 = \(mediaData?.count) 当前偏差\(currentOffset)")
    guard let dataUnwrapped = mediaData,
        dataUnwrapped.count > currentOffset else {
            //没有新的内容可以填充
            return false
    }
    
    let bytesToRespond = min(dataUnwrapped.count - currentOffset, requestedLength)
    let dataToRespond = dataUnwrapped.subdata(in: Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond)))
    dataRequest.respond(with: dataToRespond)
    //print("原始请求获得响应\(dataToRespond.count)")
    return dataUnwrapped.count >= requestedLength + requestedOffset
}

再次注意,以上代码在收到原始请求后,并没有每次都发送请求,而是在第一次收到的时候只发送一次请求,利用NSUrlSessionDatataskcontinues task特性来下载完整个媒体,所以是视频文件的头部开始下载,并且缓存也是在视频文件都下载完成之后才一次性写入文件的。因此,先不谈分块下载,以上代码会非常容易理解。接下来谈谈视频的格式问题。

为什么以上代码不能边下边播所有MP4

以上代码本质上只发送了一个NSUrlRequest,这个HTTP请求的头部没有带有Byte-Range信息,因此媒体服务器并不知道你需要请求的长度,就会把它当做一个文件流从头部请求到尾部,因此我们指定Foundation.URLSession.ResponseDisposition.allow告诉这个URLSession把它当做一个continues task来下载,于是从文件头部开始下载,但是真正的视频流并不是这么下载的。

尝试用Safari播放在线视频,抓包查看请求细节,如下图:



在请求头里有一个Range:byte字段来告诉媒体服务器需要请求的是哪一段特定长度的文件内容,对于MP4文件来说,所有数据都封装在一个个的box或者atom中,其中有两个atom尤为重要,分别是moov atommdat atom

  • moov atom:包含媒体的元数据的数据结构,包括媒体的块(box)信息,格式说明等等。(Meta data about where the video and audio atoms are, as well as information about how to play the video like the dimensions and frames per second, is all stored in a special atom called the moov atom。)
  • mdat atom: 包含媒体的媒体信息,对于视屏来说就是视频画面了。

虽然moovmdat都只有一个,但是由于MP4文件是由若干个这样的box或者atom组成的,因此这两个atom在不同媒体文件中出现的顺序可能会不一样,为了加快流媒体的播放,我们可以做的优化之一就是手动把moov提到mdat之前。

对于AVPlayer来说,只有到AVPlayerItemStatusReadyToPlay状态时,才可以开始播放视频,而进入AVPlayerItemStatusReadyToPlay状态的必要条件就是播放器读到了媒体的moov块。

那么以上代码不能边下边播的视频,是否都是mdat位于moov之后呢,答案显然是肯定的,用二进制打开一个不能边下边播的视频,查找mdatmoov的位置如下:



mdat位于0x000018的位置。



moov位于0xA08540文件的尾部,也就是说,针对不指定Byte-Range的请求,只有请求到文件尾的时候才能开始播放视频

查看一个能播放的视频,位置如下图:





moovmdat都位于文件头部,且moov位于mdat之前。

那么是不是用一个请求就可以播放所有的moov位于mdat之前的视频了呢?如果不Seek的话,答案是可以的,但是如果加入Seek的话,情况就复杂多了,所以还是要加入分块下载,才能完美解决边下边播,缓存以及Seek。

分块下载

引入分块下载最大的复杂点在于对响应数据的contentOffset的处理上,好在AVAssetResourceLoader帮我们处理了大量工作,我们只需要用好AVAssetResourceLoadingRequest就可以了。

  • 首先获取原始请求的Range-Byte
  • 构造新的请求
  • 获取响应HTTPUrlResponse
    • 填充到loadingRequest.contentInformationRequest
  • 获取响应数据
    • 获取响应头中的Content-Length
    • 计算content-offset,填充响应到原始请求,写入文件
    • 填充到loadingRequest.dataRequest
  • 请求完成

下面是代码部分,首先是获取原始请求和发送新的请求

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
    if self.session == nil {
        //构造Session
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
        configuration.networkServiceType = .video
        configuration.allowsCellularAccess = true
        self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
    }
    //构造 保存请求
    var urlRequst = URLRequest.init(url: self.initalUrl!, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) // 20s超时
    urlRequst.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
    urlRequst.httpMethod = "GET"
    //设置请求头
    guard let wrappedDataRequest = loadingRequest.dataRequest else{
        //本次请求没有数据请求
        return true
    }
    let range:NSRange = NSMakeRange(Int.init(truncatingBitPattern: wrappedDataRequest.requestedOffset), wrappedDataRequest.requestedLength)
    let rangeHeaderStr = "byes=\(range.location)-\(range.location+range.length)"
    urlRequst.setValue(rangeHeaderStr, forHTTPHeaderField: "Range")
    urlRequst.setValue(self.initalUrl?.host, forHTTPHeaderField: "Referer")
    guard let task = session?.dataTask(with: urlRequst) else{
        fatalError("cant create task for url")
    }
    task.resume()
    self.tasks[task] = loadingRequest
    return true
}

收到响应请求后,抓包查看响应的请求头,下图是2个响应的请求头:



其中的Content-LengthContent-Range是我们需要处理的内容。

  • Content-Length表示本次请求的数据长度
  • Content-Range表示本次请求的数据在总媒体文件中的位置,格式是start-end/total,因此就有Content-Length = end - start + 1

接下来是处理响应的部分代码。

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
    completionHandler(Foundation.URLSession.ResponseDisposition.allow)
    //第一次请求成功会带回来视频总大小 在响应头里Content-Range: bytes 0-1/20955211
    self.queue.async {
        if let urlRsp = response as? HTTPURLResponse{
            let contentRange:String = urlRsp.allHeaderFields["Content-Range"]
            let lengthStr = contentRange.substring(from: contentRange.index(after: contentRange.index(of: "/")!))
            let length = Int(lengthStr)
            self.totalLength = length
            //这里需要构造length大小的文件
            VideoCacheManager.share.createFile(name: self.fileCachePath, size: length)
            //填充响应
            let loadingReq = self.tasks[dataTask]
            loadingReq?.contentInformationRequest?.isByteRangeAccessSupported = true
            loadingReq?.contentInformationRequest?.contentType = rsp.allHeaderFields["Content-Type"]
            loadingReq?.contentInformationRequest?.contentLength = self.totalLength
        }
    }
}

收到响应数据后

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
    self.queue.async {
        let loadingRequest = self.tasks[dataTask]
        self.didDownLoadMediaDat(dataTask: dataTask, data: data)
    }
}
func didDownLoadMediaDat(dataTask:URLSessionDataTask,data:Data){
    //填充数据 写入文件
    let loadingReq = self.tasks[dataTask]
    loadingReq?.dataRequest?.respond(with: data)
    VideoCacheManager.share.saveFileData(name: self.fileCachePath, data: data, position: loadingReq?.dataRequest?.requestedOffset+1)
    //结束本次请求
    loadingReq?.finishLoading()
    //移除请求
    self.tasks.removeValue(forKey: dataTask)
}

当然,请求遇到错误和请求取消的回调里也要做相应的处理,只需要从数组里移除相应的请求,然后中断我们发送的UrlRequest即可。剩下的内容AVPlayer会帮我们处理,包括Seek也是这样的流程,当Seek的时候,原始请求的Range-Byte会变,并且会取消旧的原始请求。

以上就是实现分块下载和缓存的基本思路。github上搜索也会发现很多优秀成熟的完整代码,自己实现一整套逻辑遇到的坑会比较多,理解了整套机制后,在第三方的基础上修改是个不错的选择。