AppFunctions:让你的Android应用更容易被AI智能体发现

5 阅读26分钟

本文译自「AppFunctions: Making Your Android App Discoverable by AI Agents」,原文链接proandroiddev.com/appfunction…,由Ioannis Anifantakis发布于2026年5月25日。

无标题图片

简介

谷歌刚刚发布了 AppFunctions 的首个公开文档和首个可用 alpha 版本。AppFunctions 是一个 Jetpack 库,它与一个新的 Android 平台 API 配合使用,可以将你应用的部分功能转化为 AI 助手可以发现和执行的工具。

如果你曾经在服务器端使用过模型上下文协议 (MCP),那么 AppFunctions 就是它的 Android 原生版本:

思路相同,但该工具位于你的应用内部,并在设备本地运行。

目前应用是如何与 AI 助手通信的?

明确指出这里的新特性很有帮助,因为功能本身并不新……真正的新特性是它的通用性。

在此之前,让助手在应用内代表你执行操作几乎完全是第一方的功能:你可以让 Google Assistant 在 Google 日历中添加事件,让 Bixby 在三星日历中创建事件,或者让 Siri 在苹果日历中执行相同的操作。每个助手都与其各自厂商的应用紧密集成。

第三方应用并非完全被拒之门外,但每扇门的开启方式都各不相同。Android 的 App Actions 允许你将一组固定的内置意图暴露给 Google Assistant(并且通常会通过启动你的用户界面而非无头运行逻辑来解析这些意图);苹果的 App Intents / SiriKit 为 Siri 提供了类似的功能;Bixby 则有其 Capsules。

每个助手都针对特定应用,通常仅限于预定义的词汇表,而且无法跨平台移植。因此,真正意义上的助手控制要么是厂商的特权,要么是需要为每个助手编写大量的集成代码,你的应用只能运行针对特定助手的代码。

AppFunctions 旨在用一个单一的、操作系统级别的、与代理无关的接口来取代所有这些:

你的应用只需声明一次其功能,平台信任的任何调用者都可以发现并执行它。不再需要为每个助手编写代码。

本文提供什么?

本文是一个早期实践指南。

我从我用作教学示例的现有应用中的一个最小“Hello World”AppFunction开始。这是一个带有Room数据库和MVI表示层的小型笑话应用,然后将其扩展成一个包含三个函数的小型但完整的演示。

它仍然是一个演示,而不是一个大型项目,我将逐步讲解,以便你今天就可以在自己的项目中进行相同的操作。你可以在示例应用的分支中找到所有内容,链接如下。

首先说明: AppFunctions 仍处于实验阶段。截至 2026 年 5 月,Gemini 集成仅面向受信任的测试人员进行内部预览。

不过,开发者端的路径是开放的——你现在就可以使用 _adb__ 构建、注册和执行 AppFunctions,这正是我们将在本文末尾演示的内容。_

MCP到底是什么

如果你之前没有使用过 模型上下文协议 (MCP),那么一页入门将使接下来的一切变得更加容易。

MCP 是一项开放标准,最初由 Anthropic 于 2024 年末发布,现已被越来越多的人工智能产品采用,它定义了人工智能代理连接到外部工具数据源的单一方式。该模型很简单:

  • MCP 服务器 是一个公开工具列表的小进程。每个工具都有一个名称、一个简单语言描述以及一个描述其参数和返回值的 JSON 架构。
  • MCP 客户端(通常是 AI 代理)连接到服务器,询问“你有什么工具?”,阅读说明,并根据用户的请求决定调用哪个工具。
  • 它们之间的对话是JSON-RPC,通过stdio(对于本地服务器)或HTTP(对于远程服务器)进行。

在 MCP 之前,每个集成都是定制的。也就是说,每个人工智能产品都有自己的连接工具的方法。借助 MCP,任何兼容 MCP 的代理都可以使用公开“搜索我的电子邮件”的单个服务器,而无需在任何一方进行集成更改。

挑战在于传统的 MCP 服务器在你的应用程序之外**运行,通常作为单独的进程或云服务。他们缺乏对你应用程序状态的特权访问权限。如果你想要一个 MCP 风格的工具来读取笔记应用程序中未发送的草稿,你要么需要通过 API 公开该草稿,要么在应用程序自己的进程中运行 MCP 服务器。这两种选择在移动设备上都不方便。

这就是 AppFunctions 填补的空白。

AppFunction 实际上是什么

简而言之: AppFunctions 最好理解为 Android 的 MCP,但位于设备上。

