⚗️ 프로젝트#Android

자동차에 내가 만든 앱 띄우기: Android Auto 개발기

2025-09-11 | 🕒 읽는 데 0분 예상
작성자
u
카테고리
⚗️ 프로젝트
작성일
태그
Android
설명
상태
배포됨
최하위 정렬
최하위 정렬
forest_분류
forest_날짜
현재 운영 중인 라디오 앱을 차량에 연결해서 사용하려는 수요가 있음을 발견하고, 앱을 차량 내에 있는 디스플레이에 띄워보기로 했다.

Android Auto란 무엇인가

Android Auto는 Google이 개발한 자동차용 플랫폼으로, Android 휴대전화의 앱을 차량 디스플레이에 최적화하여 투영해주는 시스템이다. 운전자가 안전하게 내비게이션, 미디어 재생, 메시징 등의 기능을 차량 내에서 사용할 수 있도록 도와준다.
Android Auto와 Android Automotive OS는 구별되는 개념이다. Android Auto는 휴대전화를 차량에 연결하여 사용하는 프로젝션 방식이며, Android Automotive OS는 차량에 직접 설치되는 독립형 운영체제다. 현재 Android Auto는 500여 종 이상의 호환 차량 모델에서 사용할 수 있다.
Android Auto는 크게 스마트폰 측의 앱 서비스와 차량 측의 UI로 구성된다. 스마트폰에서는 음악 재생, 내비게이션, 메시지 등 앱의 핵심 기능을 처리하는데, 이를 위해 MediaBrowserService나 ExoPlayer 같은 미디어 플레이어가 동작한다. 차량 내부에서는 Android Auto가 제공하는 표준 UI 템플릿을 통해 인터페이스를 보여준다. 따라서 개발자는 별도의 차량용 UI를 만들 필요도 없고 만들 수도 없다. 정리하자면, 실제 앱의 동작 로직은 모두 연결된 스마트폰에서 처리하고, 차량 화면에는 UI만 보여주는 개념이다.
차량에서 동작하는 앱인 만큼 운전자의 안전과 관련된 가이드라인도 엄격하게 정해져있다.
그럼 본론으로 들어가자. UI를 직접 만들 수도 없으면 도대체 어떻게 화면에 정보를 띄워야 하는 것인가?

개발 환경 설정

필수 의존성 추가

Android Auto 미디어 앱 개발을 위해서는 Media3 라이브러리와 ExoPlayer가 필요하다. 정확히 말하자면, 이 둘은 Android Auto 확장이 아니더라도 미디어 재생을 위해 필요한 라이브러리들이다. 이 포스팅은 이미 만들어진 앱을 Android Auto 환경으로 확장하는 것에 대한 내용이니, 미디어 재생 부분은 이미 구현되어 있다고 가정하고 설명하겠다.
dependencies { implementation "androidx.media3:media3-session:1.2.0" implementation "androidx.media3:media3-exoplayer:1.2.0" implementation "androidx.media3:media3-common:1.2.0" }
여기서 중요하게 알아야 할 것은 Media3에 포함되어 있는 MediaLibraryService 이다.
위에서 언급했듯 차량 환경에서는 안전을 위해 설계된 표준 UI 템플릿만을 사용할 수 있기 때문에, 미디어 콘텐츠들을 UI로 보여주기 위해 필요한 API를 제공한다. Media Item들은 아래와 같이 객체 트리 구조로 이루어져 있다.
스마트폰이 차량과 연결되면 onGetLibraryRoot() 콜백 메서드가 호출된다. 이는 Root node에 있는 미디어 목록을 불러와 운전자가 차량 화면에서 미디어 항목들을 탐색할 수 있게 하기 위함이다. 만약 그 안에서 세부 분류를 선택하거나 더 깊은 수준으로 탐색하려는 경우 onGetChildren() 콜백 메서드를 호출해 마찬가지로 화면에 띄워준다.

