夜雨聆风学习资料网

ARTICLE · 1338089

A2UI 接入你现有 App 的 7 个坑

A2UI 接入你现有 App 的 7 个坑

大多数人认为,A2UI是架构上的重构,其实不是,你的 App 已经有 ViewModel、有网络层、有设计系统、有灰度体系、有监控埋点。A2UI 是插进来的一层:接住 Agent 发来的 JSON,渲染成原生界面,再把用户操作送回去。

所以绝大多数坑,都不出在 A2UI 本身,而出在它和你现有体系的接缝上。

今天来看看接入A2UI后常见的七个坑

坑 1:以为"加个依赖就能跑"

A2UI 不是一个库,是六个。第一次看依赖清单的人基本都会愣一下:

它们的分工是明确的:

artifact
职责
a2ui-model
协议数据模型
a2ui-engine
协议版本相关的解析与消息处理
compose-runtime
Snapshot 状态、细粒度重组
compose-uiA2uiComponent
 / Catalog / Surface
material3-a2ui
Material 3 版 Basic Catalog
compose-ui-testing
测试 API

坑在哪?

第一,版本必须成套。只升一个 artifact,很容易出现 ABI 对不上的奇怪崩溃。建议在 version catalog 里把这六个当一个整体管,统一升级,不要各升各的。

第二,别拿它当稳定依赖用。官方文档写得很清楚:公共 API 到达 1.0.0 稳定版之后才承诺严格的二进制兼容。也就是说,现在这个阶段,小版本升级带来破坏性变更,是预期内的事。

第三,material3-a2ui 依赖 Compose Material3,最好跟你现有的 Compose 版本走同一个 BOM,避免出现两个 material3 版本打架。

解法:

  • 用 version catalog 锁一组验证过能跑通的版本,先别追新;
  • 在 CI 里加一条"依赖树里只能有一个 a2ui 版本"的检查;
  • 把 A2UI 相关代码集中在一个模块里,将来升级只改一处。

坑 2:直接上 Basic Catalog,界面不像自家app风格

很多人第一步是这么干的:装上 material3-a2ui,materialA2uiBasicCatalogV1(...) 一调,跑起来了。

效果确实不错,标准 Material 3 的按钮、卡片、列表,规规矩矩。

然后设计同学看了一眼:

"这界面……你咋没按照我设计的来"

Basic Catalog 只有 18 个通用组件,而且是按 Material 3 设计的。它能保证能渲染,但保证不了"像你"。对一个有成熟设计系统的 App 来说,直接全量用它,等于把品牌视觉交给了 Google 的默认主题。

三条路,选一条:

路线 A:全自定义。 从零写自己的 Catalog,每个组件都按品牌规范实现。

控制力最强,工作量也最大。每条组件都要实现 schema、就绪判断、渲染、动作派发。

路线 B:混合目录(推荐大多数团队)。 通用组件拿官方的,品牌组件写自己的。

路线 C:覆盖官方槽位。 保留 Basic Catalog 的整体结构,只把某几个组件换成自家的。

注意一个隐形成本:catalogId 是契约的一部分。

它的 URI 里必须带主版本号(/v1/、/v2/),而且必须和 Agent 侧持有的目录定义一致。你这边改了目录却没同步,轻则 Agent 匹配不上直接不发界面,重则发来一堆校验失败的属性。

解法:

  • 从混合目录起步,先跑通链路,再逐步替换;
  • Catalog 的 JSON 文件纳入版本管理,和 App 版本一起发布;
  • catalogId
     里带上团队、应用、版本三段信息,别用随便一个 URL 糊弄。

坑 3:界面出来了,图片视频一片空白

这是接入时最典型的一个"看起来能跑,其实没接完"。

原因是设计上的:A2UI 的库故意不打包任何媒体依赖。

不带 Coil、不带 Glide、不带 Media3。为什么?为了不跟你的 App 打架。你可能已经用 Coil 了,也可能用 Glide;可能用 ExoPlayer,也可能用系统播放器。如果库硬塞一个进来,依赖冲突几乎是必然的。