你的应用程序声明一个类,使用“@AppFunction”注释其一些方法,Jetpack 注释处理器会生成元数据以及操作系统索引这些方法所需的连接。从那时起,任何具有“EXECUTE_APP_FUNCTIONS”权限的调用者(包括系统代理)都可以发现你的函数,读取其描述,并使用参数调用它。

三个属性让这变得有趣:

  1. 它是本地的。 无网络往返,无需维护服务器。代理直接调用你的应用程序并读取其当前状态。
  2. 它由操作系统索引。 你无需在运行时注册任何内容。注释处理器将生成的模式发送到你的 APK (assets/app_function_v2.xml) 中,操作系统在安装时读取它,并维护代理可以查询的注册表。
  3. 函数的文档成为其合同的一部分。 当你使用“@AppFunction(isDescribedByKDoc = true)”标记函数时,你的 KDoc 将被编码到函数的元数据中并向代理显示。编写好的 KDoc 不再只是一个文档问题。从现在开始,它成为一个运行时问题。

最低要求: **compileSdk = 37**,以及 **targetSdk = 36** 或更高,运行 Android 16 或更高版本的设备。

60 秒内完成示例应用程序

基础应用程序是一个小型 Compose 应用程序,它使用 Ktor 从远程源获取笑话并让用户标记收藏夹。它使用 Room 进行离线缓存和本地收藏夹存储以及典型的分层架构:DAO、数据源、存储库、视图模型、屏幕。这里没有什么不寻常的。它与大多数 Android 应用程序的骨架相同。

我故意添加的第一个功能很小:清除所有最喜欢的笑话。可以通过两种方式获得完全相同的功能:

  • 用户可以点击顶栏中的菜单项。
  • AI 代理可以调用​​ clearFavorites AppFunction。

两条路径最终都会调用相同的存储库方法。在我们看代码之前,这是我想告诉你的原则:AppFunction 是普通应用程序逻辑的薄壳,绝不是它的副本

我们从这一个函数开始,然后在基础知识就位后添加另外两个函数,“getFavorites”和“setFavorite”。

第 1 步——依赖关系

打开 gradle/libs.versions.toml 并添加 AppFunctions 工件。当前的 alpha 是“1.0.0-alpha09”:

[versions]
appfunctions = "1.0.0-alpha09"
[libraries]
androidx-appfunctions = { module = "androidx.appfunctions:appfunctions",

version.ref = "appfunctions" }
androidx-appfunctions-service  = { module = "androidx.appfunctions:appfunctions-service",  version.ref = "appfunctions" }
androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" }

然后在app/build.gradle.kts中:

ksp {
    arg("appfunctions:aggregateAppFunctions", "true")
}
dependencies {
    // App Functions
    implementation(libs.androidx.appfunctions)
    implementation(libs.androidx.appfunctions.service)
    ksp(libs.androidx.appfunctions.compiler)
}

