Android Automotive OS 用の駐車時向けアプリを作成してテストする

1. 始める前に

対象外:

  • Android Auto および Android Automotive OS 向けのメディアアプリ(音楽、ラジオ、ポッドキャストなどのオーディオ アプリ)を作成する方法については説明していません。これらのアプリの作成方法について詳しくは、自動車向けメディアアプリを作成するをご覧ください。

必要なもの

作成するアプリの概要

この Codelab では、既存の動画ストリーミング モバイルアプリ Road Reels を Android Automotive OS に移行する方法について学びます。

スマートフォンで実行されている初期状態版のアプリ。

Android Automotive OS エミュレータで実行されている完成版のアプリ。ディスプレイ カットアウトが追加されている。

スマートフォンで実行されている初期状態版のアプリ。

Android Automotive OS エミュレータで実行されている完成版のアプリ。ディスプレイ カットアウトが追加されている。

学習内容

  • Android Automotive OS エミュレータの使用方法
  • Android Automotive OS ビルドの作成に必要な変更
  • モバイル向けアプリ開発時の一般的な想定を、Android Automotive OS で実行するアプリに適用できない場合の対処方法
  • 自動車用アプリのさまざまな品質レベル
  • メディア セッションを使用して、他のソースからアプリの再生を操作できるようにする方法
  • システム UI とウィンドウ インセットが Android Automotive OS デバイスとモバイル デバイスでどう異なるか

2. 設定する

コードを取得する

  1. この Codelab のコードは、car-codelabs GitHub リポジトリ内の build-a-parked-app ディレクトリにあります。クローンを作成するには、次のコマンドを実行します。
git clone https://github.com/android/car-codelabs.git
  1. または、リポジトリを ZIP ファイルとしてダウンロードすることもできます。

プロジェクトを開く

  • Android Studio を起動し、build-a-parked-app/start ディレクトリのみを選択してプロジェクトをインポートします。build-a-parked-app/end ディレクトリにはソリューション コードが含まれています。不明な点がある場合や、プロジェクト全体を確認したいときは、いつでも参照できます。

コードを理解する

  • Android Studio でプロジェクトを開いたら初期状態のコードを確認します。

3. Android Automotive OS 用の駐車時向けアプリについて理解する

駐車時向けアプリは、Android Automotive OS でサポートされているアプリカテゴリのサブセットです。執筆時点では、動画ストリーミング アプリ、ウェブブラウザ、ゲームからなります。電気自動車の普及が進んでおり、車の充電中にドライバーや同乗者がアプリを利用する機会が増えることを考えると、これらのアプリと Google 搭載のハードウェアを備えた自動車の相性は非常に良いと言えます。

自動車に搭載されている画面は、多くの点で他の大画面デバイス(タブレット、折りたたみ式デバイスなど)と似ています。同程度のサイズ、解像度、アスペクト比のタッチスクリーンが、縦向きまたは横向きで設置されています(画面の向きが固定されている点はタブレットと異なります)。また、ネットワーク接続が頻繁に切り替わるコネクテッド デバイス、という点も似ています。以上の点を考慮すると、大画面向けに最適化されているアプリであれば、最小限の追加作業で自動車向けの優れたユーザー エクスペリエンスを実現できそうです。

大画面アプリと同様、自動車用アプリにも品質レベルがあります。

  • レベル 3 - 自動車対応: 大画面に対応しており、駐車時に使用できるアプリです。自動車向けに最適化された機能は搭載していませんが、他の大画面 Android デバイスと同じようにアプリを使用できます。これらの要件を満たすモバイルアプリは、自動車対応モバイルアプリ プログラムを通じて自動車に配信できます。
  • レベル 2 - 自動車向けに最適化: 自動車のセンター コンソール ディスプレイで優れたエクスペリエンスを提供できるアプリです。このレベルの要件を満たすには、アプリのカテゴリに応じて自動車専用の変更を加え、運転モードや駐車モードで使用できる機能を含める必要があります。
  • レベル 1 - 自動車向けに差別化: 自動車のさまざまなハードウェアで動作するように構築されており、運転モードと駐車モードでエクスペリエンスを調整できるアプリです。センター コンソール、計器類、追加の画面(たとえば高級車に搭載されているパノラマ ディスプレイ)など、車内のさまざまな画面に合わせて設計された最適なユーザー エクスペリエンスを提供します。

4. Android Automotive OS エミュレータでアプリを実行する

Automotive with Play Store のシステム イメージをインストールする

  1. まず、Android Studio で SDK Manager を開き、まだ選択していない場合は [SDK Platforms] タブを選択します。SDK Manager ウィンドウの右下にある [Show package details] チェックボックスがオンになっていることを確認します。
  2. Generic System Image の追加の一覧から、いずれかの Automotive with Play Store エミュレータ イメージをインストールします。イメージは、同じアーキテクチャ(x86 / ARM)のマシンでのみ実行できます。

Android Automotive OS Android Virtual Device を作成する

  1. デバイス マネージャーを開き、ウィンドウの左側にある [Category] 列で [Automotive] を選択します。次に、バンドルされたハードウェア プロファイルの一覧から Automotive (1024p landscape) を選択し、[Next] をクリックします。

選択したハードウェア プロファイル「Automotive (1024p landscape)」が表示されている Virtual Device Configuration ウィザード。

  1. 次のページに移動したら、前の手順で作成したシステム イメージを選択します。[Next] をクリックし、必要に応じて詳細オプションを選択したら、[Finish] をクリックして AVD を作成します。注: API 30 のイメージを選択した場合は、[Recommended] タブ以外のタブに表示されることがあります。

