ZhgChg.Li

AVPlayer|本地Cache機能の完全ガイド:AVURLAssetとAVAssetResourceLoaderDelegate活用法

AVPlayerとAVQueuePlayerでの本地Cache構築に悩む開発者向け。AVURLAssetとAVAssetResourceLoaderDelegateを駆使し、再生の安定性と効率を最大化する具体的手法を解説。

AVPlayer|本地Cache機能の完全ガイド:AVURLAssetとAVAssetResourceLoaderDelegate活用法
本記事は AI による翻訳です。お気づきの点があればお知らせください。

AVPlayer ローカルキャッシュ機能の完全ガイド

AVPlayer/AVQueuePlayer と AVURLAsset を使った AVAssetResourceLoaderDelegate の実装

Photo by Tyler Lastovich

Photo by Tyler Lastovich

[2023/03/12] 更新

私は以前の実装をオープンソース化しました。必要な方は直接ご利用ください。

  • カスタマイズしたキャッシュ戦略は、PINCache やその他のものを使用できます…

  • 外部は make AVAsset ファクトリーを呼び出し、URL を渡すだけで、AVAsset がキャッシュをサポートします。

  • Combine を使ったデータフロー戦略の実装

  • いくつかテストを書きました

はじめに

既に前回の「 iOS HLS Cache 実践方法探究の旅 」から半年以上経ちましたが、チームは依然として再生しながらキャッシュする機能の実現を目指しています。コストへの影響が非常に大きいためです。私たちは音楽ストリーミングプラットフォームで、同じ曲を再生するたびにファイル全体を再取得するのは、私たちにとっても、定額制でないユーザーにとっても通信量の負担が大きいです。音楽ファイルはせいぜい数MBですが、塵も積もれば山となります。

また、Android側ではすでにストリーミング再生と同時にキャッシュする機能が実装されており、以前にコスト比較をしたところ、Android版のリリース後は明らかに通信量が大幅に削減されました。より多くのユーザーがいるiOSでも、さらに効果的な通信量節約が期待できるはずです。

前回の記事の経験から、もし引き続き HLS (.m3u8/.ts) を使って目的を達成しようとすると、非常に複雑になり、場合によっては不可能になることがあります。そこで妥協して mp3 ファイルを使うことにし、これなら直接 AVAssetResourceLoaderDelegate を使って実装できます。

目標

  • 再生された音楽はローカルにキャッシュとして保存されます

  • 音楽再生時にまずローカルにキャッシュがあるか確認し、あればサーバーから再度ファイルを取得しないようにします

  • 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バイト分のデータだけが返されます。この方法により、順番にデータを分割して取得し、それをまとめて完全なファイルにすることができます。また、この方法はファイルのダウンロード再開機能にも応用できます。

どうやって応用する?

私たちはまず HEAD リクエストを使ってレスポンスヘッダーを確認し、サーバーが Range 範囲リクエストをサポートしているか、リソースの総長さ、ファイルタイプを把握します:

curl -i -X HEAD http://zhgchg.li/music.mp3

HEAD を使用すると、レスポンスヘッダーから以下の情報を取得できます:

  • Accept-Ranges: bytes はサーバーが Range リクエストをサポートしていることを示します。
    レスポンスにこの値がない、または Accept-Ranges: none の場合はサポートしていません。

  • Content-Length: リソースの全長。これによりデータを分割して取得できます。

  • Content-Type: ファイルの種類で、AVPlayerが再生時に必要な情報です。

しかし、時々私たちは GET Range: bytes=0–1 を使用します。これは 0–1 の範囲のデータを要求していますが、実際には 0–1 の内容は気にせず、Response Header の情報だけを確認したいという意味です。ネイティブの AVPlayer も GET を使っているため、本記事でも同様に使用します

しかし、HEAD メソッドの使用をおすすめします。理由は、正しい方法であることと、サーバーが Range 機能をサポートしていない場合に GET を使うとファイル全体を強制的にダウンロードしてしまうためです。

curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–1"

GETを使用すると、Response Headerから以下の情報を取得できます:

  • Accept-Ranges: bytes はサーバーが Range リクエストをサポートしていることを示します。
    レスポンスにこの値がない、または Accept-Ranges: none の場合はサポートしていません。

  • Content-Range: bytes 0–1/リソースの総長さ 、 「/」の後の数字はリソースの総長さを示し、総長さを知ることで分割してデータを取得できます。

  • Content-Type: ファイルタイプで、AVPlayerが再生時に必要な情報です。

