Fork me on GitHub

iOS AVPlayer 视频缓存的设计与实现

概述

最近一直在研究 iOS 平台的视频缓存设计方案。目标是实现视频边播边下载。后续再次播放时,则读取本地缓存数据,从而节省用户流量,提升用户体验。

基本原理

下图所示为 iOS AVPlayer 视频缓存的原理示意图。左边为原始的 AVPlayer 在播放时视频时资源的请求过程。右边为实现视频缓存的方案,其中的关键是为 AVAssetResourceLoader 设置代理,并实现 AVAssetResourceLoaderDelegate 协议所声明的两个方法。通过在这两个方法中捕获所有的 AVAssetResourceLoadingRequest 请求,并为所有的原始请求创建对应的自定义网络请求。使用自定义网络请求向远端多媒体服务器请求资源,当数据返回时,将数据返回给原始请求,并在本地进行数据缓存。

技术细节

AVAssetResourceLoaderDelegate

首先,我们需要为 AVAssetResourceLoader 设置代理。

1
2
let urlAsset = AVURLAsset(url: xxx, options: nil)
urlAsset.resourceLoader.setDelegate(self, queue: DispatchQueue.main)

注意:实现 AVAssetResourceLoaderDelegate 协议时,URL 必须是自定义的 URLScheme。我们需要把原始 URL 的 http://https:// 替换成 xxx://,协议方法才会生效。

然后,我们需要实现 AVAssetResourceLoaderDelegate 所声明的相关方法。对于视频缓存功能,我们仅需要实现以下两个方法即可。

1
2
3
4
5
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, 
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool;

func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest)

resourceLoader(_:shouldWaitForLoadingOfRequestedResource:) 方法表示代理类是否可以处理该请求。我们通过在这个方法中捕获每个原始请求,并创建对应的自定义网络请求。

resourceLoader(_:didCancel:) 方法表示 AVAssetResourceLoader 主动放弃了某个原始请求。对此,我们需要将原始请求删除,并取消对应的自定义网络请求。

自定义网络请求的创建

在上述 resourceLoader(_:shouldWaitForLoadingOfRequestedResource:) 代理方法中,我们能够捕获到一个原始请求,即一个 AVAssetResourceLoadingRequest 对象。如下所示,为 AVAssetResourceLoadingRequest 中的一些重要的属性和方法。

1
2
3
4
5
6
7
8
open class AVAssetResourceLoadingRequest : NSObject {
open var request: URLRequest { get }
open var contentInformationRequest: AVAssetResourceLoadingContentInformationRequest? { get }
open var dataRequest: AVAssetResourceLoadingDataRequest? { get }

open func finishLoading()
open func finishLoading(with error: Error?)
}

其中,request 代表原始请求,由于 AVPlayer 会触发分片下载的策略,request 请求会从 dataRequest 中获取请求的分片范围。因此,根据请求地址和请求分片,我们就可以创建自定义的网络请求。请求分片需要在 HTTP Header 中进行设置。

自定义网络请求的响应

下图所示为视频播放时的一次网络请求的时序图。

我们根据 dataRequest 中的分片信息,创建并发起自定义网络请求。当远端的服务器响应该请求后,客户端会经历一下三个步骤,并调用相应的代理方法。

  • 处理响应
  • 处理数据(多次)
  • 请求结束
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    func urlSession(_ session: URLSession, 
    dataTask: URLSessionDataTask,
    didReceive response: URLResponse,
    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)

    func urlSession(_ session: URLSession,
    dataTask: URLSessionDataTask,
    didReceive data: Data)

    func urlSession(_ session: URLSession,
    task: URLSessionTask,
    didCompleteWithError error: Error?)

处理响应

请求响应时,我们从响应头部中获取资源相关信息,如:

  • ContentType 表示文件类型
  • Content-Range 包含文件长度信息
  • Accept-Ranges 包含是否支持分片请求

我们需要把视频的信息填充到 AVAssetResourceLoadingRequestcontentInformationRequest 中,从而通知 AVAssetResourceLoader 要下载视频的视频格式、视频长度等。

处理数据

当请求的分片范围较大时,客户端分多次顺序调用数据处理代理方法。我们可以在此时对接收到的数据进行缓存。当然,还要将数据返回给 dataRequest,可以通过调用 respond(with:) 方法将数据返回给 dataRequest

请求结束

当数据传输完毕后,我们需要手动调用 finishLoading() 方法通知 AVAssetResourceLoader 数据下载完毕。如果请求失败,我们也需要手动调用 finishLoading(with:) 方法告诉 AVAssetResourceLoader 数据下载失败。

重试机制

当一个网络请求未完成时,我们拖动视频的进度条,AVAssetResourceLoader 会自动取消前一次的网络请求,从而发起一个新的网络请求。

