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播放在线视频,抓包查看请求细节,如下图:

request-header

在请求头里有一个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-before-moov

mdat位于0x000018的位置。
mdat-before-moov-2

moov位于0xA08540文件的尾部,也就是说,针对不指定Byte-Range的请求,只有请求到文件尾的时候才能开始播放视频
查看一个能播放的视频,位置如下图:
moov-before-mdat1

moov-before-mdat2

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个响应的请求头:

response

其中的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上搜索也会发现很多优秀成熟的完整代码,自己实现一整套逻辑遇到的坑会比较多,理解了整套机制后,在第三方的基础上修改是个不错的选择。

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

推荐阅读更多精彩内容