こんにちはtkyです。
前回GCP上に動画配信サーバを作成しました。
今回はこの動画配信サーバにアクセスできるクライアントアプリを作成してみようと思います。
何作ったの
Androidで動画配信アプリを作成しました。言語はKotlinです。
構成は前回PCから配信していたのをAndroid端末に変更しただけで、サーバ構成は変わらずです。

コードはGitHubのRtmpClientを参照してください。
前回ストリームキーの話をしましたが、今回はandroid固定で配信することとします。
※rtmp://xxx.xxx.xxx.xxx/live/android というURLに固定で配信、視聴することになります。
一応、動画を送信する側を「配信」、受信する側を「視聴」と呼ぶことにして進めたいと思います。
どんな感じで作ったの
実装については後述でポイントでコードを載せます。UI関連の説明はしないのでGithub見てもらえたらと思います。
技術的な話
配信側についてはRtmpPublisherライブラリを利用させていただきました。
視聴側についてはExoPlayerのextension-rtmpという拡張がありましたのでこの2つのライブラリで実現できそうです。
それぞれAndroid配信⇒VLC視聴、OBS配信⇒Android視聴という流れで画面毎に確認して、最後にAndroid配信⇒Android視聴を確認して完成としたいと思います。
OBSはPCで利用できる配信ツールの事です。
VLCはPCで利用できる動画再生ソフトですが、リアルタイム動画配信の視聴ツールとしても利用できます。
画面的な話
コードはGitHub見てもらえたらですが、簡単に構成だけ。
一応、各画面はFragmentで構成して、1Activity(MainActivity)で作ってみます。(※後にはまることになるとはこの時は知る由もなかった・・・)
- メイン画面(MainFragment)
配信ボタン、視聴ボタンを作成します。
- 配信ボタン 配信画面に移動
- 視聴ボタン 視聴画面に移動
- 配信画面(PublisherFragment)
配信開始/停止ボタンを作成します。
- 配信開始/停止ボタン 配信停止中:文言「開始」、タップ時:配信開始する 配信中:文言「停止」、タップ時:配信停止する
- 視聴画面(PlayerFragment)
遷移したら視聴開始します。また更新ボタンを設置します。
- 更新ボタン 視聴停止⇒視聴開始する
視聴側
視聴側についてはExoPlayerを使っていきます。
gradle
gradleでexoplayerに必要なモジュールとrtmp拡張の記述を記載します。
ExoPlayerは2.6.0を使っています。
// ExoPlayer
// https://github.com/google/ExoPlayer
implementation "com.google.android.exoplayer:exoplayer-core:$EXO_PLAYER_VERSION"
implementation "com.google.android.exoplayer:exoplayer-ui:$EXO_PLAYER_VERSION"
implementation "com.google.android.exoplayer:extension-rtmp:$EXO_PLAYER_VERSION"
(余談)バージョンについて
バージョン情報はすべてgradle.propertiesに記述して各gradleでこの値を参照するように作成しています。
このようにすることで1つのバージョン変更でこれを使用しているライブラリバージョンは一気に変更できて楽ですね。gradleありがとうって感じです。
# libraries GRADLE_PLUGIN_VERSION=3.1.4 APP_COMPAT_VERSION=27.1.1 CONSTRAINT_LAYOUT_VERSION=1.1.3 TIMBER_VERSION=4.5.1 BUTTERKNIFE_VERSION=8.8.1 RTMP_PUBLISHER_VERSION=1.1.2 EXO_PLAYER_VERSION=2.6.0
Kotlinコード
Kotlinに入る前にまずはXMLから。案外コード量が少ないです。
<com.google.android.exoplayer2.ui.SimpleExoPlayerView
android:id="@+id/fragment_player_exp"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:resize_mode="fill"
app:surface_type="texture_view"
app:use_controller="false"
>
Fragment側でこのViewに対して操作します。ButterKnifeを使ってちょっとだけすっきりさせます。
基本的にExoPlayerを使用するときと同じように定義して、RTMPプロトコルを使用する場合はMediaSourceのインスタンスを作るときのDataSourceFactoryの指定でRtmpDataSourceFactoryを指定するだけです。
rtmp://xxx.xxx.xxx.xxx/live/stream_key url中の
liveはrtmpサーバのアプリケーション名ですね。前回の記事をご覧ください。
・・・中略・・・
@BindView(R.id.fragment_player_exp)
lateinit var playerView: SimpleExoPlayerView
private var player: SimpleExoPlayer? = null
・・・中略・・・
/**
* 再生を開始する
* */
private fun playStart() {
// rtmp://xxx.xxx.xxx.xxx/live/stream_key
val uri = Uri.parse("rtmp://$ipAddress/live/$streamKey")
val player = ExoPlayerFactory.newSimpleInstance(
DefaultRenderersFactory(context), DefaultTrackSelector(), DefaultLoadControl())
playerView.player = player
// rtmpプロトコルを使用する場合はRtmpDataSourceFactory()を使用する
val mediaSource = ExtractorMediaSource(uri, RtmpDataSourceFactory(), DefaultExtractorsFactory(), null, null)
player.prepare(mediaSource)
player.playWhenReady = true
this.player = player
}
/**
* 再生を停止する
* */
private fun playStop() {
player?.let {
it.playWhenReady = false
it.release()
}
}
ButterKnifeについて
ButterKnifeはButtonとかViewの定義をアノテーションを使って楽に定義できますって感じのライブラリです。 OnClickのイベントとかもアノテーションだけで定義するのでわざわざButton定義して、onClickListener定義して・・・ということをしなくてよくなるのは個人的に負荷が下がります。
視聴確認
OBSでrpmt://xxx.xxx.xxx.xxx/live/androidに配信してみます。
で、アプリ起動して待ち受けてみると・・・