Android Auto 지원 선언

다음으로는 Manifest 파일에 Android Auto를 지원하는 앱임을 선언해야 한다. automotive_app_desc.xml 파일을 res/xml/ 디렉터리에 생성하고 앱의 카테고리를 명시해준다. 이때 Media, Messaging, Navigation 등과 같이 구글에서 미리 정해놓은 카테고리의 앱만 Android Auto 지원이 가능하다. 여기서는 미디어 앱이므로 media로 선언한다.
AndroidManifest.xml
<application> ... <meta-data android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc" /> ... <application/>
res/xml/automotive_app_desc.xml
<automotiveApp> <uses name="media"/> </automotiveApp>

미디어 재생 Service 설정

Auto 시스템에서 해당 Service를 인식할 수 있도록 intent-filter를 추가하고, Service binding을 위해 exported 옵션을 true로 설정해주자.
AndroidManifest.xml
<service android:name=".MediaService" android:exported="true"> <intent-filter> <action android:name="android.media.browse.MediaBrowserService" /> <action android:name="android.media.session.MediaLibraryService" /> </intent-filter> </service>

미디어 항목 추가

이제 본격적으로 Android Auto에서 미디어 콘텐츠를 제공하기 위한 MediaLibraryService를 구현해보자. 이 서비스는 차량 시스템이 우리 앱의 미디어 라이브러리에 접근할 수 있도록 표준화된 인터페이스를 제공한다. 미디어 재생을 위한 Service 코드에 MediaLibraryService API 코드를 추가하면 된다.

MediaLibraryService 선언하기

