目的
- 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は聖歌隊を意味する。
実装の方針
-
AVAssetResourceLoaderDelegate
protocolを継承したAudioLoader
クラスを作り、そのクラスを使い、ファイルをダウンロードするときの挙動をカスタマイズする -
AudioLoader
はNSURLConnectionDataDelegate
protocolも継承する。これにより、NSURLConnectionでデータを受け取ったときの処理をカスタマイズして、ダウンロード完了時にファイルをキャッシュする。 - キャッシュは、URLをキー、音楽ファイルのデータをバリューにして行う。
changeAudio(url: NSURL)
が呼び出されるたび、そのURLがキーにないか問い合わせを行い、もしあった場合はキャッシュしたファイルから音楽を再生する。
AVAssetResourceLoaderDelegateとは?
動画・音楽などを操作するAVAssetのリソースのローディングを制御するプロトコル。
let asset = AVURLAsset(URL: filePathURL, options: nil)
asset.resourceLoader.setDelegate(audioLoader, queue: dispatch_get_main_queue())
のように、AVURLAsset
(AVAsset
のサブクラス)のresourceLoader
にsetDelegate
することで、そのAVURLAsset
のファイルロードの処理を変更できる。
実装に必要なメソッド
Choristerのリポジトリと見比べてみてほしい。 https://github.com/katryo/Chorister/tree/master/Pod/Classes
resourceLoader関連
AVAssetResourceLoaderDelegateを実装するには、以下の3つのメソッドを実装する必要がある。
resourceLoader:shouldWaitForLoadingOfRequestedResource:
resourceLoader:didCancelLoadingRequest:
resourceLoader:shouldWaitForRenewalOfRequestedResource:
shouldWaitForLoadingOfRequestedResource
connectionがなければ作って通信開始。pendingRequestsにloadingRequestを追加する
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を削除する。
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を用意した。
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に処理を移譲する。
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!)
データを受け取るたびに繰り返し呼び出される。
func connection(connection: NSURLConnection, didReceiveData data: NSData) {
self.songData.appendData(data)
self.processPendingRequests()
}
音楽データの実体のself.songData
にappendData(data)
して、少しずつ音楽データを完成させてゆく。
connectionDidFinishLoading(connection: NSURLConnection!)
ダウンロード完了時に呼ばれる。このなかに、キャッシュに保存する処理を書き入れる
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
メソッドなどを使い、リクエストをさばく。
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にセットする。
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のデータを受け取る。ダウンロードできたデータ量をバイト数で計測し、ダウンロード完了を判断する。
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
に置換している。
参考
-
AVPlayerでキャッシュを作り、再生するサンプル https://gist.github.com/anonymous/83a93746d1ea52e9d23f
-
NSURLConnectionDataDelegateのチュートリアル http://www.raywenderlich.com/76735/using-nsurlprotocol-swift