アプリを実行する

前の手順で作成したエミュレータで、既存の app 実行構成を使用してアプリを実行します。アプリを操作してさまざまな画面を表示し、スマートフォンまたはタブレットのエミュレータで実行した場合の動作と比較してみてください。

301e6c0d3675e937.png

5. Android Automotive OS ビルドを作成する

アプリはこのままでも動作しますが、Android Automotive OS で適切に動作するアプリにするため、また Google Play ストアへの公開要件を満たすためには、いくつかの変更を加える必要があります。これらの変更の中には、アプリのモバイル バージョンに含めても意味がないものもあります。そこで、まずは Android Automotive OS ビルド バリアントを作成します。

フォーム ファクタのフレーバー ディメンションを追加する

まず、build.gradle.kts ファイルの flavorDimensions を変更して、ビルドの対象とするフォーム ファクタのフレーバー ディメンションを追加します。次に、それぞれのフォーム ファクタ(mobile および automotive)に productFlavors ブロックとフレーバーを追加します。

詳細については、プロダクト フレーバーを設定するをご覧ください。

build.gradle.kts(Module :app)

android {
    ...
    flavorDimensions += "formFactor"
    productFlavors {
        create("mobile") {
            // Inform Android Studio to use this flavor as the default (e.g. in the Build Variants tool window)
            isDefault = true
            // Since there is only one flavor dimension, this is optional
            dimension = "formFactor"
        }
        create("automotive") {
            // Since there is only one flavor dimension, this is optional
            dimension = "formFactor"
            // Adding a suffix makes it easier to differentiate builds (e.g. in the Play Console)
            versionNameSuffix = "-automotive"
        }
    }
    ...
}

build.gradle.kts ファイルを更新すると、ファイル上部のバナーに「Gradle files have changed since last project sync. A project sync may be necessary for the IDE to work properly.」(プロジェクトの最後の同期以降に Gradle ファイルが変更されています。IDE が正しく動作するためには、プロジェクトの同期が必要になる場合があります。)と表示されます。Android Studio がこれらのビルド構成の変更をインポートできるようにするため、このバナーの [Sync Now] ボタンをクリックします。

8685bcde6b21901f.png

次に、[Build] > [Select Build Variant...] のメニュー項目から [Build Variants] ツール ウィンドウを開き、automotiveDebug バリアントを選択します。これにより、automotive ソースセットのファイルが [Project] ウィンドウに表示され、Android Studio でアプリを実行する際にこのビルド バリアントが使用されるようになります。

19e4aa8135553f62.png

Android Automotive OS のマニフェストを作成する

次に、automotive ソースセットの AndroidManifest.xml ファイルを作成します。このファイルには、Android Automotive OS アプリに必要な要素が含まれています。

  1. [Project] ウィンドウで、app モジュールを右クリックします。表示されたプルダウンから、[New] > [Other] > [Android Manifest File] を選択します。
  2. 表示された [New Android Component] ウィンドウで、新しいファイルの [Target Source Set] として automotive を選択します。[Finish] をクリックしてファイルを作成します。

3fe290685a1026f5.png

  1. 作成した AndroidManifest.xml ファイル(パス app/src/automotive/AndroidManifest.xml 内)に、次のコードを追加します。

AndroidManifest.xml(automotive)

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!--  https://developer.android.com/training/cars/parked#required-features  -->
    <uses-feature
        android:name="android.hardware.type.automotive"
        android:required="true" />
    <uses-feature
        android:name="android.hardware.wifi"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.screen.portrait"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.screen.landscape"
        android:required="false" />
</manifest>

1 つ目の宣言は、ビルド アーティファクトを Google Play Console の Android Automotive OS トラックにアップロードするために必要なものです。この機能は、Google Play で android.hardware.type.automotive 機能(自動車など)を備えたデバイスにアプリを配信するためだけに使用します。

それ以外の宣言は、自動車のさまざまなハードウェア構成にアプリをインストールするために必要なものです。詳しくは、必須の Android Automotive OS の機能をご覧ください。

アプリを動画アプリとしてマークする

追加する必要がある最後のメタデータは automotive_app_desc.xml ファイルです。このファイルは、自動車向け Android のコンテキスト内でアプリのカテゴリを宣言するために使用します。ここで宣言するカテゴリは、Google Play Console で選択したアプリのカテゴリとは別物です。

  1. app モジュールを右クリックして [New] > [Android Resource File] を選択し、以下の値を入力してから [OK] をクリックします。
  • [File name]: automotive_app_desc.xml
  • [Resource type]: XML
  • [Root element]: automotiveApp
  • [Source set]: automotive
  • [Directory name]: xml

47ac6bf76ef8ad45.png

  1. このファイル内に次の <uses> 要素を追加して、アプリが動画アプリであることを宣言します。

automotive_app_desc.xml

<?xml version="1.0" encoding="utf-8"?>
<automotiveApp xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses
        name="video"
        tools:ignore="InvalidUsesTagAttribute" />
</automotiveApp>
  1. automotive ソースセットの AndroidManifest.xml ファイル(<uses-feature> 要素を追加したファイル)に、空の <application> 要素を追加します。その中に次の <meta-data> 要素を追加して、作成した automotive_app_desc.xml ファイルを参照します。

