スマートフォンアプリ用広告SDKを作る

市川和央氏(以下、市川):私は『広告SDKにおける新動画プレイヤーの実装』という話をいたします。私はサーバーサイドではなくてSDKというか、iOSとAndroidとか、そっちサイドの話です。

まず自己紹介です。市川和央と申します。Twitterでは右側のようなアイコンで活動しています。東京大学で博士を取りまして、そのあとファイブ株式会社という会社に入社しました。そのファイブ株式会社がLINE株式会社に吸収されたため、私は今LINEに勤めています。

博士課程では言語内DSLや構文解析をやっていました。Scalaを主に使っていたので、Scalaが書けるぞということでファイブ株式会社に入ったのですが、最近はもっぱらAndroidとiOSの開発ばかりやっていて、Objective-CやJava、Kotlin、Swiftなどを触っています。

ファイブではFiveSDKというスマートフォンアプリ用広告SDKを作っていて、LINEに吸収されたあとも継続的にこのSDKを開発して提供しています。これは、もともとは動画広告専用のSDKだったんですが、現在は静止画にも対応しています。

広告SDKは、みなさんあまりどういうものかよく知らないかもしれないので、簡単に説明します。

FiveSDKを組み込んだアプリがあったら、そのアプリが我々の広告サーバーに対してアドリクエストを飛ばすと広告が返ってきて、その広告の動画などをCDNから取得してきて、それを表示します。

それをただ表示しただけだと我々はちょっと嬉しみがないので、表示しましたよ、このユーザーは表示して最後まで見てくれましたよといった情報をビーコンサーバーと言われている情報にレポートします。

そしてそのビーコンサーバーに集まった情報をもとに、この広告はこういうユーザーに当たるようだなどと分析し、広告主に成果を見せたりします。

広告効果はあるが有効期限が課題となったキャッシュ

FiveSDKは、謳い文句の1つとして0秒ロードと謳っています。これは何かと言うと、実際に広告を見るよりはるかに前に、事前に動画ファイルのキャッシュを作っておいて、そうすると実際に動画広告を出したいときにはもうすでに動画ファイルは全部あるので、それをロードして、そのまますぐに見せることができるといったものです。

これが非常に大事でして、なぜかと言うと動画広告というのは基本的に最初の数秒間のところで効果が決まると言われています。最初の1秒でおもしろそうだとユーザーに訴求できるかどうかによって、かなり効果が違うということです。

キャッシュするのは非常にいいことで、これによって効果が非常によくなることがわかっているんですが、キャッシュというのはいつまでももっているわけにはいかないので、有効期限が存在します。

なので、残念なことに一度も見られずに捨ててしまった広告もあるかもしれません。その場合は非常にもったいなくて、せっかくユーザーにダウンロードしてもらったのに、それを捨ててしまうことになってしまいます。

これはもったいないと。これをどうにかしたいと考え、Partial Cacheプロジェクトというプロジェクトが立ち上がりました。これは何かと言いますと、動画の一番初めの部分だけを事前にキャッシュしておいて、残りの部分はダウンロードしながら再生する方法です。

一度も見なかった広告は、結局一番初めの部分だけしかキャッシュされずに捨てられるので、ユーザーが余計にダウンロードしてしまう量はかなり抑えられます。こういうことを考えて実装しようとなったんですが、これは残念なことに既存の動画プレイヤーではうまく実装できませんでした。

新しく動画プレイヤーを自分たちで実装しよう

なので、新しく動画プレイヤーを自分たちで実装しようというプロジェクトが立ち上がりました。まずは動画プレイヤーはどういうふうに作るかという簡単な構成です。動画というものは、だいたいMP4ファイルになっているわけなんですが、これをまず解析すると、サンプルバッファの列が得られます。

サンプルバッファがそれぞれの動画データや音データといったものの単位になっています。それらが得られて、それを今度はデコーダに通すと、素のデータが得られます。サンプルバッファの状態では圧縮されていたりとかして、これでは再生できないんですが。デコーダを通すことによって再生可能なものが得られます。

これを実際のビデオのハードウェアに与えることによって、動画が表示されたり音が流れたりします。ただこれだけだと、動画と音がそれぞれバラバラに流れちゃうので、それらを制御する、シンクするタイマーがさらに必要になります。

これが一般的な作り方なんですが、我々の作る新しい動画プレイヤーはこの入力の場所がちょっとだけ特殊で、最初のほうはキャッシュからデータを読み込んで、途中からいきなりデータをCDNから読むように変えるといった機能が必要になります。

我々の作る新しい動画プレイヤーはこういった構成になります。後ろのほうはなにも変わってないので、デコーダやビデオのプレイヤーはそのままOSが提供してくれているものを利用できます。

さらにiOSの場合、デコーダ以降の場所は、1つの大きな統合したクラスが提供されているので、それを利用しました。我々が新しい動画プレイヤーを作るっていうのにあたってやらなきゃいけなかったのは、サンプルバッファを入手して、それを適切に管理して適切にデコーダに入れること、AVSampleBufferDisplayLayerとAudioQueueという2つのクラス、これらがちゃんと同期して動くようにタイマーを作って管理することでした。

