稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

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

本文译自「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 代理可以调用​​ clearFavorites AppFunction。

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

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

第 1 步——依赖关系

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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”中描述应用程序本身

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

DAOJokesDao.kt):

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

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

1
2
3
4
5
6
7
8
9
10
interface LocalJokesDataSource {
    // ...
    suspend fun clearAllFavorites()
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
interface JokesRepository {
    // ...
    suspend fun clearAllFavorites(): Result<Unit>
}

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

这里没有任何东西知道或关心 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
// 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,
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
 * 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”。该集合之外的任何内容都不会在架构往返过程中幸存下来。

类型参数和类型错误

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

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
/**
 * 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 类

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

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

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

这一分类就决定了选项:

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

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

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

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

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

1
2
3
4
5
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、真实代理或两者)

验证接线的最快方法是通过“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
# 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"

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 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. 运行下面的脚本,然后观看喜爱的标记立即从屏幕上清除。请注意,你无需运行应用程序即可生效。
1
2
3
4
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 不带参数并返回结构化列表:

1
2
3
4
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 参数名称完全匹配。

1
2
3
4
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 子命令放在后面的口袋里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 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.”:

1
2
$ 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。)

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

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

结束语

Comments