AVPlayer 实践本地 Cache 功能大全
AVPlayer/AVQueuePlayer with AVURLAsset 实作 AVAssetResourceLoaderDelegate

Photo by Tyler Lastovich
[2023/03/12] Update
我将之前的实作开源了,有需求的朋友可直接使用。
-
客制化 Cache 策略,可以用 PINCache or 其他…
-
外部只需呼叫 make AVAsset 工厂,带入 URL,则 AVAsset 就能支援 Caching
-
使用 Combine 实现 Data Flow 策略
-
写了一些测试
前言
既上一篇「 iOS HLS Cache 实践方法探究之旅 」后已过了大半年,团队还是一直想要实现边播边 Cache 功能因为对成本的影响极大;我们是音乐串流平台,如果每次播放同样的歌曲都要重新拿整个档案,对我们或对非吃到饱的使用者来说都很伤流量,虽然音乐档案顶多几 MB,但积沙成塔都是钱!
另外因为 Android 那边已经有实作边播边 Cache 的功能了,之前有比较过花费,Android 端上线后明显节省了许多流量;相对更多使用者的 iOS 应该能有更好的节流体现。
根据 上一篇 的经验,如果我们要继续使用 HLS ( .m3u8/.ts) 来达成目的;事情将会变得非常复杂甚至无法达成;我们退而求其次退回去使用 mp3 档,这样就能直接使用 AVAssetResourceLoaderDelegate 进行实作。
目标
-
播放过的音乐会在本地产生 Cache 备份
-
播放音乐时先检查本地有无 Cache 读取,有则不再重伺服器要档案
-
可设 Cache 策略;上限总容量,超过时开始删除最旧的 Cache 档案
-
不干涉原本 AVPlayer 播放机制 (不然最快的方法就是自己先用 URLSession 把 mp3 载下来塞给 AVPlayer,但这样就失去原本能播到哪载到哪的功能,使用者需要等待更长时间&更消耗流量)
前导知识 (1)— HTTP/1.1 Range 范围请求、Connection Keep-Alive
HTTP/1.1 Range 范围请求
首先我们要先了解在播放影片、音乐时是怎么跟伺服器要求资料的;一般来说影片、音乐档案都很大,不可能等到全部拿完才开始播放常见的是播到哪拿到了,只要有正在播放区段的资料就能运作。
要达到这个功能的方法就是透过 HTTP/1.1 Range 只返回指定资料字节范围的资料,例如指定 0–100 就只返回 0–100 这 100 bytes 大小的资料;透过这个方法,可以依序分段取得资料,然后再汇整再一起成完整的档案;这个方法也能运用在档案下载续传功能上。
如何应用?
我们会先使用 HEAD 去看 Response Header 了解到伺服器是否支援 Range 范围请求、资源总长度、档案类型:
curl -i -X HEAD http://zhgchg.li/music.mp3
使用 HEAD 我们能从 Response Header 得到以下资讯:
-
Accept-Ranges: bytes 代表伺服器支援 Range 范围请求 如果没有 Response 这个值或是是 Accept-Ranges: none 都代表不支援
-
Content-Length: 资源总长度,我们要知道总长度才能去分段要资料。
-
Content-Type: 档案类型,AVPlayer 播放时需要知道的资讯。
但有时我们也会使用 GET Range: bytes=0–1 ,意思是我要求 0–1 范围的资料但实际我根本不 Care 0–1是什么内容,我只是要看 Response Header 的资讯; 原生 AVPlayer 就是使用 GET 去看,所以本篇也照旧使用 。
但比较建议使用 HEAD 去看,一方法比较正确,另一方面万一伺服器不支援 Range 功能;用 GET 去摸就会变强迫下载完整档案。
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–1"
使用 GET 我们能从 Response Header 得到以下资讯:
-
Accept-Ranges: bytes 代表伺服器支援 Range 范围请求 如果没有 Response 这个值或是是 Accept-Ranges: none 都代表不支援
-
Content-Range: bytes 0–1/资源总长度 ,「/」后的数字及资源总长度,我们要知道总长度才能去分段要资料。
-
Content-Type: 档案类型,AVPlayer 播放时需要知道的资讯。