@AndroidEntryPoint class SimpleMediaService : MediaLibraryService() { private lateinit var mediaLibrarySession: MediaLibrarySession @Inject lateinit var mediaLibrarySessionCallback: MediaLibrarySessionCallback ... override fun onCreate() { super.onCreate() mediaLibrarySessionCallback.setRepository(radioStationRepository) mediaLibrarySession = MediaLibrarySession.Builder( this, player, //ExoPlayer mediaLibrarySessionCallback ).build() ... } }
 
여기서는 다음과 같은 트리 구조의 Media 항목들이 있다고 가정해보자.
음악 라이브러리 (루트) └── 플레이리스트 1 └── Song 1

onGetLibraryRoot() - 루트 항목 추가

여기부터는 MediaLibrarySessionCallback 안에 작성해야 한다.
class MediaLibrarySessionCallback : MediaLibrarySession.Callback { private lateinit var repository: MediaRepository override fun onGetLibraryRoot( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, params: LibraryParams? ): ListenableFuture<LibraryResult<MediaItem>> { val rootItem = MediaItem.Builder() .setMediaId("root") .setMediaMetadata( MediaMetadata.Builder() .setTitle("음악 라이브러리") .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) .setIsPlayable(false) .setIsBrowsable(true) .build() ) .build() return Futures.immediateFuture(LibraryResult.ofItem(rootItem, params)) } }
Android Auto가 처음 연결될 때 호출되는 메서드다.  setIsPlayable(false)와 setIsBrowsable(true)를 설정하여 재생 가능한 항목이 아닌 탐색용 폴더임을 명시해주었다.

onGetChildren() - 하위 항목 추가

override fun onGetChildren( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, page: Int, pageSize: Int, params: LibraryParams? ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { return when(parentId) { "root" -> { // 플레이리스트 1개 반환 val playlist = MediaItem.Builder() .setMediaId("playlist_1") .setMediaMetadata( MediaMetadata.Builder() .setTitle("플레이리스트 1") .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) .setIsPlayable(false) .setIsBrowsable(true) .build() ) .build() Futures.immediateFuture( LibraryResult.ofItemList(ImmutableList.of(playlist), params) ) } "playlist_1" -> { // 플레이리스트에 노래 1곡 반환 val song = MediaItem.Builder() .setMediaId("song_1") .setUri("https://example.com/media/song1.mp3") .setMediaMetadata( MediaMetadata.Builder() .setTitle("Song 1") .setArtist("Artist 1") .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setIsPlayable(true) .setIsBrowsable(false) .build() ) .build() Futures.immediateFuture( LibraryResult.ofItemList(ImmutableList.of(song), params) ) } else -> { Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params)) } } }

onGetItem() - 개별 항목 상세 정보 제공

override fun onGetItem( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String ): ListenableFuture<LibraryResult<MediaItem>> { return when(mediaId) { "song_1" -> { val song = MediaItem.Builder() .setMediaId("song_1") .setUri("https://example.com/media/song1.mp3") .setMediaMetadata( MediaMetadata.Builder() .setTitle("Song A") .setArtist("Artist A") .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setIsPlayable(true) .build() ) .build() Futures.immediateFuture(LibraryResult.ofItem(song, null)) } else -> { Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)) } } }=

검색 기능 구현

여기서 repository는 전체 곡 리스트를 저장하고 있는 클래스라고 가정한다. 핵심은 검색 결과 개수를 session.notifySearchResultChanged 호출 시 넣어주고, onGetSearchResult 에서 검색 결과를 화면에 표시해주는 것이다.
override fun onSearch( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, params: LibraryParams? ): ListenableFuture<LibraryResult<Void>> { val searchQuery = query.lowercase().trim() val searchResultCount = repository.searchAllSongs(searchQuery).size session.notifySearchResultChanged(browser, query, searchResultCount, params) return Futures.immediateFuture(LibraryResult.ofVoid()) } override fun onGetSearchResult( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, page: Int, pageSize: Int, params: LibraryParams? ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { val searchQuery = query.lowercase().trim() val searchResults = repository.searchAllSongs(searchQuery) .map { song -> MediaItem.Builder() .setMediaId("song_${song.id}") .setUri(song.uri) .setMediaMetadata( MediaMetadata.Builder() .setTitle(song.title) .setArtist(song.artist) .setAlbumTitle(song.album) .setArtworkUri(song.artworkUri) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setIsPlayable(true) .setIsBrowsable(false) .build() ) .build() } return Futures.immediateFuture( LibraryResult.ofItemList(ImmutableList.copyOf(searchResults), params) ) }

테스트 환경 구축

이렇게 만든 앱이 정말 Android Auto 연결 시 차량 화면에서도 잘 작동하는 지 테스트해보기 위해서는 실제 차량이 있어야 한다?!
일리가 없다. 실제 차량 없이도 Android Auto 앱을 테스트할 수 있는 방법이 있다. Google에서 제공하는 Desktop Head Unit(DHU)을 사용하면 된다.

DHU 설치 및 설정

  1. Android Studio의 SDK Manager에서 Android Auto Desktop Head Unit emulator 설치

Android Auto 헤드 유닛 서버 시작

먼저 휴대전화에서 Android Auto 설정에 들어가서 맨 아래로 내리면 버전 버튼이 있다. 버전을 눌러 확장되는 버전 정보를 연타하면 개발자 모드가 활성화된다. 이후 우측 상단 더보기 버튼을 눌러 헤드 유닛 서버 시작을 눌러주면 된다.
notion image
notion image

DHU 실행

Android Studio의 터미널을 열어 아래와 같이 포트 포워딩을 해준 후 DHU를 실행하면 된다. 당연히 스마트폰과 개발 PC가 디버깅 모드로 연결된 상태여야 한다.
# ADB 포트 포워딩 # platform-tools 디렉터리로 이동해 실행 adb forward tcp:5277 tcp:5277 # DHU 실행 cd C:\Users\xxx\AppData\Local\Android\Sdk\extras\google\auto ./desktop-head-unit
 
위의 예제와는 조금 다르지만 라디오 앱을 Android Auto에서 실행해본 화면이다.
notion image

댓글 0