iOS
Swift

iOSアプリでWeb上の音楽ファイルをHTTPストリーミング再生しながらキャッシュにも入れるライブラリ、Choristerとその実装

More than 3 years have passed since last update.

目的

  • mp3ファイルのURLを指定してchangeAudio(NSURL(string: "http://audio.com/file.mp3"))ようにメソッドを呼び出すと、そのmp3をダウンロードしながら再生する……つまりStreaming再生を行う
  • 1ファイルすべてをダウンロードし終わると、ファイルをキャッシュに保存する
  • もう一度、同じURLに対して音楽再生しようとすると、Web上のではなくキャッシュしたファイルを使って再生する

以上の役割を果たすライブラリ、Choristerを作成した。CocoaPodsにも登録してあるので、

pod "Chorister"

でインストールできる。

ノベルゲーム風の小説投稿サービス http://denkinovel.com/ のiOSアプリのために作った。ユーザーが指定したURLの音楽ファイルを、切り替えながら再生させるのに必要だった。

ちなみにChoristerは聖歌隊を意味する。

実装の方針

  1. AVAssetResourceLoaderDelegate protocolを継承したAudioLoaderクラスを作り、そのクラスを使い、ファイルをダウンロードするときの挙動をカスタマイズする
  2. AudioLoaderNSURLConnectionDataDelegate protocolも継承する。これにより、NSURLConnectionでデータを受け取ったときの処理をカスタマイズして、ダウンロード完了時にファイルをキャッシュする。
  3. キャッシュは、URLをキー、音楽ファイルのデータをバリューにして行う。changeAudio(url: NSURL)が呼び出されるたび、そのURLがキーにないか問い合わせを行い、もしあった場合はキャッシュしたファイルから音楽を再生する。

AVAssetResourceLoaderDelegateとは?

動画・音楽などを操作するAVAssetのリソースのローディングを制御するプロトコル。

example.swift
let asset = AVURLAsset(URL: filePathURL, options: nil)
asset.resourceLoader.setDelegate(audioLoader, queue: dispatch_get_main_queue())

のように、AVURLAsset (AVAssetのサブクラス)のresourceLoadersetDelegateすることで、そのAVURLAssetのファイルロードの処理を変更できる。

Apple公式ドキュメント: https://developer.apple.com/library/prerelease/ios/documentation/AVFoundation/Reference/AVAssetResourceLoaderDelegate_Protocol/index.html

実装に必要なメソッド

Choristerのリポジトリと見比べてみてほしい。 https://github.com/katryo/Chorister/tree/master/Pod/Classes

resourceLoader関連

AVAssetResourceLoaderDelegateを実装するには、以下の3つのメソッドを実装する必要がある。

  • resourceLoader:shouldWaitForLoadingOfRequestedResource:
  • resourceLoader:didCancelLoadingRequest:
  • resourceLoader:shouldWaitForRenewalOfRequestedResource:

shouldWaitForLoadingOfRequestedResource

connectionがなければ作って通信開始。pendingRequestsにloadingRequestを追加する

AudioLoader.swift
func resourceLoader(resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
        let interceptedURL = loadingRequest.request.URL
        let actualURL = getActualURL(interceptedURL!)
        let urlString = actualURL.absoluteString
        if (connections[urlString] == nil) {
            let request = NSURLRequest(URL: actualURL)
            let connection = NSURLConnection(request: request, delegate: self, startImmediately: false)!
            connection.setDelegateQueue(NSOperationQueue.mainQueue())
            connection.start()
            connections[actualURL.absoluteString] = connection
        }
        self.pendingRequests.append(loadingRequest)
        return true
    }

getActualURLは、httpstreamingのような独自SchemeのURLを、httpのSchemeにもどすメソッド。こうして、実際に取得する先のURLを作る。

didCancelLoadingRequest

ダウンロードがキャンセルされたときに呼び出されるメソッド。pendingRequestsからloadingRequestを削除する。

AudioLoader.swift
func resourceLoader(resourceLoader: AVAssetResourceLoader, didCancelLoadingRequest loadingRequest: AVAssetResourceLoadingRequest) {
        pendingRequests = pendingRequests.filter({ $0 != loadingRequest })
    }

注意

AVAssetResourceLoaderDelegate を使うには、そのAVAssetのSchemeが "Non Standard/Non Reserved" でなくてはならない。よって、HTTPやHTTPSのような一般的なSchemeではせっかくconnection(connection: NSURLConnection, didReceiveResponse response: NSURLResponse)を実装しても、呼ばれない。

そのことについて書かれたStackOverflowの回答: http://stackoverflow.com/questions/26372141/avassetresourceloaderdelegate-not-being-called

今回はこれを回避するため、以下のように、 httpstreamingのような独自のSchemeを用意した。

example.swift
    private func loadAsset(url: NSURL) -> NSURLAsset {

(省略)

            let scheme = url.scheme
            asset = AVURLAsset(URL: urlWithCustomScheme(url, scheme: scheme + "streaming"), options: nil)
        }
        asset.resourceLoader.setDelegate(audioLoader, queue: dispatch_get_main_queue())
        return asset
    }

    private func urlWithCustomScheme(url: NSURL, scheme: String) -> NSURL {
        let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false)!
        components.scheme = scheme
        return components.URL!
    }

NSURLConnectionDataDelegateとは?

connection系のメソッド

NSURLConnection delagateパターンに必要なメソッドは以下の通り。

connection(connection: NSURLConnection!, didReceiveResponse response: NSURLResponse!)

レスポンスを受け取ったときの処理。client!.URLProtocolに処理を移譲する。

