智慧型應用程式

將應用程式與 Android 智慧系統整合

喇叭

關於

講座簡介

Android 正在演變成智慧型系統,由 Gemini 等代理協調各應用程式的工作。如要解鎖新的使用者體驗,並參與這些代理工作流程,應用程式必須公開核心功能。

本課程提供實用指南,說明如何將應用程式與 Android 的智慧層整合。我們會探討主要的整合路徑,包括裝置端 Android MCP、伺服器端 MCP 和工作自動化,並提供決策架構,協助您根據用途選擇合適的路徑。我們也會探討如何運用個人化建議,主動提供實用資訊。

您將學習下列內容:

  • 請根據應用程式需求,選取最佳整合路徑。
  • 同時導入被動和主動智慧功能。
  • 在代理整合中平衡效能與安全性。

摘要

  • Android MCP 是採用 Jetpack AppFunctions 程式庫的統一裝置端工具呼叫架構。它會將 MCP 元件對應至 Kotlin 建構,並在 Android Binder IPC 上執行。
  • 裝置端工具在存取加密資料或裝置感應器時,表現相當出色。或者,您也可以使用平台驅動的工作自動化功能,讓預先載入的代理模擬觸控事件,不必修改程式碼。
  • 應用程式會做為 Android MCP 伺服器,代理則會探索及呼叫工具。使用 AppFunctionDeclaration 宣告工具、提供 KDoc 說明,並避免依賴 Kotlin 預設值。
  • 連結的應用程式平台目前處於預先發布版階段,可讓您使用 Gemini 測試 AppFunctions。在 Google Connected Console 設定連結的應用程式,然後在 Play 管理中心上傳套件。

資源

Gemini Notebook

主題

  • android-mcp
  • appfunctions
  • gemini
  • connected-apps
  • model-context-protocol
  • on-device-ai

轉錄稿

智慧時代簡介

  • 現在我們來做個小測驗

  • 大家好。

  • 我是開發人員關係團隊的 Dan Galpin。

  • 我是 Sarah Shamsie,Android 開發人員智慧架構的產品經理。

  • 很高興各位能參加今天的課程。

  • 使用者與裝置互動的方式正在發生重大轉變。

  • 在網路時代,使用者通常會先開啟網路瀏覽器,然後使用搜尋功能,開始他們的旅程。

  • 到了行動時代,使用者行為轉變為使用更專業的原生應用程式來完成一次性動作。

  • 範例如下。

  • 朋友傳來訊息,邀請我共進晚餐。

  • 為了回覆這則訊息,我需要查看日曆確認是否有空、將活動新增至日曆、預約交通工具 (如果需要的話),然後回覆朋友說我可以參加。

  • 以前,我必須在裝置上切換不同應用程式,才能完成操作和旅程,也就是所謂的「應用程式跳轉」。

  • 但現在我們進入了智慧時代。

  • 在智慧時代,使用者開始表達高層次的意圖和目標,而非需要完成的具體動作。

  • 他們對裝置的看法有了根本性的轉變,不再認為裝置只是用來執行動作,而是能主動預測需求並協助達成目標。

  • 我們也注意到,他們希望這個智慧架構不只能在行動裝置上運作,還能跨越豐富的板型規格生態系統,包括手錶、汽車、電視、筆電、XR 和眼鏡。

  • 在智慧時代,只要一個跨應用程式提示,就能輕鬆完成前述的晚餐邀約。

  • 只要輸入一則提示,裝置上的代理就能幫我完成所有必要動作,包括查看日曆、新增活動、預約行程和傳送訊息,一氣呵成。

  • 今天,我們將進一步分享 Android 在這方面的計畫,以及開發人員如何打造一流體驗。

  • Dan。

  • 謝謝,Sarah。

  • 因此,Android 持續在核心中加入智慧功能,讓應用程式能以全新方式迎接智慧時代。

  • 這兩大開發人員智慧支柱中,第一個是應用程式內智慧,顯然是要讓應用程式更聰明,將智慧功能帶入應用程式。您可以透過 ML Kit 使用 Gemini Nano 4 等裝置端模型,總結訊息串、重寫回覆、理解圖片,或使用自然語言呼叫應用程式的功能。

  • 您甚至可以透過 Firebase AI Logic,直接在應用程式中使用雲端 Gemini 模型。

  • 不過,我們今天的重點是第二個要素,也就是確保應用程式的功能可在智慧系統中使用。

  • 那麼,什麼是智慧系統?

  • 因此,除了 Sarah 剛才提到的提示體驗外,這個系統還能深度整合,提升整個應用程式生態系統的體驗。

  • 因此,專為 Gemini Intelligence 設計的裝置會利用這些整合點,提供建議、摘要通知、改良的語音輸入,以及自動填寫表單等功能。

  • 現在,建議功能已整合至「主動協助」功能。

  • 到目前為止,我們討論的都是提示輔助。

  • 我們都習慣使用者指示代理執行一或多項工作。

  • 但主動協助功能實際上是預測使用者可能採取的下一個或多個動作。

  • 回到晚餐的例子,代理可能會主動提示使用者,根據訊息內容提供建議,例如將邀請加入日曆或預約共乘服務。

  • 但系統實際上如何提示使用者?

  • 主動協助功能可在螢幕鎖定畫面、通知、虛擬鍵盤,甚至所選應用程式中顯示動作。

  • 那麼,如何讓應用程式實際出現在這些主動式方塊中,吸引使用者造訪呢?

  • 您已經在根據最新 SDK 版本建構應用程式,並使用我們最先進的工具,但現在最重要的是確保應用程式也準備好提供提示協助,Sarah 接下來會說明這點。