知道伺服器支援 Range 范围请求后,就能分段发起范围请求:
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100"
伺服器会返回 206 Partial Content:
Content-Range: bytes 0-100/总长度
Content-Length: 100
...
(binary content)
这时我们就得到 Range 0–100 的 Data,可再继续发新请求拿 Range 100–200. .200–300…到结束。
如果拿的 Range 超过资源总长度会返回 416 Range Not Satisfiable。
另外,想拿完整档案资料除了可以请求 Range 0-总长度,也可以使用 0- 方式即可:
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–"
其他还可以同个请求要求多个 Range 资料及下条件式子,但我们用不到,详情可 参考这 。
Connection Keep-Alive
http 1.1 预设是开启状态, 此特性能实时取得已下载的资料 ,例如档案 5 mb,能 16 kb、16 kb、16 kb… 的取得,不用等到 5mb 都好才给你。
Connection: Keey-Alive
如果发现伺服器不支援 Range、 Keep-Alive ?
那也不用搞这么多了,直接自己用 URLSession 下载完 mp3 档案塞给播放器就好….但这不是我们要的结果,可以请后端帮忙修改伺服器设定。
前导知识 (2) — AVPlayer 原生是如何处理 AVURLAsset 资源?

当我们使用 AVURLAsset init with URL 资源并赋予给 AVPlayer/AVQueuePlayer 开始播放之后,同上所述,首先会用 GET Range 0–1 去取得是否支援 Range 范围请求、资源总长度、档案类型这三个资讯。
有了档案资讯后,会再发起第二次请求,请求从 0-总长度 的资料。
⚠️ AVPlayer 会请求从 0-总长度 的资料,并透过实时取得已下载的资料特性 ( 16 kb、16 kb、16 kb…) 取得到他觉得资料足够后,会发起 Cancel 取消这个网路请求 (所以实际也不会拿完,除非档案太小)。
继续播放后才会透过 Range 往后请求资料。
(这部分跟我之前想的不一样,我以为会是0–100、100–200. .这样请求)
AVPlayer 请求范例:
1. GET Range 0-1 => Response: 总长度 150000 / public.mp3 / true
2. GET 0-150000...
3. 16 kb receive
4. 16 kb receive...
5. cancel() // current offset is 700
6. 继续播放
7. GET 700-150000...
8. 16 kb receive
9. 16 kb receive...
10. cancel() // current offset is 1500
11. 继续播放
12. GET 1500-150000...
13. 16 kb receive
14. 16 kb receive...
16. If seek to...5000
17. cancel(12.) // current offset is 2000
18. GET 5000-150000...
19. 16 kb receive
20. 16 kb receive...
...
⚠️ iOS ≤12 的情况下,会先发几个较短的请求试著摸摸看(?然后才会发要求到总长度的请求; iOS ≥ 13 则会直接发要求到总长度的请求。
还有个题外的坑,就是在观察怎么拿资源的时候,我使用了 mitmproxy 工具嗅探,结果发现它显示有错,会等到 response 全部回来才会显示,而不是显示分段、使用持久连接接续下载;害我吓了一大跳!以为 iOS 很笨居然每次都要整个档案回来!下次要用工具时要有保持一点怀疑 Orz
Cancel 发起的时机
-
前面说到的第二次请求,请求从 0 开始 到总长度的资源,有足够 Data 后会发起 Cancel 取消请求。
-
Seek 时会先发起 Cancel 取消先前的请求。
⚠️ 在 AVQueuePlayer 中切换到下一个资源、AVPlayer 更换播放资源时并不会发起 Cancel 取消前一首的请求。
AVQueue Pre-buffering
其实也是同样呼叫 Resource Loader 处理,只是他要求的资料范围会比较小。
实现
有了以上前导知识后我们来看实现 AVPlayer 本地 Cache 功能的原理方式。
就是之前有提到的 AVAssetResourceLoaderDelegate ,这个接口让我们能 自行实践 Resource Loader 给 Asset 用。
Resource Loader 实际就是个打工仔,播放器是要档案资讯还是档案资料,范围哪里都哪里都是他告诉我们,我们去做就是。
看到有范例是一个 Resource Loader 服务所有 AVURLAsset ,我觉得是错的,应该要一个 Resource Loader 服务一个 AVURLAsset,跟著 AVURLAsset 的生命周期,他本来就属于 AVURLAsset。
一个 Resource Loader 服务所有 AVURLAsset 在 AVQueuePlayer 上会变得非常复杂且难以管理。
进入自订的 Resource Loader 的时机点
要注意的是不是实践了自己的 Resource Loader 他就会理你,只有当系统无法辨识处理这个资源的时候,才会走你的 Resource Loader。
所以我们在将 URL 资源给予 AVURLAsset 之前要先将 Scheme 换成我们自订的 Scheme,不能是 http/https… 这些系统能处理的 Scheme。
http://zhgchg.li/music.mp3 => cacheable://zhgchg.li/music.mp3
AVAssetResourceLoaderDelegate
只有两个方法需要实现:
- func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest : AVAssetResourceLoadingRequest) -> Bool :
此方法问我们能不能处理此资源,return true 能,return false 我们也不处理(unsupported url)。
我们能从 loadingRequest 取出要请求什么(第一次请求档案资讯还是请求资料,请求资料的话 Range 是多少到多少);知道请求后我们自行发起请求去拿资料, 在这我们就能决定要发起 URLSession 还是从本地返回 Data 。
另外也能在此做 Data 加解密操作,保护原始资料。
- func resourceLoader( _ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest : AVAssetResourceLoadingRequest) :
前述说到的 Cancel 发起时机 发起 Cancel 时…
我们可以在这去取消正在请求的 URLSession。