在上述 resourceLoader(_:didCancel:) 代理方法中,我们可以取消某一次下载请求。

分片下载

一般情况下,视频播放支持进度拖拽的功能,即 seek 功能。因此,网络请求的分片与本地的分片数据可能存在如下关系:

  • 本地缺失分片数据
  • 本地包含完整分片数据
  • 本地包含部分分片数据

通过定义一个类来表示这两种分片信息。我们对请求的分片进行检查和拆分,并按顺序进行处理。如果本地已缓存,则直接返回本地分片数据;如果本地未缓存,则创建自定义网络请求,请求分片数据。

1
2
3
4
5
6
7
8
9
enum BCQResourceFragmentType {
case local // 已缓存本地
case remote // 未缓存本地
}

final class BCQResourceFragment {
let type: BCQResourceFragmentType // 数据分片类型
let range: SVRange // 数据分片范围
}

设计实现

如图所示为 BCQMediaCache 的类图。

BCQMediaCache 使用四个类将其核心功能分为四层:

  • BCQResourceLoaderManager
  • BCQResourceLoader
  • BCQResourceFragmentDownloader
  • BCQResourceFragmentRequest

BCQResourceLoaderManager 作为 AVAssetResourceLoader 的代理,实现了 AVAssetResourceLoaderDelegate 协议的两个方法。通过这两个方法实现对原始请求 AVAssetResourceLoadingRequest 的管理,包括:保存、取消。BCQResourceLoaderManager 还可以管理多个 URL,针对不同的 URL,它将创建对应的 BCQResourceLoader。具体的资源下载任务则由 BCQResourceLoader 及以下分层来完成。

BCQResourceLoader 管理单个 URL 的资源下载。对于单个 URL,同一时刻可能存在多个网络请求,为此,BCQResourceLoader 维护一个网络请求的列表。

BCQResourceFragmentDownloader 内部包含两个属性:originRequestcustomRequest,分别表示原始网络请求和自定义网络请求。BCQResourceFragmentDownloader 将两者进行了绑定,负责处理两者之间的交互,如:

  • 根据本地保存的分片信息,对 originRequest 的请求分片进行详细拆分,得到 BCQResourceFragment 数组
  • 使用 BCQResourceFragment 数组创建并启动 customRequest
  • 根据自定义请求的响应信息配置 originRequestcontentInformationRequest
  • 将自定义请求的返回数据返回给 originRequestdataRequest
  • 通过自定义请求的结束调用通知 originRequestdataRequest

BCQResourceFragmentRequest 是数据请求的真正执行者。它根据分片的 BCQResourceFragment 数组,按顺序进行更细粒度的数据请求(远端请求或本地读取)。当从远端获取到数据时,首先向上层转发,其次异步写入本地。每个 BCQResourceFragmentRequest 单独占用一个线程,可并发执行。

BCQResourceInfo 会在初始化时从本地读取元数据 BCQResourceMeta,元数据记录了本地已缓存数据的分片信息。

BCQResourceUtils 则包含一些工具方法,如:创建缓存目录、日志打印方法等。

注意问题

自定义Scheme

实现 AVAssetResourceLoaderDelegate 协议时,URL 必须是自定义的 URLScheme。我们需要把原始 URL 的 http://https:// 替换成 xxx://,协议方法才会生效。

服务器信任证书

在请求资源时,我们可能会遇到 Challenge 验证。此时,我们需要在如下代理方法中进行 Challenge 验证。

1
2
3
func urlSession(_ session: URLSession, 
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)

Swift HTTPURLResponse Content-Range 天坑

使用 Swift 实现视频缓存方法,调试过程中遇到了一个 Swift URLHTTPResponse 天坑:关于 HTTP Header 中的 Content-Range 字段。

正常情况下或者连接 Charles 并且 Disable SSL Proxying 情况下,Content-Range 为小写,即 content-type;连接 Charles 并且 Enable SSL Proxying 情况下,Content-Range 为大写,即 Content-Range

总结

在方案设计阶段,调研了多个开源库,包括:ShortMediaCache、VIMediaCache。详细阅读了 VIMediaCache 源码,绘制其设计类图,分析其设计优点和缺点。汲取 VIMediaCache 的设计优点,最后重新设计了一套方案。

在开发调试过程中,遇到了一些坑,花了不少时间解决。具体的实现涉及到不少细节,开发过程中也花费了不少时间。

总体而言,得到了很好的锻炼,值得~

最后,附上源码

参考

  1. VIMediaCache
  2. ShortMediaCache
  3. 可能是目前最好的 AVPlayer 音视频缓存方案
  4. iOS音频播放 (九):边播边缓存
  5. iOS短视频播放缓存之道
  6. AVPlayer初体验之边下边播与视频缓存
  7. 通过Authentication Challenge来信任自签名Https证书
欣赏此文?求鼓励,求支持!