Технологии Backend-Driven UI уже давно используются во многих компаниях, включая Альфа-Банк. Существует множество реализаций этого подхода, и недавно Google представил собственное решение — Remote Compose.
Remote Compose выглядит очень перспективной технологией. Фреймворк активно развивается и поддерживается командой Google. Однако на момент написания статьи технология всё ещё находится в alpha-версии, поэтому использовать её на проде пока рано.
Но я изучил этот фреймворк и хочу поделиться своим опытом, а когда Remote Compose выйдет в бета-версию вы будете знать, как с ним работать
В статье разберём:
общую концепцию Remote Compose,
чем он отличается от классического BDUI,
какие интересные технические решения используются внутри,
несколько практических примеров использования.
Что делает Remote Compose особенным
Сегодня существует множество реализаций концепции BDUI. В большинстве из них интерфейс описывается на сервере (обычно в формате JSON), после чего на клиенте происходит маппинг этого JSON на нативные компоненты по некой схеме. Итого, если говорить о выпуске новых фич без релизов, обычно используют два подхода:
маппинг JSON в нативные компоненты (такой подход будем называть просто BDUI),
WebView.
У обоих подходов есть недостатки. Проблемы WebView хорошо известны, а ограничения BDUI обычно не упоминаются при сравнении с нативом или WebView:
неудобство верстки на JSON;
поддержка схемы — часто для реализации новых дизайнерских решений нужно обновлять схему компонентов и выпускать обновление с доработками приложения.
Remote Compose предлагает новый подход, который сохраняет преимущества BDUI и одновременно решает часть его ограничений:
Описание интерфейса на привычном Compose с использованием всех возможностей Kotlin.
Отсутствие схемы компонентов. Вместо неё используется набор низкоуровневых операций для отрисовки и управления состоянием, что позволяет описывать интерфейсы любой сложности.
Как устроен Remote Compose
Основные механизмы фреймворка уже хорошо описаны в этой статье. Тем не менее, для полноты картины разберём их ещё раз, затронем некоторые изменения произошедшие с момента выхода статьи, а также посмотрим, как фреймворк работает под капотом.
В основе Remote Compose лежат два ключевых принципа.
№1. Использование декларативного языка (Compose) для описания интерфейса и последующая его сериализация
Суть принципа заключается в том, чтобы на этапе создания запустить код, написанный на Compose, и параллельно записать в документ все операции, которые были выполнены для отрисовки этого интерфейса.
В этот документ попадают:
операции построения интерфейса,
области клика,
анимации,
состояния.
Таким образом, документ содержит в себе исчерпывающую информацию о том, как воспроизвести интерфейс. Дальше этот документ сериализуется в byteArray и отправляется на клиент.
2. Независимый от платформы и версии приложения рендеринг
Клиент получает массив байт и воспроизводит записанные в нём операции. Эти операции достаточно низкоуровневые, чтобы их одинаково интерпретировать на разных платформах. Благодаря этому один и тот же документ может корректно отрисовываться независимо от версии приложения или платформы клиента.
Для наглядности ниже приведена схема, сравнивающая пайплайн формирования интерфейса в Remote Compose и классическом BDUI.