まず動画ファイルの読み込み部分ですが、これは先ほども話したようにCDNからダウンロードしてきて、それをキャッシュしておきます。ダウンロードが非常に速い場合は、全部キャッシュされちゃうんですが、最初のほうはキャッシュがあっても途中から再生がダウンロードに追いついてしまうとストリーミングモードになるので、ダウンローダーから直接ファイルを読み込むモードになります。

まずファイルを少しずつ取得していくわけなんですが、最初はキャッシュから、途中からはキャッシュから読めなくなったらダウンローダーから読む、といったロジックになります。

それがファイルは断片的に取得していって、その取得していったものをparserに入れて、実際にこれはどういったものだったのかが得られますので、そこから今度はサンプルバッファを作り出して、それをキューに積んでいきます。

キューが十分にあれば、とりあえず今のところはこのまま動くので、これ以上は読み込む必要はないと判断して、キャッシュからの読み込み、もしくはダウンローダーからの読み込みを停止します。

その間に、例えばダウンロードが進んでキャッシュに大量に入ったら、また次に読み込みを始めろって言われたら、キャッシュから読み込むことになります。その間も追いつかなかった場合は、やはりダウンローダーから読み込むといったモードで、次にサンプルバッファが足りなくなった場合は、そのようにして動き始めます。

サンプルバッファをどのように管理するか

サンプルバッファは動画ファイルなので、非常に大きいデータ量です。全部をメモリに展開するわけにはいかないので、いい感じに保持する量を決めて、それでコントロールする必要があります。

あんまりなさすぎると、再生すると決めてからディスクに読みに行ったときに再生がカクカクになってしまうので、ある程度はもってないといけません。ただ、もちすぎるとメモリが溢れちゃうので、いい感じにガベージコレクションする必要があります。

ただガベージコレクションは、使い終わったやつを捨てればいいわけではなくて、とくにビデオのほうが少しややこしくて、キーフレームという概念があります。これは何かというと、一度デコードをやめてから再開するためにはこのキーフレームから入れ直さないとできないといったものです。

そのため、このキーフレームを捨ててしまうと、次のキーフレームまでのデコードができなくなることがあります。直前のキーフレームから先を取っておく必要があります。

そこでサンプルバッファは、こういったかたちの配列にして管理しています。こうすると、現在地点がここだとすると、これを含んでいるキーフレームの配列以降は捨てられない。逆にこのへんの真ん中あたりだとすると、これより前のキーフレームからスタートするここの配列はまるごとガベージコレクションできるといったかたちで、簡単にガベージコレクションを実装できます。

ただ、現在位置と言いましたけど、実はこの現在位置という概念が意外とちょっと面倒くさくて。どういうことかと言いますと、サンプルバッファはそのままプレイヤーに入れられるものじゃなくて、一度デコーダに通してからプレイヤーに突っ込む必要があります。

実はもたなきゃいけないポジションが2つありまして、プレイヤーが今プレイしているサンプルはどこにあるかと、デコーダにはどこまで入れたかという2つのポジションを管理する必要があります。

プレイヤーがそこまでいったのであれば、プレイヤーの地点よりはるかに前のサンプルバッファは捨ててもいいと判断できますが、デコーダのポジションで捨ててしまうと、再生されていないものを捨ててしまうことになるので、それは困ります。

そのため、サンプルバッファのガベージコレクションはプレイヤーのポジションをベースにして行う必要があります。ただデコーダに入れたもの、デコーダはやはりそれなりに時間がかかりますので、デコーダには余裕をもって入れないと、再生がちょっとカクカクしてしまいます。

ちょっと余裕にもっておく分をどれくらいにするかを決めるには、デコーダの位置をベースにして行う必要があります。とりあえず、サンプルバッファを入手してきて、そのサンプルバッファをどのように管理するかはここまでになります。

動画と音をどのように同期するか

ここから先は、動画と音をどのように同期するかといった話になります。iOSにはCMTimebaseという非常に便利なクラスが存在していまして、これをAVSampleBufferDisplayLayerに与えると、タイムベースが表す時間軸に沿って動画を再生してくれます。

CMTimebaseと動画側は簡単に同期するので、iOSの場合、CMTimebaseとAudioQueueの、この間の同期を取ることによって、動画と音がちゃんと同期します。

これはiOSに限らないかもしれないんですが、実は考えなきゃいけない時間に、クロックとタイムベースという2種類があります。これらは何かというと、そんなに難しいものではないのですが、クロックは普通の時刻。常に進み続けるようなものです。

タイムベースは、実は普通のクロックと微妙に違うところがありまして。タイムベースというのは何かと言うと、クロックを基準に動くストップウォッチのようなものです。ここを0と決めるとここが0になって、そこからクロックと一緒に同期して動きます。タイムベースは戻ることがあり、クロックは進み続けます。