需要注意两点:

  • ksp(libs.androidx.appfunctions.compiler) 依赖项将 AppFunctions 编译器注册为 KSP 处理器。这是实际连接注释处理的行。 其上方的 ksp { arg("appfunctions:aggregateAppFunctions", "true") }块将一个配置标志传递给该处理器,告诉它将项目中声明的每个@AppFunction` 聚合到一个合并的架构中。在多模块项目中,你仅在应用程序模块中设置此标志;包含“@AppFunction”声明的库模块只需要“ksp(…)”编译器依赖项。
  • 你还至少需要compileSdk = 37(和targetSdk = 36或更高)。如果你仍处于较低级别,那么这只是构建文件中的一行内容。

第 2 步 — 清单管道

操作系统在安装时需要知道两件事:

  1. 在哪里读取应用程序的 AppFunction 元数据,
  2. 当代理想要执行你的某个功能时要绑定哪个服务。

两者都位于“AndroidManifest.xml”中的“”内部:

<application
    ...>
<property
        android:name="android.app.appfunctions.app_metadata"
        android:resource="@xml/app_metadata" />
    <service
        android:name="androidx.appfunctions.service.PlatformAppFunctionService"
        android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
        android:exported="true"
        tools:targetApi="36">
        <intent-filter>
            <action android:name="android.app.appfunctions.AppFunctionService" />
        </intent-filter>
    </service>
    <!-- your activities here -->
</application>

每件作品的作用:

  • <property> 元素将操作系统指向 app_metadata.xml,其中包含 应用程序级别 描述;这就是这个应用程序的整体用途,因此人工智能代理可以知道每个应用程序的用途。每个函数的描述是一个单独的问题:它们来自每个“@AppFunction”方法的 KDoc。
  • <service> 声明公开了 PlatformAppFunctionService,它是平台用来调用函数的桥梁。幸运的是,你不是自己编写此服务;它包含在“appfunctions-service”库中。你只需使用正确的权限和意图过滤器在清单中声明它,以便系统可以找到并绑定到它。

“BIND_APP_FUNCTION_SERVICE”权限确保只有平台可以绑定到服务。你不需要请求“EXECUTE_APP_FUNCTIONS”;这是调用者的许可,而不是你的。

第 3 步 — 在“app_metadata.xml”中描述应用程序本身

清单的“”元素指向“res/xml/app_metadata.xml”。

顾名思义,该文件包含操作系统向代理公开的应用程序级描述,以及各个函数的架构。这是我添加到示例应用程序中的一个:

<?xml version="1.0" encoding="utf-8"?>
<AppFunctionAppMetadata
    xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
    appfn:description="This app allows users to view and manage jokes, including marking them as favorites and clearing the favorites list." />

现在的结构故意很小——一个根元素,一个“描述”属性。但意义大于语法。

AppFunctions 实际上为代理提供了两层文档,并且值得明确说明它们之间的关系:

这与 MCP 在服务器端使用的分层模型相同; MCP 服务器有一个描述,其中的每个工具都有自己的描述。代理首先使用高级描述来决定“这是否是查看内部的正确应用程序?”,然后深入研究功能描述以在该应用程序中选择正确的应用程序。

因此,请以我敦促你对待 KDoc 的方式对待“appfn:description”:将其写入 LLM 的运行时输入,而不是营销副本。简短、具体,重点关注应用程序支持的动词_(“查看和管理笑话”、“标记为收藏夹”、“清除收藏夹列表”),而不是定位(“Android 上最好的笑话伴侣”)_。诚实地了解应用程序的功能,因为当代理必须在三个都宣传“笑话”的不同应用程序之间进行选择时,代理会信任它。

与之并存的还有第二个属性:“appfn:displayDescription”。两者针对的受众不同。 appfn:description 是代理推理的面向 LLM 的文本,而 appfn:displayDescription 是人类可读、用户可见的描述。

这种差异体现在库声明两个属性的方式上(在 appfunctions 自己的 res/values/values.xml 中):

<attr format="string" name="description"/>
<attr format="reference|string" name="displayDescription"/>

注意不对称性。 description 是纯字符串_(内联写入,对于模型),而 displayDescriptionreference|string(将其指向可本地化的资源,对于人们)_。

** **appfn:description** 是应用程序范围内的。 每个应用程序都有一个 (这就是为什么它通过清单声明一次 _<property>_,并且它从不描述单个函数;每个函数自己的描述都来自其 KDoc。但正是因为它位于每个功能之上,所以它是唯一可以谈论你的功能如何相互关联的地方。

对于单一的应用程序功能来说,没有什么跨领域可说的,所以一句话就足够了。一旦应用程序公开了多个功能,Google 自己的 AppFunctions 指南建议为该应用程序范围内的描述提供更多结构。将其视为代理的服务器指令而不是口号:

<AppFunctionAppMetadata
    xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
    appfn:description="This app lets users view and manage jokes and their favorites.
        Operational Patterns:
        - Call 'getFavorites' to obtain a valid joke id before calling 'setFavorite'.
        Constraints:
        - 'clearFavorites' is irreversible; confirm with the user before calling it."
    appfn:displayDescription="@string/app_function_user_description" />

该指南中有两个值得沿用的约定:

  • 操作模式部分,推动代理走向令牌高效、正确的序列(例如,“在 setFavorite 之前调用 getFavorites_”),
  • 约束部分 绘制硬边界。

避免在此处重复个别功能描述或添加营销文案。每个函数的 KDoc 已经携带了函数级别的详细信息。 “androidx.appfunctions”库的发行说明是跟踪 API 稳定后其他内容的地方。

步骤 4 — 扩展数据层(无聊、正常的部分)

在我们接触任何 AppFunctions 代码之前,我们通过现有层添加新的业务功能。我之前提出的观点_(AppFunction 是正常逻辑之上的一个薄壳)_ 仅当正常逻辑首先存在时才有效。

DAOJokesDao.kt):

@Query("UPDATE joke SET isFavorite = 0")
suspend fun clearAllFavorites()

本地数据源LocalJokesDataSource.kt及其实现):

interface LocalJokesDataSource {
    // ...
    suspend fun clearAllFavorites()
}

class LocalJokesDataSourceImpl(/* ... */) : LocalJokesDataSource {
    override suspend fun clearAllFavorites() {
        database.clearAllFavorites()
    }
}

存储库JokesRepository.kt 及其实现):

interface JokesRepository {
    // ...
    suspend fun clearAllFavorites(): Result<Unit>
}

class JokesRepositoryImpl(/* ... */) : JokesRepository {
    override suspend fun clearAllFavorites(): Result<Unit> {
        return safeCall {
            localDataSource.clearAllFavorites()
        }
    }
}

这里没有任何东西知道或关心 AppFunctions。这只是普通的 Android 架构,这就是重点。

第 5 步——AppFunction 本身

现在我们添加代理将调用的实际函数。 这是一个普通的 Kotlin 类;没有继承,没有Android生命周期:

package eu.anifantakis.networkapp.jokes.features.jokes.appfunctions
import androidx.appfunctions.AppFunctionContext
import androidx.appfunctions.service.AppFunction
import eu.anifantakis.networkapp.jokes.di.AppModule

/**
 * App Functions that can be exposed to AI agents via MCP.
 */
class JokesAppFunctions {
    /**
     * Unmarks every joke the user has previously marked as a favorite, leaving the favorites list empty.
     *
     * Only the favorite flag is affected. The underlying jokes remain in the database and are still
     * visible in the main list. This operation is safe to repeat: calling it on an already-empty favorites
     * list is a no-op. It is irreversible: cleared favorite markers cannot be restored.
     *
     * @return A short human-readable status message describing whether the operation succeeded.
     */
    @AppFunction(isDescribedByKDoc = true)
    suspend fun clearFavorites(context: AppFunctionContext): String {
        val repository = AppModule.jokesRepository
        val result = repository.clearAllFavorites()
        return if (result.isSuccess) {
            "All favorite jokes have been cleared successfully."
        } else {
            "Failed to clear favorite jokes: ${result.exceptionOrNull()?.message}"
        }
    }
}

值得暂停的事情:

isDescribedByKDoc = true

上面的 KDoc 对于代理来说非常有用。它清楚地解释了涉及哪些更改(最喜欢的标志)、哪些保持不变(底层笑话)、该操作是否可以安全重试(是的,再次调用它不会造成额外的伤害)以及是否可以撤消(否)。代理阅读此内容足以决定是调用此函数还是首先要求用户确认。相比之下,像_“删除收藏夹”_这样的短语具有相同的目的,但缺乏细节;代理无法理解“删除”的含义或操作是否可以撤销。

一旦你的函数接受任何参数,也应同样注意参数名称和类型。一个名为“filter: String”的参数对于人类导航 UI 来说是具体的,但对于读取模式的法学硕士来说却是一个黑洞。

像“daysOlderThan: Int”或“category: JokeCategory”这样的名称将它们的含义向前推进;模糊的名字悄悄地扩大了特工猜测错误的空间。你的 UI 所允许的模式将在此处默默失败。

第一个参数始终是 AppFunctionContext

系统将其传入;你没有。它是你访问系统服务和识别调用者的钩子。

这里的返回类型是String

代理会将该文本视为可以向用户显示的结果。对于更复杂的函数,你将返回一个用“@AppFunctionSerialized”注释的可序列化数据类 - 然后代理接收结构化数据并可以根据需要对其进行格式化。对于像这样的“Hello World”示例,一句纯文本就足够了。

这个函数是“挂起”

那不是可选的。默认情况下,AppFunction 实现在主线程上运行,因此任何 I/O 都必须挂起。在我们的例子中,存储库调用已经在内部处理调度。

关于依赖项的说明: 这个类有一个无参数构造函数,并直接进入 _AppModule.jokesRepository_ _

这适用于 hello world。在真实的代码库中,你将通过 Hilt 或 Koin 构造函数注入存储库,然后提供一个工厂,以便操作系统知道如何实例化该类,这将我们带到下一步。

步骤 5b — 超越字符串:结构化数据、参数和错误

clearFavorites 是一个故意最小化的“hello world”:没有参数,纯字符串返回,除了状态语句之外没有错误表面。

大多数真正的 AppFunction 需要更多,而他们最常需要的三件事正是平台支持的开箱即用的三件事:

  • 结构化返回值,
  • 输入参数,
  • 和输入错误。

同一类上的另外两个函数显示了所有这三个函数。

使用@AppFunctionSerialized返回结构化数据

当你希望代理接收数据时,它可以读取、计数或引用_(不仅仅是句子),返回可序列化类型而不是字符串。你用“@AppFunctionSerialized”注释一个普通数据类,并直接返回它(或它的_“List”)_。

我们已经有了三个笑话模型:

  • 域名“笑话”,
  • 网络“JokeDto”,
  • 和房间“JokeEntity”。

添加第四个 (an _@AppFunctionSerialized_ class) 看起来多余,但它与其他人遵循的边界模型模式相同。我们无法注释域“Joke”:“@AppFunctionSerialized”存在于“androidx.appfunctions”中,将 Android 依赖项拖到域层会破坏其纯粹性。

因此,AppFunctions 边界有自己的 DTO,就像网络和持久性边界一样。而且,作为奖励,代理看到的模式可以独立于域进行塑造(请注意,此 DTO 删除了“isFavorite”,这在收藏夹列表中始终为真,并且对模型来说只是噪音)。转换是一个映射器,类似于现有的 toJoke()/toEntity() 扩展:

// AppFunctionJoke.kt
// Developer rationale lives in a plain `//` comment on purpose (see the note below): this is the
// agent-facing boundary DTO, the counterpart of the network JokeDto and the Room JokeEntity. We
// can't annotate the domain Joke (androidx code would break domain purity), so the AppFunctions
// boundary gets its own model — and we drop isFavorite, which is noise for a favorites list.
/**
 * A single joke from this app, modelled as a question-and-answer pair: the question is the set-up
 * and the answer is the punchline. Returned by joke-reading functions such as the user's favorites
 * list, and identified by a stable numeric id that other functions accept to act on this joke.
 */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class AppFunctionJoke(
    /** Stable unique identifier of the joke. Pass this back to functions that act on a single joke. */
    val id: Int,
    /** The set-up line of the joke (its "question" part). */
    val question: String,
    /** The punchline of the joke (its "answer" part). */
    val answer: String,
)
// AppFunctionJokeMapper.kt — the boundary mapper, like toJoke()/toEntity()
fun Joke.toAppFunctionJoke(): AppFunctionJoke = AppFunctionJoke(
    id = id, question = question, answer = answer,
)
/**
 * Lists every joke the user has currently marked as a favorite, including its set-up and punchline.
 *
 * Returns structured data rather than a sentence, so a calling agent can read, count, or quote
 * individual jokes. Use the id of a returned joke when calling "setFavorite" to unmark a specific
 * one. An empty list means the user has no favorites.
 *
 * @return The user's favorite jokes; an empty list if there are none.
 */