サーバーが Range 範囲リクエストをサポートしていることがわかれば、分割して範囲リクエストを送信できます:

curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100"

サーバーは 206 Partial Content を返します:

Content-Range: bytes 0-100/総長さ
Content-Length: 100
...
(binary content)

この時点で Range 0–100 のデータを取得でき、さらに Range 100–200、200–300…とリクエストを続けて最後まで取得できます。

Range がリソースの総長さを超えると、416 Range Not Satisfiable が返されます。

また、ファイル全体のデータを取得したい場合は、Range 0-総長さを指定する代わりに、0- の形式でも可能です:

curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–"

他にも同じリクエストで複数の Range データを要求したり、条件付きリクエストを行うこともできますが、今回は使用しません。詳細はこちらをご参照ください。

Connection Keep-Alive

http 1.1 はデフォルトで有効になっており、この機能によりダウンロード済みのデータをリアルタイムで取得できます。例えばファイルが5MBの場合、5MB全てが揃うのを待たずに16kb、16kb、16kb…と順次取得可能です。

Connection: Keep-Alive

もしサーバーが Range や Keep-Alive をサポートしていない場合?

それならこんなに複雑にせず、URLSessionでmp3ファイルをダウンロードしてからプレイヤーに渡せばいい…しかし、それは私たちが求めている結果ではありません。サーバー設定の変更をバックエンドに依頼してください。

前提知識 (2) — AVPlayer は AVURLAsset のリソースをどのようにネイティブで処理するか?

AVURLAsset に URL リソースを指定して初期化し、AVPlayer/AVQueuePlayer に渡して再生を開始すると、先述の通り、まず GET Range 0–1 リクエストで Range リクエストの対応可否、リソースの総長さ、ファイルタイプの3つの情報を取得します。

ファイル情報を取得した後、0から総長さまでのデータを要求する2回目のリクエストが発行されます。

⚠️ AVPlayer は 0 からファイル全体の長さまでのデータをリクエストし、リアルタイムでダウンロード済みのデータ量(16 kb、16 kb、16 kb…)を取得して、十分と判断すると Cancel を発行してこのネットワークリクエストをキャンセルします(そのため、実際には全部取得しません。ファイルが小さい場合を除く)。

再生を続けると、Rangeを使って後続のデータを要求します。

(この部分は以前考えていたのと違い、0–100、100–200のようにリクエストすると思っていました)

AVPlayer リクエスト例:

1. GET Range 0-1 => Response: 総長さ 150000 / public.mp3 / true
2. GET 0-150000...
3. 16 kb 受信
4. 16 kb 受信...
5. cancel() // 現在のオフセットは700
6. 再生を続ける
7. GET 700-150000...
8. 16 kb 受信
9. 16 kb 受信...
10. cancel() // 現在のオフセットは1500
11. 再生を続ける
12. GET 1500-150000...
13. 16 kb 受信
14. 16 kb 受信...
16. シーク先...5000
17. cancel(12.) // 現在のオフセットは2000
18. GET 5000-150000...
19. 16 kb 受信
20. 16 kb 受信...
...

⚠️ iOS ≤12 の場合、最初にいくつか短いリクエストを送って様子を見ます(?その後、全長のリクエストを送ります。iOS ≥ 13 では直接全長のリクエストを送ります。

もう一つ余談ですが、リソースの取得方法を調べる際に mitmproxy ツールを使って解析したところ、エラーが表示されました。レスポンスが全部返ってくるまで表示されず、分割や持続的接続での継続ダウンロードが表示されませんでした。これにはかなり驚いてしまい、iOSが毎回ファイル全体を取得しているのかと思いました!次回ツールを使うときは、少し疑いの目を持つべきですね Orz

Cancel を発動するタイミング

  1. 前述の2回目のリクエストは、0から全長までのリソースを要求し、十分なデータが揃うと Cancel を送信してリクエストをキャンセルします。

  2. シーク時はまず Cancel を送信して、前のリクエストをキャンセルします。

⚠️ AVQueuePlayerで次のリソースに切り替えたり、AVPlayerで再生リソースを変更しても、以前のリクエストをキャンセルするCancelは送信されません。

AVQueue の事前バッファリング

実際には同じく Resource Loader を呼び出して処理していますが、要求されるデータ範囲はより小さいです。

実装

以上の前提知識を踏まえて、AVPlayerのローカルキャッシュ機能の実現原理と方法を見ていきましょう。

以前に紹介した AVAssetResourceLoaderDelegate は、Asset に対して 独自の Resource Loader を実装 できるインターフェースです。

Resource Loader は実際には単なる作業員であり、プレーヤーがファイル情報やファイルデータ、範囲を必要とするときにそれを伝えてくれるので、私たちはそれに従って処理するだけです。

サンプルで Resource Loader がすべての AVURLAsset をサービスしている のを見ましたが、それは間違いだと思います。Resource Loader は AVURLAsset ごとに一つであり、AVURLAsset のライフサイクルに従うべきで、もともと AVURLAsset に属しています。

1つの Resource Loader が AVQueuePlayer 上のすべての AVURLAsset に対応すると、非常に複雑で管理が難しくなります。

カスタム Resource Loader に入るタイミング

注意すべき点は、自分の Resource Loader を実装したからといって必ず動作するわけではなく、システムがそのリソースを処理できない場合にのみ、自分の Resource Loader が呼ばれるということです。

なので、URLリソースをAVURLAssetに渡す前に、Schemeを自作のSchemeに変更する必要があります。http/httpsなどのシステムが処理できるSchemeではいけません。

http://zhgchg.li/music.mp3 => cacheable://zhgchg.li/music.mp3

AVAssetResourceLoaderDelegate

実装が必要なのは2つのメソッドだけです:

  • func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest : AVAssetResourceLoadingRequest) -> Bool :
    // リクエストされたリソースの読み込みを待つかどうかを判断するメソッド

