iOS HLS キャッシュ実装方法の探求旅
AVPlayer で m3u8 ストリーミング動画を再生しながらキャッシュする方法

photo by Mihis Alex
[2023/03/12] 更新
- 次の記事「AVPlayer 実践ローカルキャッシュ機能大全」では、AVPlayerのキャッシュ実装方法について解説します。
以前の実装をオープンソース化しました。必要な方はそのままご利用ください。
-
カスタムCache戦略は、PINCacheやその他の方法を使うことができます…
-
外部は make AVAsset ファクトリーを呼び出し、URL を渡すだけで、AVAsset がキャッシュをサポートします。
-
Combine を使ったデータフロー戦略の実装
-
いくつかのテストを書きました
について
HTTP Live Streaming(略してHLS)は、Appleが提唱したHTTPベースのストリーミングメディアネットワーク伝送プロトコルです。
音楽再生の場合、ストリーミングでないときは mp3 を音楽ファイルとして使い、そのファイルが大きければ全部ダウンロードするのに時間がかかります。
一方、HLS は一つのファイルを複数の小さいファイルに分割し、読み込んだ部分から再生するため、最初の分割部分を取得すればすぐに再生を開始でき、全部ダウンロードする必要はありません!
.m3u8 ファイルは、これらの分割された .ts 小ファイルのビットレート、再生順序、時間、そして全体の音声情報を記録しています。また、暗号化・復号化の保護や低遅延ライブ配信などにも対応できます。
.m3u8 ファイルの例 (aviciiwakemeup.m3u8):
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.900411,
aviciiwakemeup–00001.ts
#EXTINF:9.900400,
aviciiwakemeup–00002.ts
#EXTINF:9.900411,
aviciiwakemeup–00003.ts
#EXTINF:9.900411,
.
.
.
#EXTINF:6.269389,
aviciiwakemeup-00028.ts
#EXT-X-ENDLIST
*EXT-X-ALLOW-CACHE は iOS≥ 8/Protocol Ver.7 で非推奨 となっており、記述しても意味がありません。
目標
ストリーミングサービスにおいて、キャッシュは非常に重要です。音声ファイルは小さいものでも数MB、大きいものでは数GBに及びます。再生のたびにサーバーからファイルを毎回ダウンロードすると、サーバーへの負荷が非常に大きくなり、通信コストも膨大になります。キャッシュ層があれば、サービスのコストを大幅に節約でき、ユーザーもネットワークや時間を無駄にせずに済みます。これは双方にとってのウィンウィンの仕組みです(ただし、容量上限や定期的な削除設定を忘れずに、ユーザーの端末がいっぱいにならないように注意してください)。
問題
従来のストリーミングでない mp3/mp4 は特に難しい処理はなく、再生前にデバイスにダウンロードし、ダウンロード完了後に再生を開始していました。とにかく再生するには全部ダウンロードし終える必要があるので、自分で URLSession を使ってファイルをダウンロードし、file:// のローカルパスを AVPlayer に渡して再生するだけで済みます。あるいは正式な方法として、AVAssetResourceLoaderDelegate を使い、Delegate メソッド内でダウンロードしたデータをキャッシュする方法もあります。
ストリーミングの考え方は実にシンプルで、まず .m3u8 ファイルを読み込み、その中の情報を解析して、各 .ts ファイルをキャッシュすればよいだけです。しかし、実装してみるとそんなに簡単ではなく、想像以上に難しかったため、この文章を書くことになりました!
再生部分は引き続き iOS の AVFoundation の AVPlayer を使用し、操作上はストリーミングファイルと非ストリーミングファイルで違いはありません。
例:
let url:URL = URL(string:"https://zhgchg.li/aviciiwakemeup.m3u8")
var player: AVPlayer = AVPlayer(url: url)
player.play()
2021–01–05 更新:
私たちは妥協して mp3 ファイルの使用に戻りました。これにより、AVAssetResourceLoaderDelegate を直接使って実装が可能になります。詳しい実装は「AVPlayer 邊播邊 Cache 實戰」を参照してください。
実践方案
私たちの目標を達成できるいくつかの方法と実践時に直面した問題。
方案 1. AVAssetResourceLoaderDelegate ❌
最初のアイデアは、mp3/mp4 のときと同じ方法を使うことです!同様に AVAssetResourceLoaderDelegate を使い、Delegate メソッド内で .ts ファイルをキャッシュします。
ただし申し訳ありませんが、この方法はうまくいきません。Delegate では .ts ファイルのダウンロードリクエスト情報をキャッチできないためです。この件については、こちらの質問と回答や公式ドキュメントで詳しく説明されています。
AVAssetResourceLoaderDelegate の実装は「 AVPlayer 邊播邊 Cache 實戰 」を参考にしてください。
方案 2.1 URLProtocol リクエストのインターセプト ❌
URLProtocol は最近学んだ方法で、URL Loading System に基づくすべてのリクエスト(URLSession、API 呼び出し、画像ダウンロードなど)を私たちが傍受してリクエストやレスポンスを変更し、そのまま返すことができます。まるで何も起きていないかのように、こっそり処理されます。URLProtocol については こちらの記事 を参照してください。
この方法を適用して、AVFoundation の AVPlayer が .m3u8 や .ts のリクエストを行う際にそれを傍受し、ローカルにキャッシュがあれば直接キャッシュデータを返し、なければ実際にリクエストを送るようにします。これにより、私たちの目的を達成できます。
同様に、申し訳ありませんが、この方法も通用しません。AVFoundation の AVPlayer のリクエストは URL Loading System 上で行われていないため、 intercept できません。
一部ではシミュレーターでは可能だが実機では不可能と言われています
方案 2.2 強制的に URLProtocol を通す ❌
方案 2.1 の奇抜な方法によると、リクエストの URL をカスタムスキーム(例:streetVoiceCache://)に変更すると、AVFoundation はこのリクエストを処理できずにエラーを返します。これにより、私たちの URLProtocol がリクエストをキャッチして、やりたい処理を行うことが可能になります。
let url:URL = URL(string:"streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https")
var player: AVPlayer = AVPlayer(url: url)
player.play()
URLProtocol は streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https を検知します。このとき、元のURLに復元してから URLSession でデータを取得すれば、ここで独自にキャッシュが可能です。m3u8 内の .ts ファイルのリクエストも同様に URLProtocol で検知され、同じく独自にキャッシュできます。
すべてが完璧に見えましたが、ワクワクしながらビルドしてアプリを実行したところ、Appleに思いっきり叩かれました:
Error: 12881 “CoreMediaErrorDomain カスタムURLがリダイレクトされていません”
彼は私が与えた .ts ファイルのリクエストのレスポンスデータを受け付けません。urlProtocol:wasRedirectedTo メソッドを使って元の HTTPS リクエストにリダイレクトしなければ正常に再生できません。たとえ .ts ファイルをローカルにダウンロードして、その file:// ファイルにリダイレクトしても受け付けません。公式フォーラム(https://forums.developer.apple.com/thread/30833)を調べたところ、これはできないという結論でした。.m3u8 は必ず HTTP/Https からのものでなければならず(つまり、.m3u8 とすべての分割された .ts ファイルをローカルに置いても、file:// を使って AVPlayer で再生することはできません)、さらに .ts ファイルも URLProtocol を使って自前でデータを提供することはできません。
くそ…
方案 2.2–2 同じく方案 2.2 だが、方案 1 の AVAssetResourceLoaderDelegate と組み合わせて実装 ❌
実装方法は方案 2.2 のように、AVPlayer にカスタムスキームを渡して AVAssetResourceLoaderDelegate に入らせ、そこで自分で処理します。
同 2.2 結果:
Error: 12881 “CoreMediaErrorDomain カスタムURLがリダイレクトされていません”
公式フォーラム 同じ回答です。
復号処理に利用できます(こちらの記事 や このサンプル を参照)が、Cache機能の実現はできません。
方案 3. リバースプロキシサーバー ⍻(可能だが完璧ではない)
この方法は、HLSのキャッシュ処理について最も多くの人が提案する答えで、アプリ内にHTTPサーバーを立ててリバースプロキシサーバーとして動作させるものです。
原理も非常に簡単で、APP 上で 8080 ポートの HTTP サーバーを起動すると、URL は http://127.0.0.1:8080/ になります。そして、受け取ったリクエストに対して処理を行い、レスポンスを返すことができます。
私たちのケースに適用すると、リクエストURLを以下のように変更します:
http://127.0.0.1:8080/aviciiwakemeup.m3u8?origin=http://zhgchg.li/
HTTPサーバーのハンドラーで *.m3u8 をインターセプトして処理します。このときリクエストが来るとハンドラーに入り、何をするかは自由に決められ、どんなデータをレスポンスするかも自分でコントロールできます。.ts ファイルも同様に処理されます。ここでキャッシュ機構を実装することが可能です。
AVPlayer にとっては、http:// で始まる .m3u8 の標準ストリーミング音声ファイルなので、問題はありません。
完全な実装例はこちらを参照してください:
私もこのサンプルを参考にしたため、Local HTTP Server の部分は GCDWebServer を使用しています。その他に、より新しい Telegraph も利用可能です。(CocoaHttpServer は長期間更新されていないため、おすすめしません)
良さそうですが、一つ問題があります:
私たちのサービスは音楽ストリーミングであり、映像再生プラットフォームではありません。音楽ストリーミングでは、多くの場合ユーザーがバックグラウンドで音楽を切り替えています。このとき、ローカルHTTPサーバーはまだ動作していますか?
GCDWebServer の説明では、バックグラウンドに入ると自動的に切断され、フォアグラウンドに戻ると自動的に復帰しますが、パラメータ GCDWebServerOption_AutomaticallySuspendInBackground:false を設定することで、この機能を無効にできます。
しかし、実際に試すと一定時間リクエストを送らないとサーバーは切断されてしまい(状態は誤って isRunning のまま)、システムに強制終了されている感覚です。HTTP Server の方法を深掘りしたところ、内部はすべてソケットベースであることがわかり、公式のソケットサービスに関するドキュメントを調べた結果、この欠点は解決不可能で、背景状態で新しい接続がない場合はシステムにより停止されてしまいます。
ネット上で見つけた複雑な方法としては…長時間のリクエストを送る、または空のリクエストを繰り返し送って、サーバーがバックグラウンドでシステムに停止されないようにする方法があります。
以上はすべてアプリがバックグラウンドの状態に関するもので、フォアグラウンド時はサーバーは非常に安定しており、アイドル状態で停止されることもありません。この問題は発生しません!
結局は他のサービスに依存しているため、開発環境で問題なくても、実際の運用では rollback 処理(AVPlayer.AVPlayerItemFailedToPlayToEndTimeErrorKey 通知)を入れることをお勧めします。そうしないと、万が一サービスが停止した場合にユーザーが固まってしまいます。
だから完璧ではないんです…
方案 4. HTTPクライアント自身のキャッシュ機能を利用する ❌
私たちの .m3u8/.ts ファイルのレスポンスヘッダーには、Cache-Control、Age、eTag などの HTTP クライアントキャッシュ情報が含まれています。私たちのウェブサイトのキャッシュ機構は Chrome で問題なく動作しています。また、公式の新しい Protocol Extension for Low-Latency HLS (低遅延HLS) の初期仕様書にも、キャッシュに関して cache-control ヘッダーを設定してキャッシュを行えると記載されています。

しかし実際には、AVFoundation の AVPlayer には HTTP クライアントキャッシュの効果は全くなく、この方法も使えません!単なる夢物語です。
方案 5. AVFoundation の AVPlayer を使わずに音声ファイルを再生する ✔
音声ファイルの解析、キャッシュ、エンコード、再生機能を自作する。
かなりハードで、高度な技術力と多くの時間が必要です;調査していません。
参考用のオープンソースプレイヤーを添付します: FreeStreamer 。もしこの方法を選ぶなら、巨人の肩に乗ってサードパーティのライブラリを使う方が良いでしょう。
方案 5–1. HLS を使わない
同じ方案5、非常にハードコアで、高度な技術力と大量の時間が必要です;調査していません。
方案 6. .ts 分割ファイルを .mp3/.mp4 ファイルに変換する ✔
研究はしていませんが、確かに可能です。しかし、ダウンロード済みの .ts ファイルを個別に .mp3 や .mp4 に変換して順番に再生したり、一つのファイルに圧縮したりするのは、考えるだけで複雑で難しそうです。
ご興味があれば、こちらの記事 をご参照ください。
方案 7. ファイルを完全にダウンロードしてから再生 ⍻
この方法は厳密にはストリーミングしながらキャッシュすることではなく、実際には音声ファイル全体をダウンロードしてから再生を開始します。.m3u8の場合は、2.2の方法で述べたように、直接ダウンロードしてローカルで再生することはできません。
実装する場合は、iOS 10 以上の API AVAssetDownloadTask.makeAssetDownloadTask を使用します。実際には .m3u8 を .movpkg としてローカルにパッケージ化し、ユーザーが再生できるようにします。
こちらはキャッシュ機能というよりはオフライン再生に近いです。
また、ユーザーは「設定」->「一般」->「iPhoneストレージ」-> APPから、ダウンロード済みのパッケージ化された音声ファイルを確認・管理できます。

ダウンロード済みの動画部分
詳細な実装は以下のサンプルを参照してください:
結論
以上の探索にはほぼ一週間かかり、あちこち試行錯誤して気が狂いそうになりました。現時点で信頼できて簡単に導入できる方法はまだありません。
新しいアイデアがあればまた更新します!
参考資料
Post は Medium から ZMediumToMarkdown によって変換されました。



コメント