本地 Cache 实现方式
Cache 的部分我直接使用 PINCache ,将 Cache 工作交由他处理,免去我们要处理 Cache 读写 DeadLock、清除 Cache LRU 策略 实作上的问题。
️️⚠️️️️️️️️️️️OOM警告!
因为这边是针对音乐做 Cache 档案大小顶多 10 MB 上下,所以才能使用 PINCache 作为本地 Cache 工具;如果是要服务影片就无法使用此方法(可能一次要载入好几 GB 的资料到记忆体)
有这部分需求可参考大大的做法,用 FileHandle seek read/write 的特性进行处理。
开工!
不啰唆,先上完整专案:
AssetData
本地 Cache 资料物件映射实现 NSCoding,因 PINCache 是依赖 archivedData 方法 encode/decode。
import Foundation
import CryptoKit
class AssetDataContentInformation: NSObject, NSCoding {
@objc var contentLength: Int64 = 0
@objc var contentType: String = ""
@objc var isByteRangeAccessSupported: Bool = false
func encode(with coder: NSCoder) {
coder.encode(self.contentLength, forKey: #keyPath(AssetDataContentInformation.contentLength))
coder.encode(self.contentType, forKey: #keyPath(AssetDataContentInformation.contentType))
coder.encode(self.isByteRangeAccessSupported, forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported))
}
override init() {
super.init()
}
required init?(coder: NSCoder) {
super.init()
self.contentLength = coder.decodeInt64(forKey: #keyPath(AssetDataContentInformation.contentLength))
self.contentType = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.contentType)) as? String ?? ""
self.isByteRangeAccessSupported = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported)) as? Bool ?? false
}
}
class AssetData: NSObject, NSCoding {
@objc var contentInformation: AssetDataContentInformation = AssetDataContentInformation()
@objc var mediaData: Data = Data()
override init() {
super.init()
}
func encode(with coder: NSCoder) {
coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation))
coder.encode(self.mediaData, forKey: #keyPath(AssetData.mediaData))
}
required init?(coder: NSCoder) {
super.init()
self.contentInformation = coder.decodeObject(forKey: #keyPath(AssetData.contentInformation)) as? AssetDataContentInformation ?? AssetDataContentInformation()
self.mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data ?? Data()
}
}
AssetData 存放:
-
contentInformation: AssetDataContentInformationAssetDataContentInformation: 存放 是否支援 Range 范围请求(isByteRangeAccessSupported)、资源总长度(contentLength)、档案类型(contentType) -
mediaData: 原始音讯 Data (这边档案太大会 OOM)
PINCacheAssetDataManager
封装 Data 存入、取出 PINCache 逻辑。
import PINCache
import Foundation
protocol AssetDataManager: NSObject {
func retrieveAssetData() -> AssetData?
func saveContentInformation(_ contentInformation: AssetDataContentInformation)
func saveDownloadedData(_ data: Data, offset: Int)
func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data?
}
extension AssetDataManager {
func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data? {
if offset <= from.count && (offset + with.count) > from.count {
let start = from.count - offset
var data = from
data.append(with.subdata(in: start..<with.count))
return data
}
return nil
}
}
//
class PINCacheAssetDataManager: NSObject, AssetDataManager {
static let Cache: PINCache = PINCache(name: "ResourceLoader")
let cacheKey: String
init(cacheKey: String) {
self.cacheKey = cacheKey
super.init()
}
func saveContentInformation(_ contentInformation: AssetDataContentInformation) {
let assetData = AssetData()
assetData.contentInformation = contentInformation
PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
}
func saveDownloadedData(_ data: Data, offset: Int) {
guard let assetData = self.retrieveAssetData() else {
return
}
if let mediaData = self.mergeDownloadedDataIfIsContinuted(from: assetData.mediaData, with: data, offset: offset) {
assetData.mediaData = mediaData
PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
}
}
func retrieveAssetData() -> AssetData? {
guard let assetData = PINCacheAssetDataManager.Cache.object(forKey: cacheKey) as? AssetData else {
return nil
}
return assetData
}
}
这边多抽出 Protocol 因为未来可能使用其他储存方式替代 PINCache,所以其他程式在使用时是依赖 Protocol 而非 Class 实体。
⚠️
mergeDownloadedDataIfIsContinuted这个方法极其重要。
照线性播放只要一直 append 新 Data 到 Cache Data 中即可,但现实情况复杂得多,使用者可能播了 Range 0~100,直接 Seek 到 Range 200–500 播放;如何将已有的 0-100 Data 与新的 200–500 Data 合并就是一个很大的问题。
⚠️Data 合并有问题会出现可怕的播放鬼畜问题….
这边的答案是, 我们不处理非连续资料 ;因为敝专案仅为音讯,档案也就几 MB (≤ 10MB) 以考量开发成本就没做了,我只处理合并连续的资料(例如目前已有 0~100,新资料是 75~200,合并之后变0~200;如果新资料是 150~200,我则会忽略不合并处理)