AndroidManifest.xml(automotive)

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    ...

    <application>
        <meta-data
            android:name="com.android.automotive"
            android:resource="@xml/automotive_app_desc" />
    </application>
</manifest>

これで、アプリの Android Automotive OS ビルドの作成に必要な変更がすべて完了しました。

6. Android Automotive OS の品質要件を満たす: 操作性

Android Automotive OS のビルド バリアントを作成したら、そのアプリを自動車で安全に使用できるかどうかを確認する必要があります。

ナビゲーション アフォーダンスを追加する

Android Automotive OS エミュレータでアプリを実行したとき、詳細画面からメイン画面、またはプレーヤー画面から詳細画面に戻れないことに気付いたでしょうか。他のフォーム ファクタでは、「戻る」ナビゲーションを有効にするための [戻る] ボタンやタッチ操作が必要になりますが、Android Automotive OS デバイスにはそのような要件はありません。そのため、アプリの UI でナビゲーション アフォーダンスを提供して、ユーザーがアプリ内の画面で立ち往生しないようナビゲートする必要があります。この要件は、品質に関するガイドライン AN-1 として規定されています。

詳細画面からメイン画面への「戻る」ナビゲーションをサポートするには、次のように詳細画面の CenterAlignedTopAppBarnavigationIcon パラメータを追加します。

RoadReelsApp.kt

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton

...

navigationIcon = {
    IconButton(onClick = { navController.popBackStack() }) {
        Icon(
            Icons.AutoMirrored.Filled.ArrowBack,
            contentDescription = null
        )
    }
}

プレーヤー画面からメイン画面への「戻る」ナビゲーションをサポートするための手順は次のとおりです。

  1. TopControls コンポーザブルに変更を加え、onClose というコールバック パラメータを取れるようにするとともに、クリック時にそれを呼び出す IconButton を追加します。

PlayerControls.kt

@Composable
fun TopControls(
    title: String?,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        IconButton(
            modifier = Modifier
                .align(Alignment.TopStart),
            onClick = onClose
        ) {
            Icon(
                Icons.TwoTone.Close,
                contentDescription = "Close player",
                tint = Color.White
            )
        }

        if (title != null) { ... }
    }
}
  1. PlayerControls コンポーザブルに変更を加え、onClose コールバック パラメータを取って TopControls に渡せるようにします。

PlayerControls.kt

fun PlayerControls(
    visible: Boolean,
    playerState: PlayerState,
    onClose: () -> Unit,
    onPlayPause: () -> Unit,
    onSeek: (seekToMillis: Long) -> Unit,
    modifier: Modifier = Modifier,
) {
    AnimatedVisibility(
        visible = visible,
        enter = fadeIn(),
        exit = fadeOut()
    ) {
        Box(modifier = modifier.background(Color.Black.copy(alpha = .5f))) {
            TopControls(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(dimensionResource(R.dimen.screen_edge_padding))
                    .align(Alignment.TopCenter),
                title = playerState.mediaMetadata.title?.toString(),
                onClose = onClose
            )
            ...
        }
    }
}
  1. 次に PlayerScreen コンポーザブルに変更を加え、同じパラメータを取って PlayerControls に渡せるようにします。

PlayerScreen.kt

@Compsable
fun PlayerScreen(
    onClose: () -> Unit,
    modifier: Modifier = Modifier,
) {
    ...

    PlayerControls(
        modifier = Modifier
            .fillMaxSize(),
        visible = isShowingControls,
        playerState = playerState,
        onClose = onClose,
        onPlayPause = { if (playerState.isPlaying) player.pause() else player.play() },
        onSeek = { player.seekTo(it) }
    )
}
  1. 最後に RoadReelsNavHost で、PlayerScreen に渡す実装を指定します。

RoadReelsNavHost.kt

composable(route = Screen.Player.name) {
    PlayerScreen(onClose = { navController.popBackStack() })
}

これで、ユーザーが立ち往生することなく画面間を移動できるようになりました。また、これにより他のフォーム ファクタでもユーザー エクスペリエンスが向上する可能性があります。たとえば、細長いスマートフォンの画面上部を手で持って操作しているときに、デバイスを持ち替えなくてもアプリを簡単に操作できるようにすることも可能です。

43122e716eeeeb20.gif

画面の向きのサポートに対応する

ほとんどのモバイル デバイスとは異なり、自動車の場合は画面の向きが固定されているのが一般的です。画面を回転させることができないため、横向きまたは縦向きのいずれか一方のみがサポートされます。そのため、アプリでも両方の向きがサポートされていることを想定すべきではありません。

Android Automotive OS のマニフェストを作成するでは、android.hardware.screen.portrait 機能と android.hardware.screen.landscape 機能用に、2 つの <uses-feature> 要素を追加してrequired 属性を false に設定しました。これにより、どちらかの画面の向きに暗黙的に依存することが原因で、アプリが自動車に配信されなくなることを回避できます。ただし、これらのマニフェスト要素はアプリの配信方法にのみ影響し、アプリの動作を変更するものではありません。

現時点でこのアプリには、動画プレーヤーを開いたときにアクティビティの向きを自動的に横向きに設定する便利な機能があります。この機能により、スマートフォンが横向きになっていない場合でも、ユーザーがデバイスを操作して向きを変更する必要はありません。

