多分30分で映せるAndroid AutoのNavigation
はじめに
この記事はAndroid Advent Calendar 2021の9日目の記事です.
Android Autoはスマートフォンと自動車車両・車載機と接続することで、ドライバーに最適化されたバージョンを提供することが出来る, 自動車向けに提供されている機能・プラットフォームです.
これまではメッセージアプリや音楽プレイヤーの様なメディアアプリ向け機能のみが公開されていましたが, 今年はナビゲーションアプリ向け機能もサードパーティへ公開され, 今後が期待されるカテゴリーのひとつだと思います.
本稿ではAndroid Autoのナビゲーション向け提供機能の特徴のひとつである地図を出力するための機能を利用し, 簡単なUIを出力するまでを扱っていきます.
環境
| OS | Android Studio | Device |
|---|---|---|
| macOS Catalina | Arctic Fox / 2020.3.1 Patch 3 | Pixel6 Pro/Android 12 |
ざっくり全体の流れ
ざっくりとした流れは以下のようになります.
- テスト環境の用意
- プロジェクトの用意
- Libraryの取り込み
- CarAppServiceの生成
- AndroidManifest.xmlにCarAppServiceを登録
- Car App API levelの登録
- Android Autoをサポートしていることを定義
- Sessionの生成
- Screenの生成
- Templateの生成
- Surfaceを利用した描画
- HostValidatorの生成
テスト環境の用意
自動車向けのプラットフォームと聞くとテスト環境を用意するのも大変そうと思われるかもしれませんが, Android AutoにはDesktop Head Unit (DHU)というテスト環境が用意されています.
テストを行なうには大きくはDHUとスマートフォンのセットアップを行なう必要がありますが, 手順はそう多くなく, 自動車向けプラットフォームのテスト環境としては導入しやすい方だと思っています.
DHU
DHUのセットアップ手順は以下のとおりです. DHUのインストールの時間が掛かるようであれば, バックグラウンドで実行しつつ後続の手順を進めていってもいいと思います.
- SDK ManagerでAndroid Auto Desktop Head Unit Emulatorをインストールする
- SDK_LOCATION/extras/google/auto/ 配下にファイル群があることを確認する
- 2.配下にあるdesktop-head-unitに実行権限を付与する
chmod +x ./desktop-head-unit
スマートフォン
スマートフォンのセットアップ手順は以下のとおりです.
- 開発者オプションを有効にする
- Android Autoをインストールする (インストール済みの場合は最新のバージョンであるかを確認する)
- Android Autoの設定を開きます (OSバージョンによって異なります)
- Android 10以降
- 端末の設定からアプリ > すべてのアプリを表示 > Android Autoを開く
- アプリ内のその他の設定を開く
- Android 9以前
- Android Autoを起動する
- メニューから設定を開く
- Android 10以降
- 3.の画面最下部にあるバージョンをタップして表示を展開する
-
- で展開した箇所を10回タップし, 開発者モードを有効にする
- メニューを開きヘッドユニットサーバーを起動を選択する
- 以前に接続された車を選択しAndroid Autoに新しい車を追加が有効になっていることを確認する.
- PCとスマートフォンをUSB接続する
- スマートフォンの画面がロックされていないことを確認し, 以下のADBコマンドを実行しポートフォワードを設定する
adb forward tcp:5277 tcp:5277
テストを実行する
あとはDHUを起動すれば動作を確認出来ます.
プロジェクトの用意
通常のアプリ開発の様にPhone and Tablet向けのAndroidプロジェクトを用意します. minSdkVersionは23以上になります.
新規にプロジェクトを作成する場合はAutomotiveプロジェクトを選ばないように注意しましょう.