如果要考虑非连续合并,除了在储存上要使用其他方法(要有办法辨识空缺部分);在 Request 时也要能 Query 出哪段需要发网路请求去拿、哪段是从本地拿;要考量到这情况实作会非常复杂。

图片取自: iOS AVPlayer 视频缓存的设计与实现
CachingAVURLAsset
AVURLAsset 是 weak 持有 ResourceLoader Delegate,所以这边建议自己建立一个 AVURLAsset Class 继承自 AVURLAsset,在内部建立、赋予、持有 ResourceLoader ,让他跟著 AVURLAsset 的生命周期;另外也可以储存原始 URL、CacheKey 等资讯…。
class CachingAVURLAsset: AVURLAsset {
static let customScheme = "cacheable"
let originalURL: URL
private var _resourceLoader: ResourceLoader?
var cacheKey: String {
return self.url.lastPathComponent
}
static func isSchemeSupport(_ url: URL) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
return ["http", "https"].contains(components.scheme)
}
override init(url URL: URL, options: [String: Any]? = nil) {
self.originalURL = URL
guard var components = URLComponents(url: URL, resolvingAgainstBaseURL: false) else {
super.init(url: URL, options: options)
return
}
components.scheme = CachingAVURLAsset.customScheme
guard let url = components.url else {
super.init(url: URL, options: options)
return
}
super.init(url: url, options: options)
let resourceLoader = ResourceLoader(asset: self)
self.resourceLoader.setDelegate(resourceLoader, queue: resourceLoader.loaderQueue)
self._resourceLoader = resourceLoader
}
}
使用:
if CachingAVURLAsset.isSchemeSupport(url) {
let asset = CachingAVURLAsset(url: url)
let avplayer = AVPlayer(asset)
avplayer.play()
}
其中 isSchemeSupport() 是用来判断 URL 是否支援挂我们的 Resource Loader(排除 file:// )。
originalURL 存放原始资源 URL。
cacheKey 存放这个资源的 Cache Key,这边直接用档案名称当 Cache Key。
cacheKey 请依照现实场景做调整,如果档案名称未 hash 可能重复就建议先 hash 后当 key 避免碰撞;如果要 hash 整个 URL 当 key 也要注意 URL 是否会变动 (例如有用 CDN)。
Hash 可使用 md5…sha. .,iOS ≥ 13 可直接使用 Apple 的 CryptoKit ,其他就上 Github 找吧!
ResourceLoaderRequest
import Foundation
import CoreServices
protocol ResourceLoaderRequestDelegate: AnyObject {
func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data)
func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data)
func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>)
}
class ResourceLoaderRequest: NSObject, URLSessionDataDelegate {
struct RequestRange {
var start: Int64
var end: RequestRangeEnd
enum RequestRangeEnd {
case requestTo(Int64)
case requestToEnd
}
}
enum RequestType {
case contentInformation
case dataRequest
}
struct ResponseUnExpectedError: Error { }
private let loaderQueue: DispatchQueue
let originalURL: URL
let type: RequestType
private var session: URLSession?
private var dataTask: URLSessionDataTask?
private var assetDataManager: AssetDataManager?
private(set) var requestRange: RequestRange?
private(set) var response: URLResponse?
private(set) var downloadedData: Data = Data()
private(set) var isCancelled: Bool = false {
didSet {
if isCancelled {
self.dataTask?.cancel()
self.session?.invalidateAndCancel()
}
}
}
private(set) var isFinished: Bool = false {
didSet {
if isFinished {
self.session?.finishTasksAndInvalidate()
}
}
}
weak var delegate: ResourceLoaderRequestDelegate?
init(originalURL: URL, type: RequestType, loaderQueue: DispatchQueue, assetDataManager: AssetDataManager?) {
self.originalURL = originalURL
self.type = type
self.loaderQueue = loaderQueue
self.assetDataManager = assetDataManager
super.init()
}
func start(requestRange: RequestRange) {
guard isCancelled == false, isFinished == false else {
return
}
self.loaderQueue.async { [weak self] in
guard let self = self else {
return
}
var request = URLRequest(url: self.originalURL)
self.requestRange = requestRange
let start = String(requestRange.start)
let end: String
switch requestRange.end {
case .requestTo(let rangeEnd):
end = String(rangeEnd)
case .requestToEnd:
end = ""
}
let rangeHeader = "bytes=\(start)-\(end)"
request.setValue(rangeHeader, forHTTPHeaderField: "Range")
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
self.session = session
let dataTask = session.dataTask(with: request)
self.dataTask = dataTask
dataTask.resume()
}
}
func cancel() {
self.isCancelled = true
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard self.type == .dataRequest else {
return
}
self.loaderQueue.async {
self.delegate?.dataRequestDidReceive(self, data)
self.downloadedData.append(data)
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
self.response = response
completionHandler(.allow)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
self.isFinished = true
self.loaderQueue.async {
if self.type == .contentInformation {
guard error == nil,
let response = self.response as? HTTPURLResponse else {
let responseError = error ?? ResponseUnExpectedError()
self.delegate?.contentInformationDidComplete(self, .failure(responseError))
return
}
let contentInformation = AssetDataContentInformation()
if let rangeString = response.allHeaderFields["Content-Range"] as? String,
let bytesString = rangeString.split(separator: "/").map({String($0)}).last,
let bytes = Int64(bytesString) {
contentInformation.contentLength = bytes
}
if let mimeType = response.mimeType,
let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() {
contentInformation.contentType = contentType as String
}
if let value = response.allHeaderFields["Accept-Ranges"] as? String,
value == "bytes" {
contentInformation.isByteRangeAccessSupported = true
} else {
contentInformation.isByteRangeAccessSupported = false
}
self.assetDataManager?.saveContentInformation(contentInformation)
self.delegate?.contentInformationDidComplete(self, .success(contentInformation))
} else {
if let offset = self.requestRange?.start, self.downloadedData.count > 0 {
self.assetDataManager?.saveDownloadedData(self.downloadedData, offset: Int(offset))
}
self.delegate?.dataRequestDidComplete(self, error, self.downloadedData)
}
}
}
}
针对 Remote Request 的封装,主要是服务 ResourceLoader 发起的资料请求。
RequestType :用来区分此 Request 是 第一次请求档案资讯(contentInformation)、还是请求资料(dataRequest)
RequestRange :请求 Range 范围,end 可指定到哪(requestTo(Int64) )或全部(requestToEnd)。
档案资讯可由:
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)
中取得 Response Header,另外要注意如果要改 HEAD 去摸,不会进这个要用其他方法接。
-
isByteRangeAccessSupported:看 Response Header 中的 Accept-Ranges == bytes -
contentType:播放器要的档案类型资讯,格式是统一类识别符,不是 audio/mpeg ,而是写作 public.mp3 -
contentLength:看 Response Header 中的 Content-Range :bytes 0–1/ 资源总长度
⚠️这边要注意伺服器给的格式大小写,不一定是写作 Accept-Ranges/Content-Range;有的伺服器的格式是小写 accept-ranges、Accept-ranges…
补充:如果要考量大小写可以写 HTTPURLResponse Extension
import CoreServices
extension HTTPURLResponse {
func parseContentLengthFromContentRange() -> Int64? {
let contentRangeKeys: [String] = [
"Content-Range",
"content-range",
"Content-range",
"content-Range"
]
var rangeString: String?
for key in contentRangeKeys {
if let value = self.allHeaderFields[key] as? String {
rangeString = value
break
}
}
guard let rangeString = rangeString,
let contentLengthString = rangeString.split(separator: "/").map({String($0)}).last,
let contentLength = Int64(contentLengthString) else {
return nil
}
return contentLength
}
func parseAcceptRanges() -> Bool? {
let contentRangeKeys: [String] = [
"Accept-Ranges",
"accept-ranges",
"Accept-ranges",
"accept-Ranges"
]
var rangeString: String?
for key in contentRangeKeys {
if let value = self.allHeaderFields[key] as? String {
rangeString = value
break
}
}
guard let rangeString = rangeString else {
return nil
}
return rangeString == "bytes" \\|\\| rangeString == "Bytes"
}
func mimeTypeUTI() -> String? {
guard let mimeType = self.mimeType,
let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() else {
return nil
}
return contentType as String
}
}
使用:
-
contentLength = response.parseContentLengthFromContentRange( )
-
isByteRangeAccessSupported = response.parseAcceptRanges( )
-
contentType = response.mimeTypeUTI( )
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)
同前导知识所述,会实时取得已下载的资料,所以这个方法会一直进,片段片段的拿到 Data;我们将他 append 进 downloadedData 存放。
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
任务取消或结束时都会进这个方法,在这将已下载的资料保存下来。
如前导知识中提到的 Cancel 机制,因播放器在拿到足够资料后就会发起 Cancel,Cancel Request;所以进到这个方法时实际会是 error = NSURLErrorCancelled ,因此不管 error 我们有拿到资料都会尝试存下来。
⚠️ 因 URLSession 会用并行方式出去请求资料,所以请保持操作都在DispatchQueue里,避免资料错乱(资料错乱一样会出现可怕的播放鬼畜)。
️️⚠️URLSession 没有呼叫
finishTasksAndInvalidate或invalidateAndCancel两个方法都会强持有物件导致 Memory Leak;所以不管是取消或是完成我们都要呼叫,这样才能在任务结束释放 Request。
️️⚠️️️️️️️️️️️如果怕
downloadedDataOOM,可以在 didReceive Data 中就存入本地。
ResourceLoader
import AVFoundation
import Foundation
class ResourceLoader: NSObject {
let loaderQueue = DispatchQueue(label: "li.zhgchg.resourceLoader.queue")
private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
private let cacheKey: String
private let originalURL: URL
init(asset: CachingAVURLAsset) {
self.cacheKey = asset.cacheKey
self.originalURL = asset.originalURL
super.init()
}
deinit {
self.requests.forEach { (request) in
request.value.cancel()
}
}
}
extension ResourceLoader: AVAssetResourceLoaderDelegate {
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
let type = ResourceLoader.resourceLoaderRequestType(loadingRequest)
let assetDataManager = PINCacheAssetDataManager(cacheKey: self.cacheKey)
if let assetData = assetDataManager.retrieveAssetData() {
if type == .contentInformation {
loadingRequest.contentInformationRequest?.contentLength = assetData.contentInformation.contentLength
loadingRequest.contentInformationRequest?.contentType = assetData.contentInformation.contentType
loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = assetData.contentInformation.isByteRangeAccessSupported
loadingRequest.finishLoading()
return true
} else {
let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
if assetData.mediaData.count > 0 {
let end: Int64
switch range.end {
case .requestTo(let rangeEnd):
end = rangeEnd
case .requestToEnd:
end = assetData.contentInformation.contentLength
}
if assetData.mediaData.count >= end {
let subData = assetData.mediaData.subdata(in: Int(range.start)..<Int(end))
loadingRequest.dataRequest?.respond(with: subData)
loadingRequest.finishLoading()
return true
} else if range.start <= assetData.mediaData.count {
// has cache data...but not enough
let subEnd = (assetData.mediaData.count > end) ? Int((end)) : (assetData.mediaData.count)
let subData = assetData.mediaData.subdata(in: Int(range.start)..<subEnd)
loadingRequest.dataRequest?.respond(with: subData)
}
}
}
}
let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
let resourceLoaderRequest = ResourceLoaderRequest(originalURL: self.originalURL, type: type, loaderQueue: self.loaderQueue, assetDataManager: assetDataManager)
resourceLoaderRequest.delegate = self
self.requests[loadingRequest]?.cancel()
self.requests[loadingRequest] = resourceLoaderRequest
resourceLoaderRequest.start(requestRange: range)
return true
}
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
guard let resourceLoaderRequest = self.requests[loadingRequest] else {
return
}
resourceLoaderRequest.cancel()
requests.removeValue(forKey: loadingRequest)
}
}
extension ResourceLoader: ResourceLoaderRequestDelegate {
func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>) {
guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
return
}
switch result {
case .success(let contentInformation):
loadingRequest.contentInformationRequest?.contentType = contentInformation.contentType
loadingRequest.contentInformationRequest?.contentLength = contentInformation.contentLength
loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = contentInformation.isByteRangeAccessSupported
loadingRequest.finishLoading()
case .failure(let error):
loadingRequest.finishLoading(with: error)
}
}
func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) {
guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
return
}
loadingRequest.dataRequest?.respond(with: data)
}
func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) {
guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
return
}
loadingRequest.finishLoading(with: error)
requests.removeValue(forKey: loadingRequest)
}
}
extension ResourceLoader {
static func resourceLoaderRequestType(_ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestType {
if let _ = loadingRequest.contentInformationRequest {
return .contentInformation
} else {
return .dataRequest
}
}
static func resourceLoaderRequestRange(_ type: ResourceLoaderRequest.RequestType, _ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestRange {
if type == .contentInformation {
return ResourceLoaderRequest.RequestRange(start: 0, end: .requestTo(1))
} else {
if loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true {
let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0
return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestToEnd)
} else {
let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0
let length = Int64(loadingRequest.dataRequest?.requestedLength ?? 1)
let upperBound = lowerBound + length
return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestTo(upperBound))
}
}
}
}
loadingRequest.contentInformationRequest != nil 则代表是第一次请求,播放器要求先给档案资讯。
请求档案资讯时我们需要赋予这三项资讯:
-
loadingRequest.contentInformationRequest?.isByteRangeAccessSupported:是否支援 Range 拿 Data -
loadingRequest.contentInformationRequest?.contentType:统一类识别符 -
loadingRequest.contentInformationRequest?.contentLength:档案总长度 Int64
loadingRequest.dataRequest?.requestedOffset 可取得要求 Range 的起始 offset。
loadingRequest.dataRequest?.requestedLength 可取得要求 Range 的长度。
loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true 则不管要求 Range 的长度,直接拿到底。
loadingRequest.dataRequest?.respond(with: Data) 返回已载入的 Data 给播放器。
loadingRequest.dataRequest?.currentOffset 可取得当前 data offset, dataRequest?.respond(with: Data) 后 currentOffset 会跟著推移。
loadingRequest.finishLoading() 资料都载完了,告知播放器。
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
播放器请求资料,我们先看本地 Cache 有无资料,有则返回;若只有部分资料则一样返回部分,例如我本地有 0–100 ,播放器要求 0–200,则先返回 0–100。
若没有本地 Cache、返回的资料不够,则会发起 ResourceLoaderRequest 请求从网路拿资料。
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)
播放器取消请求,取消 ResourceLoaderRequest。
你可能有发现
resourceLoaderRequestRange的 offset 是看currentOffset,因为我们会先从本地dataRequest?.respond(with: Data)已下载 Data;所以直接看推移后的 offset 即可。
func private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
⚠️ requests 有的范例是只用
currentRequest: ResourceLoaderRequest来存放,这会有个问题,因为可能当前的 request 正在拿取,使用者又 seek 这时会取消旧的发起新的;但因不一定会照顺序发生,可能先走发新请求再走取消;所以用 Dictionary 去存取操作还是比较安全!
⚠️让所有操作都在同个 DispatchQueue 防止出现资料鬼畜。
deinit 时取消所有还在请求的 requests Resource Loader Deinit 即代表 AVURLAsset Deinit,代表播放器已经不需要这个资源了;所以我们可以 Cancel 还在取资料的 Request,已经载的一样会写入 Cache。
补充及鸣谢
感谢 Lex 汤 大大指点。
感谢 外孙女 提供开发上的意见及支持。
本篇只针对音乐小档
影片大档案可能会在 downloadedData、AssetData/PINCacheAssetDataManager 发生 Out Of Memory 问题。
同前述,如果要解决这个问题请使用 fileHandler seek read/wirte 去操作本地 Cache 读取写入(取代AssetData/PINCacheAssetDataManager);或找看看 Github 有没有大 data write/read to file 的专案可用。
AVQueuePlayer 切换播放项目时取消正在下载的项目
同前导知识中所述,在更换播放目标时是不会发起 Cancel 的;如果是 AVPlayer 会走 AVURLAsset Deinit 所以下载也会中断;但 AVQueuePlayer 不会,因为都还在 Queue 里,只是播放目标换到下一首而已。
这边唯一做法就只能接收变换播放目标通知,然后在收到通知后取消上一手的 AVURLAsset loading。
asset.cancelLoading()
音讯资料加解密
音讯加解密可在 ResourceLoaderRequest 中拿到 Data 进行、还有储存时能在 AssetData 的 encode/decode 对存在本地的 Data进行加解密。
CryptoKit SHA 使用范本:
class AssetData: NSObject, NSCoding {
static let encryptionKeyString = "encryptionKeyExzhgchgli"
...
func encode(with coder: NSCoder) {
coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation))
if #available(iOS 13.0, *),
let encryptionData = try? ChaChaPoly.seal(self.mediaData, using: AssetData.encryptionKey).combined {
coder.encode(encryptionData, forKey: #keyPath(AssetData.mediaData))
} else {
//
}
}
required init?(coder: NSCoder) {
super.init()
...
if let mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data {
if #available(iOS 13.0, *),
let sealedBox = try? ChaChaPoly.SealedBox(combined: mediaData),
let decryptedData = try? ChaChaPoly.open(sealedBox, using: AssetData.encryptionKey) {
self.mediaData = decryptedData
} else {
//
}
} else {
//
}
}
}
PINCache 相关操作
PINCache 包含 PINMemoryCache 和 PINDiskCache,PINCache 会帮我们处理从档案读到 Memory 或从 Memory 写入档案的事,我们只需要对 PINCache 进行操作。
在模拟器中查找 Cache 档案位置:

使用 NSHomeDirectory() 取得模拟器档案路径

Finder -> 前往 -> 贴上路径

在 Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader 就是我们建的 Resource Loader Cache 目录。
PINCache(name: “ResourceLoader”) 其中的 name 就是目录名称。
也可以指定 rootPath ,目录就可以改到 Documents 底下(不怕被系统清掉)。
设定 PINCache 最大上限:
PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb
PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 days

系统预设上限
设 0 的话就不会主动删除档案。
后记
原先太小看这个功能的困难度,以为三两下就能处理好;结果吃尽苦头,大概又多花了两周处理资料储存的问题,不过也就此彻底了解整个 Resource Loader 运作机制、 GCD 、Data。
参考资料
最后附上研究如何实作的参考资料
-
基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出 [ SZAVPlayer ] 有附程式(很完整,但很复杂)
-
CachingPlayerItem (简易实现,较好懂但不完整)
-
仿抖音 Swift 版 [ Github ](蛮有意思的专案,复刻抖音 APP;里面也有用到 Resource Loader)
延伸
- DLCachePlayer (Objective-C 版)



留言 · Comments