しかしながら、現在市販されている自動車にはデバイスが縦向きに固定されているものもあり、その場合はちらつきループが発生したりレターボックス表示になったりする可能性があります。

この問題を解決するには、デバイスでサポートされている画面の向きに基づくチェックを追加します。

  1. 実装を簡単にするため、まず Extensions.kt に以下を追加します。

Extensions.kt

import android.content.Context
import android.content.pm.PackageManager

...

enum class SupportedOrientation {
    Landscape,
    Portrait,
}

fun Context.supportedOrientations(): List<SupportedOrientation> {
    return when (Pair(
        packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_LANDSCAPE),
        packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_PORTRAIT)
    )) {
        Pair(true, false) -> listOf(SupportedOrientation.Landscape)
        Pair(false, true) -> listOf(SupportedOrientation.Portrait)
        // For backwards compat, if neither feature is declared, both can be assumed to be supported
        else -> listOf(SupportedOrientation.Landscape, SupportedOrientation.Portrait)
    }
}
  1. 次に、リクエストされた向きに設定するための呼び出しを無効にします。同様の問題は、モバイル デバイスのマルチ ウィンドウ モードでも発生する可能性があります。この場合も、向きが動的に設定されないようにチェックを追加できます。

PlayerScreen.kt

import com.example.android.cars.roadreels.SupportedOrientation
import com.example.android.cars.roadreels.supportedOrientations

...

LaunchedEffect(Unit) {
    ...

    // Only automatically set the orientation to landscape if the device supports landscape.
    // On devices that are portrait only, the activity may enter a compat mode and won't get to
    // use the full window available if so. The same applies if the app's window is portrait
    // in multi-window mode.
    if (context.supportedOrientations().contains(SupportedOrientation.Landscape)
        && !context.isInMultiWindowMode
    ) {
        context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
    }

    ...
}

[チェックを追加する前]: Polestar 2 エミュレータのプレーヤー画面でちらつきループが発生している(アクティビティによって画面の向きの構成変更が処理されなかった場合)

[チェックを追加する前]: Polestar 2 エミュレータのプレーヤー画面がレターボックス表示になっている(アクティビティによって画面の向きの構成変更が処理された場合)

[チェックを追加した後]: Polestar 2 エミュレータのプレーヤー画面がレターボックス表示になっていない

[チェックを追加する前]: Polestar 2 エミュレータのプレーヤー画面でちらつきループが発生している(アクティビティによって orientation構成の変更が処理されなかった場合)

[チェックを追加する前]: Polestar 2 エミュレータのプレーヤー画面がレターボックス表示になっている(アクティビティによって orientation構成の変更が処理された場合)

[チェックを追加した後]: Polestar 2 エミュレータのプレーヤー画面がレターボックス表示になっていない

このアプリではこれ以外の場所で画面の向きを設定していないため、これでレターボックス表示を回避できます。アプリ内に、横向きまたは縦向き限定の screenOrientation 属性や setRequestedOrientation 呼び出し(センサー、反転、それぞれのユーザー バリアントを含む)がないか確認し、必要に応じて削除または無効化してレターボックス表示を制限してください。詳しくは、大画面互換性モードをご覧ください。

システムバーが制御可能かどうかによって対応する

ここまでの変更により、ちらつきループやレターボックス表示は回避できましたが、「システムバーは常に非表示にできる」という想定が当てはまらなくなっています。自動車に対するユーザーニーズはスマートフォンやタブレットとは異なるため、空調などの車両制御を常に画面に表示できるよう、OEM 向けにアプリがシステムバーを非表示にできないようにするオプションが用意されています。

そのため、システム バーを非表示にできると想定してアプリを没入モードでレンダリングすると、システムバーの背後にレンダリングされてしまう可能性があります。前の手順でも確認できるように、アプリがレターボックス表示にならない場合、プレーヤーの上下のコントロールは表示されなくなってしまいます。上に示した例では、プレーヤーの閉じるボタンが表示されておらず、シークバーも使用できないためアプリを操作できなくなっています。

最も簡単な解決方法は、次のように systemBars ウィンドウ インセットのパディングをプレーヤーに適用する方法です。

PlayerScreen.kt

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding

...

Box(
    modifier = Modifier
        .fillMaxSize()
        .windowInsetsPadding(WindowInsets.systemBars)
) {
    PlayerView(...)
    PlayerControls(...)
}

ただし、この方法が理想的とは言えません。システムバーがフェードアウトするときに、他の UI 要素が飛び跳ねるように移動してしまうからです。

9c51956e2093820a.gif

ユーザー エクスペリエンスを改善するには、どのインセットが操作可能かを把握し、操作できないインセットにのみパディングを適用するようアプリを更新します。

  1. アプリ内の他の画面でもウィンドウ インセットが必要になる可能性を考え、操作可能なインセットを CompositionLocal として渡しておくとよいでしょう。com.example.android.cars.roadreels パッケージに新しいファイル LocalControllableInsets.kt を作成し、次のコードを追加します。

LocalControllableInsets.kt

import androidx.compose.runtime.compositionLocalOf

// Assume that no insets can be controlled by default
const val DEFAULT_CONTROLLABLE_INSETS = 0
val LocalControllableInsets = compositionLocalOf { DEFAULT_CONTROLLABLE_INSETS }
  1. 変化をリッスンできるよう OnControllableInsetsChangedListener を設定します。

MainActivity.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat.OnControllableInsetsChangedListener

...