このメソッドは、対象のリソースを処理できるかどうかを尋ねます。true を返すと処理可能、false を返すと処理しません(サポートされていない URL)。

私たちは loadingRequest から何を要求しているか(最初のファイル情報の要求かデータの要求か、データの場合はRangeの開始と終了)を取得できます。要求を把握した後、自分でリクエストを発行してデータを取得します。ここで URLSession を使うかローカルからデータを返すかを決定できます

また、ここでデータの暗号化・復号化を行い、元のデータを保護することもできます。

  • func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest : AVAssetResourceLoadingRequest) :
    // ローディングリクエストがキャンセルされたときに呼ばれます。

前述で述べた Cancel 発生タイミング に Cancel を発動すると…

ここで進行中の URLSession のリクエストをキャンセルできます。

ローカルキャッシュの実装方法

Cache の部分は直接 PINCache を使用し、Cache の処理を任せることで、Cache の読み書きのデッドロックや Cache の LRU 戦略の実装問題を回避しています。

️️⚠️️️️️️️️️️OOM警告!

ここでは音楽のキャッシュファイルサイズが最大で約10MB程度のため、PINCacheをローカルキャッシュツールとして使用できます。動画の場合は一度に数GBのデータをメモリに読み込む必要があるため、この方法は使えません

■■■■■■■■■■■■■■

Lex Tang @ Twitter は次のように述べています:

@zhgchgli 伝統的で保守的な方法は FileHandle を使うことです。私は約200行のSwiftコードでこれを実現しました。seek と read/write は読み書き時のOOMを効果的に防げます。その後のデータリクエストの応答ロジックは、LeetCodeのセグメントツリー関連の問題、例えば leetcode.com/problems/range… を参考にすると良いでしょう。

2021-01-06 14:35:13にツイートされました。

■■■■■■■■■■■■■■

この部分の要件がある場合は、FileHandle の seek や read/write の特性を利用した方法を参考にしてください。

仕事開始!

無駄話は抜きにして、まずは完全なプロジェクトを紹介します:

AssetData

ローカルキャッシュのデータオブジェクトは NSCoding を実装しています。これは PINCache が archivedData メソッドの encode/decode に依存しているためです。

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 : AssetDataContentInformation
    AssetDataContentInformation
    Rangeリクエスト対応の有無(isByteRangeAccessSupported)、リソースの総長(contentLength)、ファイルタイプ(contentType)を格納する。

  • mediaData : 元の音声データ (ファイルが大きすぎるとOOMになります)

■■■■■■■■■■■■■■

Lex Tang @ Twitter は次のように述べています:

@zhgchgli AssetData.mediaData が 5GB の 4K HDR 動画の場合でも、やはり OOM になりますか?また、慎重を期すならまず Accept-Ranges を確認してから Content-Range を取得すべきです。

2021年1月31日 15:06:09 にツイートされました。

■■■■■■■■■■■■■■

PINCacheAssetDataManager

Data を PINCache に保存・取得するロジックのラップ。

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 を置き換える可能性があるため、他のコードは Class インスタンスではなく Protocol に依存する形にしています。

⚠️ mergeDownloadedDataIfIsContinuted このメソッドは非常に重要です。

