🎶

AVAssetResourceLoaderDelegate を利用してアセットデータをキャッシュする

2022/09/06に公開

自作の音楽再生アプリで通信量を削減したくリソースをキャッシュする方法を調べたら AVAssetResourceLoaderDelegate というデリゲートが見つかりました
それ関連の情報が少なくせっかくなので async/await, actor が利用できる 2022 年度版として記事を残しておこうを思います

はじめに

AVAssetResourceLoaderDelegateAVURLAsset.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()
    }
}

データ読み込み

dataRequestreceivedData が変更されたタイミングで適宜呼び出して指定された範囲のデータを 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