Complete Guide to Implementing Local Cache Functionality with AVPlayer
Implementing AVPlayer/AVQueuePlayer with AVURLAsset and AVAssetResourceLoaderDelegate
ℹ️ℹ️ℹ️ The following content is translated by OpenAI.
Click here to view the original Chinese version. | 點此查看本文中文版
Complete Guide to Implementing Local Cache Functionality with AVPlayer
Implementing AVPlayer/AVQueuePlayer with AVURLAsset and AVAssetResourceLoaderDelegate
Photo by Tyler Lastovich
[2023/03/12] Update
I have open-sourced my previous implementation, so friends in need can use it directly.
- Customizable cache strategies, can use PINCache or others…
- Externally, just call the make AVAsset factory with the URL, and the AVAsset will support caching.
- Implemented data flow strategy using Combine.
- Wrote some tests.
Introduction
It has been over half a year since the last article, “Exploring iOS HLS Cache Implementation Methods,” and our team has been eager to implement the functionality of streaming while caching due to its significant impact on costs. We are a music streaming platform, and if we have to retrieve the entire file every time the same song is played, it is detrimental to both us and users who are not on unlimited plans. Although music files are usually just a few MB, every little bit adds up!
Additionally, since Android has already implemented streaming while caching, we have compared costs, and after the Android version went live, we clearly saved a lot of bandwidth; therefore, we expect even better bandwidth management for the larger iOS user base.
Based on the experience from the previous article, if we continue to use HLS (*.m3u8/*.ts) to achieve our goal, things will become very complicated, if not impossible. We decided to revert to using mp3 files, which allows us to directly implement AVAssetResourceLoaderDelegate
.
Goals
- Music that has been played will generate a local cache backup.
- When playing music, first check if there is a cache locally; if so, do not request the file from the server again.
- Cache strategies can be set; total capacity limits, and when exceeded, the oldest cache files will be deleted.
- Do not interfere with the original AVPlayer playback mechanism. (Otherwise, the fastest method would be to use URLSession to download the mp3 file and feed it to AVPlayer, but that would lose the functionality of streaming as needed, requiring users to wait longer and consuming more bandwidth.)
Prerequisite Knowledge (1)— HTTP/1.1 Range Requests, Connection Keep-Alive
HTTP/1.1 Range Requests
First, we need to understand how data is requested from the server when playing videos or music. Generally, video and music files are large, and it is not feasible to wait until the entire file is downloaded before starting playback. The common approach is to play as data is received; as long as there is data for the currently playing segment, it can function.
The way to achieve this functionality is through HTTP/1.1 Range requests, which only return the specified byte range of data. For example, specifying 0–100 will only return 100 bytes of data from 0 to 100. This method allows for sequentially obtaining data in segments, which can then be assembled into a complete file. This method can also be applied to file download resumption functionality.
How to Apply?
We will first use HEAD to check the response header to understand whether the server supports range requests, the total length of the resource, and the file type:
1
curl -i -X HEAD http://zhgchg.li/music.mp3
Using HEAD, we can obtain the following information from the response header:
- Accept-Ranges: bytes indicates that the server supports range requests. If this value is absent or if it is Accept-Ranges: none, it means it does not support range requests.
- Content-Length: the total length of the resource; we need to know the total length to segment the data requests.
- Content-Type: the file type, which is information that AVPlayer needs during playback.
However, sometimes we also use GET with Range: bytes=0–1
, meaning we request data in the range of 0–1, but we do not actually care about the content of 0–1; we just want to see the information in the response header. The native AVPlayer uses GET to check, so this article will also follow suit.
However, it is recommended to use HEAD for checking, as it is a more accurate method. On the other hand, if the server does not support range functionality, using GET will force a complete file download.
1
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–1"
Using GET, we can obtain the following information from the response header:
- Accept-Ranges: bytes indicates that the server supports range requests. If this value is absent or if it is Accept-Ranges: none, it means it does not support range requests.
- Content-Range: bytes 0–1/total length indicates the total length of the resource after the “/”; we need to know the total length to segment the data requests.
- Content-Type: the file type, which is information that AVPlayer needs during playback.
Once we know that the server supports range requests, we can start making segmented range requests:
1
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100"
The server will return 206 Partial Content:
1
2
3
4
Content-Range: bytes 0-100/total length
Content-Length: 100
...
(binary content)
At this point, we have obtained the data for range 0–100 and can continue to make new requests for ranges 100–200, 200–300, and so on until completion.
If the requested range exceeds the total length of the resource, it will return 416 Range Not Satisfiable.
Additionally, to obtain the complete file data, we can request Range 0–total length or simply use 0–:
1
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–"
Other requests can also specify multiple range data and conditions, but we will not use those; for details, please refer to this.
Connection Keep-Alive
HTTP/1.1 is enabled by default, and this feature allows for real-time retrieval of downloaded data. For example, a 5 MB file can be retrieved in chunks of 16 KB, 16 KB, 16 KB, etc., without having to wait for the entire 5 MB to be ready.
1
Connection: Keep-Alive
What if the server does not support Range or Keep-Alive?
In that case, there is no need to complicate things; just use URLSession to download the mp3 file and feed it to the player… But that is not the result we want, so we can ask the backend to modify the server settings.
Prerequisite Knowledge (2) — How does AVPlayer natively handle AVURLAsset resources?
When we use AVURLAsset initialized with a URL resource and assign it to AVPlayer/AVQueuePlayer to start playback, as mentioned above, it will first use GET Range 0–1 to obtain whether range requests are supported, the total length of the resource, and the file type.
After obtaining the file information, a second request will be made to request data from 0 to total length.
⚠️ AVPlayer will request data from 0 to total length and will use the real-time retrieval of downloaded data feature (16 KB, 16 KB, 16 KB…) to obtain enough data before it will issue a cancel to terminate this network request (so it will not actually retrieve everything unless the file is very small).
Subsequent playback will then request data further along using range requests.
(This part is different from what I previously thought; I assumed it would request 0–100, 100–200, etc.)
AVPlayer Request Example:
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: total length 150000 / public.mp3 / true
2. GET 0-150000...
3. 16 KB received
4. 16 KB received...
5. cancel() // current offset is 700
6. Continue playback
7. GET 700-150000...
8. 16 KB received
9. 16 KB received...
10. cancel() // current offset is 1500
11. Continue playback
12. GET 1500-150000...
13. 16 KB received
14. 16 KB received...
16. If seek to...5000
17. cancel(12.) // current offset is 2000
18. GET 5000-150000...
19. 16 KB received
20. 16 KB received...
...
⚠️ In iOS ≤12, it will first issue a few shorter requests to probe (?), and then it will issue a request for the total length; in iOS ≥ 13, it will directly issue a request for the total length.
There is also an unrelated pitfall; while observing how resources are fetched, I used the mitmproxy tool to sniff the traffic, and I found that it displayed an error, waiting until the entire response came back before showing it, rather than displaying it in segments and continuing the download using persistent connections; it startled me! I thought iOS was so inefficient that it had to download the entire file each time! Next time I use a tool, I need to maintain a bit of skepticism.
Timing of Cancel Requests
- The second request mentioned earlier, which requests resources from 0 to total length, will issue a cancel request once sufficient data has been received.
- During seeking, a cancel request will be issued for the previous request.
⚠️ In AVQueuePlayer, switching to the next resource or changing the playback resource in AVPlayer will not issue a cancel request for the previous one.
AVQueue Pre-buffering
This also calls the Resource Loader, but it requests a smaller range of data.
Implementation
With the above prerequisite knowledge, let’s look at how to implement the local cache functionality for AVPlayer.
This involves the previously mentioned AVAssetResourceLoaderDelegate
, which allows us to implement our own Resource Loader for the asset.
The Resource Loader is essentially a worker; whether the player needs file information or file data, and what ranges are needed, it informs us, and we take action accordingly.
I have seen examples where a Resource Loader serves all AVURLAssets, but I believe this is incorrect; there should be one Resource Loader serving one AVURLAsset, following the lifecycle of the AVURLAsset, as it inherently belongs to the AVURLAsset.
Having one Resource Loader serve all AVURLAssets in AVQueuePlayer would become very complex and difficult to manage.
Timing for Entering Custom Resource Loader
It is important to note that simply implementing your own Resource Loader does not mean it will be used; it will only be invoked when the system cannot recognize or handle the resource.
Therefore, before providing the URL resource to AVURLAsset, we need to change the scheme to our custom scheme, which cannot be http/https or any other scheme that the system can handle.
1
http://zhgchg.li/music.mp3 => cacheable://zhgchg.li/music.mp3
AVAssetResourceLoaderDelegate
Only two methods need to be implemented:
- func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest : AVAssetResourceLoadingRequest) -> Bool :
This method asks us whether we can handle this resource; returning true means we can, and returning false means we do not handle it (unsupported URL).
From loadingRequest
, we can extract what is being requested (whether it is the first request for file information or a request for data, and if it is a data request, what the range is); knowing the request, we can initiate our own request to fetch the data. At this point, we can decide whether to initiate a URLSession request or return data from local storage.
We can also perform data encryption and decryption operations here to protect the original data.
- func resourceLoader( _ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest : AVAssetResourceLoadingRequest) :
As mentioned earlier, when a cancel request is issued…
We can cancel any ongoing URLSession requests here.
Local Cache Implementation
For caching, I directly use PINCache to handle cache operations, eliminating the need for us to manage cache read/write deadlocks and implement LRU cache clearing strategies.
️️⚠️️️️️️️️️️️OOM Warning!
Since this is aimed at caching music files, which are typically around 10 MB, we can use PINCache as a local caching tool; however, this method cannot be used for video services (which may require loading several GB of data into memory at once).
For those with similar needs, you can refer to the approach of using FileHandle seek read/write features for handling this.
Let’s Get Started!
Without further ado, here is the complete project:
AssetData
The local cache data object implements NSCoding, as PINCache relies on the archivedData method for encoding/decoding.
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
stores:
contentInformation
: AssetDataContentInformationAssetDataContentInformation
: stores whether range requests are supported (isByteRangeAccessSupported), the total length of the resource (contentLength), and the file type (contentType).mediaData
: Original audio Data (this may cause OOM if the file is too large)
PINCacheAssetDataManager
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
Encapsulating the logic for storing and retrieving data in PINCache.
```swift
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
}
}
Here, the protocol is extracted because in the future, we may use other storage methods instead of PINCache, allowing other programs to rely on the protocol rather than the class instance.
⚠️
mergeDownloadedDataIfIsContinuted
This method is extremely important.
For linear playback, you can simply append new data to the cached data. However, the reality is much more complex; users might play from range 0-100 and then directly seek to range 200-500. How to merge the existing 0-100 data with the new 200-500 data is a significant challenge.
⚠️ Issues with data merging can lead to terrifying playback glitches…
The answer here is that we do not handle non-continuous data; since our project only deals with audio and the files are only a few MB (≤ 10MB), we decided not to implement it due to development costs. I only handle merging continuous data (for example, if we already have 0-100 and the new data is 75-200, the merge results in 0-200; if the new data is 150-200, I will ignore it and not merge).
If we want to consider non-continuous merging, we would need to use other methods for storage (to identify the missing parts); during requests, we would also need to query which segments require network requests and which segments can be retrieved locally. Implementing this would be very complex.
Image source: Design and Implementation of iOS AVPlayer Video Caching
CachingAVURLAsset
AVURLAsset weakly holds the ResourceLoader delegate, so it is recommended to create your own AVURLAsset class that inherits from AVURLAsset, internally creating, assigning, and holding the ResourceLoader, allowing it to follow the lifecycle of AVURLAsset. Additionally, you can store the original URL, cache key, and other information.
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
}
}
Usage:
1
2
3
4
5
if CachingAVURLAsset.isSchemeSupport(url) {
let asset = CachingAVURLAsset(url: url)
let avplayer = AVPlayer(asset)
avplayer.play()
}
The isSchemeSupport()
function is used to determine if the URL supports our Resource Loader (excluding file://).
originalURL
stores the original resource URL.
cacheKey
stores the cache key for this resource, which is directly set to the file name.
Please adjust the cacheKey
according to real-world scenarios. If the file name is not hashed and may be duplicated, it is recommended to hash it first to avoid collisions; if you want to hash the entire URL as the key, be cautious about whether the URL may change (e.g., if using a CDN).
You can use md5…sha… For iOS ≥ 13, you can directly use Apple’s CryptoKit, otherwise, you can find alternatives on 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)
}
}
}
}
This encapsulates the remote request, primarily serving the data requests initiated by the ResourceLoader.
RequestType
: Used to distinguish whether this request is for the first file information request (contentInformation) or for data request (dataRequest).
RequestRange
: The range of the request, where the end can specify where to request (requestTo(Int64)) or all (requestToEnd).
File information can be obtained from:
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)
Additionally, note that if you want to change to HEAD requests, you won’t enter this method and will need to handle it differently.
isByteRangeAccessSupported
: Checks if the response header has Accept-Ranges == bytescontentType
: The file type information required by the player, in the format of a uniform type identifier, not audio/mpeg, but written as public.mp3contentLength
: Looks at the response header’s Content-Range: bytes 0–1/ total length of the resource
⚠️ Be cautious of the case sensitivity of the format provided by the server; it may not always be written as Accept-Ranges/Content-Range; some servers may use lowercase accept-ranges, Accept-ranges…
Supplement: If you want to consider case sensitivity, you can write an 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
}
}
Usage:
- contentLength = response.parseContentLengthFromContentRange()
- isByteRangeAccessSupported = response.parseAcceptRanges()
- contentType = response.mimeTypeUTI()
1
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)
As mentioned in the introductory knowledge, this method will continuously receive the downloaded data, so it will keep entering this method, obtaining data in segments; we will append it into downloadedData
for storage.
1
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
This method will be called when the task is canceled or completed, where we will save the downloaded data.
As mentioned in the introductory knowledge, the cancel mechanism is that the player will initiate a cancel request once it has enough data; therefore, when entering this method, the actual error
will be NSURLErrorCancelled
, so regardless of the error, we will attempt to save the data we have obtained.
⚠️ Since URLSession requests data in parallel, please keep operations within DispatchQueue to avoid data corruption (data corruption can also lead to terrifying playback glitches).
️️⚠️ If neither
finishTasksAndInvalidate
norinvalidateAndCancel
is called, URLSession will strongly hold the object, causing a memory leak; therefore, regardless of whether it is canceled or completed, we must call these methods to release the request at the end of the task.
️️⚠️️️️️️️️️️️ If you are concerned about
downloadedData
causing an OOM, you can save it locally in 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
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()
}
}
}
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
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))
}
}
}
}
If loadingRequest.contentInformationRequest
!= nil, it indicates that this is the first request, and the player is asking for file information.
When requesting file information, we need to provide these three pieces of information:
loadingRequest.contentInformationRequest?.isByteRangeAccessSupported
: Indicates whether range access for data is supported.loadingRequest.contentInformationRequest?.contentType
: The uniform type identifier.loadingRequest.contentInformationRequest?.contentLength
: The total length of the file as Int64.
loadingRequest.dataRequest?.requestedOffset
can be used to obtain the starting offset of the requested range.
loadingRequest.dataRequest?.requestedLength
can be used to obtain the length of the requested range.
If loadingRequest.dataRequest?.requestsAllDataToEndOfResource
== true, it means that regardless of the requested range length, the entire resource will be fetched.
loadingRequest.dataRequest?.respond(with: Data)
returns the loaded data to the player.
loadingRequest.dataRequest?.currentOffset
can be used to obtain the current data offset, and after calling dataRequest?.respond(with: Data)
, the currentOffset
will be updated accordingly.
loadingRequest.finishLoading()
indicates that all data has been loaded and informs the player.
1
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
When the player requests data, we first check if there is any data in the local cache. If there is, we return it; if only partial data is available, we return that portion. For example, if we have data from 0–100 and the player requests 0–200, we will return 0–100 first.
If there is no local cache and the returned data is insufficient, a ResourceLoaderRequest
will be initiated to fetch data from the network.
1
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)
When the player cancels a request, we cancel the ResourceLoaderRequest
.
You may have noticed that the offset in
resourceLoaderRequestRange
is based oncurrentOffset
, because we will first respond with the already downloaded data from the local cache usingdataRequest?.respond(with: Data)
; thus, we can directly look at the updated offset.
1
func private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
⚠️ In some examples,
currentRequest: ResourceLoaderRequest
is used to store requests, which can lead to issues. If the current request is still being processed and the user seeks, the old request may be canceled while a new one is initiated. Since these actions may not occur in order, using a Dictionary for storage is safer!
⚠️ Ensure all operations are performed on the same DispatchQueue to prevent data corruption.
Cancel all ongoing requests when deinitializing
The Resource Loader deinitialization indicates that the AVURLAsset is being deinitialized, meaning the player no longer needs this resource. Therefore, we can cancel any ongoing requests for data, while the already loaded data will still be written to the cache.
Supplement and Acknowledgments
Thanks to Lex Tang for the guidance.
Thanks to my granddaughter for providing development advice and support.
This article only addresses small music files
Large video files may encounter Out Of Memory issues in downloadedData
or AssetData/PINCacheAssetDataManager
.
As mentioned earlier, to resolve this issue, please use fileHandler seek read/write to operate local cache reading and writing (instead of AssetData/PINCacheAssetDataManager
); or check if there are any GitHub projects available for large data write/read to file.
AVQueuePlayer cancels ongoing downloads when switching playback items
As stated in the previous knowledge, when changing the playback target, a cancel will not be initiated. If it were AVPlayer, it would trigger AVURLAsset deinitialization, thus interrupting the download. However, AVQueuePlayer does not do this, as it remains in the queue; it simply changes the playback target to the next song.
The only approach here is to listen for notifications of playback target changes and cancel the previous AVURLAsset loading upon receiving the notification.
1
asset.cancelLoading()
Audio data encryption and decryption
Audio encryption and decryption can be performed in ResourceLoaderRequest
when obtaining data, and during storage, it can be done in AssetData
’s encode/decode methods for the locally stored data.
CryptoKit SHA usage example:
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 related operations
PINCache includes PINMemoryCache
and PINDiskCache
, which handle reading from files to memory or writing from memory to files. We only need to operate on PINCache
.
To find the cache file location in the simulator:
Use NSHomeDirectory()
to obtain the simulator file path.
In Finder, go to -> Paste the path.
In Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader
, you will find the directory we created for the Resource Loader Cache.
PINCache(name: “ResourceLoader”)
specifies the directory name.
You can also specify rootPath
, allowing the directory to be moved under Documents (to avoid being cleared by the system).
Setting the maximum limit for PINCache:
1
2
PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb
PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 days
The system default limit.
Setting it to 0 means files will not be deleted proactively.
Postscript
Initially, I underestimated the difficulty of this feature, thinking it could be handled quickly; however, I faced many challenges and spent an additional two weeks addressing data storage issues. Nevertheless, I now have a thorough understanding of the entire Resource Loader operation mechanism, GCD, and Data.
References
Finally, here are some reference materials for researching how to implement this:
- Design and Implementation of iOS AVPlayer Video Caching - Discusses the principles only.
- Implementing Audio and Video Playback and Caching Based on AVPlayer, Supporting Synchronized Video Output [ SZAVPlayer ] - Includes code (very complete but complex).
- CachingPlayerItem - (Simple implementation, easier to understand but incomplete).
- Possibly the best AVPlayer audio and video caching solution AVAssetResourceLoaderDelegate.
- Swift version of Douyin [ Github ] - (An interesting project that replicates the Douyin app; it also uses Resource Loader).
- Exploring iOS HLS Cache Implementation Methods.
Extensions
- DLCachePlayer (Objective-C version).
If you have any questions or suggestions, feel free to contact me.
```
This article was first published on Medium ➡️ Click Here
Automatically converted and synchronized using ZMediumToMarkdown and Medium-to-jekyll-starter.