[Android for Cars] Car App Library fundamentals
안드로이드를 자동차에서도 만날 수 있다는 사실을 알고 있었던 사람? 스마트폰 화면을 미러링하는 Android Auto뿐 아니라 완전한 자동차용 운영체제 Android Automotive OS(AAOS)도 절찬리에 서비스되고 있다.
다음 코드랩을 진행해 보며 자동차용 Car App Library의 기본 내용을 공부해 보자.
Overview
Android for Cars Library는 자동차에서 사용할 수 있는 Jetpack 라이브러리를 모은 것이다. 기본적으로는 운전 환경에 맞는 UI를 제공하는 플랫폼이며, 화면 크기 및 비율, 입력 방법(터치!) 등 자동차마다 다른 하드웨어 특성에 최적화될 수 있다. Android for Cars 라이브러리를 활용하면 Android Auto와 AAOS 모두에서 작동하는 앱을 개발할 수 있다.
아직 AAOS가 개발 초기라 그런지, AAOS에서 사용할 수 있는 앱의 종류가 내비게이션, 미디어, 장소 관련 앱 정도로 한정되어 있다. 다만 점점 더 많은 카테고리를 지원할 계획이라고 하니 계속 지켜보자. 일반적인 안드로이드 앱을 AAOS에서 사용할 수 있도록 지원하는 계획도 있다고.
Android for Cars의 특징
Car App 라이브러리를 사용하여 개발된 앱(클라이언트 앱)은 Android Auto나 AAOS에서 직접 구동되지 않고, 호스트 앱을 거쳐서 구동된다. 호스트 앱은 클라이언트 앱과 연결되어 클라이언트의 UI를 그리는 역할을 한다.
Android Auto는 그 자체로 호스트이고 AAOS는 Google Automotive App Host를 호스트로 사용한다. 그런데 링크한 Google Automotive App Host는 구글 플레이에 게시된 앱이다. 앱과 시스템 사이에 뭔가 추상화 계층이 들어간 듯하다.
Car App 라이브러리에서 자주 사용하는 클래스는 다음과 같다.
CarAppService
``CarAppService``는 안드로이드 ``Service``를 상속받은 클래스로, 호스트 앱이 클라이언트와 소통하는 진입점 역할을 한다. ``CarAppService``는 주로 아래에서 설명할 ``Session`` 객체를 만드는 역할을 한다.
Session
``Session`` 객체는 차량에서 실행되는 클라이언트 앱의 인스턴스라고 보면 된다. 안드로이드의 application과 비슷한 역할인 듯. 다른 컴포넌트처럼 ``Session``도 생명주기가 있으며, 콜백에서 리소스를 가져오거나 해제할 수 있다.
``CarAppService`` 1개에 ``Session`` 여러 개가 붙을 수 있다. 예를 들어 하나의 ``CarAppService``가 메인 디스플레이용 세션과 클러스터 디스플레이용 세션 2개를 hold할 수 있다.
Screen
``Screen`` 인스턴스는 UI를 생성하는 역할을 한다. UI를 그리는 역할은 호스트가 한다.
Car App 라이브러리에서 UI는 ``Templace`` 인터페이스로 표현된다. ``ListTemplate``, ``MapTemplate`` 등이 하나의 UI를 나타낸다. 각 세션에는 user flow에 따라 여러 개의 screen을 생성하고 stack으로 관리한다. (안드로이드 navigation의 back stack과 비슷한 듯)
``Screen``도 ``Session``과 비슷한 생명주기를 갖는다. Acitvity 역할이라고 보면 될 듯.
종합
종합하면 대략 이런 모양새가 된다.
지원하는 SDK 버전
Android auto용 앱이 지원하는 최소 SDK 버전은 API 23이다. AAOS를 탑재한 차량은 최소 API 29 버전의 SDK를 탑재한다고 한다.
SDK 버전과 별개로, Car App 라이브러리에도 별도의 API 레벨이 있다. 호스트와 클라이언트가 연결을 만들 때 연결에서 사용할 Car App API 버전을 결정한다.
예를 들어 API 레벨 2에서 추가된 ``SignInTemplate``을 API 레벨이 1인 호스트에서 사용할 수는 없다(crash!). 이처럼 클라이언트가 요구하는 API 레벨이 호스트가 제공하는 API보다 낮다면 앱과 호스트가 연결될 수 없다.
따라서 SDK 버전과는 별도로 클라이언트(우리 앱)의 API 레벨을 선언해야 한다. 놀랍게도 ``build.gradle``이 아닌 manifest에서 선언한다.
<application>
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
</application>
물론 안드로이드와 동일하게 호스트의 API 레벨을 동적으로 확인한 후 더 높은 레벨의 기능을 사용할 수도 있다.
라이브러리 선언
일단 이거 하나만 선언하면 된다.
implementation "androidx.car.app:app:1.4.0" // 최신 버전으로 선언하기
CarAppService 작성
나만의 ``CarAppService``를 하나 작성해 보자.
class PlacesCarAppService: CarAppService() {
override fun createHostValidator(): HostValidator {
return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
}
override fun onCreateSession(): Session {
return PlacesSession()
}
}
``CarAppService``는 ``Service``의 ``onBind``와 ``onUnbind`` 등을 구현한 abstract class이다. 이 함수들은 호스트와 원활하게 연결되도록 이미 잘 구현되어 있으니, 우리가 다시 구현할 필요는 없다. ``createHostValidator``와 ``onCreateSession``만 오버라이드하면 된다.
``createHostValidator``는 ``CarAppService``가 연결될 호스트를 검증할 때 호출된다. 검증에 실패하면 ``CarAppService``가 호스트에 bind되지 않는다. 일단 이 글에서는 임시로 모든 호스트를 수락하도록 ``ALLOW_ALL_HOSTS_VALIDATOR``를 반환한다. 당연히 실무에서 사용해서는 절대 안 된다.
``onCreateSession``는 단순히 ``Session`` 객체를 반환하도록 구현했다. 앱이 실행되는 동안 계속 참조할 리소스가 있다면 ``onCreateSession``에서 초기화하자.
마지막으로 방금 선언한 ``PlacesCarAppService``를 manifest에 등록하자. 어쨌든 얘도 서비스이기 때문.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name="com.example.places.carappservice.PlacesCarAppService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.POI" />
</intent-filter>
</service>
</application>
</manifest>
``<action>`` 태그에서는 호스트가 앱을 찾을 수 있도록 ``CarAppService`` action을 정의한다. ``<category>``에는 앱의 카테고리를 정의한다. 이 코드랩에서는 장소 관련 앱을 개발할 예정인가보다.
Session 작성
class PlacesSession: Session() {
override fun onCreateScreen(intent: Intent): Screen {
return MainScreen(carContext)
}
}
코드랩에서는 매우 간단한 앱을 개발하기 때문에 메인 화면만을 반환한다. 복잡한 앱에서는 매개변수로 들어오는 intent를 참조하여 back stack을 조작하거나, 조건문에 따라 여러 화면을 반환할 수도 있겠다.
MainScreen 작성
이제 UI를 작성해 보자.
class MainScreen(carContext: CarContext) : Screen(carContext) {
override fun onGetTemplate(): Template {
val row = Row.Builder()
.setTitle("Hello car!")
.build()
val pane = Pane.Builder()
.addRow(row)
.build()
return PaneTemplate.Builder(pane)
.setHeaderAction(Action.APP_ICON)
.build()
}
}
음.. compose도 아니고 뭣도 아닌 신기한 형식이다. 일단 이런 식으로 UI를 정의하는 듯하다.
AAOS에서 실행하기
다음과 같이 AAOS용 모듈을 선언한다. 이 다음 화면에서는 No Activity를 선택한다.
아까 작성했던 코드의 의존성을 추가하자. 나는 ``:common:car-app-service`` 모듈에서 작성했으므로 이 모듈을 dependency에 추가하겠다.
추가로 ``androidx.car.app:app-automotive``도 선언하자.
dependencies {
...
implementation project(':common:car-app-service')
implementation "androidx.car.app:app-automotive:$car_app_library_version"
...
}
Manifest 작성
일단 ``android.hardware.type.automotive``와 ``android.software.car.templates_host`` 기능을 선언해야 한다.
``android.hardware.type.automotive``는 앱이 자동차에서 작동함을 명시한다. 이 속성이 있어야만 Google Play에 AAOS용 앱으로 등록될 수 있다.
``android.software.car.templates_host``는 템플릿 호스트를 사용한다는 의미이다. 참고로 템플릿 호스트는 차량에만 있는 시스템 기능이다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.type.automotive"
android:required="true" />
<uses-feature
android:name="android.software.car.templates_host"
android:required="true" />
...
</manifest>
이제 필수는 아니지만 우리 앱에 필요한 기능을 선언하자. 대표적으로 화면 방향이 있다. 대부분의 차량 디스플레이는 방향이 고정되어 있는 경우가 많다. 따라서 portrait 또는 landscape 하나만 선언한 앱은 디스플레이의 방향이 다른 차량과 호환되지 않는다.
그래서 ``android:required="false"``인 것.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
...
<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" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
...
</manifest>
그 후에는 ``automotive_app_desc.xml`` 파일을 추가한다.
<application>
...
<meta-data android:name="com.android.automotive"
android:resource="@xml/automotive_app_desc"/>
...
</application>
참고로 ``automotive_app_desc.xml`` 파일은 ``:common:car-app-service`` 모듈에 다음과 같이 작성하면 된다.
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="template" />
</automotiveApp>
마지막으로 라이브러리에 있는 ``CarAppActivity``를 ``<activity>`` 태그로 추가하면 된다. Activity가 호스트에 의해 실행될 수 있어야 하기 때문에 ``android:exported="true"``이다.
// AndroidManifest.xml in :automotive module
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
...
<application ...>
...
<activity
android:name="androidx.car.app.activity.CarAppActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="distractionOptimized"
android:value="true" /> // 운전 중일 때에도 앱 사용 가능
</activity>
</application>
</manifest>
실행
이제 Android Automotive 에뮬레이터에서 앱을 실행해 보자.
지도 추가하기
``MainScreen.onGetTemplate()``을 다음과 같이 수정하자.
override fun onGetTemplate(): Template {
val placesRepository = PlacesRepository()
val itemListBuilder = ItemList.Builder()
.setNoItemsMessage("No places to show")
placesRepository.getPlaces()
.forEach {
itemListBuilder.addItem(
Row.Builder()
.setTitle(it.name)
// Each item in the list *must* have a DistanceSpan applied to either the title
// or one of the its lines of text (to help drivers make decisions)
.addText(SpannableString(" ").apply {
setSpan(
DistanceSpan.create(
Distance.create(Math.random() * 100, Distance.UNIT_KILOMETERS)
), 0, 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE
)
})
.setOnClickListener { TODO() }
// Setting Metadata is optional, but is required to automatically show the
// item's location on the provided map
.setMetadata(
Metadata.Builder()
.setPlace(Place.Builder(CarLocation.create(it.latitude, it.longitude))
// Using the default PlaceMarker indicates that the host should
// decide how to style the pins it shows on the map/in the list
.setMarker(PlaceMarker.Builder().build())
.build())
.build()
).build()
)
}
return PlaceListMapTemplate.Builder()
.setTitle("Places")
.setItemList(itemListBuilder.build())
.build()
}
앱을 실행하기 전에 ``:common:car-app-service``에 map 템플릿 권한을 추가해야 한다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
...
</manifest>
다시 앱을 실행해 보면 지도가 제대로 보인다!
상세 화면 추가
이제 장소별 상세 정보를 보여주는 화면을 추가해 보자. 지도에 표시된 마커를 터치했을 때 상세 정보가 보이고, 해당 위치로 안내할 지 확인하는 화면을 구현할 것이다. 최대 4개의 텍스트를 보여줄 수 있는 ``PaneTemplate``를 활용하면 된다.
일단 리소스를 추가하자. ``:common:car-app-service`` 모듈에 다음과 같이 화살표 리소스를 추가한다.
이제 ``DetailScreen`` 클래스를 다음과 같이 작성한다.
class DetailScreen(carContext: CarContext, private val placeId: Int) : Screen(carContext) {
override fun onGetTemplate(): Template {
val place = PlacesRepository().getPlace(placeId)
?: return MessageTemplate.Builder("Place not found")
.setHeaderAction(Action.BACK)
.build()
val navigateAction = Action.Builder()
.setTitle("Navigate")
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.baseline_navigation_24
)
).build()
)
// Only certain intent actions are supported by `startCarApp`. Check its documentation
// for all of the details. To open another app that can handle navigating to a location
// you must use the CarContext.ACTION_NAVIGATE action and not Intent.ACTION_VIEW like
// you might on a phone.
.setOnClickListener { carContext.startCarApp(place.toIntent(CarContext.ACTION_NAVIGATE)) }
.build()
return PaneTemplate.Builder(
Pane.Builder()
.addAction(navigateAction)
.addRow(
Row.Builder()
.setTitle("Coordinates")
.addText("${place.latitude}, ${place.longitude}")
.build()
).addRow(
Row.Builder()
.setTitle("Description")
.addText(place.description)
.build()
).build()
)
.setTitle(place.name)
.setHeaderAction(Action.BACK)
.build()
}
}
중간에 ``carContext.startCarApp()`` 함수가 보인다. 안드로이드의 ``Context.startActivity()``와 비슷하지만, ACTION_NAVIGATE 등 특정 작업만을 지원한다.
Navigation
이제 ``MainScreen``과 ``DetailScreen`` 2개의 화면 간 navigation 로직을 작성해 보자. Car App 라이브러리에서는 스택에 화면을 push/pop하는 단순한 구조를 사용한다.
``MainScreen``에서 중간에 비어있던 ``setOnClickListener``를 다음과 같이 채운다.
.setOnClickListener { screenManager.push(DetailScreen(carContext, it.id)) }
``DetailScreen``에서 ``MainScreen``으로 돌아오는 작업은 ``DetailScreen``의 ``PaneTemplate``에 이미 선언되어 있다.
다시 앱을 실행해 보면, 내비게이션이 정상적으로 작동한다!
UI 업데이트하기
``DetailScreen``에 좋아요 기능을 추가해 보자. 우선 ``DetailScreen``에 좋아요 변수를 추가한다.
class DetailScreen(carContext: CarContext, private val placeId: Int) : Screen(carContext) {
private var isFavorite = false
...
}
다음과 같이 좋아요 아이콘을 추가한다.
이제 좋아요 아이콘을 추가한다.
val navigateAction = ...
val actionStrip = ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.baseline_favorite_24
)
).setTint(
if (isFavorite) CarColor.RED else CarColor.createCustom(
Color.LTGRAY,
Color.DKGRAY
)
).build()
)
.setOnClickListener {
isFavorite = !isFavorite
}.build()
)
.build()
...
- ``isFavorite``의 값에 따라 ``CarIcon``에 tint가 적용되고 있다.
- Click listener에서 favorite 값을 바꾸고 있다.
``PaneTemplate``에 action strip을 적용하자.
return PaneTemplate.Builder(...)
...
.setActionStrip(actionStrip)
.build()
앱을 실행해 보면, 놀랍게도 tint가 적용되지 않는다!
클릭은 되고 있지만 tint가 적용되지 않는 것 같다.
이 문제의 원인은 Car App 라이브러리가 refresh 개념을 사용하고 있기 때문이다. 사용자의 주의 분산을 막기 위해 refresh에 제한이 걸려 있다. 제한 내용은 템플릿마다 다르며, UI를 refresh하려면 명시적으로 refresh 요청을 보내야 한다.
favorite 값이 바뀐 후에 refresh를 요청하면 될 것 같다.
.setOnClickListener {
isFavorite = !isFavorite
// Request that `onGetTemplate` be called again so that updates to the
// screen's state can be picked up
invalidate()
}
다시 앱을 실행해 보면, 정상적으로 tint가 적용된다.
완성!
AAOS에서 실행할 수 있는 기본 앱을 작성해 보았다. 몇몇 코드만 추가하면 Android Auto에서도 작동하게 만들 수 있다.
이번 codelab에서는 지도 앱을 만들어 보았다. 앞으로 더 많은 카테고리의 앱을 지원할 예정이라고 하니 계속 지켜보자.
더 읽어볼 자료
https://developer.android.com/docs/quality-guidelines/car-app-quality