また, Android Autoは後述の通り専用のServiceクラスをベースに動作しますが, デフォルトとなるActivityは必要になるのでその点も注意が必要です.
Libraryの取り込み
Android for Cars App Libraryを取り込みます. 執筆の時点で最新のstableは1.0.0になっています.
dependencies {
implementation "androidx.car.app:app:1.0.0"
}
CarAppServiceの生成
ここまでで開発を進める準備が整いました.以降ではいよいよ実際にコードに触れていきます.
Android AutoはActivityではなく, 専用のCarAppServiceというServiceクラスを起点に動作するため, 先ずはこのクラスを用意します.
class NavigationSampleService : CarAppService() {
override fun createHostValidator(): HostValidator {
TODO()
}
override fun onCreateSession(): Session {
TODO()
}
}
AndroidManifest.xmlにCarAppServiceを登録
前述で作成したCarAppServiceを利用出来る様にAndroidManifest.xmlに登録します.
<service
android:name=".NavigationSampleService"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.NAVIGATION" />
</intent-filter>
</service>
まずintent-filterには
actionにandroidx.car.app.CarAppServiceを指定しているのと, categoryにSupported app categoriesで定義されているいずれかを指定する点が抑えどころです. 今回はNavigationを使いたいため, androidx.car.app.category.NAVIGATIONを指定しています.
また, Hostから参照されるためアイコンとラベルの設定が必要な点も抑えておきたい点です.
Car App API levelの登録
Car App Libraryは独自のAPIレベルを定義しており, 利用の際にはAndroidManifest.xmlに最小APIレベルを宣言する必要があります. ここでは1を指定しています.
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>
Android Autoをサポートしていることを定義
Android Autoから使える様にAndroid AutoをサポートしていることをAndroidManifest.xmlに定義する必要があるため, 定義を追加します.
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<automotiveApp>
<uses name="template" />
</automotiveApp>
Sessionの生成
次にSessionを用意します. SessionクラスではScreenという画面を扱うクラスを生成して返却する必要があります.
また本稿では言及しませんが, CarAppService, Session, そしてScreenはライフサイクルを持っていますので, 本格的に作り込んでいく際には理解が不可欠です.
class NavigationSampleService : CarAppService() {
override fun onCreateSession(): Session {
return NavigationSampleSession()
}
}
class NavigationSampleSession : Session() {
override fun onCreateScreen(intent: Intent): Screen {
TODO()
}
}
Screenの生成
Screenを生成します. ScreenではTemplateを返却する必要がありますが, ここでは一旦TODOにして進めていきます.
class NavigationSampleSession : Session() {
override fun onCreateScreen(intent: Intent): Screen = NavigationSampleScreen(carContext)
}
class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {
override fun onGetTemplate(): Template {
TODO()
}
}
Templateの生成
本稿の肝となる箇所, Templateの生成です. このTemplateは出力するレイアウトや表現によって色々な種類が用意されており, それらを駆使して車載機への画面出力を行ないます. 今回はナビゲーション向けのTemplateであるNavigationTemplateを指定します. NavigationTemplateを指定した場合, 後述するSurfaceCallbackをAppManagerに登録することで車載機へ描画出力するためのSurfaceを取得出来る様になり, 地図の様なリッチなUIを出力出来るようになります.
本来であればActionStripを設定することでメニューへ遷移させるトリガーを作ったりも出来るのですが, 今回は仮置としてタイトルの設定だけに留めています.
class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {
override fun onGetTemplate(): Template = NavigationTemplate.Builder()
.setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setTitle("TestAction")
.build()
).build()
)
.build()
}
また, NavigationTemplateを利用する場合はAndroidManifest.xmlにandroidx.car.app.NAVIGATION_TEMPLATES権限を付与する必要があるので, こちらも忘れずに定義します.
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.nyafunta.navigationsample">
<uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES"/>
Surfaceを利用した描画
いよいよ画面に出力するUIを描画します. 今回は表示することがポイントなので, Presentationを使ってUIを構築します. Presentationはセカンダリディスプレイへの出力などで用いられるAPIでAPI Lv.17からある機能です. VirtualDisplayと組み合わせることで, 出力先となるSurfaceへ簡単にUIを出力することが出来ます.
先ずはSurfaceCallbackをAppManagerに登録し, 車載機へ出力するためのSurfaceを取得します.
class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {
private val surfaceCallback = object : SurfaceCallback {
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
TODO("Not yet implemented")
}
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
TODO("Not yet implemented")
}
override fun onVisibleAreaChanged(visibleArea: Rect) {
}
override fun onStableAreaChanged(stableArea: Rect) {
}
}
init {
carContext.getCarService(AppManager::class.java).setSurfaceCallback(surfaceCallback)
}
}
CarContextというAndroid Auto向けのContextクラスを経由してAppManagerを取得し, setSurfaceCallbackでSurfaceCallbackを登録しています. これによりSurfaceのイベントを受けられるようになり, SurfaceContainer経由でSurfaceを受け取れる様になります.
次に出力処理を担うPresentationです.
private lateinit var virtualDisplay: VirtualDisplay
private lateinit var presentation: Presentation
private val surfaceCallback = object : SurfaceCallback {
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
setupOrUpdateSurface(surfaceContainer.takeIf { it.surface != null } ?: return)
}
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
virtualDisplay.surface = null
}
override fun onVisibleAreaChanged(visibleArea: Rect) {
}
override fun onStableAreaChanged(stableArea: Rect) {
}
private fun setupOrUpdateSurface(surfaceContainer: SurfaceContainer) {
val surface = surfaceContainer.surface ?: return
val width = surfaceContainer.width
val height = surfaceContainer.height
val dpi = surfaceContainer.dpi
if (isPresentationInitialized()) {
virtualDisplay.surface = surface
virtualDisplay.resize(width, height, dpi)
return
}
setupPresentation(width, height, dpi, surface)
}
private fun isPresentationInitialized(): Boolean =
this@NavigationSampleScreen::virtualDisplay.isInitialized
&& this@NavigationSampleScreen::presentation.isInitialized
private fun setupPresentation(width: Int, height: Int, dpi: Int, surface: Surface) {
virtualDisplay = carContext.getSystemService(DisplayManager::class.java)
.createVirtualDisplay(
"Presentation",
width,
height,
dpi,
surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
)
presentation = Presentation(carContext, virtualDisplay.display).apply {
setOnShowListener {
val tv = TextView(carContext)
tv.text = "This is test projection."
tv.gravity = Gravity.CENTER
setContentView(tv)
}
show()
}
}
ポイントはPresentationを準備しているsetupPresentationです.
private fun setupPresentation(width: Int, height: Int, dpi: Int, surface: Surface) {
virtualDisplay = carContext.getSystemService(DisplayManager::class.java)
.createVirtualDisplay(
"Presentation",
width,
height,
dpi,
surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
)
presentation = Presentation(carContext, virtualDisplay.display).apply {
setOnShowListener {
val tv = TextView(carContext)
tv.text = "This is test projection."
tv.gravity = Gravity.CENTER
setContentView(tv)
}
show()
}
}
VirtualDisplayにSurfaceContainerから受け取ったSurfaceを渡すことで, VirtualDisplay.displayを渡されたPresentationに設定されたViewが車載機へ出力される様になります.
改めて, 作成したNavigationSampleScreenの全体を以下に載せます.
class NavigationSampleScreen(carContext: CarContext) : Screen(carContext) {
private lateinit var virtualDisplay: VirtualDisplay
private lateinit var presentation: Presentation
private val surfaceCallback = object : SurfaceCallback {
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
setupOrUpdateSurface(surfaceContainer.takeIf { it.surface != null } ?: return)
}
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
virtualDisplay.surface = null
}
override fun onVisibleAreaChanged(visibleArea: Rect) {
}
override fun onStableAreaChanged(stableArea: Rect) {
}
private fun setupOrUpdateSurface(surfaceContainer: SurfaceContainer) {
val surface = surfaceContainer.surface ?: return
val width = surfaceContainer.width
val height = surfaceContainer.height
val dpi = surfaceContainer.dpi
if (isPresentationInitialized()) {
virtualDisplay.surface = surface
virtualDisplay.resize(width, height, dpi)
return
}
setupPresentation(width, height, dpi, surface)
}
private fun isPresentationInitialized(): Boolean =
this@NavigationSampleScreen::virtualDisplay.isInitialized
&& this@NavigationSampleScreen::presentation.isInitialized
private fun setupPresentation(width: Int, height: Int, dpi: Int, surface: Surface) {
virtualDisplay = carContext.getSystemService(DisplayManager::class.java)
.createVirtualDisplay(
"nyafunta",
width,
height,
dpi,
surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
)
presentation = Presentation(carContext, virtualDisplay.display).apply {
setOnShowListener {
val tv = TextView(carContext)
tv.text = "This is test projection."
tv.gravity = Gravity.CENTER
setContentView(tv)
}
show()
}
}
}
init {
carContext.getCarService(AppManager::class.java).setSurfaceCallback(surfaceCallback)
}
override fun onGetTemplate(): Template = NavigationTemplate.Builder()
.setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setTitle("TestAction")
.build()
).build()
)
.build()
}
また, Surfaceを利用する場合にはandroidx.car.app.ACCESS_SURFACE権限が必要になるのでこちらも忘れずにAndroidManifest.xmlに追加しておきます.
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.nyafunta.navigationsample">
<uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES"/>
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE"/>
HostValidatorの生成
最後にHostValidatorを生成し, CarAppServiceへ処理を追記します. ここではドキュメントに記載されているものをそのまま使用します.
class NavigationSampleService : CarAppService() {
override fun createHostValidator(): HostValidator =
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(R.array.hosts_allowlist_sample)
.build()
}
override fun onCreateSession(): Session {
return NavigationSampleSession()
}
}
いざ実行
アプリをインストールし, DHUを立ち上げてみましょう。 本稿に倣って試されている場合は以下のコマンドだけで実行が出来ます.
$ adb_dhu
以下の画像が表示されれば起動成功です。

まとめ
いかがでしたでしょうか?
今回は車載機へ出力することを目標としたため、その中身はざっくりとしたものとなっていますが思っていたよりは手順少なく動かすに至れたのではないでしょうか?
執筆時点では1.1がbetaとなっており, 今後が楽しみなプラットフォームのひとつかと思います.
これまで車載機向けの開発といったものにあまり関わりのなかった方が, ちょっと触ってみようかなと感じるきっかけとなれますと幸いです. といったところで本稿を締めたいと思います.
最後まで読んでくださりありがとうございました.
Discussion