本文译自「AppFunctions: Making Your Android App Discoverable by AI Agents」,原文链接https://proandroiddev.com/appfunctions-making-your-android-app-discoverable-by-ai-agents-fbfbeddf8103,由Ioannis Anifantakis发布于2026年5月25日。
简介
如果你之前没有使用过 模型上下文协议 (MCP),那么一页入门将使接下来的一切变得更加容易。
MCP 是一项开放标准,最初由 Anthropic 于 2024 年末发布,现已被越来越多的人工智能产品采用,它定义了人工智能代理连接到外部工具和数据源的单一方式。该模型很简单:
- MCP 服务器 是一个公开工具列表的小进程。每个工具都有一个名称、一个简单语言描述以及一个描述其参数和返回值的 JSON 架构。
- MCP 客户端(通常是 AI 代理)连接到服务器,询问“你有什么工具?”,阅读说明,并根据用户的请求决定调用哪个工具。
- 它们之间的对话是JSON-RPC,通过stdio(对于本地服务器)或HTTP(对于远程服务器)进行。
在 MCP 之前,每个集成都是定制的。也就是说,每个人工智能产品都有自己的连接工具的方法。借助 MCP,任何兼容 MCP 的代理都可以使用公开“搜索我的电子邮件”的单个服务器,而无需在任何一方进行集成更改。
挑战在于传统的 MCP 服务器在你的应用程序之外**运行,通常作为单独的进程或云服务。他们缺乏对你应用程序状态的特权访问权限。如果你想要一个 MCP 风格的工具来读取笔记应用程序中未发送的草稿,你要么需要通过 API 公开该草稿,要么在应用程序自己的进程中运行 MCP 服务器。这两种选择在移动设备上都不方便。
这就是 AppFunctions 填补的空白。
AppFunction 实际上是什么
基础应用程序是一个小型 Compose 应用程序,它使用 Ktor 从远程源获取笑话并让用户标记收藏夹。它使用 Room 进行离线缓存和本地收藏夹存储以及典型的分层架构:DAO、数据源、存储库、视图模型、屏幕。这里没有什么不寻常的。它与大多数 Android 应用程序的骨架相同。
我故意添加的第一个功能很小:清除所有最喜欢的笑话。可以通过两种方式获得完全相同的功能:
- 用户可以点击顶栏中的菜单项。
- AI 代理可以调用
clearFavoritesAppFunction。
两条路径最终都会调用相同的存储库方法。在我们看代码之前,这是我想告诉你的原则:AppFunction 是普通应用程序逻辑的薄壳,绝不是它的副本。
我们从这一个函数开始,然后在基础知识就位后添加另外两个函数,“getFavorites”和“setFavorite”。
第 1 步——依赖关系
操作系统在安装时需要知道两件事:
- 在哪里读取应用程序的 AppFunction 元数据,
- 当代理想要执行你的某个功能时要绑定哪个服务。
两者都位于“AndroidManifest.xml”中的“
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
每件作品的作用:
<property>元素将操作系统指向app_metadata.xml,其中包含 应用程序级别 描述;这就是这个应用程序的整体用途,因此人工智能代理可以知道每个应用程序的用途。每个函数的描述是一个单独的问题:它们来自每个“@AppFunction”方法的 KDoc。<service>声明公开了PlatformAppFunctionService,它是平台用来调用函数的桥梁。幸运的是,你不是自己编写此服务;它包含在“appfunctions-service”库中。你只需使用正确的权限和意图过滤器在清单中声明它,以便系统可以找到并绑定到它。
“BIND_APP_FUNCTION_SERVICE”权限确保只有平台可以绑定到服务。你不需要请求“EXECUTE_APP_FUNCTIONS”;这是调用者的许可,而不是你的。
第 3 步 — 在“app_metadata.xml”中描述应用程序本身
在我们接触任何 AppFunctions 代码之前,我们通过现有层添加新的业务功能。我之前提出的观点(AppFunction 是正常逻辑之上的一个薄壳) 仅当正常逻辑首先存在时才有效。
DAO(JokesDao.kt):
1 2 | |
本地数据源(LocalJokesDataSource.kt及其实现):
1 2 3 4 5 6 7 8 9 10 | |
存储库(JokesRepository.kt 及其实现):
1 2 3 4 5 6 7 8 9 10 11 12 | |
这里没有任何东西知道或关心 AppFunctions。这只是普通的 Android 架构,这就是重点。
第 5 步——AppFunction 本身
clearFavorites 是一个故意最小化的“hello world”:没有参数,纯字符串返回,除了状态语句之外没有错误表面。
大多数真正的 AppFunction 需要更多,而他们最常需要的三件事正是平台支持的开箱即用的三件事:
- 结构化返回值,
- 输入参数,
- 和输入错误。
同一类上的另外两个函数显示了所有这三个函数。
使用@AppFunctionSerialized返回结构化数据
当你希望代理接收数据时,它可以读取、计数或引用(不仅仅是句子),返回可序列化类型而不是字符串。你用“@AppFunctionSerialized”注释一个普通数据类,并直接返回它(或它的“List”)_。
我们已经有了三个笑话模型:
- 域名“笑话”,
- 网络“JokeDto”,
- 和房间“JokeEntity”。
添加第四个 (an _@AppFunctionSerialized_ class) 看起来多余,但它与其他人遵循的边界模型模式相同。我们无法注释域“Joke”:“@AppFunctionSerialized”存在于“androidx.appfunctions”中,将 Android 依赖项拖到域层会破坏其纯粹性。
因此,AppFunctions 边界有自己的 DTO,就像网络和持久性边界一样。而且,作为奖励,代理看到的模式可以独立于域进行塑造(请注意,此 DTO 删除了“isFavorite”,这在收藏夹列表中始终为真,并且对模型来说只是噪音)。转换是一个映射器,类似于现有的 toJoke()/toEntity() 扩展:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
关于可序列化上的 KDoc,有两个不明显的事情值得大声说出来,因为两者都“默默地”——它们编译得很好,只有在运行时、在代理内部表现不佳。
首先,KSP 看起来在哪里:
KSP 仅提取直接在每个属性上方内联编写的 KDoc,特别是用于
**_@AppFunctionSerialized_**classes.如果你使用类级别
_@param_或_@property_标记来记录属性,KSP 不会提取这些属性的任何内容,并且该函数在运行时显示为“元数据丢失”/“AppFunction 不可用”。将文档放在属性上,而不是放在类标头中。
这是本文中两种文档风格真正不同的地方,值得确定,因为重用“@param”的本能是如此强烈。 @param 用法是在函数中使用的方式,但不是在数据类构造函数中使用的方式。
第二,也是容易错过的:
类级 KDoc 摘要成为该类型面向代理的描述。 使用“isDescribedByKDoc = true”,你在类上方的 KDoc 块中编写的任何内容都会作为该类型的描述发送到生成的模式中,就像函数的 KDoc 摘要成为函数描述一样。
任何为 AppFunctions 注释的 KDoc 都是面向代理的副本。 在常规注释中保留仅限开发人员的基本原理(KSP 会忽略这一点),并使 KDoc 本身保持干净、具体的描述。
同样的陷阱解释了上面函数 KDoc 中的一个小而真实的细节: 它用普通引号表示“setFavorite”,而不是 KDoc 链接“[setFavorite]”。 KDoc 链接括号在进入架构的过程中未得到解析。它们会逐字保存,因此代理会读取原始的“[setFavorite]”(更糟糕的是,还会读取“[AppFunctionJoke.id]”等内部符号路径)。简单地引用名字;保存人们在 IDE 中阅读的 KDoc 的“[…]”链接。
关于支持的类型的注释,因为一旦你将“String”抛在后面,你就会立即使用它们。从这个 alpha 版本开始,@AppFunction 参数或返回值可以是: 原语 (Int、Long、Float、Double、Boolean);原始数组(IntArray、LongArray、FloatArray、DoubleArray、BooleanArray);本机类型(“String”、“PendingIntent”、“Uri”、“LocalTime”、“LocalDate”、“LocalDateTime”、“Instant”)、“@AppFunctionSerialized”对象;或任何受支持的非基本类型的“List”。该集合之外的任何内容都不会在架构往返过程中幸存下来。
类型参数和类型错误
第二个函数采用参数并按照平台期望的方式报告失败,通过抛出而不是返回错误字符串:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
第二个函数演示了四件事:
- 类型化参数将其含义带入模式中。
jokeId: Int和isFavorite: Boolean可以清楚地读取模型来决定传递什么内容。这正是之前关于参数名称是合约一部分的观点。 - 抛出错误,而不是返回。 要向调用者报告真正的失败,请抛出
androidx.appfunctions.AppFunctionException的子类。这里,当“id”不存在时,会出现“AppFunctionElementNotFoundException”。代理接收一个键入的错误,而不必解析句子,并且“@throws”行告诉它要建议什么恢复。 (该库提供了一系列这些:无效参数、元素未找到、需要许可等等。) - 所需的工作流程:是一种记录在案的约定。 当一个功能依赖于另一个功能时,Google 的 KDoc 指南会用该确切的短语拼写出来。 “所需的工作流程:首先调用 getFavorites…”,以便代理学会在写入之前调用读取。
- 线程是显式的。 AppFunction 实现默认在 UI 线程上运行,因此这两个函数本身切换到
withContext(Dispatchers.IO)。 (“clearFavorites”没有它就可以逃脱,因为它的单个 Room 调用已经在内部跳出主线程 - 但显式切换是更安全的默认值,也是要教授的规则。)
有了这些,该类现在公开了三个函数。 “clearFavorites”、“getFavorites”和“setFavorite”,下一步的工厂会同时覆盖所有这些,因为它们位于同一个封闭类中。
第 6 步 — 告诉操作系统如何构建你的 AppFunction 类
到目前为止,我们已经创建了几个新类型:JokesAppFunctions、AppFunctionJoke 和一个映射器。对于任何使用分层代码库的人来说,自然而然会问:它们属于哪一层?示例中使用了常见的 data / domain / presentation 结构,每个功能都对应一个层,因此很容易将 AppFunctions 代码放到这三层中的某一层。
请克制住这种想法;一旦你真正理解了 AppFunction 的本质,正确的答案就会直接来自清晰架构。
AppFunction 是一个入站入口点;一个驱动(主要)适配器。应用外部的某些东西(操作系统、代理)会介入并驱动你的业务逻辑。这与 Compose UI 扮演的角色完全相同;唯一的区别在于调用者。用户点击屏幕,代理调用函数。两者都位于外部的“接口适配器”环中,并向内部调用存储库。这与你的 data 层形成对比,data 层包含驱动型(辅助型)适配器——应用程序调用的出站网关(例如 Room、Ktor)。AppFunctions 指向相反的方向。
这一分类就决定了选项:
不属于
data层。data层用于出站网关,而 AppFunctionJoke 不是持久化或网络 DTO。它是入站接口的边界模型。将入口点放在data层会混淆“驱动我们的事物”和“我们驱动的事物”。不属于根/应用程序包。 根包用于真正跨功能、应用程序范围的粘合层。
clearFavorites、getFavorites和setFavorite与笑话有关,因此它们属于笑话功能。不属于
core包。一个共享的、与功能无关的模块不应该依赖于笑话领域;此处的特性逻辑会颠倒依赖关系规则。
数据/领域/表示三元组`是常见的架构结构,并非硬性规定。整洁架构的核心是领域,其外层环绕着接口适配器,这些适配器承载着所有交付机制。
包括用户界面、控件、磁贴、深度链接和应用程序函数。在这个环中,表示层和应用程序函数是同级关系,而非父子关系。因此,最简洁的方案是在 presentation 之外添加第四个包,其作用域限定于功能:
1 2 3 4 5 | |
值得保留的思维模型: appfunctions/ 之于代理,正如 presentation/ 之于用户
_架构级别相同,受众不同。
AppFunctionJoke之所以应该位于其适配器旁边,原因与 UI 模型位于展示层、JokeDto位于数据层相同:边界模型与其边界位于同一层,并且它仅依赖于内部的领域 Joke。
另一个区别使拆分更加清晰。 AppFunctions 代码有两种类型,它们位于两个不同的位置:
这是有意为之。之所以采用应用级配置,正是因为它将每个功能的 AppFunctions 聚合到一个模式中(回想一下步骤 1 中的 aggregateAppFunctions 标志)。
每个功能都贡献自己的函数;应用模块将它们组装起来。这也为多模块拆分提供了前瞻性::jokes:appfunctions 将依赖于 :jokes:domain,而聚合标志和 Provider 则保留在 :app 中。
第 7 步 — 测试(adb、真实代理或两者)
验证接线的最快方法是通过“adb shell cmd app_function”。值得精确说明此命令的作用,因为它不是假的或模拟的 - 它是同一操作系统级“AppFunctionService”的瘦壳前端,任何代理都可以通过 Kotlin 代码中的“AppFunctionManager”与其进行通信。
你在这里不是在模拟代理人;而是在模拟代理人。你正在绕过它并直接与操作系统对话。这使得 adb 成为端到端验证的理想工具,因为它删除了每个人工智能产品变量,只留下你自己的线路进行测试。
在运行 Android 16 或更高版本的设备或模拟器上构建并安装应用程序,然后列出已注册的 AppFunctions:
在运行 Android 16 或更高版本的设备或模拟器上构建并安装应用程序,并在模拟器上使用 Google 系统映像 (Google API 或 Google Play),因为纯 AOSP 映像上缺少 cmd app_function 工具 (在下面的当路由 A (adb) 可能会碰壁时详细说明)。
然后列出已注册的AppFunctions:
1 2 | |
如果所有内容均已编译且清单设置正确,你应该会看到如下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
此输出中有一些值得指出的事情:
**functionId**是我们 AppFunction 的规范标识符:eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites。请注意完全限定类名和方法名之间的“#”分隔符。这正是你在下一个命令中传递给“–function”的字符串,因此请从此输出中复制它,而不是从内存中键入它。**packageName**是eu.anifantakis.networkapp— 应用程序的applicationId。这是你传递给“–package”的内容,它与包含函数类的包不同。 (函数类位于“eu.anifantakis.networkapp.jokes.features.jokes.appfunctions”,但应用程序标识符只是“eu.anifantakis.networkapp”。)**mobileApplicationQualifiedId**指向android$apps-db/apps#eu.anifantakis.networkapp。 “apps-db”前缀是操作系统级数据库(AppSearch,内部),系统使用它来索引已安装的应用程序。我们的函数在同一数据库中的“app_functions”下有自己的条目。**scope: global**确认该函数是可见的,无需进一步的门控。如果我们使用了“@AppFunction(isEnabled = false, …)”并且尚未在运行时启用它,则此条目根本不会显示在此处。
值得一提的是,这个输出并不是一个对开发人员来说看起来很漂亮的诊断界面。它是操作系统 AppFunctions 注册表的字面视图;当代理询问“这个应用程序公开哪些功能?”时,它会读取相同的注册表。代理将看到的有关你的职能的所有内容都在这里。
端到端尝试:
要实际查看该功能的运行情况,请运行这个简短的演示:
- 运行应用程序,然后点击你想要保留的笑话旁边的心形图标,将一些笑话标记为收藏夹。
- 关闭并重新打开应用程序。 你应该注意到,当其余笑话从网络刷新时,你标记的收藏夹将被保留。
- 运行下面的脚本,然后观看喜爱的标记立即从屏幕上清除。请注意,你无需运行应用程序即可生效。
1 2 3 4 | |
如果你的收藏夹表之前有标记为“isFavorite = 1”的行,那么它们现在全部重置为“0”,并且 adb 会打印函数返回的字符串。
上面也提到了重要的观察:
无论你的应用程序是在前台、后台还是完全关闭,这都有效。
操作系统通过
_AppFunctionService_独立于你的活动生命周期来访问你的函数。这就是本文开头“由操作系统索引”的实际现金价值。代理不需要你的 UI 处于活动状态即可访问你的代码。
步骤 5b 中的两个函数可以通过相同的方式访问。 getFavorites 不带参数并返回结构化列表:
1 2 3 4 | |
setFavorite 将类型化参数作为 JSON。请注意键如何与 Kotlin 参数名称完全匹配。
1 2 3 4 | |
返回“笑话 179 现在是最喜欢的。”,后续的 getFavorites 然后将笑话 179 包含在其结构化列表中。传递一个不存在的jokeId,你可以看到类型错误路径触发 - Errorexecutingappfunction:android.app.appfunctions.AppFunctionException: Nojokefound with id 999999. (code 1500) - 调用者得到一个结构化错误,而不是状态语句。
还有一些值得使用的 adb 子命令放在后面的口袋里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
当路线 A (adb) 可能会碰壁时
上面的所有内容都是在 Google API 图像上捕获的。在纯 AOSP 映像(例如“sdk_phone64_arm64”)上,每个 cmd“app_function”子命令都会打印“No shell command implementation.”:
1 2 | |
看起来 AppFunctions 不受支持,但事实并非如此。
该行是框架的默认“Binder.onShellCommand()”输出,而不是 AppFunctions 消息。整个过程依赖于同一个 AppFunctionManagerService 的两个方法:
**onShellCommand()**:可选的 Binder 钩子。 Google 系统映像 (Google API and Google Play) 会覆盖它。 AOSP 版本没有,因此 cmd 会退回到该存根消息。 (cmd app_search的行为方式相同。)**executeAppFunction()**:IAppFunctionManagerBinder 接口的必需方法,因此无论图像如何,服务都会实现它。
因此,在 AOSP 上,仅缺少 adb shell 包装器,而不缺少 API 本身。
这与“cmd: Can’t find service: app_function”相反,这确实意味着不支持 AppFunctions;使用 adb shell getprop ro.build.fingerprint 区分两者(sdk_gphone… 有命令,sdk_phone.../test-keys 没有)。
实际影响:
adb shell dumpsys app_function 可在 AOSP 和 Google API/Play 模拟器中工作,但 cmd 仅适用于 Google API/Play 模拟器。
因此,“execute-app-function”子命令依赖于缺少的“onShellCommand()”,而“executeAppFunction()”绑定器方法只能通过“AppFunctionManager”以编程方式访问。
也就是下面的B路。因此,在 AOSP 上,主机应用程序不再是可选的。 (两种风格都可以支持它;Google API 也为你提供 cmd。)
其他路径(我还没尝试过)
本文中的“clearFavorites”功能碰巧可以安全地重复使用。无论调用一次还是十次,“UPDATE SET isFavorite = 0”都会产生相同的结果。这个事故值得暂停,因为当你编写一个破坏性的 AppFunction 时,重复调用会造成伤害,你已经重新打开了 REST 为确定性调用者解决的每个问题,并出现了一个新的问题:调用者现在是一个 LLM,可以产生幻觉、重试或出于错误的原因选择你的函数。
调用两次的“deleteJoke(id)”充其量是一个错误。调用两次的“sendPayment(amount)”是一个真正的问题。在用“@AppFunction”注释写入之前,请处理 REST 迫使你回答的相同问题:重试时会发生什么,部分失败时会发生什么,以及首先允许谁调用它?
REST 时代的一些值得继承的模式:
1) 通过设计确保写入安全,以便在可以的情况下重复写入
- 优先选择绝对集操作而不是增量操作,
- 接受客户端提供的请求 ID,以便可以检测并忽略重复的调用,
- 对于无操作和真正的改变同样返回成功
让我们更详细地看看这些项目符号……
> 优先选择绝对集操作而不是增量操作
写入最终值的 UPDATE,例如“SET status = ‘active’”,每次运行时都会给出相同的结果。
像“SET value = value + 1”这样的增量 - 每次重试都会累积,因此两次调用都会使计数器保持在 +2,即使调用者只是意味着 +1。
对于 AppFunctions,“markAsFavorite(jokeId)”是绝对的并且可以安全地重复; incrementFavoriteCount(jokeId) 是一个增量,但不是。
> 接受客户端提供的请求 ID
让呼叫者在每次呼叫时发送一个唯一的密钥。
你的函数会保留最近看到的键的一小部分记录;如果相同的键到达两次,你将返回第一次调用的缓存结果,而不是再次运行该操作。
这正是像 Stripe 这样的支付 API 能够在网络重试中幸存下来而无需重复收费的方式。它们在每个请求上都需要一个“Idempotency-Key”标头,并且你可以为任何敏感写入借用相同的模式。
> 无论操作是否真正起作用还是无操作,都以相同的方式返回成功
我们的“clearFavorites”已经做到了这一点。
两个分支都会返回“所有喜欢的笑话已成功清除”,无论十个笑话是不喜欢的还是零个。
不同的消息(“Cleared 10”与“Nothing to Clear”)会向调用者泄露状态,并诱使法学硕士根据答案选择不同的下一步。
2) 自由地暴露读取,保守地暴露写入。
没有任何规则规定存储库中的每个方法都应该有一个@AppFunction。选择对特工真正有用的最小表面。
3) 假设没有人看守门
今天,“EXECUTE_APP_FUNCTIONS”已获得特权,但代理和函数之间的执行故事仍在定义中。后续的主机应用程序文章将详细探讨该层。 在那之前,你的功能是最后一层防御,而不是第一层。
**这一切背后有一个值得大声说出来的战略要点。我们花了十年时间优化深度链接、应用索引和搜索,以吸引用户进入应用。 AppFunctions 进行了相反的优化——应用程序永远不会打开,屏幕永远不会亮起,代理和业务逻辑之间没有 UI。这改变了“最低权限”在实践中的样子:用户界面不再询问“你确定吗?”代表你。
结束语