提示輔助和 Android MCP

  • 謝謝,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 版。

Android MCP 的主要用途

  • 好,我想你可能想知道,何時該使用單一 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 應用程式的工具,為使用者提供最全面、最完整的體驗。

深入探討 Android MCP 技術

  • 現在我們來做個小測驗

  • 謝謝,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

  • 如要瞭解 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 應用程式的測試人員名單相同。

Gemini 應用程式整合功能示範

  • 接著,我們來測試整合功能。

  • 這是使用者手機上的 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 打造的優質體驗。

程式碼

程式碼片段

「Technical deep dive into Android MCP」影片 11:56 處的 Kotlin 指令碼片段
implementation(libs.androidx.appfunctions)
ksp(libs.androidx.appfunctions.compiler)
「Technical deep dive into Android MCP」影片 12:14 處的 Kotlin 程式碼片段
@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
    ...
「Technical deep dive into Android MCP」影片 12:29 處的 Kotlin 程式碼片段
/** 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,
)
「Technical deep dive into Android MCP」影片 12:38 處的 Kotlin 程式碼片段
@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) }
  }
}
「深入探討 Android MCP 技術」影片 12:44 處的 Kotlin 程式碼片段
/**
 * 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()
...
「Technical deep dive into Android MCP」影片 12:52 處的 Kotlin 程式碼片段
/**
 * @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)
「Technical deep dive into Android MCP」影片 13:30 處的 Kotlin 程式碼片段
@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 }
        ...
「Technical deep dive into Android MCP」影片 13:40 處的 Kotlin 程式碼片段
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun send(
    message: Message
): Result {
  ...
  throw AppFunctionException(
    errorCode = AppFunctionException.ErrorCode.ERROR_INVALID_ARGUMENT,
    errorMessage = "To address has invalid syntax!"
  )
}
「Technical deep dive into Android MCP」影片 14:15 處的 XML 程式碼片段
<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" />
「Technical deep dive into Android MCP」影片 15:00 處的 Kotlin 程式碼片段
viewModelScope.launch {
    val newState =
        if (enabled) {
            AppFunctionManager.APP_FUNCTION_STATE_ENABLED
        } else {
            AppFunctionManager.APP_FUNCTION_STATE_DISABLED
        }
    appFunctionManager?.setAppFunctionEnabled(id, newState)
}
「Technical deep dive into Android MCP」影片 15:12 處的 Kotlin 程式碼片段
@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
}
「Technical deep dive into Android MCP」影片 15:22 處的 Kotlin 程式碼片段
val addToCartAppFunctionAdapter =
    appFunctionManager.getHandleAppFunctionRequestAdapter<AddCurrentItemToCart>()

coroutineScope.launch {
    appFunctionManager.handleAppFunction(
        addToCartAppFunctionAdapter.adapt { quantity ->
            val newCartSize = cart.addItem(quantity)
            newCartSize
        }
    )
}
「Technical deep dive into Android MCP」影片 15:50 處的 Kotlin 程式碼片段
@AppFunctionDeclaration(isDescribedByKDoc = true)
@AppFunctionAccessLevel(
    level = AppFunctionMetadata.ACCESS_LEVEL_SELF,
    isCompatEnforcementEnabled = true,
)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
  return withContext(Dispatchers.IO) {
  ...
「Technical deep dive into Android MCP」影片 17:05 處的殼層程式碼片段
android skills add appfunctions
「Technical deep dive into Android MCP」影片 17:54 處的 Kotlin 程式碼片段
/**
 * 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))
}