@AppFunction(isDescribedByKDoc = true)
suspend fun getFavorites(context: AppFunctionContext): List<AppFunctionJoke> = withContext(Dispatchers.IO) {
    val result = AppModule.jokesRepository.getFavorites()
    result.getOrDefault(emptyList()).map { joke -> joke.toAppFunctionJoke() }
}

关于可序列化上的 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 参数或返回值可以是: 原语 (IntLongFloatDoubleBoolean);原始数组(IntArrayLongArrayFloatArrayDoubleArrayBooleanArray);本机类型(“String”、“PendingIntent”、“Uri”、“LocalTime”、“LocalDate”、“LocalDateTime”、“Instant”)、“@AppFunctionSerialized”对象;或任何受支持的非基本类型的“List”。该集合之外的任何内容都不会在架构往返过程中幸存下来。

类型参数和类型错误

第二个函数采用参数并按照平台期望的方式报告失败,通过抛出而不是返回错误字符串:

/**
 * Marks or unmarks a single joke as a favorite, identified by its id.
 *
 * Required workflow: call [getFavorites] first when you need a valid joke id to act on.
 * This sets the favorite flag to an absolute value rather than toggling it, so the call is safe to
 * repeat — requesting `isFavorite = true` on a joke that is already a favorite leaves it a favorite.
 *
 * @param jokeId The unique identifier of the joke to update.
 * @param isFavorite The desired favorite state: `true` to mark as a favorite, `false` to unmark it.
 * @return A short human-readable status message describing the new state of the joke.
 * @throws AppFunctionElementNotFoundException If no joke exists with the given [jokeId]; suggest the
 * user call getFavorites or browse the jokes list to obtain a valid id.
 */
