こんにちは、Androidアプリエンジニアの牧山(@_rmakiyama)です!
XTechグループ Advent Calendar 2020の4日目を任されました!
先日行われたZli × エキサイト 合同LTに、グループ会社枠としてLTをさせていただきました。
「テーマは技術系ならなんでもOK!」な会だったので、RadiotalkからはAndroidでオーディオアプリを作るにはなにを考えどのように作るかを簡単に説明しました。
10分のLTでは駆け足になったり省略した部分もあるので、発表内容をもとに、よりAndroidエンジニア向けに概要をまとめます。
オーディオアプリの概要
Androidにおけるオーディオアプリを作る際は、UI用のメディアコントローラと実際のプレイヤーを操作するメディアセッションに分割する、クライアント/サーバー型設計が推奨されています。
図にもあるように、Androidのmediaライブラリではクライアント/サーバー型設計のアプローチを助けるクラスが準備されています。
メディアセッションとメディアコントローラ
メディアセッションは、サーバーの役割を担うクラスになっています。 プレイヤーの操作は、すべてメディアセッションにより処理するよう実装します。そうすることで、アプリのUIからだけではなくWear OSやAndroid Autoからの操作も一貫した処理を行うことができます。
メディアコントローラは、クライアントの役割を担うクラスになっています。 このクラスを介することにより、UIからはプレイヤーがMediaPlayerなのかExoPlayerなのかといったことを意識することなく操作できます。
オーディオアプリの作成
前述の概要をもとに、ここからは簡単なオーディオアプリの基礎部分の実装方法を紹介します。
全体像は上記のようになります。
概要で説明したもの以外にMediaBrowserService
とMediaBrowser
が登場しています。
MediaBrowserService
は、AndroidのServiceを継承しており、MediaSessionとのやりとりを簡略化させてくれます。Serviceを利用することにより、オーディオをバックグラウンドで再生することを可能にしています。
MediaBrowser
は、MediaBrowserService
を介したSessionTokenの取得とMediaSessionが公開するオーディオコンテンツへのアクセスを提供します。
※ 実装にはmedia-compatサポートライブラリ
を使います。
Serviceの実装
前述の通り、MediaBrowserService
を用いて実装をしていきます。
onCreate()
でメディアセッションを初期化します。
class MyMediaBrowserService : MediaBrowserServiceCompat() { private var mediaSession: MediaSessionCompat? = null override fun onCreate() { super.onCreate() mediaSession = MediaSessionCompat(baseContext, TAG).apply { // メディアコントローラからの操作をハンドリングする。 setCallback(callback) // 初期化したMediaSessionのTokenをセットする。 // 接続したクライアントがこのトークンを用いて通信できる。 setSessionToken(sessionToken) } } // MediaSessionが提供するコールバックをそれぞれ処理する。 private val callback = object : MediaSessionCompat.Callback() { override fun onPlay() {...} override fun onStop() {...} override fun onPause() {...} ... } ...
MediaSessionCompat.Callback
では、それぞれ必要に応じてプレイヤーの操作やオーディオフォーカスの制御が必要になります。非常に重要ではありますが、ここでは割愛します。
詳細の実装については公式ドキュメントの実装ガイドを参考にしてください。
また、後述するExoPlayerのExtensionを利用すると、こちらの処理の多くが簡略化されるのでそちらも検討すると良いでしょう。
さて、MediaBrowserService
では、クライアントの空の接続を処理するために実装すべきメソッドが2つあります。それが、onGetRoot()
とonLoadChildren()
です。
onGetRoot()
では、サービスのアクセス制御を行います。こちらはアプリケーションの要件に応じて処理していきます。
... override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? ): BrowserRoot? { return if (allowBrowsing(clientPackageName, clientUid)) { // ブラウジングを許可する場合はコンテンツ階層を表すルートIDを返す BrowserRoot("root", null) } else { // 接続を拒否する場合はnullを返す null } } ...
クライアントからは、MediaBrowserCompat#subscribe
によりコンテンツを走査できます。このメソッドからonLoadChildren()
コールバックが呼ばれ、適切なコンテンツを返すことでクライアントはUIを構築できます。
ここで返したコンテンツのリストは、MediaBrowserCompat#subscribe
を呼び出すときに渡せるSubscriptionCallback
でコールバックとして流れてきます。
... override fun onLoadChildren( parentId: String, result: Result<MutableList<MediaItem>> ) { // parentIdに応じたMediaItemのリストを取得 val mediaItems: MutableList<MediaItem> = getMediaItems(parentId) result.sendResult(mediaItems) } ...
MediaBrowserの実装
MediaBrowser
は、MediaBrowserService
との接続とMediaController
の生成の役割を担っています。
まずはMediaBrowserService
との接続を実装していきます。
MediaBrowser
は生成時に、接続するServiceの名前を指定します。
connect()
を呼ぶことでMediaBrowserServiceに接続できます。
class MediaPlayerActivity : AppCompatActivity() { private lateinit var mediaBrowser: MediaBrowserCompat private lateinit var mediaController: MediaControllerCompat override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mediaBrowser = MediaBrowserCompat( this, ComponentName(this, MyMediaBrowserService::class.java), connectionCallbacks, null ) mediaBrowser.connect() } ...
接続が完了すると、MediaBrowser
の生成時に渡していたコールバックが呼ばれます。
接続後にはMediaBrowser
を通してセッショントークが取得できるため、それを用いてMediaController
を生成します。
... private lateinit var mediaController: MediaControllerCompat ... private val connectionCallbacks = object : MediaBrowserCompat.ConnectionCallback() { override fun onConnected() { // 接続したMediaSessionのトークンを取得することができる mediaBrowser.sessionToken.also { token -> // トークンを使ってMediaControllerを生成 mediaController = MediaControllerCompat( this@MediaPlayerActivity, token ) } // コンテンツの情報と再生の状態をコールバックで受け取る mediaController.registerCallback(controllerCallback) } // 必要に応じて実装 override fun onConnectionSuspended() {} override fun onConnectionFailed() {} } ...
あとは生成されたMediaControllerCompat
のTransportControls
を通してオーディオを再生するだけです。ここで大事なのは、このMediaPlayerActivityではMediaPlayerやExoPlayerといったプレイヤーに依存していないということです。どのように再生するかをMediaSessionが一貫して担うことで、Wear端末などからの操作でも同じように振る舞うことができます。
※ 今回例示した実装ではActivity
にそのまま実装をしていますが、実際のプロダクトではアーキテクチャに応じてViewModelやドメインサービス等に実装をすると良いでしょう。
オーディオアプリとしての振る舞い
ここまでの説明に含まれていませんが、必ず考慮すべき点がまだあります。
- オーディオフォーカスの管理
- 音声出力の変更の処理
- 再生中の通知の表示
オーディオフォーカスの管理は、再生中に他のアプリで音声を再生した場合の振る舞いを制御するために必要です。ここを無視すると、自分のアプリが別のアプリの音声とかぶってしまい、ユーザーにとって非常に悪い体験になってしまいます。
音声出力の変更の処理は、再生中にイヤホンが抜けた場合などの振る舞いを制御するために必要です。イヤホンが抜けて聴いていたものが大音量で流れてしまわないためにも必要です。
再生中の通知の表示は、バックグラウンドで動作するアプリには必須の要件です。
これらを愚直に実装するのはなかなか骨が折れます。これらをExoPlayerを用いて簡略化する方法を次に紹介します。
ExoPlayer用いた実装
ExoPlayerはGoogle製のライブラリで、MediaPlayerが提供していないDASHなどの再生APIもサポートしています。
2020/12/04時点での最新バージョンは2.12.2です。
ExoPlayerでは2.9.0からオーディオフォーカスのハンドリングを、2.11.0から音声出力の変更の処理のハンドリングをサポートしています。
そのため、以下のように初期化するのみで対応できるようになりました。
private val exoPlayer: ExoPlayer by lazy { SimpleExoPlayer.Builder(context).build().apply { val attr = AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.CONTENT_TYPE_MUSIC) .build() setAudioAttributes(attr, true) setHandleAudioBecomingNoisy(true) } }
さらに、ExoPlayerライブラリの中のexoplayer-ui
にはPlayerNotificationManager
というクラスが含まれています。詳細を省きますが、これを利用することで通知チャンネルの生成や再生中の音声の情報を用いた通知の構築などを切り出せるので、実装の見通しがかなり良くなります。
ExoPlayer Extension
ExoPlayerはさまざまなExtensionを提供しています。その中にあるMediaSession extension
を利用することで、MediaSessionとExoPlayerのやりとりをかなり簡略化できます。
前述の通り、実際に音声を再生するプレイヤーの制御はMediaSessionCompat.Callback()
を実装してそれぞれ操作する必要がありました。MediaSession extension
ではMediaSessionConnector
とMediaSessionConnector.PlaybackPreparer
というクラスを提供しており、MediaSessionとの統合がかなり簡単になります。
これらの実際の実装は、公式の紹介ブログやGoogleによるオーディオアプリのサンプルを参考にしてください。
最後に
今回は、Androidでオーディオアプリを作成する基礎部分について紹介しました。最初はとっつきにくいですが、一度実装してみると理にかなった設計だと感じます。またExoPlayerとそのExtensionを使うことでさらに便利になりました。
音声や動画を扱うアプリに携わらないと知れない領域でもあるので、これを機に興味を持っていただけたら幸いです!
RadiotalkではAndroidエンジニアも絶賛募集しております!
もしちょっとでも興味がある人は、Wantedlyからまずはカジュアル面談でお話しましょう!
気軽に私にDMをするでもOKです!