audioLoader.swift
    func connection(connection: NSURLConnection, didReceiveResponse response: NSURLResponse) {
        self.songData = NSMutableData()
        self.response = response
        self.processPendingRequests()
    }

まず、音楽データの実体であるself.songDataを作り、selfのプロパティにする。このself.songDataは、AudioLoaderのinit時にも作っているが、この場面では、self.songDataにはすでに別の音楽データが入っているかもしれない。なので、それをリセットしている。

また、AVAssetResourceLoadingContentInformationRequestのプロパティのcontentLengthや、contentTypeを作るために必要なMIMETypeなどを取り出すために、NSURLResponseself.responseにプロパティとすて保持する。

connection(connection: NSURLConnection!, didReceiveData data: NSData!)

データを受け取るたびに繰り返し呼び出される。

AudioLoader.swift
    func connection(connection: NSURLConnection, didReceiveData data: NSData) {
        self.songData.appendData(data)
        self.processPendingRequests()
    }

音楽データの実体のself.songDataappendData(data)して、少しずつ音楽データを完成させてゆく。

connectionDidFinishLoading(connection: NSURLConnection!)

ダウンロード完了時に呼ばれる。このなかに、キャッシュに保存する処理を書き入れる

audioLoader.swift
    func connectionDidFinishLoading(connection: NSURLConnection) {
        self.processPendingRequests()
        let url = getActualURL(connection.currentRequest.URL!)
        let urlString = url.absoluteString
        if (audioCache.objectForKey(urlString) != nil) {
            return
        }
        audioCache[urlString] = songData
    }

audioCache というのは、AwesomeCacheというライブラリを一部改造して作った、キャッシュを扱うためのオブジェクト。

ダウンロードが完了したとき、self.songDataには音楽データが100%入っているので、そのままキャッシュに入れることができる。

Protocolには必要ではないが、今回の要件のために実装したメソッド

processPendingRequests

pendingRequestsを処理する。NSURLConnectionDataDelegateのための3つのconnection...すべてのメソッドのなかで呼び出される。

ダウンロード状況にあわせて、後述するfillInContentInformationメソッドなどを使い、リクエストをさばく。

AudioLoader.swift
    private func processPendingRequests() {
        var requestsCompleted = [AVAssetResourceLoadingRequest]()
        for loadingRequest in pendingRequests {
            fillInContentInformation(loadingRequest.contentInformationRequest)
            let didRespondCompletely = respondWithDataForRequest(loadingRequest.dataRequest!)
            if didRespondCompletely == true {
                requestsCompleted.append(loadingRequest)
                loadingRequest.finishLoading()
            }
        }
        for requestCompleted in requestsCompleted {
            for (i, pendingRequest) in pendingRequests.enumerate() {
                if requestCompleted == pendingRequest {
                    pendingRequests.removeAtIndex(i)
                }
            }
        }
    }

fillInContentInformation

processPendingRequests内で使う。HTTPレスポンスを、contentInformationRequestにセットする。

AudioLoader.swift
    private func fillInContentInformation(contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
        if(contentInformationRequest == nil) {
            return
        }
        if (self.response == nil) {
            return
        }

        let mimeType = self.response!.MIMEType
        let unmanagedContentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, mimeType!, nil)
        let cfContentType = unmanagedContentType!.takeRetainedValue()
        contentInformationRequest!.contentType = String(cfContentType)
        contentInformationRequest!.byteRangeAccessSupported = true
        contentInformationRequest!.contentLength = self.response!.expectedContentLength
    }

respondWithDataForRequest

dataRequestのデータを受け取る。ダウンロードできたデータ量をバイト数で計測し、ダウンロード完了を判断する。

AudioLoader.swift
    private func respondWithDataForRequest(dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
        var startOffset = dataRequest.requestedOffset
        if dataRequest.currentOffset != 0 {
            startOffset = dataRequest.currentOffset
        }
        let songDataLength = Int64(self.songData.length)
        if songDataLength < startOffset {
            return false
        }
        let unreadBytes = songDataLength - startOffset
        let numberOfBytesToRespondWith: Int64
        if Int64(dataRequest.requestedLength) > unreadBytes {
            numberOfBytesToRespondWith = unreadBytes
        } else {
            numberOfBytesToRespondWith = Int64(dataRequest.requestedLength)
        }
        dataRequest.respondWithData(self.songData.subdataWithRange(NSMakeRange(Int(startOffset), Int(numberOfBytesToRespondWith))))
        let endOffset = startOffset + dataRequest.requestedLength
        let didRespondFully = songDataLength >= endOffset
        return didRespondFully
    }

Cacheについて

今回は、CacheにAwesomeCacheというライブラリを一部改造して使った。なぜ改造したかというと、2つの理由がある。

  • キーにURLの文字列を使うため
  • キーからキャッシュファイルのパスを探すため

AwesomeCacheはキャッシュしたデータをファイルにして保存するが、キーがそのままファイル名になる。そのため、キーに/を使うと、ディレクトリとして判断されてしまい、利用できない。本家AwesomeCacheではこの問題を防ぐために記号を一律に変換しているわけだが、そうするとロジックが複雑になってしまって実装しにくい。

また、音楽ファイルであるキャッシュファイルをそのまま再生するために、キーからパスを取得するメソッドも必要になった。

そこで、Choristerのために一部改造を加えた。

詳しくは実装を見てほしい。 pathForKey というメソッドが、自分が付け加えた、キーからパスを取得するメソッドだ。 https://github.com/katryo/Chorister/blob/master/Pod/Classes/Cache.swift#L149-L154

その下のescapeSlashesで、/slashに置換している。

参考