でた!!細長い!!! 16:9の配信に対して、スマホの画面いっぱいに拡縮して表示しようとしているのでまぁしょうがないですね 😅
こういったところを整備していくの大変そう・・・ とはいえ、視聴側は確認できました。
配信側
配信側はRtmpPublisherを使っていきます。 本当にありがたいことに使い方等がしっかりREADMEに書かれており特に不自由なく利用できました。
usageを見ると
val publisher: Publisher = Publisher.Builder(this) .setGlView(glView) .setUrl(rtmpUrl) .setSize(Publisher.Builder.DEFAULT_WIDTH, Publisher.Builder.DEFAULT_HEIGHT) .setAudioBitrate(Publisher.Builder.DEFAULT_AUDIO_BITRATE) .setVideoBitrate(Publisher.Builder.DEFAULT_VIDEO_BITRATE) .setCameraMode(Publisher.Builder.DEFAULT_MODE) .setListener(this) .build()
ということなので画面に以下のようなGLSurfaceViewを張り付けて、インスタンスをPublisherに設定すればよさそうです。
あとはこのpublisherに対して、startやstop処理をしてあげます。
ライブラリではカメラプレビューの実装も入っているので本当にこれだけで実現できる仕様になっているのがうれしい限りです。
カメラ(動画)を使用するので、Manifestには以下が必要です。 で、今回パーミッションチェックの機構は実装しないので自分でパーミッション許可します・・・
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
自分でチェックしてね ✅

