对于音视频资源来说,缓存行为分为两种:先下后播和边下边播。前者对于资源本身没有特殊要求,能够通过一个下载任务将数据转换为可播放的媒体文件即可。后者则需要其支持指定范围获取数据,这反应在 URL 响应表现为响应头中键为 accept-ranges 的值是 bytes。为了能描述这两种缓存行为,我们在 Part 1 中定义了 ZPCacheable
:它要求遵循者需要实现将 URL 异步转换为 AVURLAsset 的方法。如果过程中发生了错误,则通过 ZonPlayer.Error
这个类型去描述。这得益于 AVURLAsset 天然支持本地文件路径,并且对于流媒体来说如果有缓存需要,只需设置它的 AVAssetResourceLoaderDelegate 就可以接管数据的请求与响应。
先做饭,再吃饭 按照面向协议的套路,我们先给作为厨子的下载器定义协议:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public protocol ZPFileDownloadable { var timeout: TimeInterval { get set } @discardableResult func download ( with url : URL , destination : URL , completion : @escaping (Result <Void , ZonPlayer .Error >) -> Void ) -> ZPCCancellable } public protocol ZPCCancellable { var isCancelled: Bool { get } func cancel () }
因为存在当媒体资源还在下载过程中播放器就已经被销毁的情况,所以下载器是可以被取消的。接着,作为盛饭容器的仓库,按照对数据的操作方式描述如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public typealias FileURL = URL public typealias RemoteURL = URL public struct File { public let location: FileURL public init (location : FileURL ) { self .location = location } } public protocol ZPFileStorable { func create (file : File , with url : RemoteURL , completion : @escaping (Result <File , ZonPlayer .Error >) -> Void ) func read (with url : RemoteURL ) -> Result <File ?, ZonPlayer .Error > func fileURL (url : RemoteURL ) -> Result <FileURL , ZonPlayer .Error > func delete (with url : RemoteURL , completion : (() -> Void )? ) func deleteAll (completion : (() -> Void )? ) } extension ZPFileStorable { public func delete (with url : RemoteURL ) { delete(with: url, completion: nil ) } public func deleteAll () { deleteAll(completion: nil ) } }
可以看到,一个 URL 最终肯定会对应仓库的一个文件路径,因此我们还需要提供一种将 URL 转换为文件名的映射规则。这里需要注意的是,文件后缀一定是 AVPlayer 支持的格式,否则将无法进行播放:
1 2 3 4 5 6 7 public protocol ZPCFileNameConvertible { func map (url : URL ) -> String }
一般而言,将 URL 完整路径的 MD5 结果作为文件名即可满足绝大多数情况。但从业务角度来看,总有作妖的时候,将它单独拎出来是很有必要的。ZonPlayer 中提供了默认的厨子和餐具,不过前者或许大多数情况下不满足使用条件,比如需要修改请求头或者 CDN 切换等网络服务。不过得益于面向协议编程的好处,
ZPC.DownloadThenPlay
只需要一个遵循
ZPFileDownloadable
的厨子,相信能够很容易地通过你的网络库来实现它。
懒得做饭,下馆子哦,边上边吃 不同于将 URL 修改为本地文件 URL 路径的先下后播,AVPlayer 的流式缓存的数据响应单位是 Data。且仅当 AVPlayer 无法解析 AVURLAsset 的 URL Scheme 时,才会将它的数据响应任务委托给 AVAssetResourceLoaderDelegate。所以,第一步就是将 URL 魔改为无法解析的,直接修改或者增加 Scheme 均可以达到这一目的。ZonPlayer 选择了后者,省去记录原始 Scheme 这一个步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 extension ZPC { public final class Streaming : NSObject { private let _addedSchemePrefix = "ZonPlayer:" } } extension ZPC .Streaming : ZPCacheable { public func prepare (url : URL , completion : @escaping (Result <AVURLAsset , ZonPlayer .Error >) -> Void ) { if url.isFileURL { completion(.success(AVURLAsset (url: url))); return } let modifiedURL = URL (string: _addedSchemePrefix + url.absoluteString) ?? url let result = AVURLAsset (url: modifiedURL) result.resourceLoader.setDelegate(self , queue: _delegateQueue) completion(.success(result)) } }
在执行数据请求时,则需要将 URL 还原:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 extension ZPC .Streaming : AVAssetResourceLoaderDelegate { public func resourceLoader ( _ resourceLoader : AVAssetResourceLoader , shouldWaitForLoadingOfRequestedResource loadingRequest : AVAssetResourceLoadingRequest ) -> Bool { guard let url = loadingRequest.request.url, url.absoluteString.hasPrefix(_addedSchemePrefix) == true else { return false } return true } public func resourceLoader ( _ resourceLoader : AVAssetResourceLoader , didCancel loadingRequest : AVAssetResourceLoadingRequest ) { } }
AVAssetResourceLoadingRequest 说明了当前 AVPlayer 需要的所有数据,它包含了媒体资源的元数据请求 AVAssetResourceLoadingContentInformationRequest 和播放数据请求 AVAssetResourceLoadingDataRequest。需要注意的是,它们是不能自主初始化的类型,如果我们直接把它作为协议接口依赖的数据类型,对于单元测试来说就是灾难级的设计。为此,我们需要给用到的相关 API 分别定义协议类型以满足依赖注入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public protocol ZPCLoadingRequestable : NSObject { var isFinished: Bool { get } var theDataRequest: ZPCLoadingDataRequestable ? { get } var theMetaDataRequest: ZPCLoadingMetaDataRequestable ? { get } func finishLoading (with error : Error ?) func finishLoading () } extension AVAssetResourceLoadingRequest : ZPCLoadingRequestable { public var theDataRequest: ZPCLoadingDataRequestable ? { dataRequest } public var theMetaDataRequest: ZPCLoadingMetaDataRequestable ? { contentInformationRequest } } public protocol ZPCLoadingDataRequestable : NSObject { var requestedOffset: Int64 { get } var requestedLength: Int { get } var currentOffset: Int64 { get } var requestsAllDataToEndOfResource: Bool { get } func respond (with data : Data ) } extension AVAssetResourceLoadingDataRequest : ZPCLoadingDataRequestable {}public protocol ZPCLoadingMetaDataRequestable : NSObject { var contentType: String ? { get set } var contentLength: Int64 { get set } var isByteRangeAccessSupported: Bool { get set } } extension AVAssetResourceLoadingContentInformationRequest : ZPCLoadingMetaDataRequestable {}
有了上述的铺垫,根据范围请求的厨子就可以按照如下方式声明:
1 2 3 4 5 6 7 8 9 10 11 12 public protocol ZPCDataTaskable { var range: NSRange { get } var loadingRequest: ZPCLoadingRequestable { get } func requestData (completion : @escaping (Result <Void , ZonPlayer .Error >) -> Void ) } public protocol ZPCDataRequestable { var url: URL { get } func dataTask (forRange range : NSRange , withLoadingRequest loadingRequest : ZPCLoadingRequestable ) -> ZPCDataTaskable }
在这种场景下,数据的来源是服务器请求响应和本地仓库。为了模糊类型概念,这里额外定义了
ZPCDataTaskable
协议。它本身持有一个 loadingRequest,其内部就可以自行处理数据的回调,因此它的成功回调不再接收参数,只是作为继续获取下一个范围数据的时机。与此同时,对于仓库的声明也就呼之欲出了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public protocol ZPCDataStorable { var url: URL { get } var onError: ZPDelegate <ZonPlayer .Error , Void > { get } func getCacheFragments (completion : @escaping ([NSRange ]) -> Void ) func setMetaData (_ metaData : ZPC .MetaData ) func getMetaData (completion : @escaping (ZPC .MetaData ?) -> Void ) func writeData (_ data : Data , to range : NSRange ) func readData (from range : NSRange , completion : @escaping (Data ?) -> Void ) func clean (completion : (() -> Void )? ) } extension ZPCDataStorable { public func clean () { clean(completion: nil ) } }
其中的
ZPC.MetaData
是记录媒体资源的元数据信息,用来响应
ZPCLoadingMetaDataRequestable
。当
ZPCLoadingDataRequestable
的
requestsAllDataToEndOfResource
为
true
时,
contentLength
就会派上用场:根据当前的数据偏移位置,生成一个请求剩余全部数据的范围。
为了给使用侧增加一些自定义操作,我们可以让 ZPCDataRequestable
支持插件,ZonPlayer 内部的日志和请求数据持久化便是通过插件来实现的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public protocol ZPCDataRequestable { var plugins: [ZPCStreamingPluggable ] { get } } public protocol ZPCStreamingPluggable { func prepare (_ request : URLRequest , forRange range : NSRange ) -> URLRequest func willSend (_ request : URLRequest , forRange range : NSRange ) func didReceive (_ data : Data , forURL url : URL , withRange range : NSRange , fromRemote remoteFlag : Bool ) func didReceive (_ metaData : ZPC .MetaData , forURL url : URL , fromRemote remoteFlag : Bool ) func didComplete ( _ result : Result <Void , ZonPlayer .Error >, forURL url : URL , withRange range : NSRange , fromRemote remoteFlag : Bool ) func anErrorOccurred (in storage : ZPCDataStorable , _ error : ZonPlayer .Error ) }
到目前为止,准备工作已经完成。现在,我们从头开始并结合接口声明梳理整个功能流程:
ZPC.Streaming
遵循了 ZPCacheable
,并作为返回的 AVURLAsset 实例 resourceLoader 的代理。
当 AVPlayer 开始播放时,就会不断向 ZPC.Streaming
请求指定范围(Rt)的数据。
ZPCStreamingSourceable
用于管理所有可供使用侧自定义的功能对象,ZPCStreamingDataProvidable
负责对接代理方法回调:添加或者移除 ZPCLoadingRequestable
,每一个 URL 对应一个实例,由ZPC.Streaming
持有由 URL 查找 ZPCStreamingDataProvidable
的键值对。
ZonPlayer 内部实现为 DataPrivdier
,它本身由 DataFetcher
完成数据获取。
DataFetcher
会根据范围分析那些片段数据由 ZPCDataStorable
提供,那些片段由 ZPCDataRequestable
请求响应,后者由内部的 DataRequester
来完成。当请求结束后,通过 _SaveToDiskPlugin
来执行数据持久化的操作,请求与响应的全过程日志会被 _LogPlugin
输出到控制台。
在实际的播放过程中,用户可能存在各种各样拖动进度的操作,所以数据持久化的范围合并是最核心的代码逻辑。借鉴(CV)VIMediaCache ,ZonPlayer 轻松敲出了相关代码,😅。有个特别需要注意的是,当 AVAssetResourceLoaderDelegate
取消数据请求时,不能同时取消实际的下载请求,而应只响应取消错误给数据请求,否则在自然播放过程中,该分片范围的数据请求不会再次被触发。🌰:在 ZonPlayer Demo 中 URL 用例中,范围为 [2, 58425215) 的数据请求在响应过程中触发了取消(此时将实际下载请求也取消),然后就再也没有接受到此范围的数据请求,最终导致缓存文件数据缺失,无法被正常播放。
总结 对于很短的音频来说(如播放一个单词发音),先下后播是最优的策略。边下边播通常适用于短视频场景,在使用过程中需要额外注意缓存大小的问题。因为 ZonPlayer 默认通过 FileHandle 直接操作文件,对缓存的资源没有加上过期自动清除等控制。这或许会成为后续迭代的一个方向,如果你有需求可以自行实现相关协议接管数据的缓存行为。下一篇文章将是这一系列文章的终篇,会涉及到组件发布和捣鼓 Github Action 等方面的内容,遇到并解决问题可是一个“有趣”的过程,👋~