AVAssetResourceLoaderDelegate を利用してアセットデータをキャッシュする
自作の音楽再生アプリで通信量を削減したくリソースをキャッシュする方法を調べたら AVAssetResourceLoaderDelegate というデリゲートが見つかりました
それ関連の情報が少なくせっかくなので async/await, actor が利用できる 2022 年度版として記事を残しておこうを思います
はじめに
AVAssetResourceLoaderDelegate は AVURLAsset.resourceLoader の処理をカスタマイズできるエントリーポイントですが、読み込む URL が一般的なスキームだと呼び出されません
そのため、まずはカスタムされた AVURLAsset を返すインターフェースを用意します
public class AssetProvider {
public func asset(for url: URL) -> AVURLAsset
}
このインターフェースを通して AVPlayerItem が必要な箇所で適宜 AVURLAsset を取り出します
AssetProvider 全体
private let resourceLoaderQueue = DispatchQueue(label: "AssetResourceLoader.queue", attributes: .concurrent)
public class AssetProvider {
let session: URLSession
let cache: any Caching<URL, Data>
public init(session: URLSession, cache: some Caching<URL, Data>) {
self.session = session
self.cache = cache
}
public func asset(for url: URL) -> AVURLAsset {
if url.isFileURL {
return AVURLAsset(url: url)
}
class Asset: AVURLAsset {
var loader: AssetResourceLoader?
}
var comps = URLComponents(url: url, resolvingAgainstBaseURL: true)!
comps.scheme = "\(url.scheme ?? "")-loader"
let asset = Asset(url: comps.url!)
let loader = AssetResourceLoader(session: session, cache: cache)
asset.loader = loader
asset.resourceLoader.setDelegate(loader, queue: resourceLoaderQueue)
return asset
}
}
AVAssetResourceLoaderDelegate
ここから AssetProvider.asset(for:) の内部で移譲されたリソース読み込み処理を実装していきます
デリゲートとして以下の2つのメソッドが最低限必要です
actor AssetResourceLoader: NSObject {
private var pendingRequests: Set<AVAssetResourceLoadingRequest> = []
private var receivedData = Data()
}
extension AssetResourceLoader: AVAssetResourceLoaderDelegate {
nonisolated func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
Task { await registerRequest(loadingRequest) }
return true
}
nonisolated func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest) {
Task { await cancelRequest(loadingRequest) }
}
}
AVAssetResourceLoadingRequest と読み込み中のデータを管理するために actor にしています
loadingRequest に対してメタデータを扱う contentInformationRequest と要求データ範囲を扱う dataRequest の二種類の具体的なリクエストオブジェクトを操作します
流れとしては最初に contentInformationRequest がやってきて必要回数分 dataRequest がやってくる感じです
ダウンロード処理
キャッシュのない初回アクセスはデータのダウンロードを行います
URLSession のバイトストリームアクセスでとてもシンプルにデータを取得できます
extension AssetResourceLoader {
private func registerRequest(_ loadingRequest: AVAssetResourceLoadingRequest) {
pendingRequests.insert(loadingRequest)
handleRequests()
guard let info = loadingRequest.contentInformationRequest else { return }
Task {
do {
let url = loadingRequest.request.url!
if let cachedData = try? await cache.value(for: url) {
...
} else {
var comps = URLComponents(url: url, resolvingAgainstBaseURL: true)!
comps.scheme = comps.scheme?.replacingOccurrences(of: "-loader", with: "")
let data = try await downloadData(for: URLRequest(url: comps.url!), with: info)
cache.store(data, for: url)
}
} catch {
pendingRequests.forEach { $0.finishLoading(with: error) }
pendingRequests = []
}
}
}
private func downloadData(for request: URLRequest, with info: AVAssetResourceLoadingContentInformationRequest) async throws -> Data {
let (bytes, response) = try await session.bytes(for: request)
let urlResponse = response as? HTTPURLResponse
info.contentType = {
guard let mimeType = urlResponse?.mimeType else { return nil }
return UTType(mimeType: mimeType)?.identifier
}()
info.contentLength = urlResponse?.expectedContentLength ?? 0
info.isByteRangeAccessSupported = urlResponse?.value(forHTTPHeaderField: "accept-ranges") == "bytes"
receivedData = Data()
for try await byte in bytes {
receivedData.append(byte)
handleRequests()
}
handleRequests()
return receivedData
}
}
キャッシュ読み込み
キャッシュからの読み込みはやることは単純です
extension AssetResourceLoader {
private func registerRequest(_ loadingRequest: AVAssetResourceLoadingRequest) {
...
let url = loadingRequest.request.url!
if let cachedData = try? await cache.value(for: url) {
+ extractData(cachedData, for: url, with: info)
} else {
...
}
...
}
}
extension AssetResourceLoader {
private func extractData(_ data: Data, for url: URL, with info: AVAssetResourceLoadingContentInformationRequest) async {
receivedData = data
info.contentType = {
guard !url.pathExtension.isEmpty else { return nil }
return UTType(filenameExtension: url.pathExtension)?.identifier
}()
info.contentLength = Int64(receivedData.count)
info.isByteRangeAccessSupported = false
handleRequests()
}
}
データ読み込み
dataRequest は receivedData が変更されたタイミングで適宜呼び出して指定された範囲のデータを dataRequest.respond(with:) に渡します
読み込みが終わったら pendingRequests から取り除いて finishLoading で完了を伝えます
extension AssetResourceLoader {
private func handleRequests() {
for loadingRequest in pendingRequests {
if loadData(receivedData, into: loadingRequest) {
pendingRequests.remove(loadingRequest)
loadingRequest.finishLoading()
}
}
}
}
private func loadData(_ receivedData: Data, into loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard let dataRequest = loadingRequest.dataRequest, !receivedData.isEmpty else { return false }
let offset = Int(dataRequest.currentOffset)
let dataLength = receivedData.count
if dataLength < offset {
return false
}
let length: Int
if case let unreadLength = dataLength - offset, dataRequest.requestedLength > unreadLength {
length = unreadLength
// respond(with:) に渡せる分までたまらないと iOS16 ではエラーが起こった
if #available(iOS 16, *) {
return false
}
} else {
length = dataRequest.requestedLength
}
let end = offset + length
dataRequest.respond(with: receivedData[offset..<end])
return dataLength >= end
}
おわりに
以上でリソースをキャッシュすることができました
また async/await, actor を活用することでシンプルに実装することもできました
※ 記事中にあるキャッシュインターフェースの詳細については説明を省いていますが、拙作の DataCacheKit を利用しています
AssetResourceLoader 全体
actor AssetResourceLoader: NSObject {
let session: URLSession
let cache: any Caching<URL, Data>
private var pendingRequests: Set<AVAssetResourceLoadingRequest> = []
private var receivedData = Data()
init(session: URLSession, cache: some Caching<URL, Data>) {
self.session = session
self.cache = cache
super.init()
}
}
extension AssetResourceLoader: AVAssetResourceLoaderDelegate {
nonisolated func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
Task { await registerRequest(loadingRequest) }
return true
}
nonisolated func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest) {
Task { await cancelRequest(loadingRequest) }
}
private func registerRequest(_ loadingRequest: AVAssetResourceLoadingRequest) {
pendingRequests.insert(loadingRequest)
handleRequests()
guard let info = loadingRequest.contentInformationRequest else { return }
Task {
do {
let url = loadingRequest.request.url!
if let cachedData = try? await cache.value(for: url) {
extractData(cachedData, for: url, with: info)
} else {
var comps = URLComponents(url: url, resolvingAgainstBaseURL: true)!
comps.scheme = comps.scheme?.replacingOccurrences(of: "-loader", with: "")
let data = try await downloadData(for: URLRequest(url: comps.url!), with: info)
cache.store(data, for: url)
}
} catch {
pendingRequests.forEach { $0.finishLoading(with: error) }
pendingRequests = []
}
}
}
private func downloadData(for request: URLRequest, with info: AVAssetResourceLoadingContentInformationRequest) async throws -> Data {
let (bytes, response) = try await session.bytes(for: request)
let urlResponse = response as? HTTPURLResponse
info.contentType = {
guard let mimeType = urlResponse?.mimeType else { return nil }
return UTType(mimeType: mimeType)?.identifier
}()
info.contentLength = urlResponse?.expectedContentLength ?? 0
info.isByteRangeAccessSupported = urlResponse?.value(forHTTPHeaderField: "accept-ranges") == "bytes"
receivedData = Data()
for try await byte in bytes {
receivedData.append(byte)
handleRequests()
}
handleRequests()
return receivedData
}
private func extractData(_ data: Data, for url: URL, with info: AVAssetResourceLoadingContentInformationRequest) {
receivedData = data
info.contentType = {
guard !url.pathExtension.isEmpty else { return nil }
return UTType(filenameExtension: url.pathExtension)?.identifier
}()
info.contentLength = Int64(receivedData.count)
info.isByteRangeAccessSupported = false
if #available(iOS 16.0, *) {
info.isEntireLengthAvailableOnDemand = true
}
handleRequests()
}
private func handleRequests() {
for loadingRequest in pendingRequests {
if loadData(receivedData, into: loadingRequest) {
pendingRequests.remove(loadingRequest)
loadingRequest.finishLoading()
}
}
}
private func cancelRequest(_ loadingRequest: AVAssetResourceLoadingRequest) {
pendingRequests.remove(loadingRequest)
}
}
private func loadData(_ receivedData: Data, into loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard let dataRequest = loadingRequest.dataRequest, !receivedData.isEmpty else { return false }
let offset = Int(dataRequest.currentOffset)
let dataLength = receivedData.count
if dataLength < offset {
return false
}
let length: Int
if case let unreadLength = dataLength - offset, dataRequest.requestedLength > unreadLength {
length = unreadLength
if #available(iOS 16, *) {
return false
}
} else {
length = dataRequest.requestedLength
}
let end = offset + length
dataRequest.respond(with: receivedData[offset..<end])
return dataLength >= end
}
Discussion