所以它把怎么加载图片这个决定权留给了你。你需要自己注入:

这里有两个容易忽略的点:

第一,onError 一定要传下去。图片加载失败不是静默的——它应该走到组件的错误状态,甚至回传给 Agent。你如果只是 Log.e 一下,界面上会留一个永久的空白。

第二,urlOpener 别直接 startActivity。用户可能在详情页里,也可能在浮层里;走你们 App 自己的路由或者 DeepLink 统一入口,不然返回栈会乱。

解法:

  • 把媒体注入当成"接入的必做项",不是可选项;
  • 写一个 A2uiMediaConfig 类集中管理,别散在 Activity 里;
  • 对每个注入点都写一条测试:图片失败、视频超时、URL 非法。

坑 4:组件先到,数据后到 , 空白的闪现

跑通之后,你会发现一个诡异的现象:

界面会闪一下空白,然后才有内容。

或者更糟:直接崩。

原因藏在协议时序里。Agent 是流式生成的,它很可能先发组件骨架,再发数据:

在第 ① 步,/promo/title 还不存在。如果你直接渲染,Text 会拿到空字符串——这就是那一下闪烁。如果你的代码里对空值做了非空断言,那就是崩溃。

正确姿势是 isReady:

isReady 的语义是"依赖的数据是否已经可以解析",不是"网络请求是否完成"。返回 false,框架会把这个组件保持在未就绪状态,等数据到了再渲染。

配套的还有两件事:

  • 读动态值必须用 bind()
    ,它会自动订阅后续更新;用 [] 读到的只是当前快照,数据更新了也不会重组。
  • 只判断"必须的数据"
    。可选属性缺失不该阻塞渲染,给个默认值就行:

顺带说一句 sendDataModel。

createSurface 里有个可选参数 sendDataModel,默认 false。设为 true 后,客户端会在发给创建者的每条消息里附带完整数据模型。

这很适合"多轮对话"场景——用户填了一半表单,下一轮 Agent 需要知道当前状态。但要注意它只发给创建该 surface 的服务端,不会泄露给其他 Agent。

解法:

  • 所有动态属性都用 bind,所有必填都进 isReady;
  • 组件里不要写 !!,所有取值路径都有兜底;
  • 用 mock 消息把"先组件后数据"的顺序测一遍,这是最容易漏测的时序。

坑 5:流式更新把界面刷成幻灯片

链路通了,数据也对了,然后你发现:消息一多,界面开始卡,列表还会串行。

这是接入中期最常见的性能问题。根源通常有四个:

5.1 列表 key 只用了组件 ID

模板生成的列表里,多个子组件可能共享同一个组件 ID,但数据路径不同。如果你只拿 ID 做 key:

Compose 会复用错误的实例,第2行的内容跑到第1行去。

正确写法是 ID + 数据路径:

5.2 action 闭包捕获了旧值

Agent 可以随时更新 action 定义。如果你在 remember 里直接捕获外层变量,按钮会一直调用旧的 action,表现就是"点了没反应"或者"参数不对"。

5.3 在 Content 里做重计算

Content 会随着每次数据更新重新执行。在里面做排序、格式化、图片解码,等于把重活挂在了重组路径上。

该 remember 的就 remember,该放 ViewModel 的就别塞组件里。

5.4 Modifier 被吞掉

组件不透传 modifier,父容器给的间距、权重全部失效。表现不一定是"卡",但一定是"布局诡异",而且很难查。

框架已经帮你做的部分,也别浪费:

  • Compose 渲染器基于 Snapshot 状态做细粒度重组,只重组受影响的子树。前提是你的组件是"正确订阅"的(用 bind),否则它只能整块重建。
  • 面对超大列表索引,数据层会自动从稠密数组切成稀疏映射,防止 OOM。这是兜底,不是你乱发索引的理由。

