对于音视频资源来说,缓存行为分为两种:先下后播和边下边播。前者对于资源本身没有特殊要求,能够通过一个下载任务将数据转换为可播放的媒体文件即可。后者则需要其支持指定范围获取数据,这反应在 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 }

/// Cancel an in-process operation.
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 {
/// 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
16
extension 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
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 }

// 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
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。当 ZPCLoadingDataRequestablerequestsAllDataToEndOfResourcetrue 时,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)
}

到目前为止,准备工作已经完成。现在,我们从头开始并结合接口声明梳理整个功能流程:

  1. ZPC.Streaming 遵循了 ZPCacheable,并作为返回的 AVURLAsset 实例 resourceLoader 的代理。
  2. 当 AVPlayer 开始播放时,就会不断向 ZPC.Streaming 请求指定范围(Rt)的数据。
  3. ZPCStreamingSourceable 用于管理所有可供使用侧自定义的功能对象,ZPCStreamingDataProvidable 负责对接代理方法回调:添加或者移除 ZPCLoadingRequestable,每一个 URL 对应一个实例,由ZPC.Streaming 持有由 URL 查找 ZPCStreamingDataProvidable 的键值对。
  4. ZonPlayer 内部实现为 DataPrivdier,它本身由 DataFetcher 完成数据获取。
  5. DataFetcher 会根据范围分析那些片段数据由 ZPCDataStorable 提供,那些片段由 ZPCDataRequestable 请求响应,后者由内部的 DataRequester 来完成。当请求结束后,通过 _SaveToDiskPlugin 来执行数据持久化的操作,请求与响应的全过程日志会被 _LogPlugin 输出到控制台。

在实际的播放过程中,用户可能存在各种各样拖动进度的操作,所以数据持久化的范围合并是最核心的代码逻辑。借鉴(CV)VIMediaCache,ZonPlayer 轻松敲出了相关代码,😅。有个特别需要注意的是,当 AVAssetResourceLoaderDelegate 取消数据请求时,不能同时取消实际的下载请求,而应只响应取消错误给数据请求,否则在自然播放过程中,该分片范围的数据请求不会再次被触发。🌰:在 ZonPlayer Demo 中 URL 用例中,范围为 [2, 58425215) 的数据请求在响应过程中触发了取消(此时将实际下载请求也取消),然后就再也没有接受到此范围的数据请求,最终导致缓存文件数据缺失,无法被正常播放。

总结

对于很短的音频来说(如播放一个单词发音),先下后播是最优的策略。边下边播通常适用于短视频场景,在使用过程中需要额外注意缓存大小的问题。因为 ZonPlayer 默认通过 FileHandle 直接操作文件,对缓存的资源没有加上过期自动清除等控制。这或许会成为后续迭代的一个方向,如果你有需求可以自行实现相关协议接管数据的缓存行为。下一篇文章将是这一系列文章的终篇,会涉及到组件发布和捣鼓 Github Action 等方面的内容,遇到并解决问题可是一个“有趣”的过程,👋~