class MainActivity : ComponentActivity() {
    private lateinit var onControllableInsetsChangedListener: OnControllableInsetsChangedListener

    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()

        setContent {
            var controllableInsetsTypeMask by remember { mutableIntStateOf(DEFAULT_CONTROLLABLE_INSETS) }

            onControllableInsetsChangedListener =
                OnControllableInsetsChangedListener { _, typeMask ->
                    if (controllableInsetsTypeMask != typeMask) {
                        controllableInsetsTypeMask = typeMask
                    }
                }

            WindowCompat.getInsetsController(window, window.decorView)
                .addOnControllableInsetsChangedListener(onControllableInsetsChangedListener)

            RoadReelsTheme {
                RoadReelsApp(calculateWindowSizeClass(this))
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()

        WindowCompat.getInsetsController(window, window.decorView)
            .removeOnControllableInsetsChangedListener(onControllableInsetsChangedListener)
    }
}
  1. テーマとアプリのコンポーザブルを含む最上位レベルの CompositionLocalProvider を追加し、値を LocalControllableInsets にバインドします。

MainActivity.kt

import androidx.compose.runtime.CompositionLocalProvider

...

CompositionLocalProvider(LocalControllableInsets provides controllableInsetsTypeMask) {
    RoadReelsTheme {
        RoadReelsApp(calculateWindowSizeClass(this))
    }
}
  1. プレーヤーで現在の値を読み取り、その値を使用してパディングに使用するインセットを特定します。

PlayerScreen.kt

import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.ui.unit.dp
import com.example.android.cars.roadreels.LocalControllableInsets

...

val controllableInsetsTypeMask = LocalControllableInsets.current

// When the system bars can be hidden, ignore them when applying padding to the player and
// controls so they don't jump around as the system bars disappear. If they can't be hidden
// include them so nothing renders behind the system bars
var windowInsetsForPadding = WindowInsets(0.dp)
if (controllableInsetsTypeMask.and(WindowInsetsCompat.Type.statusBars()) == 0) {
    windowInsetsForPadding = windowInsetsForPadding.union(WindowInsets.statusBars)
}
if (controllableInsetsTypeMask.and(WindowInsetsCompat.Type.navigationBars()) == 0) {
    windowInsetsForPadding = windowInsetsForPadding.union(WindowInsets.navigationBars)
}

Box(
    modifier = Modifier
        .fillMaxSize()
        .windowInsetsPadding(windowInsetsForPadding)
) {
    PlayerView(...)
    PlayerControls(...)
}

システムバーを非表示にできる場合でも、コンテンツが飛び跳ねるように移動することはない

システムバーを非表示にできない場合は、コンテンツがそのまま表示される

システムバーを非表示にできる場合でも、コンテンツが飛び跳ねるように移動することはない

システムバーを非表示にできない場合は、コンテンツがそのまま表示される

これでかなり改善されました。システムバーを非表示にしたときにコンテンツが飛び跳ねるように移動することはありません。また、システムバーを制御できない自動車でもコントロールは完全に表示されます。

7. Android Automotive OS の品質要件を満たす: ドライバーの注意散漫

最後に、自動車とそれ以外のフォーム ファクタとの大きな違いとして、「自動車は運転するもの」という点を指摘しておく必要があります。したがって、運転中にドライバーの注意を逸らさないようにすることが非常に重要です。Android Automotive OS 用の駐車時向けアプリでは、運転を開始するときに必ず再生を一時停止する必要があります。運転を開始するとシステム オーバーレイが表示され、オーバーレイされるアプリの onPause ライフサイクル イベントが呼び出されます。この呼び出しの際に、アプリでの再生を一時停止する必要があります。

運転をシミュレーションする

エミュレータでプレーヤー ビューに移動し、コンテンツの再生を開始します。次に、運転をシミュレーションするの手順に沿って操作します。この段階では、アプリの UI がシステムによってオーバーレイされても再生は中断されません。この動作は、自動車向けアプリの品質に関するガイドライン DD-2 に違反しています。

839af1382c1f10ca.png

運転を開始したら再生を一時停止する

  1. androidx.lifecycle:lifecycle-runtime-compose アーティファクトに対する依存関係を追加します。このアーティファクトには、ライフサイクル イベントでコードを実行するために使用する LifecycleEventEffect が含まれています。

libs.version.toml

androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }

Build.gradle.kts(Module :app)

implementation(libs.androidx.lifecycle.runtime.compose)
  1. プロジェクトを同期して依存関係がダウンロードされたら、再生を一時停止するため、ON_PAUSE イベントで実行する LifecycleEventEffect を追加します。

PlayerScreen.kt

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect

...

@Composable
fun PlayerScreen(...) {
    ...
    LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) {
        player.pause()
    }

    LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
        player.play()
    }
    ...
}

修正を実装したら、先ほどと同じ手順で再生中の運転をシミュレーションし、再生が停止することを確認します。これで、DD-2 の要件を満たすことができました。

8. 遠隔ディスプレイ エミュレータでアプリをテストする

自動車で新たに採用され始めているのが 2 画面構成です。たとえば、メイン画面をセンター コンソールに配置し、サブ画面をダッシュボード上部のフロントガラス近くに配置するような構成です。アプリをメイン画面とサブ画面の間で移動させることで、ドライバーや同乗者にさまざまなオプションを提供できます。

