1. 始める前に
対象外:
- Android Auto および Android Automotive OS 向けのメディアアプリ(音楽、ラジオ、ポッドキャストなどのオーディオ アプリ)を作成する方法については説明していません。これらのアプリの作成方法について詳しくは、自動車向けメディアアプリを作成するをご覧ください。
必要なもの
- 最新の Android Studio。
- Kotlin の基本的な使用経験。
- Android 仮想デバイスを作成して Android Emulator で実行した経験。
- Jetpack Compose に関する基本的な知識。
- 副作用についての理解。
- ウィンドウ インセットに関する基本的な知識。
作成するアプリの概要
この Codelab では、既存の動画ストリーミング モバイルアプリ Road Reels を Android Automotive OS に移行する方法について学びます。
スマートフォンで実行されている初期状態版のアプリ。 | Android Automotive OS エミュレータで実行されている完成版のアプリ。ディスプレイ カットアウトが追加されている。 |
学習内容
- Android Automotive OS エミュレータの使用方法
- Android Automotive OS ビルドの作成に必要な変更
- モバイル向けアプリ開発時の一般的な想定を、Android Automotive OS で実行するアプリに適用できない場合の対処方法
- 自動車用アプリのさまざまな品質レベル
- メディア セッションを使用して、他のソースからアプリの再生を操作できるようにする方法
- システム UI とウィンドウ インセットが Android Automotive OS デバイスとモバイル デバイスでどう異なるか
2. 設定する
コードを取得する
- この Codelab のコードは、
car-codelabs
GitHub リポジトリ内のbuild-a-parked-app
ディレクトリにあります。クローンを作成するには、次のコマンドを実行します。
git clone https://github.com/android/car-codelabs.git
- または、リポジトリを 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 のシステム イメージをインストールする
- まず、Android Studio で SDK Manager を開き、まだ選択していない場合は [SDK Platforms] タブを選択します。SDK Manager ウィンドウの右下にある [Show package details] チェックボックスがオンになっていることを確認します。
- Generic System Image の追加の一覧から、API 33 Android Automotive with Google APIs エミュレータ イメージをインストールします。イメージは、同じアーキテクチャ(x86 / ARM)のマシンでのみ実行できます。
Android Automotive OS Android Virtual Device を作成する
- デバイス マネージャーを開き、ウィンドウの左側にある [Category] 列で [Automotive] を選択します。次に、バンドルされたハードウェア プロファイルの一覧から Automotive (1408p landscape) を選択し、[Next] をクリックします。
- 次のページに移動したら、前の手順で作成したシステム イメージを選択します。[Next] をクリックし、必要に応じて詳細オプションを選択したら、[Finish] をクリックして AVD を作成します。注: API 30 のイメージを選択した場合は、[Recommended] タブ以外のタブに表示されることがあります。
アプリを実行する
前の手順で作成したエミュレータで、既存の app
実行構成を使用してアプリを実行します。アプリを操作してさまざまな画面を表示し、スマートフォンまたはタブレットのエミュレータで実行した場合の動作と比較してみてください。
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 で選択したアプリのカテゴリとは別物です。
app
モジュールを右クリックして [New] > [Android Resource File] を選択し、以下の値を入力してから [OK] をクリックします。
- [File name]:
automotive_app_desc.xml
- Resource type:
XML
- [Root element]:
automotiveApp
- [Source set]:
main
- [Directory name]:
xml
- このファイル内に次の
<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>
- 既存の
<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 として規定されています。
詳細画面からメイン画面への「戻る」ナビゲーションをサポートするには、次のように詳細画面の CenterAlignedTopAppBar
に navigationIcon
パラメータを追加します。
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
)
}
}
)
}
プレーヤー画面からメイン画面への「戻る」ナビゲーションをサポートするための手順は次のとおりです。
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) { ... }
}
}
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
)
...
}
}
}
- 次に
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
)
}
- 最後に
RoadReelsNavHost
で、PlayerScreen
に渡す実装を指定します。
RoadReelsNavHost.kt
composable(route = Screen.Player.name, ...) {
PlayerScreen(onClose = { navController.popBackStack() })
}
これで、ユーザーが立ち往生することなく画面間を移動できるようになりました。また、これにより他のフォーム ファクタでもユーザー エクスペリエンスが向上する可能性があります。たとえば、細長いスマートフォンの画面上部を手で持って操作しているときに、デバイスを持ち替えなくてもアプリを簡単に操作できるようにすることも可能です。
画面の向きのサポートに対応する
ほとんどのモバイル デバイスとは異なり、自動車の場合は画面の向きが固定されているのが一般的です。画面を回転させることができないため、横向きまたは縦向きのいずれか一方のみがサポートされます。そのため、アプリでも両方の向きがサポートされていることを想定すべきではありません。
Android Automotive OS のマニフェストを作成するでは、android.hardware.screen.portrait
機能と android.hardware.screen.landscape
機能用に、2 つの <uses-feature>
要素を追加してrequired
属性を false
に設定しました。これにより、どちらかの画面の向きに暗黙的に依存することが原因で、アプリが自動車に配信されなくなることを回避できます。ただし、これらのマニフェスト要素はアプリの配信方法にのみ影響し、アプリの動作を変更するものではありません。
現時点でこのアプリには、動画プレーヤーを開いたときにアクティビティの向きを自動的に横向きに設定する便利な機能があります。この機能により、スマートフォンが横向きになっていない場合でも、ユーザーがデバイスを操作して向きを変更する必要はありません。
しかしながら、現在市販されている自動車にはデバイスが縦向きに固定されているものもあり、その場合はちらつきループが発生したりレターボックス表示になったりする可能性があります。
この問題を解決するには、デバイスでサポートされている画面の向きに基づくチェックを追加します。
- 実装を簡単にするため、まず
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)
}
}
- 次に、リクエストされた向きに設定するための呼び出しを無効にします。同様の問題は、モバイル デバイスのマルチ ウィンドウ モードでも発生する可能性があります。この場合も、向きが動的に設定されないようにチェックを追加できます。
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 エミュレータのプレーヤー画面がレターボックス表示になっていない |
このアプリではこれ以外の場所で画面の向きを設定していないため、これでレターボックス表示を回避できます。アプリ内に、横向きまたは縦向き限定の screenOrientation
属性や setRequestedOrientation
呼び出し(sensor
、reverse
、それぞれの 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 要素が飛び跳ねるように移動してしまうからです。
ユーザー エクスペリエンスを改善するには、どのインセットが操作可能かを把握し、操作できないインセットにのみパディングを適用するようアプリを更新します。
- アプリ内の他の画面でもウィンドウ インセットが必要になる可能性を考え、操作可能なインセットを
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 }
- 変化をリッスンできるよう
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)
}
}
- テーマとアプリのコンポーザブルを含む最上位レベルの
CompositionLocalProvider
を追加し、値をLocalControllableInsets
にバインドします。
MainActivity.kt
import androidx.compose.runtime.CompositionLocalProvider
...
CompositionLocalProvider(LocalControllableInsets provides controllableInsetsTypeMask) {
RoadReelsTheme {
RoadReelsApp(calculateWindowSizeClass(this))
}
}
- プレーヤーで現在の値を読み取り、その値を使用して非表示にするインセットとパディングに使用するインセットを特定します。
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 に違反しています。
運転を開始したら再生を一時停止する
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)
- プロジェクトを同期して依存関係がダウンロードされたら、再生を一時停止するため、
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 画面構成です。たとえば、メイン画面をセンター コンソールに配置し、サブ画面をダッシュボード上部のフロントガラス近くに配置するような構成です。アプリをメイン画面とサブ画面の間で移動させることで、ドライバーや同乗者にさまざまなオプションを提供できます。
自動車の遠隔ディスプレイのイメージをインストールする
- まず、Android Studio プレビューで SDK Manager を開き、まだ選択していない場合は [SDK Platforms] タブを選択します。SDK Manager ウィンドウの右下にある [Show package details] チェックボックスがオンになっていることを確認します。
- お使いのパソコンのアーキテクチャ(x86/ARM)に対応する API 33 Automotive Distant Display with Google Play エミュレータのイメージをインストールします。
Android Automotive OS Android Virtual Device を作成する
- デバイス マネージャーを開き、ウィンドウの左側にある [Category] 列で [Automotive] を選択します。次に、一覧から Automotive Distant Display with Google Play のバンドルされたハードウェア プロファイルを選択し、[Next] をクリックします。
- 次のページに移動したら、前の手順で作成したシステム イメージを選択します。[Next] をクリックし、必要に応じて詳細オプションを選択したら、[Finish] をクリックして AVD を作成します。
アプリを実行する
前の手順で作成したエミュレータで、既存の app
実行構成を使用してアプリを実行します。遠隔ディスプレイ エミュレータを使用してテストするの手順に沿って、アプリを遠隔ディスプレイに移動したりメイン画面に戻したりします。アプリの移動と操作は、メイン / 詳細画面とプレーヤー画面の両方でテストしてください。
9. 遠隔ディスプレイのアプリ エクスペリエンスを改善する
遠隔ディスプレイでアプリを使用した際に、次の 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 を使用して再生を制御およびアドバタイズするをご覧ください。
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)
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()
}
}
}
- 次に、
onCleared
メソッドに別の行を追加して、PlayerViewModel
が不要になったときにMediaSession
を解放します。
PlayerViewModel.kt
override fun onCleared() {
super.onCleared()
mediaSession?.release()
_player.value?.release()
}
- 最後に、プレーヤー画面で
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 品質ガイドラインに違反しています。これをご自身でテストするには:
- 再生を開始
- 運転をシミュレーションする
media_session dispatch
コマンドの使用アプリが隠されていても再生が再開されることに注意してください。
この問題を解決するには、デバイスのユーザー エクスペリエンスの制限をリッスンし、制限が有効な場合にのみ再生の再開を許可します。モバイルと Android Automotive OS の両方で同じロジックを使用できる方法で行うこともできます。
app
モジュールのbuild.gradle
ファイルに、次のコードを追加して Android Automotive ライブラリ を含め、その後 Gradle 同期を行います。
build.gradle.kts
android {
...
useLibrary("android.car")
}
com.example.android.cars.roadreels
パッケージを右クリックして、[New] > [Kotlin Class/File] を選択します。名前に「RoadReelsPlayer
」と入力し、[Class] タイプをクリックします。- 作成したファイルに、クラスの次のスターター実装を追加します。
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()
}
}
PlayerViewModel.kt
で、プレーヤー変数の宣言を更新し、ExoPlayer
の代わりにRoadReelsPlayer
のインスタンスを使用するようにします。この時点では、shouldPreventPlay
はデフォルト値のfalse
から更新されることがないため、動作は以前とまったく同じになります。
PlayerViewModel.kt
init {
...
_player.update { RoadReelsPlayer(application) }
}
- ユーザー エクスペリエンス制限のトラッキングを開始するには、次の
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
を呼び出すことで、再生が自動的に一時停止または再開されます。
- 次に、このセクションの冒頭で説明したように、運転をシミュレートしながら再生を再開するテストを行います。再生は再開されません。
- 最後に、ユーザー エクスペリエンスの制限が有効になったときに再生を一時停止するのは
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
このアプリでは、Scaffold
の contentWindowInsets
として systemBars
修飾子を使用しているため、コンテンツはすでにシステムバーのセーフエリア内に描画されています。システムバーが画面の上下にのみ表示されると想定しているアプリがどう表示されるかを確認するには、パラメータを次のように変更します。
RoadReelsApp.kt
contentWindowInsets = if (route?.equals(Screen.Player.name) == true) WindowInsets(0.dp) else WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
この場合、一覧画面と詳細画面がシステムバーの背後にレンダリングされてしまいます。ただし、ここまでの変更により、その後システムバーが制御できなくなってもプレーヤー画面は問題なく表示されます。
次のセクションに進む前に、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
この時点では、ディスプレイ カットアウトに合わせたレンダリングはされていません(カットアウトの正確な形状はまだ不明ですが、次のステップで明らかになります)。この動作でもまったく問題ありません。アプリのエクスペリエンスとしては、カットアウトに合わせてレンダリングされているのに正しく表示されていないほうが問題です。
ディスプレイ カットアウトに合わせてレンダリングする
ユーザーに最大限の没入感を提供するには、ディスプレイ カットアウトに合わせてレンダリングして画面領域をより有効に活用します。
- ディスプレイ カットアウトに合わせてレンダリングするには、
integers.xml
ファイルを作成して自動車固有のオーバーライドを確保します。そのためには、UI mode 修飾子の値として Car Dock を選択します(この名前は Android Auto で使用されていたものですが、Android Automotive OS でも引き続き使用します)。また、使用する値LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
は Android R で導入されたため、Android Version 修飾子を追加し、値として 30 を選択します。詳しくは、代替リソースを使用するをご覧ください。
- 作成したファイル(
res/values-car-v30/integers.xml
)に次のコードを追加します。
integers.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="windowLayoutInDisplayCutoutMode">3</integer>
</resources>
整数値 3
は LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
に対応します。res/values/integers.xml
のデフォルト値 0
(LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
に対応)をオーバーライドします。この整数値は、enableEdgeToEdge()
によって設定されたモードをオーバーライドするため、すでに MainActivity.kt
で参照されています。この属性について詳しくは、リファレンス ドキュメントをご覧ください。
アプリを実行すると、コンテンツがカットアウトまで広がって表示され没入感があります。しかし、上部のアプリバーとコンテンツの一部が、ディスプレイ カットアウトによって隠れてしまっています。システムバーが上下にのみ表示されることを想定しているアプリと同様の問題です。
上部のアプリバーを修正する
上部のアプリバーを修正するには、次の 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
パラメータを改良したもので、displayCutout
と systemBars
の 2 つのインセットで構成されています。
なお、上部のアプリバーはウィンドウの上部に配置されるため、safeDrawing
インセットの下部のコンポーネントは含めないようにしてください。不要なパディングが追加される恐れがあります。
メイン画面を修正する
メイン画面と詳細画面のコンテンツを修正する方法の 1 つとして、Scaffold
の contentWindowInsets
に systemBars
ではなく safeDrawing
を使用する方法もあります。ただし、この方法だとディスプレイ カットアウトによってコンテンツが途切れてしまい、アプリの没入感は著しく低下します。これでは、アプリがディスプレイ カットアウトに合わせてレンダリングされていない場合と大差ありません。
画面内の各コンポーネントのインセットを処理する方法であれば、より没入感のあるユーザー インターフェースを実現できます。
Scaffold
のcontentWindowInsets
に変更を加え、PlayerScreen
の場合だけでなく常に 0dp になるようにします。これにより、各画面や画面内のコンポーネントのインセット関連の動作が決まります。
RoadReelsApp.kt
Scaffold(
...,
contentWindowInsets = WindowInsets(0.dp)
) { ... }
- 行ヘッダー
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))
)
...
}
LazyRow
のcontentPadding
パラメータを削除します。次に、対応する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))
)
}
}
- 最後に、画面の下部にシステムバーやディスプレイ カットアウトのインセットがある場合に備え、
LazyColumn
の末尾にSpacer
を追加します。このようなスペーサーは、LazyColumn
の上部には必要ありません。この処理は上部のアプリバーが行います。アプリが上部ではなく下部のアプリバーを使用している場合は、windowInsetsTopHeight
修飾子を使用してリストの先頭にSpacer
を追加します。アプリが上部と下部の両方のアプリバーを使用している場合は、どちらのスペーサーも必要ありません。
MainScreen.kt
import androidx.compose.foundation.layout.windowInsetsBottomHeight
...
LazyColumn(...){
items(NUM_ROWS) { ... }
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
これで、上部のアプリバーが完全に表示され、行の最後までスクロールしてもすべてのサムネイルが完全に表示されるようになりました。
詳細画面を修正する
詳細画面はそこまで深刻ではありませんが、やはりコンテンツが切れています。
詳細画面にはスクロール可能なコンテンツはないため、最上位の Box
に windowInsetsPadding
修飾子を追加するだけで修正できます。
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)
) { ... }
プレーヤー画面を修正する
PlayerScreen
には、すでに Android Automotive OS の品質要件を満たす: 操作性で、システムバーの一部またはすべてのウィンドウ インセットにパディングを適用しています。しかし、アプリをディスプレイ カットアウトに合わせてレンダリングする場合、コンテンツが確実に隠れないようにするにはパディングだけでは不十分です。モバイル デバイスの場合は、ディスプレイ カットアウトがシステムバー内に収まらないことはほぼありません。一方、自動車の場合はディスプレイ カットアウトがシステムバーを大きくはみ出る可能性があり、上記の想定は適用できません。
これを修正するには、windowInsetsForPadding
変数の初期値を、次のようにゼロ値から displayCutout
に変更します。
PlayerScreen.kt
import androidx.compose.foundation.layout.displayCutout
...
var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)
これで、使いやすさを維持しながら画面を最大限に活用できるようになりました。
アプリをモバイル デバイスで実行した場合も、没入感がさらに高まっているはずです。リストアイテムは、ナビゲーション バーの背後も含め、画面の端までレンダリングされます。
12. 完了
これで、初めての駐車時向けアプリを移行して最適化することができました。ここで学んだことを、ぜひ実際のアプリに応用してください。
試してみたいこと
- ディメンション リソースの一部をオーバーライドして、自動車で実行するときの要素のサイズを大きくする
- 構成可能なエミュレータのさまざまな構成を試す
- 利用可能な OEM エミュレータのイメージを使用してアプリをテストする
参考資料
- Android Automotive OS 用の駐車時向けアプリを作成する
- Android Automotive OS 用の動画アプリを作成する
- Android Automotive OS 用のゲームを作成する
- Android Automotive OS 用のブラウザを作成する
- 自動車向け Android アプリの品質に関するページでは、優れたユーザー エクスペリエンスを実現し、Google Play ストアの審査に合格するために、アプリが満たす必要がある基準について説明しています。必ずアプリのカテゴリでフィルタしてください。