Radiotalk Tech Blog

Radiotalk株式会社のエンジニアが知見や取りくみについてを共有するテックブログです。

Androidでオーディオアプリを作るということ

こんにちは、Androidアプリエンジニアの牧山(@_rmakiyama)です!
XTechグループ Advent Calendar 2020の4日目を任されました!

先日行われたZli × エキサイト 合同LTに、グループ会社枠としてLTをさせていただきました。
「テーマは技術系ならなんでもOK!」な会だったので、RadiotalkからはAndroidでオーディオアプリを作るにはなにを考えどのように作るかを簡単に説明しました。

10分のLTでは駆け足になったり省略した部分もあるので、発表内容をもとに、よりAndroidエンジニア向けに概要をまとめます。

オーディオアプリの概要

Androidにおけるオーディオアプリを作る際は、UI用のメディアコントローラと実際のプレイヤーを操作するメディアセッションに分割する、クライアント/サーバー型設計が推奨されています。

f:id:radiotalk-tech:20201202185619p:plain

図にもあるように、Androidのmediaライブラリではクライアント/サーバー型設計のアプローチを助けるクラスが準備されています。

メディアセッションとメディアコントローラ

メディアセッションは、サーバーの役割を担うクラスになっています。 プレイヤーの操作は、すべてメディアセッションにより処理するよう実装します。そうすることで、アプリのUIからだけではなくWear OSやAndroid Autoからの操作も一貫した処理を行うことができます。

メディアコントローラは、クライアントの役割を担うクラスになっています。 このクラスを介することにより、UIからはプレイヤーがMediaPlayerなのかExoPlayerなのかといったことを意識することなく操作できます。

オーディオアプリの作成

前述の概要をもとに、ここからは簡単なオーディオアプリの基礎部分の実装方法を紹介します。

f:id:radiotalk-tech:20201202185652p:plain

全体像は上記のようになります。 概要で説明したもの以外にMediaBrowserServiceMediaBrowserが登場しています。

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() {}
    }
    ...

あとは生成されたMediaControllerCompatTransportControlsを通してオーディオを再生するだけです。ここで大事なのは、この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ではMediaSessionConnectorMediaSessionConnector.PlaybackPreparerというクラスを提供しており、MediaSessionとの統合がかなり簡単になります。
これらの実際の実装は、公式の紹介ブログGoogleによるオーディオアプリのサンプルを参考にしてください。

最後に

今回は、Androidでオーディオアプリを作成する基礎部分について紹介しました。最初はとっつきにくいですが、一度実装してみると理にかなった設計だと感じます。またExoPlayerとそのExtensionを使うことでさらに便利になりました。
音声や動画を扱うアプリに携わらないと知れない領域でもあるので、これを機に興味を持っていただけたら幸いです!

RadiotalkではAndroidエンジニアも絶賛募集しております!
もしちょっとでも興味がある人は、Wantedlyからまずはカジュアル面談でお話しましょう! 気軽に私にDMをするでもOKです!

www.wantedly.com

参考