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


## 目的

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

以上の役割を果たすライブラリ、[Chorister](https://github.com/katryo/Chorister)を作成した。CocoaPodsにも登録してあるので、

```
pod "Chorister"
```

でインストールできる。

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

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

## 実装の方針

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


## AVAssetResourceLoaderDelegateとは？

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

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

のように、`AVURLAsset` (`AVAsset`のサブクラス)の`resourceLoader`に`setDelegate`することで、その`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を追加する

```swift: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を削除する。

```swift: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を用意した。

```swift: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に処理を移譲する。

```swift: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`などを取り出すために、`NSURLResponse`を`self.response`にプロパティとすて保持する。

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

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

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

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

### connectionDidFinishLoading(connection: NSURLConnection!) 

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

```swift: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](https://github.com/aschuch/AwesomeCache)というライブラリを一部改造して作った、キャッシュを扱うためのオブジェクト。

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

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

### processPendingRequests

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

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

```swift: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にセットする。

```swift: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のデータを受け取る。ダウンロードできたデータ量をバイト数で計測し、ダウンロード完了を判断する。

```swift: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](https://github.com/aschuch/AwesomeCache)というライブラリを一部改造して使った。なぜ改造したかというと、2つの理由がある。

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

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

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

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

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

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

## 参考

- AVPlayerでキャッシュを作り、再生するサンプル https://gist.github.com/anonymous/83a93746d1ea52e9d23f

- NSURLConnectionDataDelegateのチュートリアル http://www.raywenderlich.com/76735/using-nsurlprotocol-swift