iOSにはCMAudioClockという、音の再生中は音と必ず同じレートで動き続けるクロックが存在するので、このクロックを使うと同期できるんですが、動画側はタイムベースを基準として動きます。つまり、タイムベースのマスタークロックとしてAudioClockを使うと、同じスピードで動くことが保証されます。

ただ、これだけだと実はうまく動きません。なぜかと言うと、0秒地点がずれていると、そもそもずっとずれたまま同じ速度で動いてしまうからです。なので再生開始時に、必ずこのタイムベースに対して補正をかける必要があります。ここまでやると、音と動画が同期して再生されます。

ミュートとミュート解除時の動作

ミュートした場合は、音が流れません。ミュートしたり音がない動画広告を流した場合は、音がないので、CMAudioClockはオーディオとではなくて、デバイスのクロックと同期して動き、とくに問題なく動画は再生されます。

ちょっとややこしいのが、ミュートしてからミュートを解除した場合で、その場合は何が起きるかと言うと、その間に別のクロックを一時的に使っていたので、ずれている可能性があります。オーディオ側と動画側の時刻がずれていることがあります。

そのため、そのズレをまたミュート解除時に補正してあげなければいけません。ただこの補正をすると何が起きるかと言うと、最初に言ったように、タイムベースの時刻は戻ることがあります。こうしたことから、タイムベースが戻ったときのことをちゃんと考えて実装する必要があります。ここまでで、だいたいの簡単な説明は終わりです。

サンプルバッファの読み込みと管理は、ファイルをキャッシュ、もしくはダウンローダーから取得して、サンプルバッファをキューに積んで、そのキュー側は再生位置とデコーダの位置の両方を見て、再生位置を見てGCして、デコーダの位置を見てさらにファイルを読み込むかどうかを決める、というふうに動きます。

同期の管理は、タイムベース、オーディオと同一レートで動くことが保証されているオーディオクロックをマスタークロックとしたタイムベースを用意して、そのタイムベースを使うことによって同期できます。ただ、完全に同期させるためにはタイムベースをオーディオが再生されたり再開されたりしたタイミングで補正しなければなりません。

話しきれなかった話

とりあえずこのへんで時間的に厳しいかなと思ったので、あとは話しきれなかった話ということで、こちらにいろいろと書いてあります。

私は一応iOSもAndroidも両方実装したので、Android側もそれなりに知ってはいるんですが、Android側はもう少しややこしくて、デコーダとプレイヤーが統合されたようなクラスがないので、そのへんも自分で書かなきゃいけなかったりして、ちょっと大変でした。

あとはプレイヤー全体でどういうふうに状態管理するかの、オートマトンがどんなふうになっているかとか、そのへんもちょっと書きたかったんですが、今回は書ききれないかなと思って省略しました。MP4からどうやってサンプルバッファを作るかもけっこう割愛しています。MP4の話も一応スライドは作ったんですが、とりあえずこのへんにしておこうかと思います。

ご清聴ありがとうございました。

質疑応答

司会者:どうもありがとうございました。質問が来ています。「話がちょっとずれてしまいますが、配信する可能性がある動画ファイル、クリエイティブも増えてしまうと思いますが、キャッシュ対象の動画の選定は、どのように行っているのでしょうか?」ということですが、「キャッシュするタイミングは、Wi-Fiに接続している場合のみなど、なにか工夫していますか?」ということです。どうですか?

市川:非常に申し訳ないんですが、私はあまりサーバーサイドは触っていないので、ちょっと詳しいオークションロジックとかはわからないという感じです。申し訳ないです。

キャッシュするタイミングに関しては、Wi-Fiに接続している場合のみとかではやっていないです。というのも、最近だとむしろWi-Fiのほうが回線が弱いということもあり、例えば公衆のWi-Fiだと非常に弱かったりもしますので、その辺はやっていません。ただ、一応Wi-Fiにつながっているかの情報は取っています。

司会者:なるほど。ありがとうございます。次の質問です。GCのところで、デコード済みだけど未再生なデータは捨てられないという説明がありましたが、再生されてなくても、decodeされていたら、Output Bufferにあるので捨ててもいいような気がしたのですが、そうではないのでしょうか。

市川:ポーズすると、アウトプットバッファがクリアされるため、ポーズしてからリジュームする場合を考えて、削除しないようにしています。FiveSDKは広告SDKなので、できるだけアプリ側の動作を阻害しないように、ポーズしているときにはできるだけリソースを解放するようにしています。

デコード済みのサンプルは非圧縮であるためサイズが非常に大きく、またメインメモリと異なるビデオメモリの上に乗っている可能性があります。そのためデコード済みのデータはポーズ時に削除するようにしています。

デコード前のサンプルはデコード済みのものと比較すると十分小さく、またこれを手放した場合は、ディスクからの再読み込みが必要となりコストが高いため、保持し続けるという選択をしています。

司会者:ありがとうございました。