線形再生の場合は新しいデータをキャッシュデータにどんどん追加すればよいですが、実際はもっと複雑です。ユーザーが Range 0〜100 を再生した後、直接 Range 200〜500 にシークすることもあります。既存の 0〜100 のデータと新しい 200〜500 のデータをどう結合するかが大きな問題です。

⚠️データの結合に問題があると、深刻な再生の乱れが発生します….

こちらの回答は、非連続データは処理しません;当プロジェクトは音声のみで、ファイルサイズも数MB(≤10MB)なので、開発コストを考慮して対応していません。連続したデータの結合のみ処理しています(例えば、現在0〜100があり、新しいデータが75〜200の場合は結合して0〜200にします;新しいデータが150〜200の場合は無視して結合しません)。

非連続の結合を考慮する場合、保存方法を別途工夫し(欠損部分を識別できるようにする)、リクエスト時にもどの区間をネットワークから取得し、どの区間をローカルから取得するかをクエリできる必要があります。この状況を考慮した実装は非常に複雑になります。

画像出典: iOS AVPlayer 動画キャッシュの設計と実装

画像出典: iOS AVPlayer 動画キャッシュの設計と実装

[2026/05/10] 更新済み

5年が経ち、「非連続データを処理しない」という妥協に対して、ようやくよりクリーンな解決策が見つかりました――「どのバイトレンジがキャッシュされているか」という集合問題を抽出し、ジェネリックで整数座標、閉区間のコンテナに任せる方法です:Rangeable

Rangeable は Hashable 要素を「結合された非重複整数区間の集合」にマッピングするコンテナで、もともとは ZMediumToMarkdown のマークダウンレンダラーから抽出されたものです。同じ API が本記事の非連続キャッシュ問題も解決できるため、このケースを RFC §1.3.1 に第二のリファレンスコンシューマとして追加しました。

原文の2つの課題への対応

  • (Q1) 読み取り範囲: Range: bytes=lo-hi が与えられたとき、O(log n)以内に「[lo, hi] の中で最初のキャッシュ済みプレフィックスはどこか?最初のギャップはどこから始まるか?」を答えられるようにする。

  • (Q2) 書き込み: 新しく受け取ったバイト区間 [a, b] を、既存の [0, 100][200, 500] などと自動的にマージできるようにする(100/101 のような整数で隣接している場合も含む)。本文で紹介した mergeDownloadedDataIfIsContinuted は当初、「新しいデータがちょうど末尾に接続している」ケースのみを処理し、それ以外はすべて無視していた。

Rangeable はこの2つの処理を transitions(over:) / subscript[i] / insert(...) の3つのAPIに分けています。また、バイトのインデックスバイト本体の保存を分離しています——インデックスは Rangeable<CacheToken> に任せ、バイト本体は FileHandle.seek + write でスパースファイルに書き込みます(これにより元記事の ⚠️OOM 警告が解消され、mp3全体を Data にして PINCache に入れる必要がなくなりました)。

1) AssetDataManager プロトコルの書き換え

旧プロトコルでは mergeDownloadedDataIfIsContinuted を使って連続したデータを繋げていましたが、新プロトコルでは「どの区間がキャッシュ済みかを教えてもらい」、ResourceLoader がファイルから取得するかネットワークから取得するかを自分で決めます:

protocol AssetDataManager: AnyObject {
    var contentInformation: AssetDataContentInformation? { get }
    func saveContentInformation(_ info: AssetDataContentInformation)
    /// ダウンロードした断片を書き込み、キャッシュインデックスに自動でマージする。連続性は要求しない。
    func saveDownloadedData(_ data: Data, offset: Int64) throws
    /// [start, end] 内の**startから始まる連続したキャッシュ済みプレフィックス**を読み取る。
    /// ギャップにかかっている場合は nil を返す。
    func cachedPrefix(in range: ClosedRange<Int64>) throws -> Data?
    /// [start, end] 内の最初のギャップを取得する。全て揃っていれば nil を返す。
    func missingRange(in range: ClosedRange<Int64>) -> ClosedRange<Int64>?
}

2) RangeableAssetDataManager(PINCacheAssetDataManagerの代替)

二つの鍵:

  • Bytes を FileHandle で sparse file に書き込む(これは本文中 Lex Tang がコメントで言及した FileHandle seek read/write の方法)。

  • ビット範囲のインデックスには Rangeable<CacheToken> を使用します(mergeDownloadedDataIfIsContinuted のすべての if-else を置き換え)。