@AppFunction(isDescribedByKDoc = true)
suspend fun setFavorite(
    context: AppFunctionContext,
    jokeId: Int,
    isFavorite: Boolean,
): String = withContext(Dispatchers.IO) {
    val joke = AppModule.jokesRepository.getJokeById(jokeId).getOrNull()
        ?: throw AppFunctionElementNotFoundException("No joke found with id $jokeId.")
    val result = AppModule.jokesRepository.setFavorite(jokeId, isFavorite)
    if (result.isSuccess) {
        if (isFavorite) "Joke ${joke.id} is now a favorite." else "Joke ${joke.id} is no longer a favorite."
    } else {
        "Failed to update joke ${joke.id}: ${result.exceptionOrNull()?.message}"
    }
}

第二个函数演示了四件事:

  • 类型化参数将其含义带入模式中。 jokeId: IntisFavorite: Boolean 可以清楚地读取模型来决定传递什么内容。这正是之前关于参数名称是合约一部分的观点。
  • 抛出错误,而不是返回。 要向调用者报告真正的失败,请抛出androidx.appfunctions.AppFunctionException的子类。这里,当“id”不存在时,会出现“AppFunctionElementNotFoundException”。代理接收一个键入的错误,而不必解析句子,并且“@throws”行告诉它要建议什么恢复。 (该库提供了一系列这些:无效参数、元素未找到、需要许可等等。)
  • 所需的工作流程:是一种记录在案的约定。 当一个功能依赖于另一个功能时,Google 的 KDoc 指南会用该确切的短语拼写出来。 “所需的工作流程:首先调用 getFavorites...”,以便代理学会在写入之前调用读取。
  • 线程是显式的。 AppFunction 实现默认在 UI 线程上运行,因此这两个函数本身切换到 withContext(Dispatchers.IO)。 (“clearFavorites”没有它就可以逃脱,因为它的单个 Room 调用已经在内部跳出主线程 - 但显式切换是更安全的默认值,也是要教授的规则。)

