Skip to content
一套基于 Swift 的 iOS AVPlayer 视频缓存方案
Swift
Branch: master
Clone or download

Latest commit

Fetching latest commit…
Cannot retrieve the latest commit at this time.

Files

Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
BCQMediaCache.xcodeproj
BCQMediaCache
BCQMediaCacheTests
BCQMediaCacheUITests
README.md

README.md

概述

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

一套基于 AVPlayer 的视频缓存 BCQMediaCache。 【源码传送门】

基本原理

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

技术细节

AVAssetResourceLoaderDelegate

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

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

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

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

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

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

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

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

自定义网络请求的创建

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

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 中的分片信息,创建并发起自定义网络请求。当远端的服务器响应该请求后,客户端会经历一下三个步骤,并调用相应的代理方法。

  • 处理响应
  • 处理数据(多次)
  • 请求结束
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 功能。因此,网络请求的分片与本地的分片数据可能存在如下关系:

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

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

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 验证。

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证书
You can’t perform that action at this time.