自動車の遠隔ディスプレイのイメージをインストールする

  1. まず、Android Studio で SDK Manager を開き、まだ選択していない場合は [SDK Platforms] タブを選択します。SDK Manager ウィンドウの右下にある [Show package details] チェックボックスがオンになっていることを確認します。
  2. お使いパソコンのアーキテクチャ(x86 / ARM)に対応する Automotive Distant Display with Google APIs エミュレータのイメージをインストールします。

Android Automotive OS Android Virtual Device を作成する

  1. デバイス マネージャーを開き、ウィンドウの左側にある [Category] 列で [Automotive] を選択します。次に、一覧から Automotive Distant Display のバンドルされたハードウェア プロファイル を選択し、[Next] をクリックします。
  2. 次のページに移動したら、前の手順で作成したシステム イメージを選択します。[Next] をクリックし、必要に応じて詳細オプションを選択したら、[Finish] をクリックして AVD を作成します。

アプリを実行する

前の手順で作成したエミュレータで、既存の app 実行構成を使用してアプリを実行します。遠隔ディスプレイ エミュレータを使用してテストするの手順に沿って、アプリを遠隔ディスプレイに移動したりメイン画面に戻したりします。アプリの移動と操作は、メイン / 詳細画面とプレーヤー画面の両方でテストしてください。

b277bd18a94e9c1b.png

9. 遠隔ディスプレイのアプリ エクスペリエンスを改善する

遠隔ディスプレイでアプリを使用した際に、次の 2 点に気付いたでしょうか。

  1. 遠隔ディスプレイとの間でアプリを移動すると再生が最初に戻る。
  2. アプリが遠隔ディスプレイに表示されている間は、再生状態を変更するなどのアプリ操作を行えない。

アプリの継続性を改善する

再生が最初に戻る問題は、構成変更に伴ってアクティビティが再作成されることが原因です。アプリは Compose を使用して作成されており、構成変更がサイズ関連であるため、サイズベースの構成変更に関するアクティビティの再作成を制限することで、Compose で構成変更が処理されるように簡単に修正できます。これにより、アクティビティの再作成による再生停止や再読み込みが発生しなくなり、ディスプレイ間をシームレスに移動できるようになります。

AndroidManifest.xml

<activity
    android:name="com.example.android.cars.roadreels.MainActivity"
    ...
    android:configChanges="screenSize|smallestScreenSize|orientation|screenLayout|density">
        ...
</activity>

再生コントロールを実装する

遠隔ディスプレイに移動したときにアプリを操作できない問題を解決するには MediaSession を実装します。MediaSession は、音声や動画のプレーヤーを操作するための汎用的な手段を提供します。詳しくは、MediaSession を使用して再生を制御およびアドバタイズするをご覧ください。

  1. androidx.media3:media3-session アーティファクトへの依存関係を追加します。

libs.version.toml

androidx-media3-mediasession = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }

build.gradle.kts (Module :app)

implementation(libs.androidx.media3.mediasession)
  1. ビルダーを使用して MediaSession を作成します。

PlayerScreen.kt

import androidx.media3.session.MediaSession

@Composable
fun PlayerScreen(...) {
    ...
    val mediaSession = remember(context, player) {
        MediaSession.Builder(context, player).build()
    }
    ...
}
  1. 次に、Player コンポーザブルの DisposableEffectonDispose ブロックに別の行を追加して、Player がコンポジション ツリーを離れるときに MediaSession をリリースします。

PlayerScreen.kt

DisposableEffect(Unit) {
    onDispose {
        mediaSession.release()
        player.release()
        ...
    }
}
  1. 最後に、プレーヤー画面で adb shell cmd media_session dispatch コマンドを使用してメディア コントロールをテストします。
# To play content
adb shell cmd media_session dispatch play

# To pause content
adb shell cmd media_session dispatch pause

# To toggle the playing state
adb shell cmd media_session dispatch play-pause

以上により、遠隔ディスプレイを備えた自動車でのアプリの動作がかなり改善されます。加えて、他のフォーム ファクタでの動作も改善されます。画面を回転できるデバイスや、ユーザーがアプリのウィンドウ サイズを変更できるデバイスにも、アプリがシームレスに対応できるようになります。

さらには、メディア セッションの統合により、自動車のハードウェアやソフトウェアのコントロールだけでなく、他のソース(Google アシスタントのクエリ、ヘッドフォンの一時停止ボタンなど)からもアプリの再生を操作できるようになり、さまざまなフォーム ファクタでアプリを操作するオプションを提供できるようになります。

10. さまざまなシステム構成でアプリをテストする

アプリがメインディスプレイと遠隔ディスプレイで適切に動作することが確認できたら、最後にさまざまなシステムバー構成とディスプレイ カットアウトがアプリでどのように処理されるかを確認します。ウィンドウ インセットとディスプレイ カットアウトを使用するでも説明していますが、Android Automotive OS デバイスの構成には、モバイル フォーム ファクタに適用される一般的な想定が当てはまない場合があります。

ここでは、実行時に構成可能なエミュレータをダウンロードし、左側にシステムバーが表示されるように構成されたエミュレータでアプリをテストします。

Android Automotive with Google APIs イメージをインストールする

  1. まず、Android Studio で SDK Manager を開き、まだ選択していない場合は [SDK Platforms] タブを選択します。SDK Manager ウィンドウの右下にある [Show package details] チェックボックスがオンになっていることを確認します。
  2. お使いのパソコンのアーキテクチャ(x86 / ARM)に対応する API 33 Android Automotive with Google APIs エミュレータのイメージをインストールします。