import Foundation
import Rangeable
private enum CacheToken: Hashable { case cached }
final class RangeableAssetDataManager: AssetDataManager {
    private let queue = DispatchQueue(label: "li.zhgchg.rangeableAssetDataManager")
    private let fileURL: URL
    private let metaURL: URL
    private let handle: FileHandle
    private(set) var contentInformation: AssetDataContentInformation?
    private var ranges = Rangeable<CacheToken>()
    init(cacheKey: String, root: URL) throws {
        let dir = root.appendingPathComponent("ResourceLoader", isDirectory: true)
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        self.fileURL = dir.appendingPathComponent("\(cacheKey).bin")
        self.metaURL = dir.appendingPathComponent("\(cacheKey).meta")
        if !FileManager.default.fileExists(atPath: fileURL.path) {
            FileManager.default.createFile(atPath: fileURL.path, contents: nil)
        }
        self.handle = try FileHandle(forUpdating: fileURL)
        loadMeta()
    }
    deinit { try? handle.close() }
    func saveContentInformation(_ info: AssetDataContentInformation) {
        queue.sync {
            self.contentInformation = info
            persistMeta()
        }
    }
    func saveDownloadedData(_ data: Data, offset: Int64) throws {
        guard !data.isEmpty else { return }
        try queue.sync {
            try handle.seek(toOffset: UInt64(offset))
            try handle.write(contentsOf: data)
            // Rangeable は隣接または重複する区間を自動的に union し、非連続は disjoint のまま保持します。
            try ranges.insert(.cached,
                              start: Int(offset),
                              end:   Int(offset) + data.count - 1)
            persistMeta()
        }
    }
    func cachedPrefix(in range: ClosedRange<Int64>) throws -> Data? {
        try queue.sync {
            guard ranges[Int(range.lowerBound)].objs.contains(.cached) else { return nil }
            let evs = try ranges.transitions(lo: Int(range.lowerBound),
                                             hi: Int(range.upperBound))
            // close の座標は cached 区間の終了点 + 1(RFC §4.1.1)
            let runEndExclusive: Int64 = {
                if let close = evs.first(where: { $0.kind == .close }),
                   let c = close.coordinate {
                    return Int64(c)
                }
                return range.upperBound + 1
            }()
            let sliceEndExclusive = min(runEndExclusive, range.upperBound + 1)
            let length = Int(sliceEndExclusive - range.lowerBound)
            try handle.seek(toOffset: UInt64(range.lowerBound))
            return try handle.read(upToCount: length)
        }
    }
    func missingRange(in range: ClosedRange<Int64>) -> ClosedRange<Int64>? {
        queue.sync {
            let evs = (try? ranges.transitions(lo: Int(range.lowerBound),
                                               hi: Int(range.upperBound))) ?? []
            let firstByteCached = ranges[Int(range.lowerBound)].objs.contains(.cached)
            if firstByteCached {
                guard let close = evs.first(where: { $0.kind == .close }),
                      let gapStart = close.coordinate,
                      Int64(gapStart) <= range.upperBound else { return nil }
                let nextOpen = evs.first(where: {
                    $0.kind == .open && ($0.coordinate ?? .max) > gapStart
                })?.coordinate
                let gapEnd = nextOpen.map { Int64($0) - 1 } ?? range.upperBound
                return Int64(gapStart)...min(gapEnd, range.upperBound)
            } else {
                let nextOpen = evs.first(where: { $0.kind == .open })?.coordinate
                let gapEnd = nextOpen.map { Int64($0) - 1 } ?? range.upperBound
                return range.lowerBound...gapEnd
            }
        }
    }
    private func loadMeta() {
        guard let data = try? Data(contentsOf: metaURL),
              let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else { return }
        if let info = dict["info"] as? [String: Any] {
            let ci = AssetDataContentInformation()
            ci.contentLength = (info["len"] as? NSNumber)?.int64Value ?? 0
            ci.contentType = info["type"] as? String ?? ""
            ci.isByteRangeAccessSupported = (info["range"] as? Bool) ?? false
            self.contentInformation = ci
        }
        if let segs = dict["segments"] as? [[Int]] {
            for seg in segs where seg.count == 2 {
                try? ranges.insert(.cached, start: seg[0], end: seg[1])
            }
        }
    }
    private func persistMeta() {
        var dict: [String: Any] = [:]
        if let ci = contentInformation {
            dict["info"] = ["len": ci.contentLength,
                            "type": ci.contentType,
                            "range": ci.isByteRangeAccessSupported]
        }
        // Rangeable.getRange(of:) は「結合済み」のすべての disjoint 区間を一度に返します
        dict["segments"] = ranges.getRange(of: .cached).map { [$0.lo, $0.hi] }
        if let data = try? JSONSerialization.data(withJSONObject: dict) {
            try? data.write(to: metaURL, options: .atomic)
        }
    }
}

