NewsPicks AndroidアプリにPicture in Picture機能を実装した話

こんにちは。

NewsPicksエンジニアのmarshallStonesです。 NewsPicksではiOS/Androidアプリケーションを開発するチームに所属しています。 レビューやお問い合わせなどからユーザーの皆様から導入を希望されておりましたPicture in Pictureを Androidアプリで実装するにあたって、苦労した点、工夫した点などを共有させていただきたいと思います。

Picture in Pictureモード移行時におけるtips

Picture in Pictureモード時の挙動は、PictureInPictureParamsを使用して設定することが可能です。 PictureInPictureParamsに設定したい値を登録し、enterPictureInPictureMode(PictureInPictureParams)を使用してPicture in Pictureモードに移行します。

val builder = PictureInPictureParams.Builder()
// ここでフルスクリーン時のvideoのコントロール用のUIを全部非表示にしている
hideControllerWhenPIP()
// PIP時のサイズをRationalを利用して設定する
builder.setAspectRatio(Rational(16, 9))
// Paramsを引数にPIPモードに移行する
activity?.enterPictureInPictureMode(builder.build())

こちらで設定したRationalの比率に応じてonConfigurationChangedが発生します。 サンプルのコードでは16:9の比率で指定したためConfiguration.ORIENTATION_LANDSCAPE のイベントが発生します。 iOSとは違いView単位でPicture in Picture化するわけではなく、Activity単位で行うのでonConfigurationChanged を受けてPicture in Picture状態に適したレイアウト変更をする必要があります。 また、Picture in Pictureモード移行に関しては後述する端末のアプリ単位の設定なども関わりますので Picture in Pictureモード移行用のボタンの表示を出し分けしたりする必要もあるかと思います。 その場合は以下のようなコードを用意するのが良いでしょう。

/**
 * アプリのGeneral設定にてPIPが許可されているか
 */
fun hasPIPPermission(context: Context): Boolean {
    val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager?
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            appOps?.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, android.os.Process.myUid(), context.packageName) == AppOpsManager.MODE_ALLOWED
        } else {
            appOps?.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, android.os.Process.myUid(), context.packageName) == AppOpsManager.MODE_ALLOWED
        }
    } else {
        false
    }
}

Picture in Pictureの表示条件やレイアウト制限に関するTips

Picture in Picture時には、layout.xmlを使用して独自のデザインを適用することが出来ず、 RemoteActionを使用して任意のボタンにアクションを紐づけてやる必要があります。 Picture in Picture時のViewをタップするとデフォルトアクションとともに上記で設定したアクションが表示されます。 設定ボタン・拡大ボタン・削除ボタンは、デフォルトで表示されるものです。

f:id:marshallStones:20210310163513p:plain

設定ボタンを押下すると、設定アプリ内のアプリの詳細設定画面内Picture in Picture項目の画面が開かれます。 拡大ボタンを押下すると、Picture in Picture起動元のActivityのonPictureInPictureModeChangedが呼ばれます。 この際にPicture in Picture起動前のAcitivityのOrientationに基づきonConfigurationChangedが呼ばれるので必要に応じてレイアウトを行う必要があります。

Picture in Pictureと音声バックグラウンド再生サービスの組み合わせについて

動画を扱うアプリケーションの場合、端末の画面オフ時も音声のみの再生を続けたいという要件がある場合があります。 Picture in Pictureモードの起動元Activityで動画を再生している機構がServiceでない場合ActivityがonStop状態に移行すると再生が停止します。 このような用件を満たすため、exoPlayerを用いた動画機構をService化し、Activityのレイアウトに定義したPlayerViewに Serviceから生成したexoPlayer Instanceをセットするような実装を用いれば、動画自体の再生をServiceが行っているので ActivityがonStop状態に移行しても音声の再生を継続することができますが、 弊社では音声バックグラウンド再生の要件の実現するために、動画の再生機能とは切り離した音声バックグラウンド再生用のサービスを用意して対応しています。 画面オフイベントの検知方法は以下の通りです。

/**
 * スクリーンオフ(画面ロック)時にバックグラウンド再生を開始する
 */
private val screenStateReceiver: BroadcastReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val action = intent.action
        if (ACTION_SCREEN_OFF == action) {
            if (!liveMovieSharedViewModel.isOnPIP) {
                startAudioBackground()
            }
        }
    }
}

また、パケット量の節約のため音声バックグラウンドサービス移行時に入力ソースを音声のみのもの(事前に用意してあるもの)に切り替えたり、 意図しない音声バックグラウンド再生開始を防ぐために以下のコードで音量のチェックを行ったりしています。

val manager = requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager
val volume = manager.getStreamVolume(AudioManager.STREAM_MUSIC)
// 音量が0の場合は再生開始しない
if (volume == 0) return

Picture in Picture利用時のタスク管理についてのtips

Picture in Pictureモードに移行すると、起動元のタスクとは切り離され別タスクで立ち上がります。 そのため、Picture in Pictureモードを起動するたびに別タスクとして切り離されてしまい タスクリストに山のようにスタックされてしまうようになってしまいました。 タスクリストにスタックされた状態というのはつまりRecentAppsに表示されている状態です。

こちらはRecentAppsから除外する以下のoptionをmanifestにてAcitivityに指定することで対策できますが、タスクが一件余計に残ってしまっていました。

android:excludeFromRecents="true"
android:autoRemoveFromRecents="true"

また、この仕様が上述の音声バックグラウンド再生機構とバッティングして苦労しました。 音声バックグラウンド再生開始の起点としてScreenOffイベントをBroadCastReceiverで取得していましたが、 Picture in Pictureをxボタンでクローズした後も切り離された動画再生用Activityが生き続け ScreenOffイベントが発生した際に反応してしまい、意図しないバックグラウンド再生開始を行なってしまいました。 上述の通り、Picture in Pictureのクローズボタンのようなdefault Buttonのイベントをキャッチすることはできません。 その代わり、ActivityにてonPictureInPictureModeChangedと、Picture in Pictureからの画面復帰の場合は onResume, の押下時にはonStopがコールされます。 こちらをフックして別タスクで起動した動画再生Activityをfinishするようにしました。 LiveDeta#observe() だと Activity が裏側にあるなど onStop() 時にイベントを受け取れないため、 coroutineやRx Javaを利用して状態を連携することをお勧めします。

この仕組みによりPicture in Pictureのクローズボタンで閉じられたActivityを終了することができるようになり、 RecentAppsに不要なタスクが積まれることがなくなりました。

終わりに

先日のアップデートで、NewsPicksアプリはiOS/AndroidともPicture in Pictureに対応いたしました。 多くの皆様にご好評いただいている動画コンテンツを、アプリ内の記事を読みながら、 また端末のホーム画面やその他のアプリを使用しながらでもお楽しみいただけるようになります。 開発して感じたことは公式リファレンス以外にPicture in Pictureに関する情報が少なく、またPicture in Pictureを実際に導入したアプリも少なかったことです。 こちらの記事が同様の機能をこれから開発する開発者様の助けになり、また弊社のサービスに興味を持っていただけるきっかけになりましたら幸いです。

© Uzabase, Inc. All rights reserved.