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やその他の方法を使用できます…

  • 外部は単に AVAsset ファクトリーを呼び出し、URL を渡すだけで、AVAsset がキャッシュ対応になります。

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

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

前書き

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

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

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

目標

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

  • 音楽再生時にまずローカルにキャッシュがあるか確認し、あればサーバーからの再取得を行いません

  • キャッシュポリシーを設定可能;総容量の上限を超えた場合、最も古いキャッシュファイルから削除を開始します

  • 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 の内容は気にせず、レスポンスヘッダーの情報だけを確認したい場合です。ネイティブの AVPlayer は GET を使って確認しているため、本記事でも同様に使用します

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

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

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

  • 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
...
(バイナリコンテンツ)

この時点で 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データを要求したり、条件式を付けたりできますが、今回は使用しません。詳細はこちらをご参照ください。

コネクションのキープアライブ

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から総長までのデータを要求します。

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

再生を続けながら、Range ヘッダーを使って後続のデータをリクエストします。

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

AVPlayer リクエスト例:

1. GET Range 0-1 => レスポンス: 総長さ 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で再生リソースを変更しても、前のリクエストのキャンセルは発生しません。

AVQueue の事前バッファリング

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

実装

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

以前に触れた AVAssetResourceLoaderDelegate は、このインターフェースを使って 自分で Resource Loader を実装し、Asset に提供する ことができます。

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

サンプルでは 1つの Resource Loader がすべての AVURLAsset を担当している 例を見ましたが、それは誤りだと思います。Resource Loader は AVURLAsset ごとに1つずつ作成し、AVURLAsset のライフサイクルに従うべきであり、本来は AVURLAsset に属しています。

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

カスタム 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 を使うかローカルから Data を返すかを決定できます。

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

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

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

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

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

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

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

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

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

作業開始!

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

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になります)

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 の代わりに他のストレージ方法を使う可能性があるため、他のコードはクラスのインスタンスではなく 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 视频缓存的设计与实现

CachingAVURLAsset

AVURLAsset は ResourceLoader Delegate を weak で保持するため、ここでは 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)"
            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 :レスポンスヘッダーの 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( )

  • isByteRangeAccessSupported = response.parseAcceptRanges( )

  • contentType = response.mimeTypeUTI() // レスポンスの MIME タイプを取得する

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

前述の知識の通り、ダウンロード済みのデータをリアルタイムで取得するため、このメソッドは継続的に呼ばれ、断片ごとに Data を取得します。取得したデータは downloadedData に append して保存します。

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

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

前述の Cancel 機構で述べたように、プレーヤーは十分なデータを取得すると Cancel リクエストを送信します。そのため、このメソッドに入る時点で実際には error = NSURLErrorCancelled となります。したがって、エラーに関係なくデータを取得できていれば、保存を試みます。

⚠️ 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 を見るだけで十分だからです。

// プライベートな辞書。AVAssetResourceLoadingRequestをキーに、ResourceLoaderRequestを値として保持する
private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]

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

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

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

補足および謝辞

感謝 Lex 汤 さんのご指導に感謝します。

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

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

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

前述の問題を解決するには、fileHandler の seek/read/write を使ってローカルキャッシュの読み書きを行ってください(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バージョンの場合の処理
        }
    }
    
    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 {
              // 復号処理に失敗した場合の処理
            }
        } else {
            // mediaDataが存在しない場合の処理
        }
    }
}

PINCache に関する操作

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

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

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. TikTok風 Swift版 [ Github ](面白いプロジェクトで、TikTokアプリを再現しています;Resource Loaderも使われています)

  6. iOS HLS Cache 実践方法探求の旅

拡張

Post は Medium から ZMediumToMarkdown によって変換されました。

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

ZhgChgLi

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

コメント