Google 產品經理
Android 正在演變成智慧型系統,由 Gemini 等代理協調各應用程式的工作。如要解鎖新的使用者體驗,並參與這些代理工作流程,應用程式必須公開核心功能。
本課程提供實用指南,說明如何將應用程式與 Android 的智慧層整合。我們會探討主要的整合路徑,包括裝置端 Android MCP、伺服器端 MCP 和工作自動化,並提供決策架構,協助您根據用途選擇合適的路徑。我們也會探討如何運用個人化建議,主動提供實用資訊。
您將學習下列內容:
AppFunctionDeclaration 宣告工具、提供 KDoc 說明,並避免依賴 Kotlin 預設值。轉錄稿
現在我們來做個小測驗
大家好。
我是開發人員關係團隊的 Dan Galpin。
我是 Sarah Shamsie,Android 開發人員智慧架構的產品經理。
很高興各位能參加今天的課程。
使用者與裝置互動的方式正在發生重大轉變。
在網路時代,使用者通常會先開啟網路瀏覽器,然後使用搜尋功能,開始他們的旅程。
到了行動時代,使用者行為轉變為使用更專業的原生應用程式來完成一次性動作。
範例如下。
朋友傳來訊息,邀請我共進晚餐。
為了回覆這則訊息,我需要查看日曆確認是否有空、將活動新增至日曆、預約交通工具 (如果需要的話),然後回覆朋友說我可以參加。
以前,我必須在裝置上切換不同應用程式,才能完成操作和旅程,也就是所謂的「應用程式跳轉」。
但現在我們進入了智慧時代。
在智慧時代,使用者開始表達高層次的意圖和目標,而非需要完成的具體動作。
他們對裝置的看法有了根本性的轉變,不再認為裝置只是用來執行動作,而是能主動預測需求並協助達成目標。
我們也注意到,他們希望這個智慧架構不只能在行動裝置上運作,還能跨越豐富的板型規格生態系統,包括手錶、汽車、電視、筆電、XR 和眼鏡。
在智慧時代,只要一個跨應用程式提示,就能輕鬆完成前述的晚餐邀約。
只要輸入一則提示,裝置上的代理就能幫我完成所有必要動作,包括查看日曆、新增活動、預約行程和傳送訊息,一氣呵成。
今天,我們將進一步分享 Android 在這方面的計畫,以及開發人員如何打造一流體驗。
Dan。
謝謝,Sarah。
因此,Android 持續在核心中加入智慧功能,讓應用程式能以全新方式迎接智慧時代。
這兩大開發人員智慧支柱中,第一個是應用程式內智慧,顯然是要讓應用程式更聰明,將智慧功能帶入應用程式。您可以透過 ML Kit 使用 Gemini Nano 4 等裝置端模型,總結訊息串、重寫回覆、理解圖片,或使用自然語言呼叫應用程式的功能。
您甚至可以透過 Firebase AI Logic,直接在應用程式中使用雲端 Gemini 模型。
不過,我們今天的重點是第二個要素,也就是確保應用程式的功能可在智慧系統中使用。
那麼,什麼是智慧系統?
因此,除了 Sarah 剛才提到的提示體驗外,這個系統還能深度整合,提升整個應用程式生態系統的體驗。
因此,專為 Gemini Intelligence 設計的裝置會利用這些整合點,提供建議、摘要通知、改良的語音輸入,以及自動填寫表單等功能。
現在,建議功能已整合至「主動協助」功能。
到目前為止,我們討論的都是提示輔助。
我們都習慣使用者指示代理執行一或多項工作。
但主動協助功能實際上是預測使用者可能採取的下一個或多個動作。
回到晚餐的例子,代理可能會主動提示使用者,根據訊息內容提供建議,例如將邀請加入日曆或預約共乘服務。
但系統實際上如何提示使用者?
主動協助功能可在螢幕鎖定畫面、通知、虛擬鍵盤,甚至所選應用程式中顯示動作。
那麼,如何讓應用程式實際出現在這些主動式方塊中,吸引使用者造訪呢?
您已經在根據最新 SDK 版本建構應用程式,並使用我們最先進的工具,但現在最重要的是確保應用程式也準備好提供提示協助,Sarah 接下來會說明這點。
謝謝,Dan。
好的,提示式協助是指使用者直接向代理提出要求,請代理啟動及協調工作,甚至跨應用程式執行。
這項功能可自動執行原先需要手動完成且耗時的工作流程。
Android 智慧系統主要著重於 Gemini 等系統代理,可存取使用者裝置上的應用程式。
應用程式可以透過幾種不同方式,顯示在這項體驗中,並與系統代理互動。
一種是平台導向的工作自動化解決方案,另一種則是直接將 API 整合至系統代理。
我們一律建議盡可能使用直接 API 整合功能進行建構。
這些控制項由開發人員控管,因此您可以決定及控管應用程式和品牌的實際顯示方式。
此外,這類廣告的速度更快、更可靠,可為使用者提供更優質的體驗。
如要與代理整合,可以考慮使用兩種 API 路徑。
Model Context Protocol (簡稱 MCP) 是伺服器端呼叫函式的業界最佳做法。
不過,針對裝置端工具呼叫,我們正式推出 Android MCP,這是 Android 上的統一函式呼叫架構。
Android MCP 採用 Jetpack AppFunctions 程式庫,在技術和概念上都與 MCP 密切相關,同時支援許多 Android 開發人員熟悉的 Kotlin 實作方式。
因此,實作 API 相當直覺且容易使用,您也可以運用從已建立的伺服器端 MCP 整合中學到的任何知識。
許多 MCP 原始物件與 Jetpack AppFunctions 建構函式一對一對應。
以下舉幾個例子說明。
MCP 工具端點可完美對應至加上 AppFunctionDeclaration 註解的 Kotlin 函式。
pydantic 模型會對應至以 AppFunctionSerializable 註解的 Kotlin 資料類別。
不過,Android MCP 是在最佳化的 Android Binder IPC 上執行,而非串流 HTTP 和/或標準 I/O 傳輸。
今天,我們很高興宣布 Jetpack 程式庫 AppFunctions 已推出 Beta 版。
好,我想你可能想知道,何時該使用單一 API 路徑,何時該使用 Android MCP。
整體來說,如果想運用裝置端或本機情境完成使用者歷程,Android MCP 特別實用。
接著來看看 Android MCP 的實際應用範例。
具體來說,如需存取本機加密資料,建議使用 Android MCP。
這對訊息和通訊應用程式來說尤其重要,因為訊息記錄必須透過端對端加密,保留在裝置端。
Android MCP 也適用於純裝置端和離線動作,代理確實支援這類動作。
我們的 Wear OS 合作夥伴 Samsung Health 推出了一項功能,可啟動跑步和運動,完全在裝置端進行。
使用者通常希望這類動作能快速完成、高度可靠、低延遲,有時甚至在低連線能力環境中也能執行。確保整個工具呼叫和查詢轉送都能在裝置端進行,就能確保這一點。
如要存取裝置感應器或直接與裝置硬體互動,Android MCP 也是首選 API 路徑。
舉例來說,許多汽車應用程式都希望使用者能透過 UWB 連線,立即解鎖車門,而且通常不必動手。
Android MCP 就能派上用場。
最後,如果應用程式或 API 沒有可用的 Cloud Endpoints,這就是您的解決方案。
許多裝置製造商的第一方應用程式僅限用戶端使用,因此他們會使用 Android MCP。
舉例來說,Gemini 可用於搜尋及擷取僅限用戶端的相片庫應用程式 (例如小米相簿) 中的相片。
我可以在這裡開啟 Gemini,然後說:「找出我上週用小米相簿拍攝的 Android 機器人相片。」
小米公開了搜尋相片的應用程式函式,Gemini 隨後呼叫該函式,相關相片就會直接傳回並顯示在 Gemini 應用程式 UI 中,使用者完全不必離開。
Android 作業系統也採用相同的架構,執行裝置端動作。
Android MCP 支援最近推出的「裝置說明」功能,可將 Android 作業系統直接整合至 Gemini 應用程式。
這項功能已在 Pixel 和 Lenovo 裝置上推出,目前正逐步水平擴展至更廣泛的 Android OEM 生態系統。
使用者可以前往 Gemini,詢問如何調整設定、裝置設定和系統設定,以提升裝置體驗。
舉例來說,色盲使用者不必在各種選單中尋找,只要問問 Gemini:「我是色盲,
如何讓這部手機更方便我使用?
Gemini 會呼叫並叫用本機應用程式功能,並透過要求進行智慧推論,並建議使用者變更系統設定的實際方式,以及變更裝置設定的特定方式,以改善體驗。
接著,應用程式內控制項會直接顯示在 Gemini 應用程式 UI 中,方便使用者立即控制。
舉例來說,為了滿足色盲最佳化要求,Gemini 實際上會顯示這個色彩校正切換鈕,讓使用者能即時變更。
Android MCP 體驗已在特定裝置上推出,我們也與多位合作夥伴攜手,為 Android 生態系統帶來更多用途。
雖然明確宣告 Android MCP 工具是我們的黃金標準,但我們知道使用者有時會要求代理完成複雜或不常見的用途,這時平台驅動的工作自動化功能就能派上用場。
在這個模式下,OEM 預先載入的代理程式可以使用 Android 的電腦控制 API,模擬應用程式介面上的觸控事件。
開發人員不需要變更程式碼,代理程式會分析應用程式畫面,像使用者一樣輕觸 UI。
為確保使用者能控管和安全,代理必須在可能涉及敏感操作 (例如付款流程) 時暫停,並要求使用者確認後再繼續。
一起來看看實際範例。
使用者拍了一張咖啡和巧克力之旅的相片,想請 Gemini 查詢 Expedia,看看能否找到類似行程,但將人數調整為六人。
Gemini 不需直接整合 API,就能在背景操作 Expedia 的使用者介面,就像使用者一樣。
接著,系統會顯示找到的相關選項,並在需要最終確認和預訂時,順暢地交由使用者處理。
請注意,這三種做法並不互斥,搭配使用效果更佳。
使用者和開發人員應預期代理程式能協調伺服器實作和本機 Android 應用程式的工具,為使用者提供最全面、最完整的體驗。
現在我們來做個小測驗
謝謝,Sarah。
我們將深入探討 Android MCP。
另外,我想讓您知道,我會盡可能從 Jetpacker 提取 MCP 程式碼片段。Jetpacker 是我們的旅遊範例應用程式,實際上是使用 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 插入依附元件,插入 AppFunctions 可用的資料存放區。
@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)首先,請著重於名稱清楚明確的最小參數子集。
將非必要參數設為可為空值,並在函式主體中解析其預設值。
這不只是最佳做法,
這完全行不通,因為 AppFunctions 程式庫會為省略的可為空值參數傳遞空值,因此系統會忽略 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,讓我們回到架構圖。
代理用戶端應用程式向平台註冊,以便呼叫 AppFunction。
例如 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
}因此,您要宣告 AppFunctionSignature,而非 AppFunction。
接著,您可以在 UI 中為 AppFunction 實作處理常式。
val addToCartAppFunctionAdapter =
appFunctionManager.getHandleAppFunctionRequestAdapter<AddCurrentItemToCart>()
coroutineScope.launch {
appFunctionManager.handleAppFunction(
addToCartAppFunctionAdapter.adapt { quantity ->
val newCartSize = cart.addItem(quantity)
newCartSize
}
)
}最棒的是,處理常式的生命週期會自動與您使用的協同程式範圍生命週期連結。
現在,您需要快取這個 AppFunctions 要求轉接器 (抱歉,是處理 AppFunctions 要求轉接器),因為程式庫會在背景使用反射來例項化。
此外,您也可以建立限定範圍的 AppFunction,其註冊作業不會直接與 Android 生命週期連結。
最後,我們還有一個稱為存取層級的概念。
@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,並將其納入統一的工具呼叫架構。
只要定義一次,就能涵蓋系統層級代理和本機應用程式內模型,避免工具宣告分散。
如要瞭解詳情,請觀看「Unlock agentic capabilities in your app with ML Kit GenAI」這場演講,或直接查看 Jetpack 的最新內容。
這項功能很酷。
好的,回到 Sarah 的例子。
現在我們來做個小測驗
剛才 Dan 說明瞭 Android MCP 的運作方式。
現在就來付諸實行。
請記住,您的應用程式是伺服器,而代理程式是用戶端,因此目標應該是確保應用程式是適合他們的伺服器。
我們將以旅遊範例應用程式 Jetpacker 為例,從第一個工具宣告開始,逐步介紹如何透過 Gemini 實際呼叫函式。
您可以手動編寫 AppFunctions,也可以使用程式設計代理協助您開始。
我們推出了 AppFunctions 技能,可協助您開始編寫函式,並透過 Android CLI 連結至 Android Studio,或與您選擇的程式設計代理搭配使用。
android skills add appfunctions這項技能的運作方式分為三個步驟。
首先,系統會分析程式碼集,找出可做為實用工具的核心使用者歷程,例如播放媒體、建立記事或傳送訊息。
接著,系統會生成 AppFunction 宣告,公開這些歷程,並提供所需的實作和平台支援。
例如 Gradle 和 KSP、應用程式中繼資料 XML,以及依附元件插入。
最後,代理會根據 KDoc 說明,決定呼叫函式的時機和方式,因此系統會修正 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))
}說明是一行句子,開頭是動詞和資源:為行程新增費用。
行程和類別欄位可為空值,因為使用者不一定會指定這些欄位,但函式會在主體中解析這些欄位。
如果要求失敗,則會擲回 AppFunctionException,讓代理知道發生什麼問題。
接著來看看測試。
函式現在是代理可使用的服務。
因此,您實際上需要測試兩個部分:函式的行為和代理的行為。
首先,我們會使用您可能已編寫的單元測試,但請注意,當代理實際執行函式呼叫時,有幾點需要留意。
首先,Kotlin 預設值不適用於此處。
省略的參數會以空值、false 或 0 的形式傳入。
因此,先前預設為 10 的限制在這裡實際上會是 0。
呼叫也會在主要執行緒上啟動,而從測試呼叫函式不一定會讓這一點變得明顯,因此您應該在背景調度工具上執行標準 IO。
如果發生錯誤,請擲回 AppFunctionException。
其他任何錯誤都會以一般不明錯誤的形式傳送至代理,並附上原始例外狀況訊息。
輸入內容會來自模型,因此請務必測試極端案例,例如空白、美元的同義詞 (如 USD) 和超出範圍的值。
在代理方面,您需要瞭解代理是否在適當的時機選擇工具,並正確執行函式。
每個代理的行為都略有不同,因此請務必使用預期會使用函式的代理測試函式。
您可以使用我們的開放原始碼範例代理,或 Gemini 測試這項整合功能。
如要瞭解 Gemini 如何與 AppFunctions 互動,或想在 Gemini 應用程式中啟動 Android MCP,可以使用我們今天稍早推出的連結的應用程式平台,目前為預先發布版。
如要瞭解詳情,請查看我們先前的演講,瞭解如何透過連結的應用程式平台,將應用程式擴充至 Gemini 和 Google 搜尋。
加入搶先體驗計畫後,只要兩個步驟就能測試 Gemini。
首先,在 Google Connected Console 中,建立 Gemini 可實際使用的連結應用程式。
接著,我們需要在 Play 管理中心上傳應用程式套件,以便存取 AppFunctions,並啟動內部測試群組,讓測試人員實際驗證函式。
首先,請前往 Google Connected Console。
請點選右上方的「建立應用程式」按鈕,建立連結的應用程式。
建立連結的應用程式時,請在這個畫面選擇 Android MCP。
如果您要建構伺服器端 MCP 實作項目,請在這裡上傳伺服器程式碼。
點選「設定」後,與開發人員帳戶相關聯的 Google Play 應用程式會顯示在這裡,方便您連結至已連結的應用程式。
如果尚未建立 Google Play 應用程式,請先在 Play 管理中心建立。
這裡已有 Jetpacker,因此我們會繼續連結。
接著,新增使用者在 Gemini 前端與應用程式互動時,會看到的詳細資料。
包括應用程式圖示、顯示名稱、連結應用程式的功能,以及使用者實際互動時可用的範例提示詞。
這些文字應具描述性,才能有效引導使用者。
好的,連結應用程式設定到此結束,但您可能會想知道連結應用程式實際上如何取得函式存取權。
由於函式包含在應用程式程式碼中,我們現在需要前往 Play 管理中心上傳應用程式套件並建立測試群組,才能實際與使用者一起測試。
因此,我們在這個畫面實際操作上傳應用程式套件,然後就會看到 Play 偵測到 AppFunctions,您可以直接將這些函式連結至連結的應用程式。
現在,我們可以看到 Jetpack 連結的應用程式已整合您的函式。
接下來,我們需要新增測試人員,確保有人能實際驗證函式,並確認函式運作正常。
請點選這裡,查看已連結的應用程式版本。
在這個畫面中,您可以選取「管理測試人員」,並設定有限的測試人員名單,讓他們在 Gemini 中存取連結的應用程式。
您可以將測試人員新增至這個測試群組,方法與其他版本相同。
請確認連結應用程式和 Google Play 應用程式的測試人員名單相同。
接著,我們來測試整合功能。
這是使用者手機上的 Jetpacker 應用程式,其中已記錄這場高峰會的行程。
我會開啟 Gemini 連結應用程式。
我需要確認裝置上安裝的應用程式版本,與先前上傳到 Play 的版本相同,而且我們應該使用已加入測試人員名單的帳戶來存取 Gemini。
因此,我會在這裡提及 Jetpacker,並詢問山景城飯店房間的確認號碼。
Gemini 呼叫裝置上的 Jetpacker AppFunctions,並回答「RED11223 at the Redwood Grove Hotel」。
接著,我會問:「我還有多少預算可以投入 ADS?」
函式呼叫會傳回 $1850。
現在我想做點調整,因此請 Gemini 在 ADS 行程中,加入 $45 美元的機場 Uber 費用。
Gemini 呼叫我們先前的 addExpense 函式,直接將這項活動加入行程。
當我開啟 Jetpacker 時,這筆費用已列入,預算也相應減少。
如果想立即開始為 Gemini 建構連結的應用程式,請申請存取連結的應用程式平台 (目前為預先發布版)。
現在我們來做個小測驗
現在來總結一下。
因此,我們正處於行動運算技術大幅轉變的初期,現在正是確保應用程式做好準備的時機。
因此,請先找出應用程式的核心高價值使用者歷程。
使用伺服器端 MCP 公開雲端後端,並透過 Android MCP 處理端對端加密資料、本機作業、裝置感應器和其他裝置端項目。
如要立即設計原型,可以下載 Android Studio 和 Android CLI。
您可以將 AndroidX AppFunctions Beta 版依附元件新增至建構檔案,並在本機測試第一個裝置端函式。
如要搶先使用 Gemini 投入正式環境,請掃描這個 QR code 報名。
當然,請務必觀看這些相關講座,瞭解如何使用連結的應用程式平台,以及如何搭配 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))
}