Позвольте начать с ощущения, которое вы наверняка уже испытывали. Вы открываете приложение с ИИ-ассистентом и пишете:
«Забронируй столик на 4 человек завтра в 20:00, желательно на улице».
А ассистент отвечает:
«Конечно! Какой ресторан? В помещении или на улице? В 20:00 или 20:30? Есть ли особые пожелания? Ответьте, пожалуйста, на эти вопросы».
И вот вы уже гоняете целые предложения туда-сюда, просто чтобы заполнить то, что по сути является обычной формой. На телефоне. Медленно.
Любой Android-разработчик уже знает, как сделать лучше. Показать небольшую карточку. Слайдер для количества гостей, выбор даты и времени, два чипа — «В помещении» и «На улице» — и одну большую кнопку «Подтвердить». Одно нажатие — и готово.
Поэтому вот главный вопрос, ради которого вообще существует эта технология:
ИИ-агент знает, какой экран нужен пользователю. Ваше приложение знает, как его нарисовать. Как этим двум сторонам безопасно общаться друг с другом?
Именно эту задачу решает A2UI (Agent-to-UI). А теперь для Android существует и официальная библиотека Jetpack: Agent-to-UI renderer for Jetpack Compose.
Вот один и тот же запрос, обработанный старым способом и через A2UI.
К концу этого руководства вы полностью поймёте:
- почему ИИ-агентам вообще нужен протокол UI;
- что такое A2UI и какие пять понятий лежат в его основе;
- какие четыре сообщения может отправлять агент и как работает data binding;
- как устроен Jetpack Compose renderer и как пошагово его подключить;
- как создавать собственные компоненты и собственный каталог дизайн-системы;
- как A2UI сохраняет высокую производительность, справляется с ошибками ИИ и переживает обновления приложения.
Эта статья задумана как единственный материал про A2UI, который вам понадобится. Берите кофе.
Часть 1: Почему бы просто не сделать всё очевидным способом?
До A2UI было несколько заманчивых вариантов. У каждого есть серьёзный недостаток.
- Только текст. Агент просто отвечает сообщениями. Просто, но медленно и мучительно для всего, что похоже на форму.
- Пусть ИИ генерирует код — Kotlin, JavaScript. Звучит мощно, но тогда внутри приложения будет выполняться код, написанный моделью. Это кошмар с точки зрения безопасности: такой механизм можно атаковать через prompt injection, а на Android к тому же нельзя просто так компилировать Kotlin во время выполнения.
- Возвращать HTML в WebView. Безопаснее, чем исполнять код, но это не ощущается нативно. Такой UI игнорирует вашу Material-тему, шрифты, тёмный режим и accessibility.
- Пусть агент описывает UI, а приложение рисует его нативно. Это и есть A2UI. Агент отправляет небольшое описание. Ваше приложение строит из него настоящие Compose-компоненты, используя вашу тему и только те компоненты, которые вы разрешили.
Последний вариант выигрывает потому, что агент никогда не исполняет код на устройстве. Он отправляет только данные.
Часть 2: Одна идея, которую нужно запомнить
Если вы забудете всё остальное, оставьте в голове только эту картинку. Всё ниже — лишь детали поверх неё.
ИИ-агент заказывает по меню. Ваше приложение готовит блюдо. Агент никогда не заходит на кухню.
- Клиент — это ИИ-агент. Он решает, чего хочет.
- Меню — это каталог. В нём перечислено ровно то, что можно заказать.
- Кухня — это ваше приложение. Оно решает, как именно готовится каждое блюдо, используя собственные рецепты — ваш Compose-код и дизайн-систему.
Клиент может заказать только то, что есть в меню. Он никогда не может зайти на кухню. А если закажет то, чего в меню нет, кухня спокойно отвечает: «У нас такого нет», а не загорается.
Вот и вся модель безопасности в одном предложении. Запомните её.
Часть 3: Так что же такое A2UI?
A2UI — открытый протокол Google для пользовательских интерфейсов, управляемых агентами. Официальный renderer для Jetpack Compose сейчас поддерживает версию спецификации 0.9.1.
Если говорить проще, A2UI — это:
- Декларативный, а не исполняемый подход. Агент отправляет описание UI в JSON, но никогда не отправляет код.
- Каталоги компонентов. Ваше приложение публикует список поддерживаемых компонентов, и агент может использовать только их.
- Поддержка стриминга. UI может появляться по частям, пока модель ещё продолжает генерировать ответ.
- Независимость от транспорта. A2UI всё равно, как передаётся JSON. Это может быть A2A, AG-UI, MCP, WebSocket или обычный HTTP.
- Независимость от UI-фреймворка. Один и тот же JSON можно отрисовать веб-рендерером, Flutter-рендерером или Jetpack Compose renderer.
Вот весь цикл целиком.
Поток всегда одинаков:
- Пользователь чего-то просит.
- Агент решает, что UI будет полезен, и начинает стримить A2UI-сообщения.
- Ваше приложение рисует нативный Compose UI на основе этих сообщений.
- Пользователь взаимодействует с интерфейсом — например, нажимает кнопку. Приложение отправляет действие обратно агенту.
- Агент отвечает новыми A2UI-сообщениями — обновляет UI, показывает подтверждение и так далее.
Часть 4: Пять слов, на которых держится всё
A2UI построен на пяти простых понятиях. Разберитесь в них — и всё остальное станет понятным.
- Surface. Один самостоятельный блок агентного UI, например одна карточка в чате. У него есть уникальный
id, и он связан ровно с одним каталогом. - Component. Один UI-элемент:
Text,Button,Card,Slider,TextFieldи так далее. У каждого естьidи тип. - Catalog. Меню. Формальный список компонентов, их свойств и функций, которые агент может использовать.
- Data Model. Небольшой JSON-объект, хранящий значения, которые показывает UI. Компоненты не хардкодят текст — они читают данные из этой модели.
- Action. То, что происходит при взаимодействии пользователя. Это либо событие, отправляемое агенту, либо локальная функция вроде «открыть ссылку».
Обратите внимание на разделение: компоненты описывают структуру — что рисовать, а data model хранит состояние — какие значения показывать. Именно это разделение делает A2UI таким эффективным.
Часть 5: Четыре сообщения, которые может отправлять агент
Это сердце протокола. Агент общается с приложением всего четырьмя типами сообщений. В каждом сообщении также есть поле version.
createSurfaceоткрывает новую surface и выбирает для неё каталог. «Открой стол №5 и используй меню обеда».updateComponentsдобавляет или обновляет компоненты в surface. «Вот что положить на тарелку».updateDataModelзадаёт или изменяет значения в data model. «Сделай поострее».deleteSurfaceполностью удаляет surface. «Стол №5 освобождён».
Обычно они приходят именно в таком порядке.
Посмотрим на настоящий JSON. Сначала агент создаёт surface:
{
"version": "v0.9",
"createSurface": {
"surfaceId": "booking_1",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"sendDataModel": true
}
}
sendDataModel: true означает: «всякий раз, когда эта surface отправляет что-то обратно агенту, прикладывай всю data model». Очень удобно для форм.
Затем агент отправляет структуру:
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "booking_1",
"components": [
{ "id": "root", "component": "Column", "children": ["title", "subtitle"] },
{ "id": "title", "component": "Text", "text": "Book a table" },
{ "id": "subtitle", "component": "Text", "text": { "path": "/restaurant/name" } }
]
}
}
Затем данные:
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": "booking_1",
"path": "/restaurant",
"value": { "name": "Spice Route, Connaught Place" }
}
}
Две небольшие детали здесь очень важны.
- Во-первых, должен существовать ровно один компонент с
id"root". Именно с него начинается отрисовка. Если другие компоненты пришли раньшеroot, они просто сохраняются до тех пор, покаrootне появится. - Во-вторых, текст
subtitle— не фиксированная строка. Это{ "path": "/restaurant/name" }. Компонент читает значение из data model. Это называется data binding, и именно благодаря ему агент может изменить экран, отправив маленькое обновление данных, а не пересылая весь UI заново.
Часть 6: Почему компоненты передаются плоским списком, а не деревом
Когда мы думаем о UI, мы обычно представляем дерево: Card содержит Column, внутри которого находятся Text и Button. Почему же A2UI отправляет плоский список, в котором каждый компонент просто ссылается на своих детей по id?
Потому что именно так работают LLM. Модель генерирует текст токен за токеном. Если бы весь UI представлял собой один гигантский вложенный объект, приложение не смогло бы безопасно нарисовать ничего до самого последнего закрывающего символа.
С плоским списком каждый компонент — небольшой, законченный и независимый объект. Приложение может сохранить его сразу после получения и отрисовать всё, что уже возможно.
Это даёт четыре преимущества:
- Прогрессивный рендеринг. Родитель может появиться, пока дочерние элементы ещё загружаются.
- Произвольный порядок. Агент может отправлять компоненты в любом порядке.
- Дешёвые обновления. Чтобы изменить одну кнопку, агент повторно отправляет только этот компонент с тем же
id. - Простая валидация. Приложение может проверить, что каждая ссылка по
idуказывает на реально существующий компонент.
Часть 7: Data binding, медленно и подробно
Именно эта часть обычно вызывает больше всего вопросов, поэтому разберём её по шагам.
Значения могут быть трёх типов:
"text": "Hello" // 1. fixed value
"text": { "path": "/user/name" } // 2. read from the data model
"text": { "call": "formatString", // 3. built by a function
"args": { "value": "Hi ${/user/name}!" } }
Пути вроде /user/name — обычные указатели внутрь JSON data model.
Списки используют шаблон и массив
Допустим, агент хочет показать доступные временные слоты, но заранее не знает, сколько их будет. Вместо одного компонента на каждый слот он отправляет один шаблон и связывает его с массивом.
Правило простое:
- путь, начинающийся с
/, является абсолютным. Он всегда читается от корня, поэтому/restaurant/nameодинаков для каждой строки; - путь без начального
/является относительным к текущему элементу списка, поэтомуtimeпревращается в/slots/0/time,/slots/1/timeи так далее.
Один шаблон превращается в три строки. Если агент позже пришлёт пять слотов, вы получите пять строк — без повторной отправки UI.
Поля ввода используют двусторонний binding
Такие компоненты, как TextField, Slider, CheckBox и ChoicePicker, используют двусторонний биндинг (привязку). Они читают значения из data model и записывают изменения обратно, когда пользователь взаимодействует с интерфейсом.
Есть два очень важных правила:
- Ввод текста не вызывает сетевой запрос. Каждое нажатие клавиши обновляет только локальную data model на устройстве.
- Данные отправляются агенту только при выполнении action. Когда пользователь нажимает кнопку, action может передать выбранные значения или всю модель целиком, если включён
sendDataModel.
Это сохраняет ввод быстрым и приватным. Агент никогда не видит недописанный текст.
Actions, функции и валидация
Action кнопки может отправлять событие агенту:
"action": {
"event": {
"name": "confirm_booking",
"context": {
"guests": { "path": "/booking/guests" },
"seating": { "path": "/booking/seating" }
}
}
}
Или вызвать локальную функцию без обращения к серверу:
"action": { "functionCall": { "call": "openUrl", "args": { "url": "${/restaurant/website}" } } }
Обратите внимание: функции вызываются по имени. Агент никогда не отправляет код функции. Он может вызвать только те функции, которые ваш каталог уже зарегистрировал, например openUrl, required, regex, email или formatCurrency.
Поля ввода и кнопки также могут объявлять проверки. Если проверка не проходит — например, номер телефона пустой — кнопка автоматически становится недоступной. Агент может выразить условие «активировать Confirm только тогда, когда форма валидна», вообще не написав никакого кода.
Часть 8: Знакомство с Jetpack Compose renderer
Всё, что было выше, относится к протоколу и одинаково работает на вебе, Flutter и Android. Теперь — Android-часть.
Agent-to-UI renderer for Jetpack Compose — официальная реализация AndroidX. Она преобразует A2UI JSON в Compose-компоненты, использует собственную систему состояния Compose для обновлений и не зависит от конкретной дизайн-системы, поэтому вы можете использовать свою.
Архитектура разделена на несколько слоёв, у каждого своя чёткая задача.
Артефакты снизу вверх:
androidx.a2ui:a2ui-modelиandroidx.a2ui:a2ui-engine: типы данных протокола, валидация схемы и обработка сообщений;androidx.a2ui.compose:compose-runtime: реактивное состояние на базе Snapshot и точечные обновления;androidx.a2ui.compose:compose-ui: API компонентов и каталогов —A2uiComponent,A2uiCatalog,A2uiProperty;androidx.compose.material3:material3-a2ui: готовый Basic Catalog на Material 3;androidx.a2ui.compose:compose-ui-testing: API для тестирования.
Главное: ядро не зависит от дизайн-системы. Можно использовать Material 3 Basic Catalog, сделать полностью собственный каталог или смешать оба варианта.
Вот что происходит с одним сообщением внутри рендера.
JSON разбирается, валидируется по схеме, затем обновляются компоненты и data model, после чего выполняется отрисовка. Если сообщение некорректно или содержит неизвестный компонент, наружу отправляется ошибка, чтобы агент мог исправить себя.
Запоминать весь конвейер не нужно. Достаточно понимать, что он существует и защищает ваш UI.
Часть 9: Настройка шаг за шагом
Шаг 0: Добавьте зависимости
Эти библиотеки пока находятся в alpha, поэтому всегда выбирайте самые свежие версии на страницах релизов AndroidX.
dependencies {
implementation("androidx.a2ui.compose:compose-runtime:<latest-version>")
implementation("androidx.a2ui.compose:compose-ui:<latest-version>")
// Optional: ready-made Material 3 Basic Catalog
implementation("androidx.compose.material3:material3-a2ui:<latest-version>")
androidTestImplementation("androidx.a2ui.compose:compose-ui-testing:<latest-version>")
}
Шаг 1: Создайте слой данных во ViewModel
Парсер и processor создаются один раз. Processor знает о вашем каталоге.
class AgenticUiViewModel(
private val agentClient: AgentClient // your own networking layer
) : ViewModel() {
private val parser = A2uiMessageParser()
private val processor = A2uiMessageProcessor(
catalogs = listOf(appCatalog) // Material 3, custom, or both
)
// The active surfaces, exposed to the UI
val a2uiSurfaces: StateFlow<List<A2uiSurfaceModel>> = processor.activeSurfaces
init {
// process incoming messages off the main thread
viewModelScope.launch(Dispatchers.Default) { processor.collectMessages() }
// send user actions and errors back to the agent
viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
processor.outboundEvents.collect(::handleOutboundEvent)
}
// feed every A2UI message from the agent into the processor
viewModelScope.launch {
agentClient.a2uiMessages().collect { json -> onNetworkMessage(json) }
}
}
fun onNetworkMessage(json: String) = processor.processInput(parser, json)
}
В этом init происходят три простые вещи.
- Вход: JSON-сообщения от агента поступают через
onNetworkMessage. - Обработка:
collectMessages()выполняет работу вне оснвного потока. - Выход: пользовательские действия и ошибки уходят через
outboundEvents, после чего вы отправляете их обратно агенту.AgentClientздесь — ваш собственный код, использующий любой выбранный транспорт.
Шаг 2: Нарисуйте surfaces
Если используется Material 3 Basic Catalog, отрисовка почти сводится к одной строке на каждую surface.
@Composable
fun AgenticUiScreen(viewModel: AgenticUiViewModel) {
val surfaces by viewModel.a2uiSurfaces.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
surfaces.forEach { surface ->
key(surface.id) {
A2uiSurface(surfaceModel = surface, modifier = Modifier.padding(16.dp))
}
}
}
}
A2uiSurface уже умеет обрабатывать состояния загрузки, ошибки и успеха с плавными анимированными переходами. Каждое состояние можно настроить:
A2uiSurface(
surfaceModel = surface,
loadingContent = { CircularProgressIndicator() },
errorContent = { e -> Text("Couldn't show this card: ${e.message}") },
transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(400)) }
)
Это важно, потому что каждый компонент — и вся surface целиком — всегда находится в одном из трёх состояний.
Поскольку каждый уровень независимо обрабатывает своё состояние, один сломанный дочерний компонент никогда не уничтожает всю карточку. Ошибку покажет только этот конкретный фрагмент, а всё вокруг продолжит работать.
Часть 10: Каталоги — меню на практике
Есть три способа предоставить агенту «меню».
- A: Простой каталог на Material 3. Самый быстрый для старта. В нём уже есть стандартные компоненты —
Text,Button,Card,TextField,Sliderи другие — построенные на Material 3. - B: Собственный каталог. Ваши компоненты и полный контроль над брендингом.
- C: Гибридный вариант. Рекомендуемый для начала. Используйте Material 3 для базовых элементов, добавьте собственные компоненты — для бизнес-логики, например
PriceTagилиRestaurantCard.
Использование Basic Catalog выглядит так. В него намеренно не встроена библиотека для изображений или видео — вы подключаете ту, которой уже пользуетесь:
val basicCatalog = materialA2uiBasicCatalogV1(
image = MaterialA2uiBasicCatalogV1Defaults.image { url, desc, scale, modifier, onError ->
AsyncImage(model = url, contentDescription = desc,
contentScale = scale, modifier = modifier,
onError = { onError(it.result.throwable) })
},
urlOpener = { url -> appNavigator.openUrl(url) },
messageFormatter = { pattern, locale, args -> MessageFormat.format(context, locale, pattern, args) },
localeProvider = A2uiLocaleProvider.Default
)
Обратите внимание на urlOpener. Когда агент вызывает функцию openUrl, именно ваш код решает, что произойдёт. Вы можете проверить домен, открыть Custom Tab или вообще отказать. Агент никогда не получает контроль над устройством.
Откуда агент вообще знает, какое меню поддерживает ваше приложение? Через capability negotiation. Приложение сообщает catalogId, которые оно поддерживает, а агент выбирает один из них при создании surface.
Также можно объявить несколько версий каталога одновременно. Это ключ к тому, чтобы переживать обновления приложения — к этому мы ещё вернёмся.
Часть 11: Создаём собственный компонент
Собственный компонент — это Kotlin-объект, реализующий интерфейс A2uiComponent. У него пять простых задач.
Свойства объявляются через A2uiProperty. Это удобно, потому что одно объявление используется сразу для двух вещей: генерации JSON Schema, которую читает агент, и чтения значения во время выполнения. Таким образом, ваш код и представление агента о компоненте не могут разойтись.
Компонент отображения
Вот PriceTag для приложения с ресторанами. Агент отправляет {«component»: «PriceTag», «amount»: { «path»: «/restaurant/avgPrice» }}, а компонент отображает его:
object PriceTagComponent : A2uiComponent {
private val amountProp = A2uiProperty.dynamicString("amount", required = true)
override val name = "PriceTag"
override val description = "Shows the average price for two people, in rupees."
override val properties = listOf(amountProp)
// don't draw until the bound value has actually arrived
@Composable
override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties) =
properties.bind(amountProp) != null
@Composable
override fun A2uiComponentScope.Content(
properties: A2uiComponentProperties, modifier: Modifier
) {
val amount = properties.bind(amountProp) ?: "" // reactive: updates on its own
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
Text("₹$amount for two", modifier = Modifier.padding(12.dp, 6.dp))
}
}
}
Здесь важно понять две вещи.
properties.bind(prop)читает значение и подписывается на его изменения. Когда/restaurant/avgPriceменяется, компонент сам перерисовывается.isReadyудерживает компонент в состоянии загрузки до тех пор, пока важное значение реально не появится. Именно это позволяет делать прогрессивный рендеринг, чтобы пользователь не видел карточку с пустыми полями.
Компонент ввода
Для полей ввода нужно получить значение и возможность записывать изменения обратно. Для этого используется bindUpdater:
val valueProp = A2uiProperty.dynamicBoolean("value")
val checked = properties.bind(valueProp) ?: false
val onChange = properties.bindUpdater(valueProp) // may be null!
Switch(
checked = checked,
onCheckedChange = onChange,
enabled = onChange != null // read-only if the agent sent a fixed value, not a path
)
Почему bindUpdater может вернуть null? Потому что агент мог передать фиксированное значение: «value»: true, а не path. В фиксированное значение нельзя записывать, поэтому renderer сообщает, что оно read-only. Корректно обработать такой случай — хорошая практика.
Контейнер, который рендерит дочерние элементы
Контейнеры отображают своих детей, наблюдая за состоянием каждого из них. Именно здесь плоский список компонентов соединяется с прогрессивным рендерингом:
val childReferences = properties.bindChildReferences(childrenProp) ?: return
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
childReferences.forEach { reference ->
key(reference.id, reference.baseDataPath) {
when (val childState = observeA2uiComponentState(reference)) {
is A2uiComponentState.Loading -> LinearProgressIndicator()
is A2uiComponentState.Error -> Text("Couldn't load this item")
is A2uiComponentState.Success -> A2uiComponent(childState.component)
}
}
}
}
key(reference.id, reference.baseDataPath) — маленькая, но важная деталь. В списке шаблонов один и тот же component id переиспользуется для каждого элемента, поэтому одного id недостаточно для уникальности. baseDataPath, например /slots/0 или /slots/1, делает ключ каждого элемента уникальным.
Как всё сходится: рекурсия
A2uiComponent(...) — не конкретный виджет. Это маршрутизатор. Он смотрит на тип компонента, находит соответствующую реализацию в каталоге и вызывает её Content. Этот Content может снова вызвать A2uiComponent для своих детей. И так далее, вниз по дереву.
Каждый уровень независимо обрабатывает loading, error и success. Именно поэтому UI получается таким устойчивым.
Часть 12: Полный пример, кадр за кадром
Соберём всё вместе на примере карточки бронирования.
Агент отправляет createSurface, затем layout, затем данные. Вот что пользователь реально видит с течением времени.
В момент 0 surface создана, но пустая — показывается спиннер. Когда приходят компоненты, появляется layout с плейсхолдерами. Когда приходят данные, значения заполняются, и карточка становится полностью интерактивной.
Теперь пользователь передвигает слайдер на 6 и вводит примечание. В сеть ничего не отправляется. Меняется только локальная data model. Затем пользователь нажимает Confirm. Приложение подставляет значения и отправляет агенту одно действие. Агент бронирует столик и просто заменяет root-компонент на сообщение: «You’re booked!». При этом количество гостей повторно не отправляется — оно уже находится в data model.
Вот полный round trip на одной картинке.
Обратите внимание, как агент повторно использовал /booking/guests, не пересылая это значение. Именно в этом преимущество разделения структуры и данных.
Часть 13: Три вещи, которые делают это готовым к production
1. Перерисовывается только изменившаяся часть
Data model живёт в состоянии Compose Snapshot. Когда компонент вызывает properties.bind(…), Compose запоминает, от какого именно пути зависит этот компонент. Поэтому, когда агент обновляет одно значение, recomposition происходит только у того компонента, который читает это значение. Всё остальное пропускается.
Для агентов, стримящих частые обновления — live score, цены, progress bar — это огромный выигрыш в производительности по сравнению с полной перерисовкой карточки.
2. Система ожидает, что ИИ будет ошибаться
LLM галлюцинируют. Они могут придумать несуществующий компонент, отправить число там, где ожидалась строка, сослаться на дочерний элемент, которого нет. Рендер изначально рассчитан на это. Каждое сообщение проходит валидацию по схеме до попадания в UI. Если что-то не так, рендер помечает ошибкой только этот фрагмент, сохраняет остальной UI рабочим, отправляет ошибку обратно агенту, чтобы тот мог исправиться на следующем ходе.
Приложение не падает. Пользователь видит аккуратный fallback. Агент получает точную ошибку, которую может исправить.
3. Старые версии приложения продолжают работать
Пользователи не обновляют приложение в один и тот же день. Поэтому каталог версионируется, и можно одновременно зарегистрировать несколько его версий. При радикальных изменениях вы добавляете новую версию и сохраняете старую. Старые приложения в Play Store продолжают использовать прежний каталог, новые — новый, и никто не встречается с неработающим экраном.
На уровне библиотеки действует тот же принцип. Поскольку это библиотеки AndroidX, после достижения API версии 1.0.0 публичные интерфейсы не получают ломающих изменений. Новые возможности протокола добавляются как обратно совместимые расширения с default-значениями, а действительно несовместимые изменения приводят к появлению нового интерфейса, например A2uiComponentV2, рядом со старым.
Часть 14: Тестирование A2UI-интерфейсов
Для этого существует отдельный testing artifact: androidx.a2ui.compose:compose-ui-testing. Практичный подход:
- Записывайте реальные ответы агента в файлы, по одному A2UI-сообщению на строку.
- Проигрывайте их в тестах через
processor.processInput(parser, json). - Проверяйте отрендеренное Compose-дерево обычными Compose testing API.
- Храните отдельную папку с намеренно «плохим» JSON (неизвестные компоненты, неправильные типы, отсутствующий
root). Проверяйте, что UI показывает fallback вместо падения и что ошибка действительно отправляется наружу.
Так утверждение «LLM может вернуть что угодно» превращается в обычный воспроизводимый тестовый кейс.
Часть 15: Когда стоит использовать A2UI?
Хорошо подходит:
- для чатов и ассистентов, которым нужны формы, карточки, picker’ы и подтверждения;
- для сценариев, где следующий вопрос зависит от предыдущего ответа;
- для удалённых субагентов — путешествия, платежи, поддержка — которым нужно показывать UI внутри приложения;
- когда один агентный backend должен обслуживать Android, web и Flutter одним и тем же JSON.
Скорее не подходит:
- для статических экранов, которые никогда не меняются — просто пишите обычный Compose;
- для игр или сложных кастомных анимаций;
- для pixel-perfect интерфейсов ручной работы, где агент вообще не участвует.
Простое правило: если агент решает, что показывать, используйте A2UI. Если вы уже знаете, что показывать, пишите Compose напрямую.
Часть 16: Несколько полезных привычек
- Начинайте с гибридного каталога: Material 3 для примитивов, свои компоненты — для предметной области.
- Делайте кастомные компоненты небольшими: лучше
PriceTag, чем целый экран. Модели лучше собирают интерфейсы из маленьких частей. - Пишите понятные
descriptionдля компонентов и свойств. Агент буквально читает их. - Всегда реализуйте
isReadyдля компонентов, которые выглядят плохо без данных. - Используйте
key(...)в контейнерах и обязательно добавляйтеbaseDataPathдля template-children. - Выполняйте processing loop вне main thread.
- Версионируйте каталог с первого дня.
- Не предполагайте, что
bindUpdaterвсегда неnull. Фиксированные значения read-only. - Не отправляйте агенту каждое нажатие клавиши. Двусторонний binding намеренно локальный. Отправляйте данные по action.
- Всё, что выходит из приложения, всё равно нужно валидировать: ссылки, платежи, персональные данные. JSON агента проверяет renderer, но финальные решения о доверии остаются за вами.
Всё это в одной строке
Многие годы «ИИ в мобильных приложениях» в основном означал чат-пузырь с текстом внутри. A2UI меняет это. Он даёт агентам безопасный и структурированный способ сказать «Покажи пользователю форму». А вам, разработчику, оставляет полный контроль над тем как эта форма выглядит, как она себя ведёт и насколько она безопасна.
Jetpack Compose renderer реализует эту идею очень по-андроидовски. Он использует Snapshot state для точечных обновлений, анимированные переходы между состояниями, чистую систему каталога для вашей дизайн-системы и защитную обработку ошибок, заранее предполагающую, что модель может ошибиться.
Технология всё ещё молодая — библиотеки в alpha, протокол движется к версии 1.0 — поэтому некоторые API ещё изменятся. Но сама ментальная модель уже не изменится. Если нужно запомнить всего одну строку, запомните эту:
Агент заказывает по меню. Ваше приложение готовит блюдо. Агент никогда не заходит на кухню.
Поймёте это — поймёте A2UI.
Ресурсы
Официальное руководство: Agent-to-UI renderer for Jetpack Compose на developer.android.com.
Спецификация и протокол A2UI (v0.9.1): a2ui.org
Исходный код: github.com/a2ui-project/a2ui
Удачного кодинга!

