AVPlayer 本地 Cache 实作攻略|使用 AVAssetResourceLoaderDelegate 节省 iOS 音乐串流流量
针对 iOS 音乐串流播放需求,实现 AVPlayer 本地 Cache 功能,避免重复下载同一档案,降低流量成本;透过自订 Resource Loader 与 PINCache 管理快取,支援分段 Range 请求与播放不中断,提升使用体验与效能。
Click here to view the English version of this article.
點擊這裡查看本文章正體中文版本。
基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。
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 范围请求、资源总长度、档案类型:
1
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 去摸就会变强迫下载完整档案。
1
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 范围请求后,就能分段发起范围请求:
1
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100"
伺服器会返回 206 Partial Content:
1
2
3
4
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- 方式即可:
1
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 都好才给你。
1
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。
1
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。
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
40
41
42
43
44
45
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 逻辑。
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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 等资讯…。
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
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
}
}
使用:
1
2
3
4
5
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
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
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 == bytescontentType
:播放器要的档案类型资讯,格式是统一类识别符,不是 audio/mpeg ,而是写作 public.mp3contentLength
:看 Response Header 中的 Content-Range :bytes 0–1/ 资源总长度
⚠️这边要注意伺服器给的格式大小写,不一定是写作 Accept-Ranges/Content-Range;有的伺服器的格式是小写 accept-ranges、Accept-ranges…
补充:如果要考量大小写可以写 HTTPURLResponse Extension
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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( )
1
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)
同前导知识所述,会实时取得已下载的资料,所以这个方法会一直进,片段片段的拿到 Data;我们将他 append 进 downloadedData
存放。
1
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
任务取消或结束时都会进这个方法,在这将已下载的资料保存下来。
如前导知识中提到的 Cancel 机制,因播放器在拿到足够资料后就会发起 Cancel,Cancel Request;所以进到这个方法时实际会是 error = NSURLErrorCancelled
,因此不管 error 我们有拿到资料都会尝试存下来。
⚠️ 因 URLSession 会用并行方式出去请求资料,所以请保持操作都在DispatchQueue里,避免资料错乱(资料错乱一样会出现可怕的播放鬼畜)。
️️⚠️URLSession 没有呼叫
finishTasksAndInvalidate
或invalidateAndCancel
两个方法都会强持有物件导致 Memory Leak;所以不管是取消或是完成我们都要呼叫,这样才能在任务结束释放 Request。
️️⚠️️️️️️️️️️️如果怕
downloadedData
OOM,可以在 didReceive Data 中就存入本地。
ResourceLoader
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
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 拿 DataloadingRequest.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()
资料都载完了,告知播放器。
1
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
播放器请求资料,我们先看本地 Cache 有无资料,有则返回;若只有部分资料则一样返回部分,例如我本地有 0–100 ,播放器要求 0–200,则先返回 0–100。
若没有本地 Cache、返回的资料不够,则会发起 ResourceLoaderRequest 请求从网路拿资料。
1
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)
播放器取消请求,取消 ResourceLoaderRequest。
你可能有发现
resourceLoaderRequestRange
的 offset 是看currentOffset
,因为我们会先从本地dataRequest?.respond(with: Data)
已下载 Data;所以直接看推移后的 offset 即可。
1
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。
1
asset.cancelLoading()
音讯资料加解密
音讯加解密可在 ResourceLoaderRequest 中拿到 Data 进行、还有储存时能在 AssetData 的 encode/decode 对存在本地的 Data进行加解密。
CryptoKit SHA 使用范本:
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
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 最大上限:
1
2
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 版)
有任何问题及指教欢迎 与我联络 。
本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。