Site icon AppTractor

A2UI в Jetpack Compose: как ИИ-агенты могут создавать нативные Android-интерфейсы

Позвольте начать с ощущения, которое вы наверняка уже испытывали. Вы открываете приложение с ИИ-ассистентом и пишете:

«Забронируй столик на 4 человек завтра в 20:00, желательно на улице».

А ассистент отвечает:

«Конечно! Какой ресторан? В помещении или на улице? В 20:00 или 20:30? Есть ли особые пожелания? Ответьте, пожалуйста, на эти вопросы».

И вот вы уже гоняете целые предложения туда-сюда, просто чтобы заполнить то, что по сути является обычной формой. На телефоне. Медленно.

Любой Android-разработчик уже знает, как сделать лучше. Показать небольшую карточку. Слайдер для количества гостей, выбор даты и времени, два чипа — «В помещении» и «На улице» — и одну большую кнопку «Подтвердить». Одно нажатие — и готово.

Поэтому вот главный вопрос, ради которого вообще существует эта технология:

ИИ-агент знает, какой экран нужен пользователю. Ваше приложение знает, как его нарисовать. Как этим двум сторонам безопасно общаться друг с другом?

Именно эту задачу решает A2UI (Agent-to-UI). А теперь для Android существует и официальная библиотека Jetpack: Agent-to-UI renderer for Jetpack Compose.

Вот один и тот же запрос, обработанный старым способом и через A2UI.

К концу этого руководства вы полностью поймёте:

Эта статья задумана как единственный материал про A2UI, который вам понадобится. Берите кофе.

Часть 1: Почему бы просто не сделать всё очевидным способом?

До A2UI было несколько заманчивых вариантов. У каждого есть серьёзный недостаток.

Последний вариант выигрывает потому, что агент никогда не исполняет код на устройстве. Он отправляет только данные.

Часть 2: Одна идея, которую нужно запомнить

Если вы забудете всё остальное, оставьте в голове только эту картинку. Всё ниже — лишь детали поверх неё.

ИИ-агент заказывает по меню. Ваше приложение готовит блюдо. Агент никогда не заходит на кухню.

Клиент может заказать только то, что есть в меню. Он никогда не может зайти на кухню. А если закажет то, чего в меню нет, кухня спокойно отвечает: «У нас такого нет», а не загорается.

Вот и вся модель безопасности в одном предложении. Запомните её.

Часть 3: Так что же такое A2UI?

A2UI — открытый протокол Google для пользовательских интерфейсов, управляемых агентами. Официальный renderer для Jetpack Compose сейчас поддерживает версию спецификации 0.9.1.

Если говорить проще, A2UI — это:

Вот весь цикл целиком.

Поток всегда одинаков:

  1. Пользователь чего-то просит.
  2. Агент решает, что UI будет полезен, и начинает стримить A2UI-сообщения.
  3. Ваше приложение рисует нативный Compose UI на основе этих сообщений.
  4. Пользователь взаимодействует с интерфейсом — например, нажимает кнопку. Приложение отправляет действие обратно агенту.
  5. Агент отвечает новыми A2UI-сообщениями — обновляет UI, показывает подтверждение и так далее.

Часть 4: Пять слов, на которых держится всё

A2UI построен на пяти простых понятиях. Разберитесь в них — и всё остальное станет понятным.

  1. Surface. Один самостоятельный блок агентного UI, например одна карточка в чате. У него есть уникальный id, и он связан ровно с одним каталогом.
  2. Component. Один UI-элемент: Text, Button, Card, Slider, TextField и так далее. У каждого есть id и тип.
  3. Catalog. Меню. Формальный список компонентов, их свойств и функций, которые агент может использовать.
  4. Data Model. Небольшой JSON-объект, хранящий значения, которые показывает UI. Компоненты не хардкодят текст — они читают данные из этой модели.
  5. Action. То, что происходит при взаимодействии пользователя. Это либо событие, отправляемое агенту, либо локальная функция вроде «открыть ссылку».

Обратите внимание на разделение: компоненты описывают структуру — что рисовать, а data model хранит состояние — какие значения показывать. Именно это разделение делает A2UI таким эффективным.

Часть 5: Четыре сообщения, которые может отправлять агент

Это сердце протокола. Агент общается с приложением всего четырьмя типами сообщений. В каждом сообщении также есть поле version.

Обычно они приходят именно в таком порядке.

Посмотрим на настоящий 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" }
  }
}

Две небольшие детали здесь очень важны.

Часть 6: Почему компоненты передаются плоским списком, а не деревом

Когда мы думаем о UI, мы обычно представляем дерево: Card содержит Column, внутри которого находятся Text и Button. Почему же A2UI отправляет плоский список, в котором каждый компонент просто ссылается на своих детей по id?

Потому что именно так работают LLM. Модель генерирует текст токен за токеном. Если бы весь UI представлял собой один гигантский вложенный объект, приложение не смогло бы безопасно нарисовать ничего до самого последнего закрывающего символа.

С плоским списком каждый компонент — небольшой, законченный и независимый объект. Приложение может сохранить его сразу после получения и отрисовать всё, что уже возможно.

Это даёт четыре преимущества:

Часть 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.

Списки используют шаблон и массив

Допустим, агент хочет показать доступные временные слоты, но заранее не знает, сколько их будет. Вместо одного компонента на каждый слот он отправляет один шаблон и связывает его с массивом.

Правило простое:

Один шаблон превращается в три строки. Если агент позже пришлёт пять слотов, вы получите пять строк — без повторной отправки UI.

Поля ввода используют двусторонний binding

Такие компоненты, как TextField, Slider, CheckBox и ChoicePicker, используют двусторонний биндинг (привязку). Они читают значения из data model и записывают изменения обратно, когда пользователь взаимодействует с интерфейсом.

Есть два очень важных правила:

Это сохраняет ввод быстрым и приватным. Агент никогда не видит недописанный текст.

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 для обновлений и не зависит от конкретной дизайн-системы, поэтому вы можете использовать свою.

Архитектура разделена на несколько слоёв, у каждого своя чёткая задача.

Артефакты снизу вверх:

Главное: ядро не зависит от дизайн-системы. Можно использовать 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 происходят три простые вещи.

Шаг 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: Каталоги — меню на практике

Есть три способа предоставить агенту «меню».

Использование 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))
        }
    }
}

Здесь важно понять две вещи.

Компонент ввода

Для полей ввода нужно получить значение и возможность записывать изменения обратно. Для этого используется 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. Практичный подход:

  1. Записывайте реальные ответы агента в файлы, по одному A2UI-сообщению на строку.
  2. Проигрывайте их в тестах через processor.processInput(parser, json).
  3. Проверяйте отрендеренное Compose-дерево обычными Compose testing API.
  4. Храните отдельную папку с намеренно «плохим» JSON (неизвестные компоненты, неправильные типы, отсутствующий root). Проверяйте, что UI показывает fallback вместо падения и что ошибка действительно отправляется наружу.

Так утверждение «LLM может вернуть что угодно» превращается в обычный воспроизводимый тестовый кейс.

Часть 15: Когда стоит использовать A2UI?

Хорошо подходит:

Скорее не подходит:

Простое правило: если агент решает, что показывать, используйте A2UI. Если вы уже знаете, что показывать, пишите Compose напрямую.

Часть 16: Несколько полезных привычек

Всё это в одной строке

Многие годы «ИИ в мобильных приложениях» в основном означал чат-пузырь с текстом внутри. 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

Удачного кодинга!

Источник

Exit mobile version