解法:

  • 列表 key 检查一遍:key(id, baseDataPath);
  • 所有回调检查一遍:rememberUpdatedState;
  • 抽一个性能基线:连续灌 100 条 updateDataModel,看帧率和重组次数。

坑 6:出错了,但没人知道

A2UI 的错误处理很"礼貌":未知组件拦截、schema 校验失败、组件渲染异常——大部分都会被优雅降级,然后把错误回传给 Agent。

用户体验很好:不崩,界面还在。

但你的监控上什么也看不到。

这是最容易埋雷的地方。上线之后用户反馈"有些卡片不显示",你翻遍 Crash 平台——零。因为根本没崩,只是被降级了。

解法:把错误当指标。

第一,旁路监听出站事件。A2uiMessageProcessor 的 outboundEvents 里既有用户动作,也有发回 Agent 的错误。挂一个旁路,把错误类型、surfaceId、组件名打出来:

第二,用 A2uiActionInterceptor。 初始化 processor 时可以传一个 action 拦截器,用户交互和动作派发的链路都能在这里观察、记录、甚至改写。这是天然的埋点入口。

第三,surface 生命周期也要记。 什么时候创建、什么时候删除、一个页面同时存在几个 surface——这些数字异常时,往往比崩溃更早暴露问题。

第四,小心重复创建。surfaceId 创建后不可变,对同一个 ID 重复 createSurface 会直接报错。页面重建、配置变更、重复请求,都可能触发重复创建。做好幂等,或者先删后建。

解法:

  • 未知组件率、校验失败率、降级率,做成三个看板指标;
  • 错误码里带上组件名和 catalogId,方便定位是客户端没实现、还是 Agent 发错了;
  • 灰度期间加日志开关,出问题能立刻拿到原始 JSON。

坑 7:灰度上线后,"AI 突然不发界面了"

最后一个坑最隐蔽,因为它不报错。

某天灰度放量,一部分用户发现:AI 只回文字,不显示界面了。

排查半天,代码没改、服务没挂。原因是协商失败。

回顾一下 A2UI 的协商机制:

  1. 客户端在每条消息的 metadata 里,带一份按偏好排序的 supportedCatalogIds;
  2. Agent 从中挑一个最匹配的;
  3. 挑不到?宁可不发 UI。

第 3 条是这个协议很诚实的地方:它不会硬塞一个渲染不了的东西。但对你来说,就是"功能悄悄消失了"。

常见的翻车方式:

  • 客户端发了 /v2/catalog.json,Agent 那边还只有 v1 —— 不匹配;
  • 两边目录同名不同版,URI 差一个字符 —— 不匹配;
  • 灰度只放开了客户端,Agent 的 prompt 和工具还没切 —— 不匹配;
  • 客户端声明列表的顺序改了,Agent 选中了另一个目录 —— 行为变了。

解法:多版本并存 + 本地兜底。

第一,客户端同时注册多个版本:

旧 Agent 匹配 v1,新 Agent 优先用 v2,迁移期两边都能活。

第二,声明顺序就是偏好顺序。 把最新、最想要的版本放前面,但别把旧版本删掉——那是你的安全网。

第三,给"没有界面"设计一个兜底。 协商失败时,Agent 不发 UI,你的页面不能是一片空白。降级到静态页面、纯文本卡片、或者一个"暂时无法展示"的提示,都比空白强。

第四,灰度要两端一起放。 客户端 catalog、Agent 侧 catalog、prompt 版本、模型版本,这四个变量最好绑成一个发布单元。任何一个单独动,都可能制造"只在一部分用户身上出现"的玄学问题。


写在最后

A2UI 的文档写得很好,但它写的是协议应该怎么工作,而你面对的,是协议在一个已经跑了五年的 App 里,怎么不死。这中间的差距,就是这7个坑。

好消息是,它们都不是技术难题——没有一个是需要你重写架构的。坏消息是,它们都需要有人在接缝处多看一眼。

真正决定接入成败的,往往不是你会不会写 Compose 组件,而是你有没有在第一次 createSurface 之前,就把"出错的时候怎么办"想清楚。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料