Android Automotive OS Android Virtual Device を作成する

  1. デバイス マネージャーを開き、ウィンドウの左側にある [Category] 列で [Automotive] を選択します。次に、バンドルされたハードウェア プロファイルの一覧から Automotive (1080p landscape) を選択し、[Next] をクリックします。
  2. 次のページに移動したら、前の手順で作成したシステム イメージを選択します。[Next] をクリックし、必要に応じて詳細オプションを選択したら、[Finish] をクリックして AVD を作成します。

サイド システムバーを構成する

構成可能なエミュレータを使用してテストするで説明したように、自動車のさまざまなシステム構成をエミュレートできるよう幅広いオプションが用意されています。

この Codelab では、com.android.systemui.rro.left を使用してさまざまなシステムバー構成をテストします。これを有効にするには次のコマンドを実行します。

adb shell cmd overlay enable --user 0 com.android.systemui.rro.left

b642703a7278b219.png

このアプリでは、ScaffoldcontentWindowInsets として systemBars 修飾子を使用しているため、コンテンツはすでにシステムバーのセーフエリア内に描画されています。システムバーが画面の上下にのみ表示されると想定しているアプリがどう表示されるかを確認するには、パラメータを次のように変更します。

RoadReelsApp.kt

contentWindowInsets = if (route?.equals(Screen.Player.name) == true) WindowInsets(0.dp) else WindowInsets.systemBars.only(WindowInsetsSides.Vertical)

この場合、一覧画面と詳細画面がシステムバーの背後にレンダリングされてしまいます。ただし、ここまでの変更により、その後システムバーが制御できなくなってもプレーヤー画面は問題なく表示されます。

9898f7298a7dfb4.gif

次のセクションに進む前に、windowContentPadding パラメータに加えた変更を元に戻してください。

11. ディスプレイ カットアウトを使用する

最後に、モバイル デバイスの場合とは大きく異なる、一部の自動車のディスプレイ カットアウトにも対応できるようにします。モバイル デバイスの場合はノッチやピンホールカメラのためのカットアウトが一般的ですが、Android Automotive OS を搭載した自動車には、非矩形の画面(輪郭が曲線になっているもの)が採用されている場合もあります。

このようなディスプレイ カットアウトでのアプリの動作を確認するには、まず次のコマンドを実行してディスプレイ カットアウトを有効にします。

adb shell cmd overlay enable --user 0 com.android.internal.display.cutout.emulation.free_form

アプリの動作を詳細にテストするため、最後のセクションで使用した左側のシステムバーも有効にします(無効になっている場合のみ)。

adb shell cmd overlay enable --user 0 com.android.systemui.rro.left

この時点では、ディスプレイ カットアウトに合わせたレンダリングはされていません(カットアウトの正確な形状はまだ不明ですが、次のステップで明らかになります)。この動作でもまったく問題ありません。アプリのエクスペリエンスとしては、カットアウトに合わせてレンダリングされているのに正しく表示されていないほうが問題です。

212628db84981025.gif

ディスプレイ カットアウトに合わせてレンダリングする

ユーザーに最大限の没入感を提供するには、ディスプレイ カットアウトに合わせてレンダリングして画面領域をより有効に活用します。

  1. ディスプレイ カットアウトに合わせてレンダリングするには、integers.xml ファイルを作成して自動車固有のオーバーライドを確保します。そのためには、UI mode 修飾子の値として Car Dock を選択します(この名前は Android Auto で使用されていたものですが、Android Automotive OS でも引き続き使用します)。また、LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS は Android R で導入されたため、Android Version 修飾子を追加し、値として 30 を選択します。詳しくは、代替リソースを使用するをご覧ください。

22b7f17657cac3fd.png

  1. 作成したファイル(res/values-car-v30/integers.xml)に次のコードを追加します。

integers.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <integer name="windowLayoutInDisplayCutoutMode">3</integer>
</resources>

整数値 3LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS に対応します。res/values/integers.xml のデフォルト値 0LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT に対応)をオーバーライドします。この整数値は、enableEdgeToEdge() によって設定されたモードをオーバーライドするため、すでに MainActivity.kt で参照されています。この属性について詳しくは、リファレンス ドキュメントをご覧ください。

アプリを実行すると、コンテンツがカットアウトまで広がって表示され没入感があります。しかし、上部のアプリバーとコンテンツの一部が、ディスプレイ カットアウトによって隠れてしまっています。システムバーが上下にのみ表示されることを想定しているアプリと同様の問題です。

f0eefa42dee6f7c7.gif

上部のアプリバーを修正する

上部のアプリバーを修正するには、次の windowInsets パラメータを CenterAlignedTopAppBar コンポーザブルに追加します。

RoadReelsApp.kt

import androidx.compose.foundation.layout.safeDrawing

...

windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)

safeDrawing は、systemBars で上部のアプリバーのみを配置するデフォルトの windowInsets パラメータを改良したもので、displayCutoutsystemBars の 2 つのインセットで構成されています。

なお、上部のアプリバーはウィンドウの上部に配置されるため、safeDrawing インセットの下部のコンポーネントは含めないようにしてください。不要なパディングが追加される恐れがあります。

7d59ebb63ada5f71.gif

メイン画面を修正する

