⌈iOS⌋从头撸一个播放器 III —— 缓存
对于音视频资源来说,缓存行为分为两种:先下后播和边下边播。前者对于资源本身没有特殊要求,能够通过一个下载任务将数据转换为可播放的媒体文件即可。后者则需要其支持指定范围获取数据,这反应在 URL 响应表现为响应头中键为 accept-ranges 的值是 bytes。为了能描述这两种缓存行为,我们在 Part 1 中定义了 ZPCacheable
:它要求遵循者需要实现将 URL 异步转换为 AVURLAsset 的方法。如果过程中发生了错误,则通过 ZonPlayer.Error
这个类型去描述。这得益于 AVURLAsset 天然支持本地文件路径,并且对于流媒体来说如果有缓存需要,只需设置它的 AVAssetResourceLoaderDelegate 就可以接管数据的请求与响应。
先做饭,再吃饭
按照面向协议的套路,我们先给作为厨子的下载器定义协议:
1 | public protocol ZPFileDownloadable { |
因为存在当媒体资源还在下载过程中播放器就已经被销毁的情况,所以下载器是可以被取消的。接着,作为盛饭容器的仓库,按照对数据的操作方式描述如下:
1 | public typealias FileURL = URL |
可以看到,一个 URL 最终肯定会对应仓库的一个文件路径,因此我们还需要提供一种将 URL 转换为文件名的映射规则。这里需要注意的是,文件后缀一定是 AVPlayer 支持的格式,否则将无法进行播放:1
2
3
4
5
6
7public protocol ZPCFileNameConvertible {
/// Convert url to file name.
///
/// - Important: File name must contain valid extension to help player playback, eg: .mp3, .mp4.
/// ZPC.Config treated url path extension as file extension by default.
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
16extension ZPC {
/// Download during playback base on AVAssetResourceLoader.
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
21extension 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 }
// recover url and request
return true
}
public func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest
) {
// Cancel
}
}
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/**
AVAssetResourceLoadingRequest,
AVAssetResourceLoadingDataRequest,
AVAssetResourceLoadingContentInformationRequest
cannot create a instance, so define a protocol to make unit test available.
*/
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 {
// Avoid naming conflicts
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
12public 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
15public 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
22public 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 等方面的内容,遇到并解决问题可是一个“有趣”的过程,👋~