上篇文章介绍了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
是AVPlayer
在向媒体服务器请求数据时的代理,为了实现边下边播,需要实现自定义请求,需要实现的两个方法如下:
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
}
再次注意,以上代码在收到原始请求后,并没有每次都发送请求,而是在第一次收到的时候只发送一次请求,利用NSUrlSessionDatatask
的continues task特性来下载完整个媒体,所以是视频文件的头部开始下载,并且缓存也是在视频文件都下载完成之后才一次性写入文件的。因此,先不谈分块下载,以上代码会非常容易理解。接下来谈谈视频的格式问题。
以上代码本质上只发送了一个NSUrlRequest
,这个HTTP请求的头部没有带有Byte-Range
信息,因此媒体服务器并不知道你需要请求的长度,就会把它当做一个文件流从头部请求到尾部,因此我们指定Foundation.URLSession.ResponseDisposition.allow
告诉这个URLSession
把它当做一个continues task来下载,于是从文件头部开始下载,但是真正的视频流并不是这么下载的。
尝试用Safari播放在线视频,抓包查看请求细节,如下图:
在请求头里有一个Range:byte
字段来告诉媒体服务器需要请求的是哪一段特定长度的文件内容,对于MP4文件来说,所有数据都封装在一个个的box或者atom中,其中有两个atom尤为重要,分别是moov atom
和mdat atom
。
虽然moov
和mdat
都只有一个,但是由于MP4文件是由若干个这样的box或者atom组成的,因此这两个atom
在不同媒体文件中出现的顺序可能会不一样,为了加快流媒体的播放,我们可以做的优化之一就是手动把moov
提到mdat
之前。
对于AVPlayer
来说,只有到AVPlayerItemStatusReadyToPlay
状态时,才可以开始播放视频,而进入AVPlayerItemStatusReadyToPlay
状态的必要条件就是播放器读到了媒体的moov
块。
那么以上代码不能边下边播的视频,是否都是mdat
位于moov
之后呢,答案显然是肯定的,用二进制打开一个不能边下边播的视频,查找mdat
和moov
的位置如下:
mdat
位于0x000018
的位置。
moov
位于0xA08540
文件的尾部,也就是说,针对不指定Byte-Range
的请求,只有请求到文件尾的时候才能开始播放视频
查看一个能播放的视频,位置如下图:
moov
和mdat
都位于文件头部,且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-Length
和Content-Range
是我们需要处理的内容。
接下来是处理响应的部分代码。
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上搜索也会发现很多优秀成熟的完整代码,自己实现一整套逻辑遇到的坑会比较多,理解了整套机制后,在第三方的基础上修改是个不错的选择。