元の mergeDownloadedDataIfIsContinuted の if-else がなくなり、代わりに ranges.insert(.cached, ...) を使って、Rangeable 自身が重複や隣接のマージを処理します。

3) ResourceLoader.shouldWait… の書き換え

旧バージョンの「ローカルに足りなければ全体をネットから取得」から「まず cached prefix を返し → ギャップ部分だけネットから取得」に変更しました。後半部分は以前にダウンロード済みであれば、次の loadingRequestcurrentOffset を進めた後、cachedPrefix が引き継ぎます:

func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
                    shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
    let type = ResourceLoader.resourceLoaderRequestType(loadingRequest)
    if type == .contentInformation {
        if let info = manager.contentInformation {
            loadingRequest.contentInformationRequest?.contentLength = info.contentLength
            loadingRequest.contentInformationRequest?.contentType   = info.contentType
            loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = info.isByteRangeAccessSupported
            loadingRequest.finishLoading()
            return true
        }
        return startNetworkRequest(for: loadingRequest, type: .contentInformation)
    }
    let req = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
    let lo = req.start
    let hi: Int64 = {
        switch req.end {
        case .requestTo(let e): return e - 1
        case .requestToEnd:     return manager.contentInformation?.contentLength ?? lo
        }
    }()
    guard lo <= hi else { loadingRequest.finishLoading(); return true }
    // 1) まずローカルで取得できるプレフィックスをプレーヤーに渡す
    if let cached = try? manager.cachedPrefix(in: lo...hi), !cached.isEmpty {
        loadingRequest.dataRequest?.respond(with: cached)
        let nextOffset = lo + Int64(cached.count)
        if nextOffset > hi {
            loadingRequest.finishLoading()
            return true
        }
        // 2) プレフィックスを渡した後に不足があれば、ギャップの開始位置からネットワークリクエストを開始
        return startNetworkRequest(for: loadingRequest,
                                   type: .dataRequest,
                                   from: nextOffset, to: hi)
    }
    return startNetworkRequest(for: loadingRequest,
                               type: .dataRequest,
                               from: lo, to: hi)
}

AVPlayer の cancel/seek の仕組みは変更不要です:5000 に seek すると → 新しい loadingRequest が発生し、新しい cachedPrefix は(以前にダウンロード済みであれば)5000 からのデータを直接取得します;前のリクエストを cancel しても → sparse file に部分的に書き込まれたバイトは Rangeable のインデックスに残り、データは失われません。

CachingAVURLAsset

AVURLAsset は ResourceLoader Delegate を weak 参照するため、ここでは AVURLAsset を継承した独自の AVURLAsset クラスを作成し、その内部で ResourceLoader を生成・割り当て・保持して、AVURLAsset のライフサイクルに合わせることをおすすめします。また、元の URL や CacheKey などの情報も保存できます…。

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
    }
}

使用:

if CachingAVURLAsset.isSchemeSupport(url) {
  let asset = CachingAVURLAsset(url: url)
  let avplayer = AVPlayer(asset)
  avplayer.play()
}

isSchemeSupport() は、URL が私たちの Resource Loader をサポートしているかどうか(file:// を除外)を判定するために使われます。

originalURL は元のリソースの URL を格納します。

cacheKey はこのリソースのキャッシュキーを保存します。ここではファイル名をそのままキャッシュキーとして使用します。

cacheKey は実際の状況に応じて調整してください。ファイル名がハッシュ化されていない場合、重複の可能性があるため、衝突を避けるために先にハッシュ化してキーにすることをおすすめします。URL全体をハッシュ化してキーにする場合も、URLが変動するかどうか(例えばCDNを使用している場合)に注意してください。

Hash は md5 や sha などを使用できます。iOS 13 以降では Apple の CryptoKit を直接使えます。それ以外は Github を探してください!

ResourceLoaderRequest

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)" // Rangeヘッダーを設定
            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 :このリクエストがファイル情報(contentInformation)の初回要求か、データ要求(dataRequest)かを区別するために使用します。

RequestRange :要求する Range の範囲。end は指定した位置まで(requestTo(Int64))または最後まで(requestToEnd)可能。

ファイル情報は以下から取得できます:

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)