メイン画面と詳細画面のコンテンツを修正する方法の 1 つとして、ScaffoldcontentWindowInsetssystemBars ではなく safeDrawing を使用する方法もあります。ただし、この方法だとディスプレイ カットアウトによってコンテンツが途切れてしまい、アプリの没入感は著しく低下します。これでは、アプリがディスプレイ カットアウトに合わせてレンダリングされていない場合と大差ありません。

6b3824ca3214cbfa.gif

画面内の各コンポーネントのインセットを処理する方法であれば、より没入感のあるユーザー インターフェースを実現できます。

  1. ScaffoldcontentWindowInsets に変更を加え、PlayerScreen の場合だけでなく常に 0dp になるようにします。これにより、各画面や画面内のコンポーネントのインセット関連の動作が決まります。

RoadReelsApp.kt

Scaffold(
    ...,
    contentWindowInsets = WindowInsets(0.dp)
) { ... }
  1. 行ヘッダー Text コンポーザブルの windowInsetsPadding に変更を加え、safeDrawing インセットの水平コンポーネントを使用するように設定します。これらのインセットの上部コンポーネントは、上部のアプリバーによって処理されます。下部コンポーネントについては後で処理します。

MainScreen.kt

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding

...

LazyColumn(
    contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.screen_edge_padding))
) {
    items(NUM_ROWS) { rowIndex: Int ->
        Text(
            "Row $rowIndex",
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier
                .padding(
                    horizontal = dimensionResource(R.dimen.screen_edge_padding),
                    vertical = dimensionResource(R.dimen.row_header_vertical_padding)
                )
                .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
        )
    ...
}
  1. LazyRowcontentPadding パラメータを削除します。次に、対応する safeDrawing コンポーネントの幅の Spacer を各 LazyRow の先頭と末尾に追加して、すべてのサムネイルが完全に表示されるようにします。widthIn 修飾子を使用して、これらのスペーサーの幅がコンテンツのパディングの幅以上になるようにします。これらの要素がないと、行の最初または最後までスワイプしたときに、端のアイテムがシステムバーやディスプレイ カットアウトに隠れてしまうことがあります。

MainScreen.kt

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsStartWidth

...

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_item_spacing)),
) {
    item {
        Spacer(
            Modifier
                .windowInsetsStartWidth(WindowInsets.safeDrawing)
                .widthIn(min = dimensionResource(R.dimen.screen_edge_padding))
        )
    }
    items(NUM_ITEMS_PER_ROW) { ... }
    item {
        Spacer(
            Modifier
                .windowInsetsEndWidth(WindowInsets.safeDrawing)
                .widthIn(min = dimensionResource(R.dimen.screen_edge_padding))
        )
    }
}
  1. 最後に、画面の下部にシステムバーやディスプレイ カットアウトのインセットがある場合に備え、LazyColumn の末尾に Spacer を追加します。このようなスペーサーは、LazyColumn の上部には必要ありません。この処理は上部のアプリバーが行います。アプリが上部ではなく下部のアプリバーを使用している場合は、windowInsetsTopHeight 修飾子を使用してリストの先頭に Spacer を追加します。アプリが上部と下部の両方のアプリバーを使用している場合は、どちらのスペーサーも必要ありません。

MainScreen.kt

import androidx.compose.foundation.layout.windowInsetsBottomHeight

...

LazyColumn(...){
    items(NUM_ROWS) { ... }
    item {
        Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
    }
}

これで、上部のアプリバーが完全に表示され、行の最後までスクロールしてもすべてのサムネイルが完全に表示されるようになりました。

543706473398114a.gif

詳細画面を修正する

f622958a8d0c16c8.png

詳細画面はそこまで深刻ではありませんが、やはりコンテンツが切れています。

詳細画面にはスクロール可能なコンテンツはないため、最上位の BoxwindowInsetsPadding 修飾子を追加するだけで修正できます。

DetailScreen.kt

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding

...

Box(
    modifier = modifier
        .padding(dimensionResource(R.dimen.screen_edge_padding))
        .windowInsetsPadding(WindowInsets.safeDrawing)
) { ... }

bdd6de6010fc139d.png

プレーヤー画面を修正する

PlayerScreen には、すでに Android Automotive OS の品質要件を満たす: 操作性で、システムバーの一部またはすべてのウィンドウ インセットにパディングを適用しています。しかし、アプリをディスプレイ カットアウトに合わせてレンダリングする場合、コンテンツが確実に隠れないようにするにはパディングだけでは不十分です。モバイル デバイスの場合は、ディスプレイ カットアウトがシステムバー内に収まらないことはほぼありません。一方、自動車の場合はディスプレイ カットアウトがシステムバーを大きくはみ出る可能性があり、上記の想定は適用できません。

427227df5e44f554.png

これを修正するには、windowInsetsForPadding 変数の初期値を、次のようにゼロ値から displayCutout に変更します。

PlayerScreen.kt

import androidx.compose.foundation.layout.displayCutout

...

var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)

b523d8c1e1423757.gif

これで、使いやすさを維持しながら画面を最大限に活用できるようになりました。

アプリをモバイル デバイスで実行した場合も、没入感がさらに高まっているはずです。リストアイテムは、ナビゲーション バーの背後も含め、画面の端までレンダリングされます。

dc7918499a33df31.png

12. 完了

これで、初めての駐車時向けアプリを移行して最適化することができました。ここで学んだことを、ぜひ実際のアプリに応用してください。

試してみたいこと

参考資料