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 の追加の一覧から、API 33 Android Automotive with Google APIs エミュレータ イメージをインストールします。イメージは、同じアーキテクチャ(x86 / ARM)のマシンでのみ実行できます。

Android Automotive OS Android Virtual Device を作成する

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

アプリを実行する

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

599922cd078f2589.png

5. マニフェストを更新して Android Automotive OS のサポートを宣言する

アプリはこのままでも動作しますが、Android Automotive OS で適切に動作するアプリにするため、また Google Play ストアへの公開要件を満たすためには、いくつかの変更を加える必要があります。これらの変更により、同じ APK または App Bundle でモバイルと Android Automotive OS デバイスの両方をサポートできるようになりました。最初の変更は、AndroidManifest.xml ファイルを更新して、アプリが Android Automotive OS デバイスをサポートし、動画アプリであることを示すことです。

自動車用ハードウェア機能を宣言する

アプリが Android Automotive OS デバイスに対応していることを示すには、AndroidManifest.xml ファイルに次の <uses-feature> 要素を追加します。

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    ...
    <uses-feature
        android:name="android.hardware.type.automotive"
        android:required="false" />
    ...
</manifest>

android:required 属性に false の値を使用すると、生成された APK または App Bundle を Android Automotive OS デバイスとモバイル デバイスの両方に配布できます。詳しくは、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]: main
  • [Directory name]: xml

9fc697aec93d9d09.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. 既存の <application> 要素内に、作成した automotive_app_desc.xml ファイルを参照する次の <meta-data> 要素を追加します。

AndroidManifest.xml

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

これで、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

...

} else if (route?.startsWith(Screen.Detail.name) == true) {
    CenterAlignedTopAppBar(
        title = { Text(stringResource(R.string.bbb_title)) },
        navigationIcon = {
            IconButton(onClick = { navController.popBackStack() }) {
                Icon(
                    Icons.AutoMirrored.Filled.ArrowBack,
                    contentDescription = null
                )
            }
        }
    )
}

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

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

PlayerControls.kt

import androidx.compose.material.icons.twotone.Close

...

@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(
    uiState: PlayerUiState,
    onClose: () -> Unit,
    onPlayPause: () -> Unit,
    onSeek: (seekToMillis: Long) -> Unit,
    modifier: Modifier = Modifier,
) {
    AnimatedVisibility(
        visible = uiState.isShowingControls,
        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 = uiState.mediaMetadata.title?.toString(),
                onClose = onClose
            )
            ...
        }
    }
}
  1. 次に PlayerScreen コンポーザブルに変更を加え、同じパラメータを取って PlayerControls に渡せるようにします。

PlayerScreen.kt

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

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

RoadReelsNavHost.kt

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

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

46cf7ec051b32ddf.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

...

DisposableEffect(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 (activity.supportedOrientations().contains(SupportedOrientation.Landscape)
        && !activity.isInMultiWindowMode
    ) {
        activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
    }

    ...
}

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

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

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

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

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

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

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

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

ここまでの変更により、ちらつきループやレターボックス表示は回避できましたが、「システムバーは常に非表示にできる」という想定が当てはまらなくなっています。自動車に対するユーザーニーズはスマートフォンやタブレットとは異なるため、空調などの車両制御を常に画面に表示できるよう、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 要素が飛び跳ねるように移動してしまうからです。

9fa1de6d2518340a.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.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.unit.dp
import com.example.android.cars.roadreels.LocalControllableInsets

...

val controllableInsetsTypeMask by rememberUpdatedState(LocalControllableInsets.current)

DisposableEffect(Unit) {
    ...
    windowInsetsController.hide(WindowInsetsCompat.Type.systemBars().and(controllableInsetsTypeMask))
    ...
}

...

// 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 に違反しています。

c2eda16df688c102.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(:app モジュール)

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

PlayerScreen.kt

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

...

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

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

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

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

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

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

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