レスポンスヘッダーを取得する際、HEADリクエストに変更するとこの処理に入らないため、別の方法で対応する必要があることに注意してください。

  • isByteRangeAccessSupported :レスポンスヘッダーの Accept-Ranges == bytes を確認すること

  • contentType :プレーヤーが要求するファイルタイプ情報で、フォーマットはユニフォームタイプ識別子(UTI)です。audio/mpeg ではなく、public.mp3 と記述します。

  • contentLength :Response Header の Content-Range を見てください :bytes 0–1/ リソースの総長さ

⚠️ここで注意すべきは、サーバーが返すヘッダーの大文字・小文字の形式です。必ずしも Accept-Ranges や Content-Range と書かれているとは限りません。サーバーによっては accept-ranges や Accept-ranges のように小文字や異なる表記の場合もあります。

補足:大文字・小文字を考慮する場合は HTTPURLResponse の Extension を作成できます

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() // responseからContent-Rangeを解析してcontentLengthを取得する

  • isByteRangeAccessSupported = response.parseAcceptRanges() // バイトレンジアクセスがサポートされているか解析する

  • contentType = response.mimeTypeUTI()

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)

前述の知識に基づき、ダウンロード済みのデータをリアルタイムで取得するため、この方法では断片的にデータを取得し続けます。取得したデータは downloadedData に追加して保存します。

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)

タスクがキャンセルまたは終了したときに必ずこのメソッドに入ります。ここでダウンロード済みのデータを保存します。

前述の知識で述べた Cancel 機構により、プレーヤーは十分なデータを取得すると Cancel、Cancel Request を発行します。そのため、このメソッドに入る時の実際の error は error = NSURLErrorCancelled となります。したがって、error に関わらずデータを取得できていれば保存を試みます。

⚠️ URLSessionは並行してリクエストを送るため、操作は必ずDispatchQueue内で行い、データの混乱を防いでください(データが混乱すると再生が異常になります)。

️️⚠️URLSession は finishTasksAndInvalidate または invalidateAndCancel のどちらかのメソッドを呼び出さないとオブジェクトを強く保持し続け、メモリリークが発生します。そのため、キャンセルでも完了でも必ず呼び出して、タスク終了時にリクエストを解放する必要があります。

️️⚠️️️️️️️️️️️もし downloadedData がメモリ不足(OOM)になるのを防ぎたい場合は、didReceive Data 内でローカルに保存してください。

ResourceLoader

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 {
                        // キャッシュデータはあるが不足している場合
                        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 は初回のリクエストを意味し、プレーヤーがまずファイル情報の提供を要求していることを示します。

ファイル情報を要求する際には、以下の3つの情報を付与する必要があります:

  • loadingRequest.contentInformationRequest?.isByteRangeAccessSupported :Rangeリクエストによるデータ取得をサポートしているかどうか

  • loadingRequest.contentInformationRequest?.contentType :ユニフォームタイプ識別子

  • loadingRequest.contentInformationRequest?.contentLength :ファイルの総長さ Int64

loadingRequest.dataRequest?.requestedOffset は要求された Range の開始オフセットを取得できます。

loadingRequest.dataRequest?.requestedLength は要求された Range の長さを取得できます。

loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true の場合、Range の長さに関係なく、最後までデータを取得します。

loadingRequest.dataRequest?.respond(with: Data) は、読み込んだデータをプレーヤーに返します。

loadingRequest.dataRequest?.currentOffset は現在のデータオフセットを取得できます。dataRequest?.respond(with: Data) を呼ぶと、currentOffset はそれに応じて進みます。

loadingRequest.finishLoading() データの読み込みが完了したことをプレーヤーに通知します。

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool

プレーヤーがデータを要求すると、まずローカルキャッシュにデータがあるか確認し、あれば返します。部分的にしかない場合も同様に部分的に返します。例えば、ローカルに 0–100 があり、プレーヤーが 0–200 を要求した場合は、まず 0–100 を返します。

ローカルキャッシュがなく、返却されるデータが不足している場合は、ResourceLoaderRequest を発行してネットワークからデータを取得します。

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)

プレーヤーがリクエストをキャンセルし、ResourceLoaderRequestをキャンセルします。

おそらくお気づきかもしれませんが resourceLoaderRequestRange の offset は currentOffset を参照しています。これは、まずローカルの dataRequest?.respond(with: Data) で既にダウンロード済みのデータを返すためです。したがって、そのまま進んだ offset を見るだけで問題ありません。

func private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
// リクエストの辞書を保持