有了这些,该类现在公开了三个函数。 “clearFavorites”、“getFavorites”和“setFavorite”,下一步的工厂会同时覆盖所有这些,因为它们位于同一个封闭类中。

第 6 步 — 告诉操作系统如何构建你的 AppFunction 类

当代理调用它时,操作系统(而不是你的代码)会实例化你的 AppFunctions 类。所以你需要为其声明一个工厂。钩子是“AppFunctionConfiguration.Provider”,在“Application”子类上实现:

package eu.anifantakis.networkapp
import android.app.Application
import androidx.appfunctions.service.AppFunctionConfiguration
import eu.anifantakis.networkapp.jokes.di.AppModule
import eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions

class MyApplication : Application(), AppFunctionConfiguration.Provider {
    override fun onCreate() {
        super.onCreate()
        AppModule.initialize(applicationContext)
    }
    override val appFunctionConfiguration: AppFunctionConfiguration
        get() = AppFunctionConfiguration.Builder()
            .addEnclosingClassFactory(JokesAppFunctions::class.java) { JokesAppFunctions() }
            .build()
}

addEnclosureClassFactory 接受包含你的 AppFunctions 的类和一个知道如何构建实例的 lambda。对于多个 AppFunction 类,你可以在调用“build()”之前链接多个“addEnendingClassFactory”调用。使用 Hilt,你可以通过字段注入类并从 lambda 返回注入的实例。谷歌的官方文档准确地展示了这种模式。

不要忘记在清单中注册“MyApplication”:

<application android:name=".MyApplication"
    ...>

该代码位于哪里?

到目前为止,我们已经创建了几个新类型:JokesAppFunctionsAppFunctionJoke 和一个映射器。对于任何使用分层代码库的人来说,自然而然会问:它们属于哪一层?示例中使用了常见的 data / domain / presentation 结构,每个功能都对应一个层,因此很容易将 AppFunctions 代码放到这三层中的某一层。

请克制住这种想法;一旦你真正理解了 AppFunction 的本质,正确的答案就会直接来自清晰架构。

AppFunction 是一个入站入口点;一个驱动(主要)适配器。应用外部的某些东西(操作系统、代理)会介入并驱动你的业务逻辑。这与 Compose UI 扮演的角色完全相同;唯一的区别在于调用者。用户点击屏幕,代理调用函数。两者都位于外部的“接口适配器”环中,并向内部调用存储库。这与你的 data 层形成对比,data 层包含驱动型(辅助型)适配器——应用程序调用的出站网关(例如 Room、Ktor)。AppFunctions 指向相反的方向。

这一分类就决定了选项:

  • 不属于 data 层。data 层用于出站网关,而 AppFunctionJoke 不是持久化或网络 DTO。它是入站接口的边界模型。将入口点放在 data 层会混淆“驱动我们的事物”和“我们驱动的事物”。

  • 不属于根/应用程序包。 根包用于真正跨功能、应用程序范围的粘合层。clearFavoritesgetFavoritessetFavorite 与笑话有关,因此它们属于笑话功能。

  • 不属于 core 包。一个共享的、与功能无关的模块不应该依赖于笑话领域;此处的特性逻辑会颠倒依赖关系规则。

