Google 产品经理
Android 正在演变为一个智能系统,其中 Gemini 等智能体可以跨应用编排任务。为了解锁新的用户体验并参与这些智能体工作流,您的应用必须公开其核心功能。
本讲座是一份实用指南,旨在帮助您将应用与 Android 的智能层集成。我们将探讨关键集成路径,包括设备端 Android MCP、服务器端 MCP 和任务自动化,并提供一个决策框架,帮助您根据具体用例选择合适的路径。我们还将探索“个人使用场景”功能带来的主动式体验。
您将学习如何:
AppFunctionDeclaration 声明工具,提供 KDoc 说明,并避免依赖 Kotlin 默认值。转写文稿
好了
大家好
我是开发者关系团队的 Dan Galpin。
我是 Sarah Shamsie,负责 Android 开发者智能框架的产品经理。
非常高兴您能参加今天的课程。
我们正在见证用户与设备互动方式的范式转变。
在网络时代,用户通常会先打开网络浏览器,然后使用搜索功能,以此开启他们的旅程。
在移动时代,用户行为发生了转变,他们开始使用更专业的原生应用来完成一次性操作。
示例如下:
我收到了一条朋友发来的消息,邀请我共进晚餐。
为了回复这条消息,我需要查看日历,看看自己是否有空,将活动添加到日历中,如果需要交通工具,还要预订行程,然后回复朋友说我可以参加。
以前,这需要所谓的“应用跳转”,即我基本上必须在设备上的不同应用之间切换,才能真正完成操作并完成我的旅程。
但现在,我们已经进入了智能时代。
在智能时代,用户在开始使用时会表达高层次的意图和目标,而不是他们需要完成的具体操作。
用户对设备的看法发生了根本性转变,他们不再认为设备只是一个执行操作的工具,而是认为设备能够主动预测他们的需求并帮助他们实现目标。
我们注意到,他们希望这个智能框架不仅能在移动设备上运行,还能在丰富多样的设备形态生态系统中运行,包括手表、汽车、电视、笔记本电脑、XR 和眼镜。
在智能时代,一个跨应用提示就能轻松搞定前面提到的晚餐邀请。
只需一条提示,我设备上的智能体就能帮我完成所需的所有操作,包括查看日历、添加活动、预约行程和发送消息,一气呵成。
今天,我们将详细介绍 Android 在这一领域的计划,以及作为开发者,您如何打造一流的体验。
Dan。
谢谢,Sarah。
因此,Android 正在持续增强其核心智能,为您的应用提供新的方式,让它们能够更好地适应这个智能时代。
这两个开发者智能支柱中的第一个是应用内智能,这显然是为了让您的应用更智能,将智能引入您的应用,您可以通过机器学习套件使用 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 应用界面中,因此用户无需离开 Gemini 应用。
我们还依靠完全相同的架构在 Android OS 中实现设备端操作。
Android MCP 为最近推出的“设备帮助”功能提供支持,该功能将 Android OS 直接集成到 Gemini 应用中。
此功能已在 Pixel 和联想设备上推出,目前正在更广泛的 Android OEM 生态系统中推广。
用户可以前往 Gemini,询问如何优化设置、设备设置和系统设置,以提升设备体验,从而使用“设备帮助”功能。
例如,色盲用户无需在各种菜单中苦苦搜寻,只需问问 Gemini:“我是色盲。
怎样才能让这部手机更适合我使用?”
Gemini 会调用并执行本地应用功能,通过智能推理分析请求,并建议用户如何更改系统设置,以及如何更改设备设置来提升体验。
然后,应用内控件会直接显示在 Gemini 应用界面中,以便用户立即进行控制。
例如,在满足此色盲优化请求时,Gemini 实际上会显示此色彩校正切换开关,以便用户可以随时进行更改。
Android MCP 体验已在部分设备上推出,我们正在与多个合作伙伴合作,为 Android 生态系统带来更多应用场景。
虽然明确的 Android MCP 工具声明是我们的黄金标准,但我们知道,在某些情况下,用户会要求智能体完成复杂或不太常见的用例,而这正是我们平台驱动的任务自动化功能的用武之地。
在此模式下,OEM 预装的代理可以使用 Android 的计算机控制 API 在应用界面上模拟触摸事件。
这不需要开发者更改任何代码,而是需要智能体通过分析应用界面,像用户一样在界面中点按。
为确保用户控制权和安全性,智能体在执行可能敏感的操作(例如支付流程)时,必须暂停并请求用户确认后才能继续。
我们来看一个实际示例。
在这里,用户拍摄了一张咖啡和巧克力之旅的照片,他们想问问 Gemini 在 Expedia 上查找类似的行程,但将参加者人数调整为 6 人。
无需直接集成 API,Gemini 即可在后台像用户一样浏览 Expedia 的界面。
然后,它会显示找到的相关选项,并在需要最终确认和预订时无缝移交给用户。
请务必注意,这三种不同的路径并非互斥,实际上,它们相辅相成。
用户和开发者应该期待智能体能够协调不同服务器实现中的工具,并与本地 Android 应用协同工作,从而为用户提供最全面、最完整的体验。
好了
谢谢,Sarah。
我们将深入探究 Android MCP。
另外,我想让大家知道,我尽可能从 Jetpacker 中提取 MCP 代码段,Jetpacker 是我们的旅行示例应用,实际上是在 Antigravity 的帮助下构建的。
您在主题演讲中已经看到过。
它搭载了各种炫酷的智能功能,包括与机器学习套件生成式 AI 和机器学习套件语音识别 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)首先,您需要专注于具有清晰、独特名称的最小参数子集。
将非必需参数设置为可为 null,并在函数正文中解析其默认值。
这不仅仅是一项最佳实践。
这确实行不通,因为 AppFunctions 库会为省略的可为 null 的参数传递 null,从而导致 Kotlin 默认值被忽略。
我甚至不会展示第一种情况的示例,但请不要使用包罗万象的参数。
像完整上下文或对话记录这样的功能,效果都不好。
它们可能会导致智能体传递大量数据并导致数据渗漏,这是一种不好的做法。
由于 AppFunction 默认在界面线程上执行,因此您需要选择适当的协程调度程序,确保所有长时间运行的操作都在后台执行。
@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,这一点就非常重要。
您还可以创建限定于应用中特定屏幕或 activity 的 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。
然后,您可以在界面中为 AppFunction 实现处理程序。
val addToCartAppFunctionAdapter =
appFunctionManager.getHandleAppFunctionRequestAdapter<AddCurrentItemToCart>()
coroutineScope.launch {
appFunctionManager.handleAppFunction(
addToCartAppFunctionAdapter.adapt { quantity ->
val newCartSize = cart.addItem(quantity)
newCartSize
}
)
}这种方法的优点在于,处理程序的生命周期会自动与您使用的协程作用域的生命周期相关联。
现在,您需要缓存此 AppFunctions 请求适配器,抱歉,是 handle 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”访问权限级别,这分别表示哪些函数仅供您自己的应用内代理(例如使用机器学习套件 生成式 AI 构建的代理)使用、哪些函数仅供系统使用,以及哪些函数可供受信任的系统代理使用。
为了帮助您构建自己的应用内智能体,我们之前已经宣布,您也看到了,机器学习套件 原生支持 AppFunctions,并将其作为统一工具调用框架的一部分。
您只需定义一次,即可同时覆盖系统级智能体和本地应用内模型,从而避免工具声明碎片化等问题。
如需了解详情,您可以观看这个演讲:借助机器学习套件 生成式 AI 在应用中解锁智能体功能,也可以直接去看看 Jetpack 的最新内容。
这很酷。
好了,我们回到 Sarah 的例子。
好了
刚才 Dan 介绍了 Android MCP 的底层工作原理。
我们来实际操作一下。
请记住,您的应用是服务器,而代理是客户端,因此目标应该是确保您的应用是适合它们的服务器。
我们将以旅行示例应用 Jetpacker 为例,从第一个工具声明开始,逐步演示如何通过 Gemini 执行实际的函数调用。
您可以手动编写 AppFunctions,也可以使用编码智能体来帮助您入门。
我们发布了 AppFunctions 技能,可帮助您开始编写函数,您可以通过 Android CLI 将这些函数关联到 Android Studio,也可以将其与您选择的编码智能体搭配使用。
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))
}说明是一句以动词和资源开头的句子:向行程添加费用。
trip 和 category 字段可为 null,因为用户可能并不总是指定这些字段,但函数会在正文中解析它们。
如果请求无法成功,它会抛出 AppFunctionException,以便智能体了解出了什么问题。
现在,我们来看看测试。
您的函数现在是智能体可以使用的服务。
因此,您实际上需要测试两个部分:函数的行为方式和智能体的行为方式。
对于您的函数,我们首先会使用您可能已经编写的单元测试,但当智能体实际执行函数调用时,您需要注意以下几点。
首先,Kotlin 默认值不适用于此处。
省略的参数会以 null、false 或 0 的形式到达。
因此,之前默认值为 10 的限制在此处实际上将为 0。
调用也会在主线程上启动,而从测试中调用函数可能并不总是那么明显,因此您应该在后台调度程序上运行标准 IO。
当出现故障时,抛出 AppFunctionException。
其他任何错误都会以一般未知错误的形式传达给您的代理,并附带原始异常消息。
而您的输入将来自模型,因此您一定要测试边缘情况,例如空白、美元的同义词(如 USD)以及超出范围的值。
对于智能体端,您需要了解智能体是否在适当的时间选择了您的工具,以及它是否正确执行了函数。
每个智能体的行为都略有不同,因此请务必使用您希望使用这些函数的任何智能体来测试这些函数。
您可以使用我们的开源示例代理进行测试,也可以使用 Gemini 测试此集成。
如果您想了解 Gemini 如何与 AppFunctions 互动,或者想在 Gemini 应用中发布 Android MCP,可以使用我们今天早些时候发布的 Connected Apps Platform,该平台目前处于预览阶段。
如需了解详情,请观看我们之前的演讲,了解如何通过 Connected Apps Platform 将应用扩展到 Gemini 和 Google 搜索。
加入预览计划后,只需两个步骤即可使用 Gemini 进行测试。
首先,在 Google 一站式管理中心内,我们将创建一个 Gemini 能够实际使用的关联应用。
其次,在 Play 管理中心内,我们需要上传您的 app bundle,以便访问 AppFunctions,并且您可以启动内部测试轨道,以便测试人员实际验证这些功能。
我们先从 Google 一站式管理中心开始。
在这里,您将使用右上角的“创建应用”按钮创建一个关联的应用。
创建关联的应用时,请在此屏幕上选择“Android MCP”。
如果您要构建服务器端 MCP 实现,则需要在此处上传服务器代码。
点击“设置”后,与您的开发者账号关联的所有 Google Play 应用都会显示在此处,供您关联到已关联的应用。
如果您还没有 Google Play 应用,则需要先在 Play 管理中心内创建一个。
在这里,我们已经有了 Jetpacker,因此我们将继续关联该账号。
接下来,添加用户在 Gemini 前端尝试与您的应用互动时会看到的详细信息。
这包括应用图标、显示名称、关联的应用具备的功能,以及用户可用于实际互动的示例提示词等。
这些名称应具有描述性,以便更好地引导用户。
好了,关联的应用设置到此结束,但您可能想知道关联的应用实际上是如何访问这些函数的。
由于这些函数包含在应用代码中,我们现在需要前往 Play 管理中心,上传 app bundle 并创建测试轨道,以便通过实际用户进行测试。
因此,在此屏幕上,我们实际上会上传 app bundle,然后我们会在此处看到 Google Play 可以检测到 AppFunctions,并且您可以将它们直接关联到关联的应用。
现在,我们可以看到 Jetpack 关联的应用已完成,并且已集成您的函数。
接下来,我们需要添加测试人员,以便有人实际验证您的函数并确保其正常运行。
因此,请点击此处进入关联的应用版本。
在此界面上,您可以选择“管理测试人员”,并配置一个有限的测试人员名单,名单中的人员将能够在关联的应用发布后在 Gemini 中访问该应用。
您可以像为任何其他版本添加测试人员一样,为此测试轨道添加测试人员。
确保关联应用的测试人员名单与 Google Play 应用的测试人员名单相同。
好了,现在我们来测试一下集成。
这是用户手机上的 Jetpacker 应用,其中已经记录了此次峰会的行程。
我来调出 Gemini 关联该应用。
我需要确保设备上安装的应用版本与我们之前上传到 Play 的版本相同,并且我们应该使用已添加到测试人员列表中的 Gemini 账号。
在这里,我会提及 Jetpacker,并询问我在山景城预订的酒店房间的确认号。
Gemini 在设备上调用 Jetpacker 的 AppFunctions,并回答:“RED11223,在红杉林酒店。”
接下来,我会问:“我的 ADS 预算还剩多少?”
函数调用将返回 $1850。
现在,我想做一些调整,所以我会让 Gemini 在我的 ADS 行程中添加 45 美元的机场 Uber 费用。
Gemini 调用我们之前创建的 addExpense 函数,将这些信息直接添加到我的行程中。
当我打开 Jetpacker 时,这笔支出已经记录在案,预算也相应减少了。
如果您想立即开始为 Gemini 构建关联的应用,请申请访问 Connected Apps Platform,该平台目前处于预览阶段。
好了
现在,我们来总结一下。
因此,我们正处于移动计算领域巨大变革的开端,现在正是确保您的应用做好准备的时候。
因此,首先要确定应用的核心高价值用户体验历程。
使用服务器端 MCP 公开云端后端,并使用 Android MCP 处理端到端加密数据、本地操作、设备传感器和其他设备端内容。
现在,如需进行原型设计,您可以下载 Android Studio 和 Android CLI。
您可以将 AndroidX AppFunctions Beta 版依赖项添加到 build 文件中,并在本地测试您的第一个设备端函数。
如需注册以尽早开始使用 Gemini 投入生产,您可以通过此处的二维码进行注册。
当然,请务必观看这些相关演讲,了解如何使用 Connected Apps Platform,以及如何将内部 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))
}