⚠️ requests の一部の例では currentRequest: ResourceLoaderRequest のみを使って保存していますが、これは問題があります。現在のリクエストが取得中にユーザーがシークすると、古いリクエストをキャンセルして新しいリクエストを発行します。しかし、順序通りに処理されるとは限らず、新しいリクエストが先に処理されてからキャンセルが行われる場合もあります。そのため、Dictionary を使って管理する方が安全です!

⚠️すべての操作を同じ DispatchQueue 内で実行し、データの不整合を防ぎます。

deinit 時にまだリクエスト中のすべての requests をキャンセルする
Resource Loader の deinit は AVURLAsset の deinit を意味し、プレーヤーがこのリソースをもう必要としないことを示します。したがって、まだデータを取得中のリクエストはキャンセルできます。すでに取得済みのデータは Cache に書き込みます。

補足と謝辞

感謝 Lex 汤 さんのご指導。

感謝 外孫女 からの開発に関する意見とサポートをいただきました。

本記事は音楽の小ファイルのみ対象です

動画の大きなファイルは downloadedData や AssetData/PINCacheAssetDataManager でメモリ不足の問題が発生する可能性があります。

前述のように、この問題を解決するには、fileHandler の seek、read、write を使ってローカル Cache の読み書きを操作してください(AssetData/PINCacheAssetDataManager の代わりに)。または、Github に大容量データのファイル書き込み/読み込み用のプロジェクトがあるか探してみてください。

AVQueuePlayer が再生アイテムを切り替える際に進行中のダウンロードをキャンセルする

前述の知識にあるように、再生ターゲットを切り替えても Cancel は発生しません。AVPlayer の場合は AVURLAsset の Deinit が呼ばれるためダウンロードは中断されますが、AVQueuePlayer はキュー内に残っているため中断されず、単に再生ターゲットが次の曲に切り替わるだけです。

ここで唯一の方法は、再生ターゲット変更の通知を受け取り、その通知を受けた後に前の AVURLAsset の読み込みをキャンセルすることだけです。

asset.cancelLoading()

音声データの暗号化と復号化

音声の暗号化・復号は ResourceLoaderRequest 内で Data を取得して行い、保存時には AssetData の encode/decode でローカルに存在する Data に対して暗号化・復号を行えます。

CryptoKit SHA 使用サンプル:

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 {
          // iOS 13未満の場合の処理
        }
    }
    
    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 {
              // iOS 13未満の場合の処理
            }
        } else {
            // mediaDataが存在しない場合の処理
        }
    }
}

PINCache に関する操作

PINCache は PINMemoryCache と PINDiskCache を含んでおり、PINCache がファイルからメモリへの読み込みやメモリからファイルへの書き込みを処理してくれます。私たちは PINCache に対して操作するだけで済みます。

シミュレーターで Cache ファイルの場所を探す:

NSHomeDirectory() を使ってシミュレーターのファイルパスを取得する

Finder -> 移動 -> パスを貼り付け

Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader は、私たちが作成した Resource Loader キャッシュのディレクトリです。

PINCache(name: “ResourceLoader”) の name はディレクトリ名です。

rootPath を指定することもでき、ディレクトリを Documents 以下に変更できます(システムに削除される心配がありません)。

PINCache の最大上限を設定:

 PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // 最大: 300MB
 PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90日間

システムのデフォルト上限

システムのデフォルト上限

0 に設定すると、ファイルは自動的に削除されません。

あとがき

当初この機能の難易度を軽く見ていて、すぐに終わると思っていたが、苦労してしまい、データ保存の問題にさらに2週間ほど費やした。しかし、そのおかげで Resource Loader の仕組みや GCD、Data を完全に理解することができた。

参考資料

最後に実装方法を研究するための参考資料を添付します。

  1. iOS AVPlayer ビデオキャッシュの設計と実装 原理のみを説明しています

  2. AVPlayerを使った音声・動画再生とキャッシュの実装、動画映像の同期出力対応 [ SZAVPlayer ] にコードが付いています(非常に充実していますが複雑です)

  3. CachingPlayerItem (簡単に実装されており理解しやすいですが、完全ではありません)

  4. おそらく現在最高の AVPlayer 音声・動画キャッシュソリューション AVAssetResourceLoaderDelegate

  5. 抖音風 Swift バージョン [ Github ](とても面白いプロジェクトで、抖音アプリを再現しています。中で Resource Loader も使用されています)

  6. iOS HLS キャッシュ実践方法探求の旅

延伸

PostZMediumToMarkdown によって Medium から変換されました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント