音视频三、AVPlayer离线缓存实现

前言

使用AVPlayer播放网络音视频时,系统虽然做了临时缓存处理,但是临时缓存不可访问,并且在AVPlayer会话结束的时候临时缓存会被清除(?),等下次再播又要重新联网下载,这比较影响用户体验,那么该如何实现离线缓存以及边缓存边播放的功能呢?

猜想中的方案

  1. 在模型层存储已经加载好的AVAsset对象,这样在整个app会话中都可以工作(AVPlayer 不会加载同样的文件多次))。显然重启之后失效,不可行。

  2. 使用AVAssetExportSession将AVAsset转换成NSData, 但AVAssetExportSession只支持本地文件,网络文件会报错。

  3. 使用AVAssetResourceLoader控制加载过程,该方法可行。下面着重介绍该方法。

AVAssetResourceLoader

img

顾名思义,AVAssetResourceLoader负责加载AVURLAsset指定的资源文件,并决定传递多少数据给AVPlayer。当指定的资源不能被加载时,AVAssetResourceLoader会向AVAssetResourceLoaderDelegate发送消息请求AVAssetResourceLoaderDelegate去加载数据,然后根据代理的处理结果进行下一步的处理。 所以为了让代理工作,通常要使用AVAssetResourceLoader无法加载的自定义协议。

NSURLComponents *components = [[NSURLComponents alloc] initWithURL:URL resolvingAgainstBaseURL:NO];
components.scheme = @“customscheme”; 
AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:[components URL] options:options];

AVAssetResourceLoaderDelegate

AVAssetResourceLoaderDelegate可以帮助AVAssetResourceLoader加载AVAssetResourceLoader不能加载的指定资源。设置delegate的方法如下:

var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.scheme = assetCustomScheme
let asset = AVURLAsset(url: components?.url ?? url)
let item = AVPlayerItem(asset: asset)
if let loader = item.cacheLoader {
    asset.resourceLoader.setDelegate(loader, queue: .main)
} else {
    let loader = XYAssetLoader()
    objc_setAssociatedObject(item, AVPlayerItem.assetLoaderKey, loader, .OBJC_ASSOCIATION_RETAIN)
    asset.resourceLoader.setDelegate(loader, queue: .main)
}

AVAssetResourceLoaderDelegate具体方法如下:

resourceLoader:shouldWaitForLoadingOfRequestedResource:方法

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;

AVAssetResourceLoader寻问代理是否它能加载请求的资源。返回 YES,表示代理可以加载loadingRequest的参数指定的资源;NO,表示不可以, 这时AVAssetResourceLoader会认为资源加载失败,因为自己和代理都无法处理。这个方法其实就是对AVAssetResourceLoader的请求过程进行拦截,可以自行下载数据,然后回填给AVPlayer。

更多参考戳这里

数据回填

[aVAssetResourceLoadingDataRequest respondWithData: data]

完成加载

[aVAssetResourceLoadingRequest finishLoading]

加载失败

[aVAssetResourceLoadingRequest finishLoadingWithError: error]

resourceLoader:didCancelLoadingRequest:方法

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest; 

通知代理:先前的加载请求/loadingRequest已经被取消了,这里我们需要取消loadingRequest所指定的数据的读取或下载操作。

更多代理方法参考这里

AVAssetResourceLoadingRequest

AVAssetResourceLoadingRequest代表数据加载的请求,内部封装了实际的URLRequest、URLResponse等信息。查看AVAssetResourceLoadingRequest的头文件可知,有2种数据加载请求:AVAssetResourceLoadingContentInformationRequest 和 AVAssetResourceLoadingDataRequest。

AVAssetResourceLoadingContentInformationRequest

当 AVPlayer 触发下载时,总是会先发起一个 Range 为 0-2 的数据请求,这个请求的作用其实是用来确认视频数据的信息的,如文件类型、文件数据长度。AVAssetResourceLoadingContentInformationRequest就是用来保存这些信息的。

当下载器发起这个请求,收到服务端返回的 response 后,我们要把视频的信息填充到 AVAssetResourceLoadingRequest 的 contentInformationRequest 属性中,告知下载的视频格式以及视频长度。AVAssetResourceLoadingRequest 在 - (void)finishLoading 的时候,会根据 contentInformationRequest 中的信息,去判断接下去要怎么处理。例如:下载 AVURLAsset 中 URL 指向的文件,获取到的文件的 contentType 是系统不支持的类型,将无法正常播放。

AVAssetResourceLoadingDataRequest

当 AVPlayer 获取完视频信息后,就要进行视频文件的分片下载,AVAssetResourceLoadingDataRequest 就是用来指定分片的偏移、大小,以及接受分片数据的。

- (void)respondWithData:(NSData *)data;

AVAssetResourceLoadingDataRequest 使用该方法来接收下载的数据,这个方法可以调用多次,接收增量连续的 data 数据。

当 AVAssetResourceLoadingRequest 要求的所有数据都下载完毕,调用 - (void)finishLoading 完成下载,AVAssetResourceLoader 会继续发起之后的数据片段的请求。如果本次请求失败,可以直接调用 - (void)finishLoadingWithError: 结束下载。

离线缓存的具体实现

上面已经知道了AVAssetResourceLoader的整个工作过程,因此,我们可以在代理方法中用URLSessionDataTask进行数据下载,然后回填给AVPlayer进行播放, 最后按请求范围保存到本地,下次播放时,在请求范围内的已下载的直接用本地缓存数据回填,范围内的未下载的开启下载,这样基本实现了离线缓存的功能。下面列一下实现要点:

1.当监测到发送contentInformationRequest时只需要发送HEAD请求无需下载数据(类似于get请求,只不过返回的响应中没有具体的内容,用于获取HTTP响应头)。记得finishLoading()否则无法进行音视频文件的分片请求。

var urlComponents = URLComponents(string: (loadingRequest.request.url?.absoluteString)!)
urlComponents?.scheme = "http"

if let contentInfoRequest = loadingRequest.contentInformationRequest {
    if contentInfoRequest.contentLength == 0 {
        var headRequest = URLRequest(url: (urlComponents?.url****)!)
        headRequest.httpMethod = "HEAD"
        let dataTask = session.dataTask(with: headRequest) { (data, response, error) in
            if let response = response {
                self.fillContentInfoRequest(request: contentInfoRequest, withResponse: response)
                loadingRequest.finishLoading()
            }
        }
        dataTask.resume()
    }
}

private func fillContentInfoRequest(request: AVAssetResourceLoadingContentInformationRequest, withResponse response: URLResponse) {
    request.contentType = response.mimeType
    if let httpResponse = response as? HTTPURLResponse {
        if let acceptRanges = httpResponse.allHeaderFields["Accept-Ranges"] as? String {
            request.isByteRangeAccessSupported = acceptRanges.elementsEqual("bytes")
        }
        if let contentLength = httpResponse.allHeaderFields["Content-Length"] as? String {
            request.contentLength = Int64(contentLength)!
        }
    }
}

2.dataRequest需要设置请求头以支持分片请求。记得finishLoading()。

let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = dataRequest.requestedLength
var allHTTPHeaderFields = ["Range" : "bytes=\(requestedOffset)-\(requestedOffset + requestedLength - 1)"]
if dataRequest.requestsAllDataToEndOfResource {
    allHTTPHeaderFields = ["Range" : "bytes=\(requestedOffset)-"]
}

var dataRequest = URLRequest(url: url)
dataRequest.httpMethod = "GET"
dataRequest.cachePolicy = .returnCacheDataElseLoad
for (headerField, headerValue) in allHTTPHeaderFields {
    dataRequest.setValue(headerValue, forHTTPHeaderField: headerField)
}

let dataTask = session.dataTask(with: dataRequest) { (data, response, error) in
    guard error == nil else { return }
    if let sliceData = data {
        loadingRequest.dataRequest?.respond(with: sliceData)
        loadingRequest.finishLoading()
        // 保存数据
        if sliceData.count > 0 {
            print("sliceData.count > 0")
            self.fileWriteHandle?.write(sliceData)
            self.fileWriteHandle?.synchronizeFile()
            self.fileWriteHandle?.seekToEndOfFile()
        }
    }
}
dataTask.resume()

使用FileHandle进行读写文件操作十分方便。

let foldPath = NSTemporaryDirectory().appending("/XYCache")
fileWriteHandle = FileHandle(forWritingAtPath: foldPath.appending("/tmp.mp4"))

3.数据缓存

一种方法是使用NSURLCache。NSURLCache能够缓存视频文件,详见以前cache的文章,DVAssetLoaderDelegate就是用NSURLCache做离线缓存的,但貌似断网以后还要请求不能播放。

另一种方案是使用配置文件记录哪些数据已经被缓存到本地了。AVPlayerCacheSupport就是用这用方式。
AVPlayerCacheSupport源码

AVPlayerCacheSupport在请求到播放内容相关信息之后会会将响应头、文件字节长度、已缓存下载的ranges信息保存在本地的索引文件中,当进行文件分片下载时,会不断更新合并ranges数组。当拖动进度条时,会拿请求的range跟本地的ranges数组进行比较,决定开启本地(MCAVPlayerItemLocalCacheTask)或者网络(MCAVPlayerItemRemoteCacheTask)任务获取数据,然后回填数据。

参考:

iOS音频播放 (九):边播边缓存 AVPlayerCacheSupport

Vito的猫屋

iOS音频篇:AVPlayer的缓存实现