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