Google 제품 관리자
Android는 Gemini와 같은 에이전트가 여러 앱에서 작업을 조정하는 인텔리전스 시스템으로 진화하고 있습니다. 새로운 사용자 환경을 활용하고 이러한 에이전트 워크플로에 참여하려면 앱이 핵심 기능을 노출해야 합니다.
이 세션은 앱을 Android의 인텔리전스 레이어와 통합하는 방법을 안내하는 실용적인 가이드입니다. 온디바이스 Android MCP, 서버 측 MCP, 작업 자동화를 비롯한 주요 통합 경로를 살펴보고 사용 사례에 적합한 경로를 선택할 수 있는 의사 결정 프레임워크를 제공합니다. 또한 개인별 맞춤 AI를 통한 선제적 환경도 살펴봅니다.
다음을 수행하는 방법을 배우게 됩니다.
AppFunctionDeclaration를 사용하여 도구를 선언하고, KDoc 설명을 제공하고, Kotlin 기본값에 의존하지 마세요.스크립트
좋습니다.
안녕하세요.
저는 개발자 관계팀의 댄 갤핀입니다.
저는 Android 개발자 인텔리전스 프레임워크를 담당하는 제품 관리자 사라 샴시입니다.
오늘 세션에 참여해 주셔서 감사합니다.
사용자가 기기와 상호작용하는 방식에 패러다임의 변화가 일어나고 있습니다.
웹 시대에는 사용자가 일반적으로 웹브라우저로 이동한 후 검색 기능을 사용하여 여정을 시작했습니다.
모바일 시대에는 일회성 작업을 완료하기 위해 더 전문화된 네이티브 앱을 사용하는 방향으로 사용자 행동이 변화했습니다.
예를 들면 다음과 같습니다
친구가 저녁 식사에 초대하는 메시지를 보냅니다.
이 메시지에 답장하려면 캘린더를 확인하여 시간이 되는지 확인하고, 캘린더에 일정을 추가하고, 이동 수단이 필요한 경우 차량을 예약한 다음, 친구에게 참석할 수 있다고 답장해야 합니다.
이전에는 앱 호핑이라는 작업이 필요했습니다. 기본적으로 기기에서 여러 앱을 전환해야 작업을 완료하고 여정을 완료할 수 있었습니다.
하지만 이제 우리는 지능형 시대에 살고 있습니다.
지능형 시대에는 사용자가 수행해야 할 구체적인 작업이 아닌 상위 수준의 의도와 목표를 표현하는 것으로 여정을 시작합니다.
사용자가 기기를 단순히 작업을 수행하는 도구가 아니라, 자신의 필요를 선제적으로 예측하고 목표 달성을 도와주는 존재로 인식하는 근본적인 변화가 일어나고 있습니다.
또한 사용자는 이 인텔리전스 프레임워크가 휴대기기뿐만 아니라 시계, 자동차, TV, 노트북, XR, 글라스 등 다양한 폼 팩터 생태계에서 작동하기를 기대하고 있습니다.
지능형 시대에는 단일 교차 앱 프롬프트로 앞서 언급한 저녁 식사 초대 예시를 실제로 구현할 수 있습니다.
프롬프트 하나만으로 기기의 에이전트가 캘린더 확인, 일정 추가, 차량 예약, 메시지 전송 등 필요한 모든 작업을 한 번에 처리할 수 있습니다.
오늘은 이 분야에 대한 Android의 계획과 개발자가 최고 수준의 환경을 구축하는 방법을 자세히 알아보겠습니다.
Dan.
사라님, 감사합니다.
Android는 핵심에 인텔리전스를 계속 추가하고 있으며, 이는 앱이 이 인텔리전스 시대에 대비할 수 있는 새로운 방법을 제공합니다.
이 두 가지 개발자 인텔리전스 핵심 요소 중 첫 번째는 인앱 인텔리전스입니다. 이는 앱을 더 스마트하게 만들고 앱에 인텔리전스를 도입하는 것으로, ML Kit를 통해 Gemini Nano 4와 같은 온디바이스 모델을 사용하여 메시지 대화목록을 요약하고, 답장을 다시 작성하고, 이미지를 이해하거나, 자연어를 사용하여 앱의 함수를 호출할 수 있습니다.
Firebase AI Logic을 통해 앱에서 바로 클라우드의 Gemini 모델을 사용할 수도 있습니다.
하지만 오늘 집중할 부분은 두 번째 핵심 요소인 앱의 기능을 인텔리전스 시스템 내에서 사용할 수 있도록 하는 것입니다.
그렇다면 인텔리전스 시스템이란 무엇일까요?
따라서 앞서 사라가 언급한 프롬프트 환경에 대해 이야기하고 호스팅하는 것 외에도 앱 생태계 전반에서 환경을 개선하기 위해 긴밀한 통합을 지원하는 시스템입니다.
따라서 Gemini 인텔리전스를 위해 설계된 기기는 이러한 통합 지점을 활용하여 추천, 요약된 알림, 개선된 음성 입력, 자동 양식 작성과 같은 기능을 제공합니다.
이제 추천은 능동적 지원이라는 기능의 일부입니다.
지금까지는 프롬프트 지원에 대해 이야기했습니다.
사용자가 에이전트에게 자신을 대신해 하나 이상의 작업을 수행하도록 지시하는 방식은 우리 모두에게 익숙합니다.
하지만 능동적 지원은 실제로 사용자가 취할 수 있는 다음 작업을 예측하는 것입니다.
저녁 식사 예시로 돌아가서, 에이전트는 메시지에 대한 응답으로 사용자에게 캘린더에 초대를 추가하거나 차량 공유를 예약할 수 있는 제안을 사전에 표시할 수 있습니다.
하지만 실제로 사용자에게 어떻게 메시지를 표시할까요?
능동적 지원은 잠금 화면, 알림, 가상 키보드, 선택한 앱 내에서 작업을 표시할 수 있습니다.
그렇다면 앱이 이러한 사전 추천 칩에 실제로 표시되어 트래픽을 유도하려면 어떻게 해야 할까요?
이미 최신 SDK 버전을 사용하고 최신 도구로 빌드하고 계시겠지만, 지금 할 수 있는 가장 좋은 방법은 앱이 프롬프트 지원을 받을 준비가 되어 있는지 확인하는 것입니다. 이에 대해서는 사라가 이어서 설명해 드리겠습니다.
댄, 감사합니다.
네, 프롬프트 지원은 사용자가 에이전트에게 직접 가서 작업을 시작하고 조정하도록 요청하는 것을 포함합니다. 앱 간에도 가능하죠.
이를 통해 이전에는 수동으로 수행하던 시간 소모적인 워크플로를 자동화할 수 있습니다.
Android의 지능형 시스템의 경우, 사용자의 기기에서 앱을 활용할 수 있는 Gemini와 같은 시스템 에이전트에 주로 초점을 맞추고 있습니다.
앱이 이 환경에 표시되고 시스템 에이전트와 상호작용하는 방법에는 몇 가지가 있습니다.
플랫폼 기반 작업 자동화 솔루션이 있고 시스템 에이전트에 직접 API를 통합하는 방법이 있습니다.
가능한 경우 항상 직접 API 통합을 사용하여 빌드하는 것이 좋습니다.
이러한 요소는 개발자가 제어할 수 있으므로 앱과 브랜드가 실제로 표시되는 방식을 결정하고 제어할 수 있습니다.
또한 더 빠르고 안정적이어서 사용자에게 더 우수한 환경을 제공합니다.
에이전트와 통합하려는 경우 고려할 수 있는 API 경로가 두 가지 있습니다.
모델 컨텍스트 프로토콜(줄여서 MCP)은 서버 측 함수 호출을 위한 업계 권장사항입니다.
하지만 온디바이스 도구 호출의 경우 Android의 통합 함수 호출 프레임워크인 Android MCP를 공식적으로 발표합니다.
Android MCP는 Jetpack AppFunctions 라이브러리를 기반으로 합니다. 이 라이브러리는 기술적으로나 개념적으로 MCP와 밀접하게 연계되어 있으며, 많은 Android 개발자에게 익숙한 Kotlin 구현을 지원합니다.
따라서 API를 비교적 직관적이고 쉽게 구현할 수 있으며, 이미 설정된 서버 측 MCP 통합에서 얻은 지식을 활용할 수 있습니다.
많은 MCP 프리미티브가 Jetpack AppFunctions 구문에 일대일로 매핑됩니다.
몇 가지 예를 살펴보겠습니다.
MCP 도구 엔드포인트는 AppFunctionDeclaration 주석이 달린 Kotlin 함수에 잘 매핑됩니다.
pydantic 모델은 AppFunctionSerializable 주석이 달린 Kotlin 데이터 클래스에 매핑됩니다.
하지만 Android MCP는 스트리밍 가능한 HTTP 또는 표준 I/O 전송 대신 최적화된 Android Binder IPC에서 실행됩니다.
오늘 Jetpack 라이브러리인 AppFunctions가 베타 버전으로 출시되었다는 소식을 전해드리게 되어 매우 기쁩니다.
그렇다면 언제 API 경로를 사용하고 언제 Android MCP를 사용하는 것이 적절한지 궁금하실 것입니다.
기본적으로 Android MCP는 기기 내 또는 로컬 컨텍스트를 활용하여 사용자 여정을 완료하려는 경우에 특히 유용합니다.
Android MCP가 진가를 발휘하는 몇 가지 예를 살펴보겠습니다.
특히 로컬의 암호화된 데이터에 액세스해야 하는 경우 Android MCP를 고려해야 합니다.
이는 엔드 투 엔드 암호화로 인해 메시지 기록이 기기 내에 유지되어야 하는 메시지 및 커뮤니케이션 앱에서 특히 중요합니다.
Android MCP는 에이전트가 지원하는 순수 기기 내 및 오프라인 작업에도 유용합니다.
Wear OS 파트너인 삼성 헬스는 전적으로 기기에서 조정되는 달리기 및 운동 시작 기능을 출시했습니다.
사용자는 이러한 유형의 작업을 매우 빠르게, 높은 안정성, 짧은 지연 시간으로 수행하기를 원하는 경우가 많으며, 때로는 연결성이 낮은 환경에서도 전체 도구 호출 및 라우팅과 쿼리 라우팅이 기기 내에서 이루어지도록 하면 이를 보장할 수 있습니다.
Android MCP는 기기 센서에 액세스하거나 기기 하드웨어와 직접 상호작용하려는 경우에도 선호되는 API 경로입니다.
예를 들어 많은 자동차 앱은 사용자가 UWB 연결을 통해 즉시, 그리고 핸즈프리로 자동차 문을 잠금 해제할 수 있도록 허용하고자 합니다.
Android MCP가 도움이 될 수 있습니다.
마지막으로, 앱이나 API에 사용할 수 있는 Cloud Endpoints가 없는 경우에도 이 솔루션을 사용할 수 있습니다.
기기 제조업체의 퍼스트 파티 앱 중 상당수는 클라이언트 측 전용이므로 이러한 이유로 Android MCP를 사용합니다.
예를 들어 Gemini를 사용하여 Xiaomi Gallery와 같은 클라이언트 측 전용 갤러리 앱에서 사진을 검색하고 가져올 수 있습니다.
여기에서 Gemini를 불러와 "지난주에 Xiaomi 갤러리에서 찍은 Android 봇 사진을 찾아 줘"라고 요청할 수 있습니다.
Xiaomi는 사진을 검색하는 앱 기능을 노출하고, Gemini는 이 기능을 호출하며, 관련 사진이 반환되어 Gemini 앱 UI에 직접 표시되므로 사용자는 앱을 나갈 필요가 없습니다.
또한 Android OS의 온디바이스 작업에도 동일한 아키텍처를 사용하고 있습니다.
Android MCP는 최근 출시된 기기 도움말 기능을 지원하며, 이는 Gemini 앱에 직접 통합된 Android OS입니다.
이 기능은 이미 Pixel 및 Lenovo 기기에서 출시되었으며 현재 더 광범위한 Android OEM 생태계로 수평 확장되고 있습니다.
사용자는 Gemini로 이동하여 기기 환경을 개선하기 위해 설정, 기기 설정, 시스템 설정을 최적화하는 방법을 물어봄으로써 기기 도움말을 사용할 수 있습니다.
예를 들어 색맹인 사용자는 다양한 메뉴를 일일이 살펴보는 대신 Gemini에게 물어볼 수 있습니다. "나는 색맹이야.
이 휴대전화를 나에게 더 잘 맞게 사용하려면 어떻게 해야 해?"
Gemini는 로컬 앱 기능을 호출하고, 요청을 지능적으로 추론하며, 사용자가 시스템 설정을 변경할 수 있는 실제 방법과 환경을 개선하기 위해 기기 설정을 변경할 수 있는 구체적인 방법을 제안합니다.
그러면 인앱 제어 기능이 Gemini 앱 UI에 직접 표시되므로 사용자가 즉시 제어할 수 있습니다.
예를 들어 색맹 최적화 요청을 처리할 때 Gemini는 실제로 색상 보정 전환 버튼을 표시하여 사용자가 즉시 변경할 수 있도록 합니다.
Android MCP 환경은 이미 일부 기기에서 사용할 수 있으며, Google은 여러 파트너와 협력하여 Android 생태계에 더 많은 사용 사례를 제공하기 위해 노력하고 있습니다.
명시적인 Android MCP 도구 선언이 가장 이상적인 방식이지만, 사용자가 에이전트에게 복잡하거나 일반적이지 않은 사용 사례를 완료해 달라고 요청하는 경우도 있을 수 있습니다. 이때 플랫폼 기반 작업 자동화 기능이 유용합니다.
이 모델에서 OEM 사전 로드 에이전트는 Android의 컴퓨터 제어 API를 사용하여 앱 표면에서 터치 이벤트를 시뮬레이션할 수 있습니다.
이 경우 개발자가 코드를 변경할 필요가 없으며, 에이전트가 앱 화면을 분석하여 사용자와 마찬가지로 UI를 탭해야 합니다.
사용자 제어와 안전을 보장하려면 에이전트가 결제 흐름과 같이 민감할 수 있는 작업에서 일시중지하고 사용자에게 계속 진행할지 확인을 요청하는 것이 중요합니다.
실제 예를 살펴보겠습니다.
여기에서 사용자는 커피 및 초콜릿 투어 사진을 찍고 Expedia에 문의하거나 Gemini에게 Expedia를 살펴보고 비슷한 투어를 찾을 수 있는지 물어보고 참석자 수를 6명으로 조정하고 싶어 합니다.
Gemini는 직접적인 API 통합 없이도 사용자와 동일한 방식으로 백그라운드에서 Expedia의 UI를 탐색합니다.
그런 다음 검색된 관련 옵션을 표시하고 최종 확인 및 예약 시점에 사용자에게 원활하게 전달합니다.
이 세 가지 경로는 상호 배타적이지 않으며, 함께 사용할 때 더 효과적이라는 점을 이해하는 것이 중요합니다.
사용자와 개발자는 에이전트가 서버 구현 전반에서 도구를 조정하고 로컬 Android 앱을 조정하여 사용자에게 가장 포괄적이고 전체적인 환경을 제공할 수 있을 것으로 기대할 수 있습니다.
좋습니다.
사라님, 감사합니다.
Android MCP에 대해 좀 더 자세히 알아보겠습니다.
가능한 경우, Google의 여행 샘플 앱인 Jetpacker에서 MCP 코드 스니펫을 가져오고 있다는 점을 알려드리고 싶습니다. 이 앱은 실제로 Antigravity의 도움을 받아 빌드되었습니다.
키노트에서 보셨을 겁니다.
ML Kit 생성형 AI 및 ML Kit 음성 인식 API와의 온디바이스 통합을 비롯한 다양한 지능형 기능이 탑재되어 있습니다.
이제 Android MCP와 클라우드 기반 MCP는 유사한 구조를 갖습니다.
둘 다 클라이언트와 서버가 있습니다.
MCP 환경에서는 서버가 도구를 제공하고 클라이언트가 이러한 도구를 검색하고 호출할 수 있습니다.
이제 Android MCP를 사용하면 앱이 서버가 되고 에이전트가 클라이언트가 됩니다.
Android 플랫폼은 도구 레지스트리 역할을 합니다.
이제 앱은 서버를 제공하는 서비스를 등록하는 것으로 시작하며, 여기서는 샘플에 AppFunctions Jetpack 라이브러리 종속 항목을 추가하는 것으로 시작합니다.
implementation(libs.androidx.appfunctions)
ksp(libs.androidx.appfunctions.compiler)그런 다음 KSP 주석을 사용하여 AppFunctionService를 만듭니다.
이 서비스를 사용하면 앱 기능이 Android 수명 주기를 제대로 준수할 수 있습니다.
Hilt를 사용한 종속 항목 삽입을 통해 AppFunction에서 사용할 수 있는 데이터 저장소를 삽입할 수 있습니다.
@RequiresApi(36)
@AndroidEntryPoint
@AppFunctionServiceEntryPoint(
serviceName = "JetPackerAppFunctionService",
appFunctionXmlFileName = "jetpacker_app_function_service"
)
abstract class BaseJetPackerAppFunctionService : AppFunctionService() {
@Inject internal lateinit var tripDao: TripDao
@Inject internal lateinit var expenseDao: ExpenseDao
...죄송합니다.
다이어그램으로 돌아가겠습니다.
Android MCP 서버에 서비스를 설정한 후 앱은 AppFunctions 형식의 도구와 함께 도구에서 사용하는 관련 데이터 모델을 등록합니다.
이제 AppFunctionSerializable 주석을 사용하여 데이터 모델을 등록할 수 있습니다. 이 KDoc은 정말 멋집니다.
/** Represents an event in a trip itinerary. */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class TimelineEventSerializable(
/** The event's unique identifier. */
val id: String,
/** The trip ID this event belongs to. */
val tripId: String,
/** The type of event (e.g., TRANSPORTATION, ACCOMMODATION, FOOD_AND_DRINK). */
val type: String,
/** The timestamp of the event. */
val timestamp: Long,
/** The title of the event. */
val title: String,
/** The location of the event. */
val location: String,
/** A description of the event. */
val description: String?,
/** The title of the trip this event belongs to. */
val tripTitle: String? = null,
/** Optional title of a matched itinerary event when added as a reminder. */
val matchedEventTitle: String? = null,
/** Execution status of the tool call (e.g., SUCCESS). */
val status: String? = "SUCCESS",
/** Human-readable completion message instructing the agent not to repeat the call. */
val message: String? = null,
)에이전트가 모델 사용 방법을 이해하는 데 사용하는 메타데이터가 될 뿐입니다.
AppFunctionDeclaration 주석을 사용하여 함수를 도구로 등록할 수 있습니다.
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
if (fallback.isNotEmpty()) { eventDao.insertEvents(fallback) }
fallback
}
events.map { it.toSerializable(tripTitle = trip?.title) }
}
}isDescribedByKDoc를 true로 설정하면 KDoc는 에이전트에게 함수가 수행하는 작업을 이해할 수 있는 설명을 제공하며, 이는 사람이 이해하는 데도 도움이 됩니다.
/**
* Retrieves the itinerary events (with their eventId, title, type, and location) for a specific
* trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a
* specific event's ID to retrieve ticket prices, hotel nightly rates, baggage allowance, or
* visitor tips.
*
* @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
...다음은 파라미터 설계에 관한 몇 가지 권장사항입니다.
/**
* @param tripId Optional ID of the trip (e.g., '2026-1').
* Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
// resolveTrip defaults to the active trip if tripId == null
val trip = resolveTrip(tripId = tripId)우선, 명확하고 고유한 이름을 가진 최소한의 매개변수 하위 집합에 집중해야 합니다.
필수적이지 않은 매개변수에서 null을 허용하도록 설정하고 함수 본문에서 기본값을 확인합니다.
이는 단순히 권장사항이 아닙니다.
AppFunctions 라이브러리가 생략된 null 허용 매개변수에 null을 전달하므로 Kotlin 기본값이 무시되어 작동하지 않습니다.
첫 번째 예시는 보여주지도 않겠지만, 포괄적인 매개변수를 사용하지 마세요.
전체 컨텍스트나 대화 기록과 같은 것들은 좋지 않습니다.
에이전트가 대량의 데이터를 전달하고 유출로 이어질 수 있으며, 이는 잘못된 관행입니다.
AppFunction은 기본적으로 UI 스레드에서 실행되므로 적절한 코루틴 디스패처를 선택하여 장기 실행 작업이 백그라운드에서 실행되도록 해야 합니다.
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?:
tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
...에이전트가 오류를 해결하는 방법을 이해할 수 있도록 필요한 경우 자세한 정보와 함께 AppFunction 예외를 발생시켜야 합니다.
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun send(
message: Message
): Result {
...
throw AppFunctionException(
errorCode = AppFunctionException.ErrorCode.ERROR_INVALID_ARGUMENT,
errorMessage = "To address has invalid syntax!"
)
}이제 AppFunction이 있으니 아키텍처 다이어그램으로 돌아가 보겠습니다.
에이전트 클라이언트 앱은 플랫폼에 등록하여 AppFunctions를 호출합니다.
예를 들어 Gemini가 여기에 해당합니다.
이를 위해서는 권한이 있는 권한을 보유해야 하며, 등록된 에이전트는 Android 플랫폼에서 AppFunction 메타데이터를 조회하고 등록된 AppFunction을 하나 이상 실행하여 사용자의 요청을 처리해야 하는지 결정할 수 있습니다.
하나 이상의 AppFunction이 있을 때 에이전트는 실제로 어떻게 결정할까요?
마지막으로 해야 할 일은 앱 메타데이터 파일에 앱 기능을 설명하는 것입니다.
<AppFunctionAppMetadata
xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
appfn:description="This app manages trips and travel expenses.
Operational Patterns:
- Use 'getAllTrips' to find the correct 'tripId' before
adding expenses or managing trip details.
Constraints:
- Expenses must be associated with a valid trip ID."
appfn:displayDescription="@string/user_visible_description" />에이전트는 먼저 AppFunctionAppMetadata 내에서 이 설명을 읽은 다음 개별 AppFunction의 설명을 실제로 읽어 도구를 선택합니다.
따라서 설명의 시작 부분에 앱 또는 도구의 기본 도메인에 대한 간략한 설명을 집중적으로 작성해야 합니다.
그다음에는 에이전트가 작업의 논리적 흐름을 파악하는 데 도움이 되는 운영 패턴을 제공할 수 있습니다. 이는 전제 조건을 설명하거나 에이전트가 작업을 완료하기 위한 최적의 경로를 안내하는 데 유용합니다.
또한 입력 데이터 유형의 논리적 한계를 정의하거나 운영 패턴을 강화하는 제약 조건에 관한 섹션을 추가할 수 있습니다.
지금까지 Android MCP 기본사항을 살펴봤습니다. 하지만 이 외에도 할 수 있는 작업이 몇 가지 더 있습니다.
AppFunctions를 동적으로 사용 설정하거나 사용 중지할 수 있다는 점이 흥미롭습니다.
viewModelScope.launch {
val newState =
if (enabled) {
AppFunctionManager.APP_FUNCTION_STATE_ENABLED
} else {
AppFunctionManager.APP_FUNCTION_STATE_DISABLED
}
appFunctionManager?.setAppFunctionEnabled(id, newState)
}예를 들어 사용자가 로그인한 경우에만 AppFunction을 사용할 수 있도록 하려는 경우에 중요합니다.
앱의 특정 화면이나 활동으로 범위가 지정된 AppFunction을 만들 수도 있습니다.
@AppFunctionSignature(
scope = AppFunctionMetadata.SCOPE_ACTIVITY,
appFunctionXmlFileName = "cart_functions"
)
fun interface AddCurrentItemToCart {
/** Adds the item currently shown on screen to the cart. Returns the new cart size. */
suspend fun addToCart(quantity: Int): Int
}따라서 AppFunction을 선언하는 대신 AppFunctionSignature를 선언합니다.
그런 다음 UI에서 AppFunction의 핸들러를 구현할 수 있습니다.
val addToCartAppFunctionAdapter =
appFunctionManager.getHandleAppFunctionRequestAdapter<AddCurrentItemToCart>()
coroutineScope.launch {
appFunctionManager.handleAppFunction(
addToCartAppFunctionAdapter.adapt { quantity ->
val newCartSize = cart.addItem(quantity)
newCartSize
}
)
}여기서 흥미로운 점은 핸들러의 수명 주기가 사용하는 코루틴 범위의 수명 주기에 자동으로 연결된다는 것입니다.
이제 이 AppFunctions 요청 어댑터(죄송합니다. AppFunctions 요청 어댑터 핸들)를 캐시해야 합니다. 라이브러리가 내부적으로 리플렉션을 사용하여 인스턴스화하기 때문입니다.
또한 등록이 Android 수명 주기에 직접 연결되지 않는 범위가 지정된 AppFunctions를 만드는 기능도 있습니다.
마지막으로 액세스 수준이라는 것이 있습니다.
@AppFunctionDeclaration(isDescribedByKDoc = true)
@AppFunctionAccessLevel(
level = AppFunctionMetadata.ACCESS_LEVEL_SELF,
isCompatEnforcementEnabled = true,
)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
...또한 self, system, trusted 액세스 수준을 가져와 사용할 수 있습니다. 이는 ML Kit GenAI로 빌드된 것과 같은 자체 인앱 에이전트만 사용할 수 있는 함수, 시스템만 사용할 수 있는 함수, 신뢰할 수 있는 시스템 에이전트가 사용할 수 있는 함수를 나타냅니다.
또한 자체 인앱 에이전트를 빌드하는 데 도움이 되도록 ML Kit가 통합 도구 호출 프레임워크의 일부로 AppFunctions를 기본적으로 지원한다는 사실을 이미 발표했습니다.
한 번 정의하면 시스템 수준 에이전트와 로컬 인앱 모델 모두에 적용되므로 도구 선언이 분산되는 것을 방지할 수 있습니다.
자세한 내용은 'ML Kit GenAI로 앱에서 에이전트 기능 활용하기'라는 강연을 확인하거나 Jetpack의 최신 기능을 직접 살펴보세요.
꽤 괜찮죠.
다시 사라에게 돌아가겠습니다.
좋습니다.
댄이 Android MCP의 내부 작동 방식을 보여주었습니다.
이제 실행으로 옮길 차례입니다.
앱은 서버이고 에이전트는 클라이언트이므로 앱이 에이전트에게 좋은 서버가 되도록 하는 것이 목표입니다.
여행 샘플 앱인 Jetpacker를 통해 첫 번째 도구 선언부터 Gemini를 통한 실제 함수 호출까지 살펴보겠습니다.
AppFunctions를 직접 작성하거나 코딩 에이전트를 사용하여 시작할 수 있습니다.
AppFunctions 기술이 출시되었습니다. 이 기술을 사용하면 함수 작성을 시작할 수 있으며, Android CLI를 통해 Android 스튜디오에 연결하거나 원하는 코딩 에이전트와 함께 사용할 수 있습니다.
android skills add appfunctions스킬은 세 단계로 작동합니다.
먼저 코드베이스를 분석하여 미디어 재생, 메모 작성, 메시지 전송과 같이 유용한 도구가 될 수 있는 핵심 사용자 여정을 식별합니다.
그런 다음, 이러한 여정을 노출하는 AppFunction 선언을 생성하고, 필요한 구현 및 플랫폼 지원을 포함하여 완성합니다.
예를 들어 Gradle 및 KSP, 앱 메타데이터 XML, 종속 항목 삽입이 있습니다.
마지막으로 KDoc 설명을 미세 조정합니다. 에이전트가 이 설명을 사용하여 함수를 호출할 시기와 방법을 결정하기 때문입니다.
이러한 유형의 결과를 찾고 있습니다.
Jetpacker를 위한 addExpense 함수입니다.
/**
* Adds an expense to a trip.
*
* @param tripId ID of the trip. If null, the active trip is used.
* @param category Expense category. If null, it's inferred from the title.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun addExpense(
title: String,
amount: Double,
currency: String,
tripId: String? = null,
category: String? = null,
): ExpenseSerializable = withContext(Dispatchers.IO) {
if (amount <= 0.0) {
throw AppFunctionInvalidArgumentException("amount must be greater than zero")
}
val trip = resolveTrip(tripId)
?: throw AppFunctionElementNotFoundException("No trip with ID $tripId")
val expense = Expense(trip.id, title, amount, currency,
category = category ?: inferExpenseCategory(title))
expenseDao.insertExpense(expense)
expense.toSerializable(remainingBudgetUsd(trip.id))
}설명은 동사와 리소스로 시작하는 한 줄 문장입니다. 여행에 비용을 추가합니다.
사용자가 항상 지정하지는 않을 수 있으므로 이동 및 카테고리 필드는 null을 허용하지만 함수는 본문에서 이를 처리합니다.
요청이 성공하지 못하면 에이전트가 무엇이 잘못되었는지 알 수 있도록 AppFunctionException를 발생시킵니다.
이제 테스트를 살펴보겠습니다.
이제 함수는 에이전트가 사용할 수 있는 서비스입니다.
따라서 실제로 테스트해야 하는 부분은 함수의 동작 방식과 에이전트의 동작 방식 두 가지입니다.
함수의 경우 이미 작성했을 수 있는 단위 테스트부터 시작하지만, 에이전트가 실제로 함수 호출을 수행할 때는 몇 가지 유의해야 할 사항이 있습니다.
첫째, Kotlin 기본값은 여기에 적용되지 않습니다.
생략된 매개변수는 null, false 또는 0으로 도착합니다.
따라서 이전에 기본값이 10이었던 한도가 여기서는 실제로 0이 됩니다.
또한 호출은 기본 스레드에서 시작되며 테스트에서 함수를 호출하는 것이 항상 명확하지는 않을 수 있으므로 백그라운드 디스패처에서 표준 IO를 실행해야 합니다.
실패하면 AppFunctionException를 발생시킵니다.
그 외의 모든 오류는 원시 예외 메시지가 첨부된 일반적인 알 수 없는 오류로 에이전트에 전달됩니다.
입력은 모델에서 가져오므로 공백, USD와 같은 달러의 동의어, 범위를 벗어난 값과 같은 극단적인 시나리오를 반드시 테스트해야 합니다.
에이전트 측면에서는 에이전트가 적절한 시점에 도구를 선택하고 함수를 올바르게 실행하는지 확인해야 합니다.
각 에이전트는 약간씩 다르게 동작하므로 함수를 사용할 것으로 예상되는 에이전트로 함수를 테스트하는 것이 중요합니다.
오픈소스 샘플 에이전트를 사용하여 테스트하거나 Gemini를 사용하여 이 통합을 테스트할 수 있습니다.
Gemini가 AppFunctions와 상호작용하는 방식을 확인하거나 Gemini 앱에서 Android MCP를 실행하려면 오늘 출시된 연결된 앱 플랫폼을 사용하면 됩니다. 이 플랫폼은 현재 프리뷰 버전으로 제공됩니다.
연결된 앱 플랫폼을 통해 앱을 Gemini 및 Google 검색으로 확장하는 방법에 관한 이전 강연을 확인하여 자세히 알아보세요.
프리뷰 프로그램에 참여하면 Gemini로 테스트하는 과정은 두 단계로 구성됩니다.
먼저 Google Connected Console에서 Gemini가 실제로 사용할 수 있는 연결된 앱을 만듭니다.
두 번째로, Play Console에서 App Bundle을 업로드해야 합니다. 그래야 AppFunctions에 액세스할 수 있고 내부 테스트 트랙을 시작하여 테스터가 실제로 기능을 검증할 수 있습니다.
Google Connected Console에서 시작해 보겠습니다.
여기에서 오른쪽 상단의 '앱 만들기' 버튼을 사용하여 연결된 앱을 만듭니다.
연결된 앱을 만들 때 이 화면에서 Android MCP를 선택합니다.
서버 측 MCP 구현을 빌드하는 경우 여기에 서버 코드를 업로드합니다.
'설정'을 클릭하면 개발자 계정과 연결된 모든 Google Play 앱이 여기에 표시되어 연결된 앱에 연결할 수 있습니다.
아직 Google Play 앱이 없다면 먼저 Play Console에서 앱을 만들어야 합니다.
여기에는 이미 Jetpacker가 있으므로 연결해 보겠습니다.
다음으로, 사용자가 앱과 상호작용하려고 할 때 Gemini 프런트엔드에 표시될 세부정보를 추가합니다.
여기에는 앱 아이콘, 표시 이름, 연결된 앱의 기능, 사용자가 실제로 상호작용하는 데 사용할 수 있는 프롬프트 예시 등이 포함됩니다.
사용자를 가장 잘 안내할 수 있도록 설명이 포함되어야 합니다.
연결된 앱 설정이 완료되었습니다. 하지만 연결된 앱이 실제로 함수에 어떻게 액세스하는지 궁금하실 수 있습니다.
함수가 앱 코드 내에 포함되어 있으므로 이제 Play Console로 이동하여 App Bundle을 업로드하고 테스트 트랙을 만들어 실제 사용자를 대상으로 실제로 테스트해야 합니다.
이 화면에서 App Bundle을 업로드하면 Play에서 AppFunctions를 감지할 수 있으며, 이를 연결된 앱에 직접 연결할 수 있습니다.
이제 기능이 통합된 Jetpack 연결 앱이 완성되었습니다.
다음으로, 함수를 검증하고 제대로 작동하는지 확인할 수 있는 테스터를 추가해야 합니다.
여기에서 연결된 앱 버전을 클릭합니다.
이 화면에서 '테스터 관리'를 선택하고 Gemini에서 연결된 앱을 사용할 수 있게 되면 액세스할 수 있는 테스터의 제한된 목록을 구성할 수 있습니다.
다른 출시 버전과 동일한 방식으로 이 테스트 트랙에 테스터를 추가할 수 있습니다.
연결된 앱의 테스터 목록이 Google Play 앱의 테스터 목록과 동일한지 확인합니다.
이제 통합을 테스트해 보겠습니다.
사용자의 휴대전화에 설치된 Jetpacker 앱에 이번 서밋을 위한 여행이 이미 기록되어 있습니다.
Gemini를 실행하여 앱을 연결하겠습니다.
이전에 Play에 업로드한 것과 동일한 버전의 앱이 기기에 설치되어 있는지 확인해야 하며, 테스터 목록에 추가된 Gemini 계정을 사용해야 합니다.
여기에서 Jetpacker를 멘션하고 마운틴뷰에 있는 호텔 객실의 확인 번호를 요청하겠습니다.
Gemini는 기기에서 Jetpacker의 AppFunctions를 호출하고 "Redwood Grove Hotel의 RED11223"이라고 대답합니다.
다음으로 "ADS에 사용할 수 있는 예산이 얼마나 남았나요?"라고 물어보겠습니다.
함수 호출은 1,850달러를 반환합니다.
이제 변경사항을 적용하고 싶으므로 Gemini에게 ADS 여행에 공항 Uber 비용 45달러를 추가해 달라고 요청하겠습니다.
Gemini는 앞서 언급한 addExpense 함수를 호출하여 이 일정을 내 운항 일정에 직접 추가합니다.
Jetpacker를 열면 지출이 이미 반영되어 있고 예산도 그에 따라 줄어듭니다.
지금 바로 Gemini용 연결된 앱을 빌드하려면 현재 프리뷰 버전으로 제공되는 연결된 앱 플랫폼에 대한 액세스 권한을 신청하세요.
좋습니다.
이제 마무리하겠습니다.
모바일 컴퓨팅의 대대적인 변화가 시작되는 시점에 앱이 준비되어 있는지 확인해야 합니다.
따라서 먼저 앱의 핵심적인 가치 높은 사용자 여정을 파악하세요.
서버 측 MCP를 사용하여 클라우드의 백엔드를 노출하고 Android MCP를 사용하여 엔드 투 엔드 암호화 데이터, 로컬 작업, 기기 센서, 기타 온디바이스 항목을 처리합니다.
지금 프로토타입을 제작하려면 Android 스튜디오와 Android CLI를 다운로드하면 됩니다.
빌드 파일에 AndroidX AppFunctions 베타 종속 항목을 추가하고 로컬에서 첫 번째 기기 내 함수를 테스트할 수 있습니다.
Gemini를 사용한 프로덕션 전환을 조기에 시작하려면 이 QR 코드를 통해 등록하세요.
물론 연결된 앱 플랫폼을 사용하는 방법과 ML Kit 또는 자체 에이전트와 함께 내부 AppFunctions를 사용하여 자체 인앱 에이전트를 구동하는 방법에 관한 관련 강연도 확인해 보세요.
마지막으로, 소중한 시간을 내어 주셔서 감사합니다.
Android를 위해 개발 중인 멋진 환경을 기대하겠습니다.
implementation(libs.androidx.appfunctions)
ksp(libs.androidx.appfunctions.compiler)@RequiresApi(36)
@AndroidEntryPoint
@AppFunctionServiceEntryPoint(
serviceName = "JetPackerAppFunctionService",
appFunctionXmlFileName = "jetpacker_app_function_service"
)
abstract class BaseJetPackerAppFunctionService : AppFunctionService() {
@Inject internal lateinit var tripDao: TripDao
@Inject internal lateinit var expenseDao: ExpenseDao
.../** Represents an event in a trip itinerary. */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class TimelineEventSerializable(
/** The event's unique identifier. */
val id: String,
/** The trip ID this event belongs to. */
val tripId: String,
/** The type of event (e.g., TRANSPORTATION, ACCOMMODATION, FOOD_AND_DRINK). */
val type: String,
/** The timestamp of the event. */
val timestamp: Long,
/** The title of the event. */
val title: String,
/** The location of the event. */
val location: String,
/** A description of the event. */
val description: String?,
/** The title of the trip this event belongs to. */
val tripTitle: String? = null,
/** Optional title of a matched itinerary event when added as a reminder. */
val matchedEventTitle: String? = null,
/** Execution status of the tool call (e.g., SUCCESS). */
val status: String? = "SUCCESS",
/** Human-readable completion message instructing the agent not to repeat the call. */
val message: String? = null,
)@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
if (fallback.isNotEmpty()) { eventDao.insertEvents(fallback) }
fallback
}
events.map { it.toSerializable(tripTitle = trip?.title) }
}
}/**
* Retrieves the itinerary events (with their eventId, title, type, and location) for a specific
* trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a
* specific event's ID to retrieve ticket prices, hotel nightly rates, baggage allowance, or
* visitor tips.
*
* @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
.../**
* @param tripId Optional ID of the trip (e.g., '2026-1').
* Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
// resolveTrip defaults to the active trip if tripId == null
val trip = resolveTrip(tripId = tripId)@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?:
tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
...@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun send(
message: Message
): Result {
...
throw AppFunctionException(
errorCode = AppFunctionException.ErrorCode.ERROR_INVALID_ARGUMENT,
errorMessage = "To address has invalid syntax!"
)
}<AppFunctionAppMetadata
xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
appfn:description="This app manages trips and travel expenses.
Operational Patterns:
- Use 'getAllTrips' to find the correct 'tripId' before
adding expenses or managing trip details.
Constraints:
- Expenses must be associated with a valid trip ID."
appfn:displayDescription="@string/user_visible_description" />viewModelScope.launch {
val newState =
if (enabled) {
AppFunctionManager.APP_FUNCTION_STATE_ENABLED
} else {
AppFunctionManager.APP_FUNCTION_STATE_DISABLED
}
appFunctionManager?.setAppFunctionEnabled(id, newState)
}@AppFunctionSignature(
scope = AppFunctionMetadata.SCOPE_ACTIVITY,
appFunctionXmlFileName = "cart_functions"
)
fun interface AddCurrentItemToCart {
/** Adds the item currently shown on screen to the cart. Returns the new cart size. */
suspend fun addToCart(quantity: Int): Int
}val addToCartAppFunctionAdapter =
appFunctionManager.getHandleAppFunctionRequestAdapter<AddCurrentItemToCart>()
coroutineScope.launch {
appFunctionManager.handleAppFunction(
addToCartAppFunctionAdapter.adapt { quantity ->
val newCartSize = cart.addItem(quantity)
newCartSize
}
)
}@AppFunctionDeclaration(isDescribedByKDoc = true)
@AppFunctionAccessLevel(
level = AppFunctionMetadata.ACCESS_LEVEL_SELF,
isCompatEnforcementEnabled = true,
)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
...android skills add appfunctions/**
* Adds an expense to a trip.
*
* @param tripId ID of the trip. If null, the active trip is used.
* @param category Expense category. If null, it's inferred from the title.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun addExpense(
title: String,
amount: Double,
currency: String,
tripId: String? = null,
category: String? = null,
): ExpenseSerializable = withContext(Dispatchers.IO) {
if (amount <= 0.0) {
throw AppFunctionInvalidArgumentException("amount must be greater than zero")
}
val trip = resolveTrip(tripId)
?: throw AppFunctionElementNotFoundException("No trip with ID $tripId")
val expense = Expense(trip.id, title, amount, currency,
category = category ?: inferExpenseCategory(title))
expenseDao.insertExpense(expense)
expense.toSerializable(remainingBudgetUsd(trip.id))
}