数据/领域/表示三元组`是常见的架构结构,并非硬性规定。整洁架构的核心是领域,其外层环绕着接口适配器,这些适配器承载着所有交付机制。

包括用户界面、控件、磁贴、深度链接和应用程序函数。在这个环中,表示层和应用程序函数是同级关系,而非父子关系。因此,最简洁的方案是在 presentation 之外添加第四个包,其作用域限定于功能:

features/jokes/
├─ data/          ← driven adapters: Room, Ktor, DTOs, mappers
├─ domain/        ← Joke, JokesRepository (no Android, no androidx.appfunctions)
├─ presentation/  ← Compose screens + ViewModels   (delivery to a human)
└─ appfunctions/  ← JokesAppFunctions, AppFunctionJoke, mapper  (delivery to an agent)

值得保留的思维模型: appfunctions/ 之于代理,正如 presentation/ 之于用户

_架构级别相同,受众不同。 AppFunctionJoke 之所以应该位于其适配器旁边,原因与 UI 模型位于展示层、JokeDto 位于数据层相同:边界模型与其边界位于同一层,并且它仅依赖于内部的领域 Joke。

另一个区别使拆分更加清晰。 AppFunctions 代码有两种类型,它们位于两个不同的位置:

这是有意为之。之所以采用应用级配置,正是因为它将每个功能的 AppFunctions 聚合到一个模式中(回想一下步骤 1 中的 aggregateAppFunctions 标志)。

每个功能都贡献自己的函数;应用模块将它们组装起来。这也为多模块拆分提供了前瞻性::jokes:appfunctions 将依赖于 :jokes:domain,而聚合标志和 Provider 则保留在 :app 中。

第 7 步 — 测试(adb、真实代理或两者)

这是大多数读者会停下来问正确问题的地方:我已经构建并注册了一个 AppFunction,那么我如何真正看到代理调用它?

共有三个答案,每个答案都有不同的用途。

路线 A — 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:

# You Type this for the first 10 lines of app functions for our package
adb shell cmd app_function list-app-functions | grep -A 10 "eu.anifantakis.networkapp.jokes"

如果所有内容均已编译且清单设置正确,你应该会看到如下内容:

# OUTPUT of first 10 lines:
"eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites"
        ],
        "packageNameHash": [        -1179891122],
        "scope": [ "global"        ],
        "mobileApplicationQualifiedId": [ "android$apps-db\/apps#eu.anifantakis.networkapp"        ],
--
            "android$apps-db\/app_functions#eu.anifantakis.networkapp\/eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions\\#clearFavorites"
          ],
          "functionId": [ "eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites"          ],
          "packageName": ["eu.anifantakis.networkapp"          ]
        }
      }
    }
  ],
  "com.google.android.permissioncontroller": [
  {
ioannisanif@192 MitropolitikoNetworkApp %

此输出中有一些值得指出的事情:

  • **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. 运行下面的脚本,然后观看喜爱的标记立即从屏幕上清除。请注意,你无需运行应用程序即可生效。
adb shell cmd app_function execute-app-function \
  --package eu.anifantakis.networkapp \
  --function eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites \
  --parameters '{}'

如果你的收藏夹表之前有标记为“isFavorite = 1”的行,那么它们现在全部重置为“0”,并且 adb 会打印函数返回的字符串。

上面也提到了重要的观察:

无论你的应用程序是在前台、后台还是完全关闭,这都有效。

操作系统通过 _AppFunctionService_ 独立于你的活动生命周期来访问你的函数。这就是本文开头“由操作系统索引”的实际现金价值。

代理不需要你的 UI 处于活动状态即可访问你的代码。

步骤 5b 中的两个函数可以通过相同的方式访问。 getFavorites 不带参数并返回结构化列表:

adb shell cmd app_function execute-app-function \
  --package eu.anifantakis.networkapp \
  --function eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#getFavorites \
  --parameters '{}'

setFavorite 将类型化参数作为 JSON。请注意键如何与 Kotlin 参数名称完全匹配。

adb shell "cmd app_function execute-app-function \
  --package eu.anifantakis.networkapp \
  --function eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#setFavorite \
  --parameters '{\"jokeId\":179,\"isFavorite\":true}'"

返回“笑话 179 现在是最喜欢的。”,后续的 getFavorites 然后将笑话 179 包含在其结构化列表中。传递一个不存在的jokeId,你可以看到类型错误路径触发 - Errorexecutingappfunction:android.app.appfunctions.AppFunctionException: Nojokefound with id 999999. (code 1500) - 调用者得到一个结构化错误,而不是状态语句。

还有一些值得使用的 adb 子命令放在后面的口袋里:

# Confirm the device even supports AppFunctions (prints a help page if so).
# "cmd: Can't find service: app_function" → no AppFunctions support on this build.
# "No shell command implementation."

→ AOSP image: the service is present but its adb
#

front-end is not (see "When Route A might hit a wall"
#

below; use `dumpsys app_function` to inspect instead).
adb shell cmd app_function help
# Append --brief-yaml to any execute call for terser, more readable output.
adb shell cmd app_function execute-app-function --brief-yaml \
  --package eu.anifantakis.networkapp \
  --function eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#getFavorites \
  --parameters '{}'
# Toggle a single function on or off at runtime (ties into the @AppFunction(isEnabled = false) flag).
adb shell cmd app_function set-enabled \
  --package eu.anifantakis.networkapp \
  --function eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#setFavorite \
  --state disable

当路线 A (adb) 可能会碰壁时

上面的所有内容都是在 Google API 图像上捕获的。在纯 AOSP 映像(例如“sdk_phone64_arm64”)上,每个 cmd“app_function”子命令都会打印“No shell command implementation.”:

$ adb shell cmd app_function
**No shell command implementation.**

看起来 AppFunctions 不受支持,但事实并非如此。

该行是框架的默认“Binder.onShellCommand()”输出,而不是 AppFunctions 消息。整个过程依赖于同一个 AppFunctionManagerService 的两个方法:

  • **onShellCommand()** :可选的 Binder 钩子。 Google 系统映像 (Google API and Google Play) 会覆盖它。 AOSP 版本没有,因此 cmd 会退回到该存根消息。 (cmd app_search 的行为方式相同。)
  • **executeAppFunction()**IAppFunctionManager Binder 接口的必需方法,因此无论图像如何,服务都会实现它。

因此,在 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。)

其他路径(我还没尝试过)

adb 是我实际用来验证这个 hello world 的唯一途径。外部调用者还有另外两种方式可以访问 AppFunction,这两种方式都值得了解,即使我还无法从第一手经验中与它们交谈。

路线 B ​​— 使用“AppFunctionManager”的自定义主机应用程序。

这是最接近生产形态的:设备上的第二个应用程序,其工作是发现并调用另一个应用程序通过 IPC 公开的函数,中间没有“adb”。

这也是 Google 在 I/O 大会上首次公开展示 AppFunctions 时所展示的模式,因为当时还没有真正的代理可以演示,所以他们为此构建了一个小型主机应用程序。

问题是“EXECUTE_APP_FUNCTIONS”目前是 Android 16 版本上的特权权限,因此无法通过普通的“adb install”安装主机应用程序并授予该权限;你需要一个 userdebug 版本、一个 root 设备或一个 /system/priv-app 安装。

我自己还没有构建这个端到端的框架,所以我不会假装在这里演示它,但后续文章已按计划进行,它将展示一个主机应用程序调用我们在本文中编写的笑话 AppFunction。

路线 C — Android Studio 中的 Gemini 作为 LLM 循环检查。

Google 的文档建议在 Android Studio 中使用 Gemini,并提示“执行_ _adb shell cmd app_function_ 了解该工具的工作原理,然后充当聊天代理......”

实际上,这为你提供了一个 LLM 驱动 adb,它可以测试你的函数描述是否足够清晰,以便模型能够选择正确的函数。我也没有亲自尝试过这个,所以我不能说比文档更多的内容。

Gemini 在真正的手机上怎么样?

在撰写本文时,Gemini 与 AppFunctions 的完整集成处于受信任测试人员的私人预览中。你还不能只安装应用程序,在手机上打开 Gemini,然后让它调用你的函数。

关于破坏性函数的注释

本文中的“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。这改变了“最低权限”在实践中的样子:用户界面不再询问“你确定吗?”代表你。

结束语

我不断回顾的代码行是这样的:

@AppFunction(isDescribedByKDoc = true)

该单个标志将文档转变为运行时行为。你编写的 KDoc 是 LLM 读取的合同,用于决定是否调用你的函数以及向其中传递什么内容。 Vague KDoc 的意思是“困惑的代理”。精确的 KDoc,就像对待公共 API 描述一样谨慎,意味着代理会出于正确的原因选择你的函数。

这比任何特定的注释或清单条目更重要的是 AppFunctions 要求我们做出的转变。

有用的链接

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!