Модель операций
Ранее в статье уже упоминались операции, теперь разберём подробнее, что они из себя представляют.
Remote Compose определяет набор операций, который в совокупности покрывает все аспекты построения интерфейса: отрисовку, компоновку элементов, управление состоянием, анимации и обработку пользовательских взаимодействий. На момент написания статьи библиотека содержит 141 операцию.
Операции отрисовки
Этот набор операций во многом повторяет методы, доступные в Android Canvas: отрисовка фигур с заданными координатами и размерами, текста, цветов и других графических элементов.
Операции компоновки
В эту категорию относятся Container, Component и Layout операции.
Container — это операции, которые содержат внутри себя другие операции.
Component — это разновидность Container-операций, которые дополнительно реализуют интерфейс Measurable и наследуются от PaintOperation, то есть могут участвовать в отрисовке.
Layout-операции задают взаиморасположение компонентов.
Операции управления состоянием и анимациями
Операции состояния можно сравнить со State<T> в обычном Compose. Их задача — объявить в документе переменные, на изменения которых могут подписываться другие операции. Одни переменные могут быть зависимы от других. Для описания таких связей применяются Expression, по аналогии с deriverStateOf<T>. Анимации — это частный случай Expression-операций, где одна из переменных это время.
Операции взаимодействий
Операции взаимодействия определяют интерактивные зоны интерфейса. Они описывают:
координаты и размеры области взаимодействия (например, клика),
действие, которое должно произойти при взаимодействии.
Таким действием может быть:
изменение переменной состояния,
отправка события наружу.
Для наглядности представим, как могла бы выглядеть кнопка, описанная на языке операций:
Нарисовать прямоугольник размером 120 x 56 dp с координатами (0, 0).
Задать скругление углов радиусом 12.
Залить фигуру красным цветом.
Нарисовать текст "Отправить" размером 14sp.
Добавить зону клика размером 120 x 56 dp с действием "Submit".
Ключевое преимущество такой модели в том, что клиент и сервер не должны договариваться о высокоуровневых компонентах вроде «кнопки». Вместо этого интерфейс описывается через примитивные операции, из которых можно собрать любой UI.
Формирование документа (Creation)
Самое интересное происходит на creation-стороне, поэтому разберем подробнее, как именно формируется документ. Но сначала важно отметить, что для захвата интерфейса необязательно использовать Compose, фреймворк предоставляет возможность записывать операции на более низком уровне, с него мы и начнем
RemoteComposeWriter
RemoteComposeWriter — это абстракция с API, напоминающим Compose, но работающая на более низком уровне. В отличие от @RemoteComposable, который мы рассмотрим позднее, вызовы методов writer сразу записывают операции в буфер.
Пример: функция column() в RemoteComposeWriter
/** * Add a column layout * * @param modifier list of modifiers for the layout * @param horizontal horizontal positioning * @param vertical vertical positioning * @param content content of the layout */ public void column( @NonNull RecordingModifier modifier, int horizontal, int vertical, @NonNull RemoteComposeWriterInterface content) { startColumn(modifier, horizontal, vertical); content.run(); endColumn(); } /** * Start a column layout */ public void startColumn(@NonNull RecordingModifier modifier, int horizontal, int vertical) { int componentId = modifier.getComponentId(); float spacedBy = modifier.getSpacedBy(); mBuffer.addColumnStart(componentId, -1, horizontal, vertical, spacedBy); for (RecordingModifier.Element m : modifier.getList()) { m.write(this); } addContentStart(); } /** End a column layout */ public void endColumn() { mBuffer.addContainerEnd(); mBuffer.addContainerEnd(); }
Простой пример описания интерфейса с помощью writer
fun getRawBytesExample(): ByteArray { val writer = RemoteComposeWriter( 1080, 2400, null, RcPlatformServices.None, ).apply { root { column( RecordingModifier() .width(120) .height(56) .background(Color.BLACK), ColumnLayout.START, ColumnLayout.TOP ) { // остальной контент } } } return writer.encodeToByteArray() }
Интересно, что больше половины исходников Remote Compose написаны на Java.
Буфер
Класс RemoteComposeBuffer содержит в себе методы для записи всех возможных операций в байтовом представлении. В свою очередь, каждая операция имеет метод apply(WireBuffer buffer, …), который задаёт, как именно она должна быть записана в байтовом представлении.
RemoteComposeBuffer addColumnStart()
/** * Add a column start tag * * @param componentId component id * @param animationId animation id * @param horizontal horizontal alignment * @param vertical vertical alignment * @param spacedBy spacing between items */ public void addColumnStart( int componentId, int animationId, int horizontal, int vertical, float spacedBy) { mLastComponentId = getComponentId(componentId); ColumnLayout.apply(mBuffer, mLastComponentId, animationId, horizontal, vertical, spacedBy); } Операция ColumnLayout public static void apply( @NonNull WireBuffer buffer, int componentId, int animationId, int horizontalPositioning, int verticalPositioning, float spacedBy) { buffer.start(Operations.LAYOUT_COLUMN); buffer.writeInt(componentId); buffer.writeInt(animationId); buffer.writeInt(horizontalPositioning); buffer.writeInt(verticalPositioning); buffer.writeFloat(spacedBy); }
Таким образом, последовательная запись инструкций в формате...
[OP_CODE] [ARG1] [ARG2] ...
...формирует итоговый ByteArray. Хоть такой формат плохо читается человеком, но получается значительно компактнее, чем JSON, а десериализация выполняется гораздо быстрее.
@RemoteComposable
Теперь перейдем к тому, как уже @RemoteComposable функции превращаются в массив байтов. Первое, что хочется отметить, что аннотация @RemoteComposable никак не влияет на компилятор Compose, а просто является удобным маркером, обозначающим, что функция нужна для захвата интерфейса, однако фреймворк захватывает только функции с префиксом Remote: RemoteColumn, RemoteBox, и т.д.
@Composable @RemoteComposable fun SimpleExample() { RemoteText( text = "Hello world", ) }
На данный момент во фреймворке реализовано 2 способа захвата интерфейса через Compose, в коде одна из них помечена как V2, поэтому для простоты первый будем называть просто V1.
V1
Первоначальная реализация захвата Compose-интерфейса основана на создании кастомной View — CaptureComposeView, которая наследуется от AbstractComposeView. Идея заключается в том, чтобы перехватывать операции отрисовки, передавая в метод dispatchDraw() специальный RecordingCanvas.
К каждой Remote* функции добавляется специальный DrawModifier, который записывает компонент (к которому он применён) в RemoteComposeWriter.
RemoteComposeColumnModifier
internal class RemoteComposeColumnModifier( public val modifier: RemoteModifier, public val horizontalAlignment: RemoteAlignment.Horizontal, public val verticalArrangement: RemoteArrangement.Vertical, ) : DrawModifier { override fun ContentDrawScope.draw() { drawIntoRemoteCanvas { canvas -> // RecordingCanvas canvas.document.startColumn( canvas.toRecordingModifier(modifier), horizontalAlignment.toRemote(), verticalArrangement.toRemote(), ) this@draw.drawContent() canvas.document.endColumn() } } }
V2
Эта реализация захвата документа появилась недавно, и по своему принципу схожа с Compose Multiplatform, где ключевую роль играет кастомный Applier<N>.
Что такое Applier?
Если коротко, то сущность Applier<N> в Compose — это мост между абстрактным описанием интерфейса и его фактической отрисовкой. Тип N — это узел (Node) дерева композиции, необходимый для реализации фактической отрисовки на целевой платформе. Applier работает с деревом, трансформируя его, применяя к нему изменения.
При записи документа V2 реализация создаёт кастомный Composition cо своим Applier –RemoteComposeApplierV2, и узлом — RemoteComposeNodeV2. Задача этого узла, вместо настоящей отрисовки на экране, записывать последовательность операций.
RemoteColumnNodeV2
internal class RemoteColumnNodeV2 : RemoteComposeNodeV2() { var verticalArrangement: RemoteArrangement.Vertical = RemoteArrangement.Top var horizontalAlignment: RemoteAlignment.Horizontal = RemoteAlignment.Start override fun render(creationState: RemoteComposeCreationState, remoteCanvas: RemoteCanvas) { val recordingModifier = creationState.toRecordingModifier(modifier) creationState.document.startColumn( recordingModifier, horizontalAlignment.toRemote(), verticalArrangement.toRemote(), ) renderChildren(creationState, remoteCanvas) creationState.document.endColumn() } }
Преимущество такой реализации в том, что не нужно создавать виртуальный дисплей и быть привязанным к Android SDK.

Воспроизведение документа (Player)
Воспроизведение документа поддерживается как во View, так и в Compose, но, к сожалению, пока только на Android. По словам одного из разработчиков фреймворка, это ограничение временное, поскольку технических препятствий для переноса плеера на другие платформы нет.
Для воспроизведения документа достаточно создать объект RemoteDocument, передав в него ByteArray, и вызвать соответствующую Composable-функцию. В случае со View необходимо установить поле document.
@Composable fun RemoteScreen(byteArray: ByteArray) { val document = remember { RemoteDocument(byteArray).document } RemoteDocumentPlayer( document = document, documentWidth = document.width, documentHeight = document.height, onNamedAction = { action, value, stateUpdater -> Log.d("debug", "onNamedAction action = $action, value = $value") }, ) }
Как работает отрисовка
Начало работы клиента начинается с десериализаии полученного на вход ByteArray в последовательность операций. В существующих источниках по Remote Compose player сравнивают с проигрывателем видео, который просто воспроизводит на Canvas операции оду за другой, как кадры. Но это сильное упрощение, и на практике это не совсем так.
Сначала происходит этап инициализации, player проходит по списку операций и выполняет действия в зависимости от их типа:
Container-операции — формируют иерархию элементов. Они определяют структуру layout и группируют другие контейнеры и операции отрисовки.
Операции состояния и выражения — формируют несколько map-полей, которые используются при вычислении значений во время рендеринга.
Зоны взаимодействия — определяют интерактивные области интерфейса. Они сохраняются в список, который используется при обработке пользовательского ввода (например, кликов) для определения области, пересекающейся с координатами события.
После этапа инициализации начинается непосредственная отрисовка, которая выполняется Paint-операциями. Каждая такая операция реализует единственный метод:
public abstract void paint(@NonNull PaintContext context);
PaintContext — это абстрактный класс, содержащий методы, соответствующие операциям рисования. Он нужен для того, чтобы отделить модель операций от конкретной реализации рендеринга. В Android-реализации эти методы просто делегируют вызовы Canvas.
На стороне player фактически уже нет прямой связи с оригинальным Compose, хотя внешне можно заметить сходство с фазами layout и draw.
Перерисовка
При изменении состояния происходит обновление зависимых операций. Такие операции помечаются флагом mDirty = true, и при следующем invalidate они пересчитываются и выполняются повторно. Это приводит к обновлению соответствующих участков интерфейса.
В данном случае под перерисовкой подразумевается обновление уже загруженного документа. При этом player самостоятельно не выполняет сетевые запросы и не получает новую разметку. Загрузку новой версии документа можно выполнять вручную или автоматизировать через механизм actions, о котором будет рассказано позже.
Практическое использование
В этом разделе разберём практическое использование Remote Compose.
Подключение зависимостей
Для начала посмотрим, из каких библиотек состоит Remote Compose.
remote-core — библиотека с общими сущностями, интерфейсами, а также инфраструктурой сериализации: механизмы записи и восстановления операций из буфера.
-
remote-creation:
creation-core — абстракции над классами из remote-core;
creation-compose — главная библиотека, оправдывающая «Compose» в названии фреймворка, содержит функционал, позволяющий описывать документ через compose-код;
creation-android/jvm — платформенные классы.
-
remote-player:
player-core — базовая функциональность для выполнения операций, например, класс
AndroidPaintContextреализует методыPaintContext, делегируя вызовы в Android Canvas;player-view — View-компонент для отображения документа;
player-compose — Composable-функция для отображения документа.
dependencies { implementation("androidx.compose.remote:remote-core:1.0.0-alpha07") // Use to create Remote Compose documents implementation("androidx.compose.remote:remote-creation-core:1.0.0-alpha07") implementation("androidx.compose.remote:remote-creation-android:1.0.0-alpha07") implementation("androidx.compose.remote:remote-creation-jvm:1.0.0-alpha07") implementation("androidx.compose.remote:remote-creation-compose:1.0.0-alpha07") // Use to render a Remote Compose document implementation("androidx.compose.remote:remote-player-core:1.0.0-alpha07") implementation("androidx.compose.remote:remote-player-view:1.0.0-alpha07") implementation("androidx.compose.remote:remote-player-compose:1.0.0-alpha07") }
Первая проблема, с которой вы столкнетесь при попытке попробовать фреймворк, — это подключение зависимостей для JVM-сервера. Библиотека creation-compose, позволяющая создавать документы через Compose — это Android AAR-библиотека. Означает, что её можно подключить только к Android-модулю, а не к произвольному JVM-серверу. Сейчас для работы на JVM можно использовать RemoteComposeWriter, такой подход описан в этой статье, однако полноценная поддержка на JVM только ожидается в будущем.
В дальнейших примерах создание и воспроизведение документа будут происходить в одном приложении. При этом генерацию документа мы вынесем в отдельный модуль, представив, что обращения к нему — это запросы к серверу, возвращающие ByteArray.
Пример №1: простой
Здесь создадим несколько базовых функций для демонстрации работы фреймворка.
Creator
suspend fun getBasicDocument(context: Context): ByteArray { return captureSingleRemoteDocument( context = context, content = { BasicExample() }, ).bytes } @Composable @RemoteComposable fun BasicExample() { RemoteColumn( modifier = RemoteModifier .background(color = RemoteColor(Color.Gray)) .height(50.rdp) .fillMaxWidth(), verticalArrangement = RemoteArrangement.Center, horizontalAlignment = RemoteAlignment.CenterHorizontally ) { RemoteText( text = "Hello world!", fontSize = 24.rsp ) } }
Player
@OptIn(ExperimentalRemotePlayerApi::class) @SuppressLint("RestrictedApi") @Composable fun RemoteSimpleScreen() { val context = LocalContext.current val document by produceState<CoreDocument?>(null) { val bytes = getBasicDocument(context) value = RemoteDocument(bytes).document } document?.let { document -> RemoteDocumentPlayer( modifier = Modifier.fillMaxSize(), document = document, documentWidth = document.width, documentHeight = document.height, ) } }
Результат:

Пример №2: управление состоянием и анимации
В этом примере будет демонстрация сразу нескольких механик:
обновление внутреннего состояния,
анимация,
отправка действий за пределы документа.
Creator
@SuppressLint("RestrictedApi") @RemoteComposable @Composable fun AnimationSimpleExample() { val visibility = rememberMutableRemoteInt(0) val opacity = animateRemoteFloat( rf = visibility.toRemoteFloat(), duration = 0.3f, initialValue = 0f ) RemoteColumn( modifier = RemoteModifier .fillMaxSize(), horizontalAlignment = RemoteAlignment.CenterHorizontally, ) { RemoteText( modifier = RemoteModifier .visibility(visibility) .graphicsLayer(alpha = opacity), text = "animated", color = RemoteColor(Color.Black), fontSize = 20.rsp ) RemoteSpacer(12.rdp) RemoteText( text = "animate: ".rs + opacity.toRemoteString(before = 1), modifier = RemoteModifier .background(color = Color.LightGray) .clickable( ValueChange(visibility, (visibility + 1) % 2), ) .padding(8.rdp), fontSize = 20.rsp ) } }
Для этого примера добавим параметр onNamedAction в функцию RemoteDocumentPlayer, чтобы обрабатывать HostAction:
Player
RemoteDocumentPlayer( modifier = Modifier.fillMaxSize(), document = document, documentWidth = document.width, documentHeight = document.height, onNamedAction = { name, value, _ -> Toast.makeText(context, "$name: $value", Toast.LENGTH_SHORT).show() } )
Результат:

Пример №3: картинки
Картинки можно показывать двумя способами: встроить Bitmap прямо в документ или передать некий идентификатор, по которому клиентская часть должна загрузть Bitmap самостоятельно:
Creator
@SuppressLint("RestrictedApi") @Composable @RemoteComposable fun LazyImageExample() { RemoteColumn { // Статическое изображение, bitmap сохраняется в документ val staticBitmap = remember { createBitmap(10, 10) .apply { eraseColor(android.graphics.Color.GREEN) } .asImageBitmap() } RemoteImage( modifier = RemoteModifier .size(100.rdp), bitmap = staticBitmap, contentDescription = null ) // Динамичкская подгрузка изображения на клиенте val dynamicBitmap = rememberNamedRemoteBitmap( name = "image", url = "https://raw.githubusercontent.com/test-images/png/refs/heads/main/202105/cs-black-000.png", ) RemoteImage( modifier = RemoteModifier.size(100.rdp), bitmap = dynamicBitmap ) } } @SuppressLint("RestrictedApi") @Composable @RemoteComposable // Существующий RemoteImage не работает из-за бага fun RemoteImage(modifier: RemoteModifier, bitmap: RemoteBitmap) { RemoteCanvas(modifier) { drawScaledBitmap(bitmap, srcSize = size) } }
Чтобы клиент мог загружать изображения, необходимо передать в player параметр bitmapLoader.
Player
fun loadBitmap(url: String): InputStream { // Любая реализация функции, которая по идентификатору // вернет bitmap в inputStream формате return ByteArrayInputStream(byteArrayOf()) } //... RemoteDocumentPlayer( modifier = Modifier.fillMaxSize(), document = document, documentWidth = document.width, documentHeight = document.height, bitmapLoader = remember { BitmapLoader { url -> loadBitmap(url) } } )
Результат:

Выше были показаны далеко не все возможности фреймворка, а лишь его ключевые механизмы. Остальные функции
работают аналогично обычному Compose (Row, Box, Scroll и т.д.),
или пока работают нестабильно,
или ещё не реализованы.
Ниже приведена таблица функциональности, доступной на момент выхода версии 1.0.0-alpha7:
Базовые компоненты: лэйауты, текст, канвас |
✅ |
Обработка кликов |
✅ |
Скролл |
✅ |
Динамика |
✅ |
Анимации |
✅ |
Картинки |
✅ |
Смена светлой и темной темы |
? - Есть только на Writer API |
Ленивые списки |
❌ - Пока отсутствуют |
обработка Action от изменения state (аналог LaunchedEffect) |
❌ - Пока отсутствуют |
Текстовые поля |
❌ - Пока отсутствуют |
Выводы
Благодаря новой концепции и поддержке командой Google, Remote Compose выглядит очень перспективной технологией. Фреймворк активно развивается — новые версии выходят примерно раз в две недели, а функциональность постепенно расширяется.
Однако из-за alpha-версии использовать технологию на проде пока рано. Многие возможности ещё не реализованы или работают нестабильно, а API может существенно измениться. Тем не менее уже сейчас можно её попробовать и выделить несколько сильных и слабых сторон подхода:
Плюсы:
Гибкость: модель операций позволяет описывать интерфейсы практически любой сложности — от отдельных UI-компонентов до полноценных экранов.
Независимость от версии: минимум ограничений по версии приложения.
Знакомый стек: верстка на (почти) Compose + Kotlin.
Возможности интеграции с существующими BDUI-решениями.
Минусы
Незрелость технологии: фреймворк находится в alpha-версии, поэтому часть функциональности отсутствует или работает нестабильно.
Не полный аналог Compose: несмотря на схожий синтаксис, некоторые возможности Compose недоступны. Например, нельзя просто передать лямбду в onClick — взаимодействия должны описываться через специальные объекты.
Зависимость от Android-окружения: на данный момент библиотека, позволяющая создавать интерфейсы через Compose, распространяется как Android-модуль.
Полезные ссылки
Комментарии (6)

gev
07.04.2026 11:20Есть и другие варианты. Например:
https://github.com/gev/glue/blob/main/flutter/ARCHITECTURE.md

mishkaky
07.04.2026 11:20Звучит очень интересно, с учетом того, что можно будет сразу на три платформы это раскатывать

Neikist
07.04.2026 11:20А может наконец оглянетесь назад и увидите что никому нафиг не нужны ни пуши по 5 раз в день со всякой тупой рекламой в вашем приложении, ни "сторисы" каждый раз при запуске приложения на весь экран (которые еще и нужно дождаться пока загрузятся чтобы закрыть), и в целом чуть откатиться в старые времена, оффлайн работу сделать, чтобы не смотреть по 5-15 секунд при запуске как всякие анимации бегают, а сразу видеть пусть даже не информацию (которая может быть устаревшей, путь даже по мне это лучше лоадеров), но хотя бы банально раскладку экрана, пункты меню и прочее? Сейчас ощущение что стартовый экран каждый раз загружается заново с бека. Смысл в приложении, если оно не дает преимуществ перед вебом?
cmyser
Генерировать интерфейс из json ? Не очень перспективно
danilkha Автор
Видимо в гугл думают так же) Remote Compose как раз призван уйти от этого