Android Automotive OS Android Virtual Device を作成する

  1. デバイス マネージャーを開き、ウィンドウの左側にある [Category] 列で [Automotive] を選択します。次に、一覧から Automotive Distant Display with Google Play のバンドルされたハードウェア プロファイルを選択し、[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(:app モジュール)

implementation(libs.androidx.media3.mediasession)
  1. PlayerViewModel で、メディア セッションを保持する変数を追加し、そのビルダーを使用して MediaSession を作成します。

PlayerViewModel.kt

import androidx.media3.session.MediaSession
...

class PlayerViewModel(...) {
    ...

    private var mediaSession: MediaSession? = null
    
    init {
        viewModelScope.launch {
            _player.onEach { player ->
                playerUiStateUpdateJob?.cancel()
                mediaSession?.release()

                if (player != null) {
                    initializePlayer(player)
                    mediaSession = MediaSession.Builder(application, player).build()
                    playerUiStateUpdateJob = viewModelScope.launch {... }
                }
            }.collect()
        }
    }
}
  1. 次に、onCleared メソッドに別の行を追加して、PlayerViewModel が不要になったときに MediaSession を解放します。

PlayerViewModel.kt

override fun onCleared() {
    super.onCleared()
    mediaSession?.release()
    _player.value?.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

再生の再開を制限する

MediaSession をサポートすると、アプリが遠隔ディスプレイに移動しているときに再生を制御できますが、この機能は新たな問題を引き起こす可能性があります。つまり、ユーザー エクスペリエンスの制限が適用されている間に再生を再開できるため、これも DD-2 品質ガイドラインに違反しています。これをご自身でテストするには:

  1. 再生を開始
  2. 運転をシミュレーションする
  3. media_session dispatch コマンドの使用アプリが隠されていても再生が再開されることに注意してください。

この問題を解決するには、デバイスのユーザー エクスペリエンスの制限をリッスンし、制限が有効な場合にのみ再生の再開を許可します。モバイルと Android Automotive OS の両方で同じロジックを使用できる方法で行うこともできます。

  1. app モジュールの build.gradle ファイルに、次のコードを追加して Android Automotive ライブラリ を含め、その後 Gradle 同期を行います。

build.gradle.kts

android {
    ...
    useLibrary("android.car")
}
  1. com.example.android.cars.roadreels パッケージを右クリックして、[New] > [Kotlin Class/File] を選択します。名前に「RoadReelsPlayer」と入力し、[Class] タイプをクリックします。
  2. 作成したファイルに、クラスの次のスターター実装を追加します。ForwardingSimpleBasePlayer を拡張すると、getState() メソッドをオーバーライドすることで、ラップされたプレーヤーでサポートされているコマンドと操作を簡単に変更できます。

RoadReelsPlayer.kt

import android.content.Context
import androidx.media3.common.ForwardingSimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer

@UnstableApi
class RoadReelsPlayer(context: Context) :
    ForwardingSimpleBasePlayer(ExoPlayer.Builder(context).build()) {
    private var shouldPreventPlay = false

    override fun getState(): State {
        val state = super.getState()

        return state.buildUpon()
            .setAvailableCommands(
                state.availableCommands.buildUpon().removeIf(COMMAND_PLAY_PAUSE, shouldPreventPlay)
                    .build()
            ).build()
    }
}
  1. PlayerViewModel.kt で、プレーヤー変数の宣言を更新し、ExoPlayer の代わりに RoadReelsPlayer のインスタンスを使用するようにします。この時点では、shouldPreventPlay はデフォルト値の false から更新されることがないため、動作は以前とまったく同じになります。

PlayerViewModel.kt

init {
    ...
    _player.update { RoadReelsPlayer(application) }
}
  1. ユーザー エクスペリエンス制限のトラッキングを開始するには、次の init ブロックと handleRelease 実装を追加します。

RoadReelsPlayer.kt

import android.car.Car
import android.car.drivingstate.CarUxRestrictions
import android.car.drivingstate.CarUxRestrictionsManager
import android.content.pm.PackageManager
import com.google.common.util.concurrent.ListenableFuture

...

@UnstableApi
class RoadReelsPlayer(context: Context) :
    ForwardingSimpleBasePlayer(ExoPlayer.Builder(context).build()) {
    ...
    private var pausedByUxRestrictions = false
    private lateinit var carUxRestrictionsManager: CarUxRestrictionsManager

   init {
        with(context) {
            // Only listen to UX restrictions if the device is running Android Automotive OS
            if (packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) {
                val car = Car.createCar(context)
                carUxRestrictionsManager =
                    car.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as CarUxRestrictionsManager

                // Get the initial UX restrictions and update the player state
                shouldPreventPlay =
                    carUxRestrictionsManager.currentCarUxRestrictions.isRequiresDistractionOptimization
                invalidateState()

                // Register a listener to update the player state as the UX restrictions change
                carUxRestrictionsManager.registerListener { carUxRestrictions: CarUxRestrictions ->
                    shouldPreventPlay = carUxRestrictions.isRequiresDistractionOptimization

                    if (!shouldPreventPlay && pausedByUxRestrictions) {
                        handleSetPlayWhenReady(true)
                        invalidateState()
                    } else if (shouldPreventPlay && isPlaying) {
                        pausedByUxRestrictions = true
                        handleSetPlayWhenReady(false)
                        invalidateState()
                    }
                }
            }

            addListener(object : Player.Listener {
                override fun onEvents(player: Player, events: Player.Events) {
                    if (events.contains(EVENT_IS_PLAYING_CHANGED) && isPlaying) {
                        pausedByUxRestrictions = false
                    }
                }
            })
        }
    }

    ...

    override fun handleRelease(): ListenableFuture<*> {
        if (::carUxRestrictionsManager.isInitialized) {
            carUxRestrictionsManager.unregisterListener()
        }
        return super.handleRelease()
    }
}

注意すべき点がいくつかあります。

  • CarUxRestrictionsManager は、Android Automotive OS 以外のデバイスではインスタンス化も使用もされないため、lateinit 変数として保存されますが、プレーヤーがリリースされたときにリスナーをクリーンアップする必要があります。
  • UX の制限状態を判断する際に参照されるのは isRequiresDistractionOptimization 値のみです。CarUxRestrictions クラスには、有効になっている制限に関する詳細が含まれていますが、これらは制限が有効になっている間も引き続き表示される注意散漫防止の最適化済みアプリ(ナビゲーション アプリなど)が使用することを目的としているため、参照する必要はありません。
  • shouldPreventPlay 変数が更新されると、invalidateState() が呼び出され、プレーヤーの状態の変化がコンシューマに通知されます。
  • リスナー自体では、適切な値を指定して handleSetPlayWhenReady を呼び出すことで、再生が自動的に一時停止または再開されます。
  1. 次に、このセクションの冒頭で説明したように、運転をシミュレートしながら再生を再開するテストを行います。再生は再開されません。
  2. 最後に、ユーザー エクスペリエンスの制限が有効になったときに再生を一時停止するのは RoadReelsPlayer が処理するため、ON_PAUSE 中に LifecycleEventEffect でプレーヤーを一時停止する必要はありません。代わりに、これを ON_STOP に変更すると、ユーザーがアプリを離れてランチャーに移動したり、別のアプリを開いたりしたときに再生が停止します。

PlayerScreen.kt

LifecycleEventEffect(Lifecycle.Event.ON_START) {
    viewModel.play()
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
    viewModel.pause()
}

内容のまとめ

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

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

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

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

このセクションでは、左側にシステムバーが表示されるようにエミュレータを構成し、その構成でアプリをテストする方法を学びます。

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

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

この 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.top_and_right

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

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

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

935aa1d4ee3eb72.png

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

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

  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 で参照されています。この属性について詳しくは、リファレンス ドキュメントをご覧ください。

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

1d791b9e2ec91bda.png

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

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

RoadReelsApp.kt

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

...

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

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

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

21d9a237572f85c2.png

メイン画面を修正する

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

80bca44f0962a4a1.png

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

  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))
    }
}

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

b437a762e31abd02.png

詳細画面を修正する

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

adf17e27b576ec5a.png

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

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

fc14798bc71110d3.png

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

PlayerScreen.kt

import androidx.compose.foundation.layout.displayCutout

...

var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)

cce55d3f8129935d.png

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

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

dc7918499a33df31.png

12. 完了

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

試してみたいこと

参考資料