Kotlinコード
こちらもKotlinに入る前にまずはXMLからいきましょう。Publisherに渡すためのGLSurfaceViewを定義します。
<android.opengl.GLSurfaceView
android:id="@+id/fragment_publisher_glv"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
Kotlinコードは抜粋で載せますので、詳細はRtmpClientを見てください。
/**
* preparePublisher
*
* */
private fun preparePublisher() {
// rtmp://xxx.xxx.xxx.xxx/live/stream_key
val url = "rtmp://$ipAddress/live/$streamKey"
// 転送不可を下げるため動画サイズは320x240とする
publisher = Publisher.Builder(activity as AppCompatActivity)
.setGlView(glView)
.setUrl(url)
.setSize(320, 240)
.setAudioBitrate(Publisher.Builder.DEFAULT_AUDIO_BITRATE)
.setVideoBitrate(Publisher.Builder.DEFAULT_VIDEO_BITRATE)
.setCameraMode(Publisher.Builder.DEFAULT_MODE)
.setListener(this)
.build()
}
で、実行してメイン画面から「配信ボタン」を押してみます。
Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.hardware.Camera$Parameters com.takusemba.rtmppublisher.CameraClient.open()' on a null object reference
at com.takusemba.rtmppublisher.RtmpPublisher.onResume(RtmpPublisher.java:100)
at java.lang.reflect.Method.invoke(Native Method)
at android.arch.lifecycle.ClassesInfoCache$MethodReference.invokeCallback(ClassesInfoCache.java:218)
at android.arch.lifecycle.ClassesInfoCache$CallbackInfo.invokeMethodsForEvent(ClassesInfoCache.java:193)
at android.arch.lifecycle.ClassesInfoCache$CallbackInfo.invokeCallbacks(ClassesInfoCache.java:184)
at android.arch.lifecycle.ReflectiveGenericLifecycleObserver.onStateChanged(ReflectiveGenericLifecycleObserver.java:36)
at android.arch.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
at android.arch.lifecycle.LifecycleRegistry.addObserver(LifecycleRegistry.java:180)
at com.takusemba.rtmppublisher.RtmpPublisher.<init>(RtmpPublisher.java:39)
at com.takusemba.rtmppublisher.Publisher$Builder.build(Publisher.java:158)
・・・中略・・・
あれーーーー落ちるんですけどーーーー!!!
ライブラリを調べてみるとLifecycleObserver使っているようでした。さらに調べてみると、RtmpPublisherクラスのコンストラクタの初めにactivity.getLifecycle().addObserver(this);しているようですね。
これによりaddObserver(this)した瞬間に@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)アノテーションが付いているメソッドが呼ばれて、まだカメラインスタンスが作成できていないので、NPEの運びとなります。
fragmentはまだonResumeしていないのですが、ActivityはすでにResume状態なので、簡単に言うと1ActivityにしてFragmentでこれを実装しようとすると詰みということがわかりました 😇😇😇😇
ということでPublisherActivityを作成し急遽2Activity構成に変更です・・・
PublisherActivityについてはGithub参照ください。
で、ようやく配信!ちゃんと映像がでました! VLCでも確認すると、画像は非常に乱れていますが、配信出来ていることが確認できました。

もう一台あるAndroid端末で配信、別のAndroid端末で視聴をやってみても3,4秒程度の遅延(かつ画像解像度は低い・・・)で視聴することができました。
配信時のインスタンス負荷状況
一応、配信時のGCPの負荷状況載せておきます。 GCPのインスタンスについては前回記事見てください。
ネットワークの転送自体はとがったようになっているのに対して、ディスクIOはピークの後ろにも若干波がありますね。
RTMPサーバの配信データの置き場所
/usr/local/nginx/html/live/hls
を確認すると何も入っていなかったことから、一定時間たつと配信データは削除されるようですね。
この一定時間(約5分?)は誰が設定して、どう処理しているのかなどは調査していません。余裕を見て調べつつサーバサイドと仲良くなれたらよいなと思います。

まとめ
- Androidで動画配信及び視聴クライアントを作成した
- 今回ストリームキーは
android固定にしたが、できればストリームキー生成はサーバ側に任せてみたい - 視聴側はExoPlayer使えば良いが、配信側は独自で作った方が良さそう(動画配信サービスとしてちゃんと作ろうと思った場合)
- 想像以上に画像が荒かった。動画のサイズと通信速度かな?と思いつつどこが原因か切り分けてはいないので、余裕を見て調べてみたい。
今回作成したコードはGitHubにアップしていますのでRtmpClientを参照してください。
参考文献
様々なサイトを活用させていただきました。感謝です!!
RtmpPublisher https://github.com/TakuSemba/RtmpPublisher
ExoPlayer関連 https://qiita.com/niusounds/items/cce4ff69f5911908259b