Site icon AppTractor

RemoteCompose: другая парадигма SDUI в Jetpack Compose

Создание динамических пользовательских интерфейсов долгое время оставалось фундаментальной проблемой в разработке Android-приложений. Традиционный подход требует перекомпиляции и повторного развертывания всего приложения при каждом изменении интерфейса, что создает значительные препятствия для A/B-тестирования, флагов функций и обновления контента в реальном времени.

Рассмотрим сценарий, когда ваша маркетинговая команда хочет протестировать новый дизайн кнопки оформления заказа: в традиционной модели это простое изменение требует времени разработчиков, проверки кода, тестирования качества, отправки в магазин приложений и недель ожидания одобрения пользователей. RemoteCompose становится мощным решением этой проблемы, позволяя разработчикам создавать, передавать и отображать макеты пользовательского интерфейса Jetpack Compose во время выполнения без какой-либо перекомпиляции.

В этой статье вы узнаете, что такое RemoteCompose, поймете его основную архитектуру и откроете для себя преимущества, которые он предоставляет для динамического создания экранов с помощью Jetpack Compose. Это не руководство по использованию библиотеки, это исследование парадигмального сдвига, который она представляет для разработки пользовательских интерфейсов Android.

Интеграция и зависимости

Прежде чем углубляться в концепции, вот как добавить RemoteCompose в ваш проект. Для серверов и бэкендов, работающих на JVM без зависимостей Android:

// settings.gradle
repositories {
  maven {
    url = uri("https://androidx.dev/snapshots/builds/14511716/artifacts/repository")
  }
}

// JVM server - no Android dependencies
dependencies {
    implementation("androidx.compose.remote:remote-core:1.0.0-SNAPSHOT")
    implementation("androidx.compose.remote:remote-creation-compose:1.0.0-SNAPSHOT")
}

// Compose-based app
dependencies {
    implementation("androidx.compose.remote:remote-player-compose:1.0.0-SNAPSHOT")
    implementation("androidx.compose.remote:remote-tooling-preview:1.0.0-SNAPSHOT")
}

// View-based app
dependencies {
    implementation("androidx.compose.remote:remote-player-view:1.0.0-SNAPSHOT")
}

Обратите внимание, что RemoteCompose всё ещё находится в разработке у команды AndroidX и официально не опубликован; он доступен только в репозитории Maven Snapshot AndroidX.

Понимание основной абстракции

В основе RemoteCompose лежит фреймворк, позволяющий удалённо отрисовывать компоненты пользовательского интерфейса Compose. От традиционных подходов к пользовательскому интерфейсу его отличает приверженность двум фундаментальным принципам: декларативной сериализации документов и платформенно-независимой отрисовке. Это не просто технические особенности; это архитектурные решения, которые коренным образом меняют ваше представление о развертывании пользовательского интерфейса.

Декларативная сериализация документов

Декларативная сериализация документов означает, что вы можете запечатлеть (capture) любой макет Jetpack Compose и получить его в компактном сериализованном формате. Представьте это как «скриншот» вашего пользовательского интерфейса, только вместо пикселей вы захватываете фактические инструкции отрисовки. Этот полученный документ содержит всё необходимое для воссоздания пользовательского интерфейса: фигуры, цвета, текст, изображения, анимацию и даже интерактивные области касания.

// On the server or creation side
val document = captureRemoteDocument(
    context = context,
    creationDisplayInfo = displayInfo,
    profile = profile
) {
    RemoteColumn(modifier = RemoteModifier.fillMaxSize()) {
        RemoteText("Dynamic Content")
        RemoteButton(onClick = { /* action */ }) {
            RemoteText("Click Me")
        }
    }

Результат? Это ByteArray, который можно отправить по сети. Преимущество этого подхода заключается в том, что на стороне создания пишется стандартный код Compose. Не нужно изучать новый DSL (Domain-Specific Language), поддерживать схему JSON или осваивать язык шаблонов. Если вы можете написать это на Compose, вы можете захватить это с помощью RemoteCompose.

Вы можете захватить обычный Compose, который будет фиксировать вызовы отрисовки (draw calls — которые очень статичны), но более типичным является использование специализированных API, которые отражают Compose, но предназначены для сериализации и удаленного воспроизведения, таких как RemoteColumn, RemoteButton, RemoteText и т.д.

Независимая от платформы отрисовка

Независимая от платформы отрисовка означает, что захваченный документ может передаваться по сети и отображаться на любом устройстве Android без необходимости использования исходного кода Compose. Клиентскому устройству не нужны ваши композабл функции, ваши модели представления или ваша бизнес-логика — ему нужны только байты документа и проигрыватель.

// On the client or player side
RemoteDocumentPlayer(
    document = remoteDocument.document,
    documentWidth = windowInfo.containerSize.width,
    documentHeight = windowInfo.containerSize.height,
    onAction = { actionId, value ->
        // Handle user interactions
    }
)

Эти свойства — не просто удобства; это архитектурные ограничения, позволяющие полностью отделить определение пользовательского интерфейса от развертывания. Формат документа фиксирует не только статические макеты, но и состояние, анимацию и взаимодействия, что делает его полным представлением пользовательского опыта.

Сравнение подходов: почему не JSON или WebViews?

Прежде чем углубляться, стоит понять, почему RemoteCompose использует именно этот подход, а не альтернативы.

Серверно-управляемый пользовательский интерфейс на основе JSON, такой как Epoxy от Airbnb или подход Shopify, требует определения схемы, которая сопоставляется с нативными компонентами. Это хорошо работает для структурированного контента, но испытывает трудности со сложными анимациями и переходами, кастомной отрисовкой и графикой, форматированным текстом со встроенными стилями и визуальными эффектами, такими как градиенты и тени.

WebViews предлагают полную гибкость, но приводят к дополнительным затратам на производительность из-за отдельного процесса рендеринга, несогласованному внешнему виду между веб-стилизацией и нативным дизайном, нехватке памяти, поскольку каждый WebView является ресурсоемким, и сложностям в обработке касаний с конфликтами жестов.

RemoteCompose выбирает третий путь: захват фактических операций рисования, которые выполняет Compose. Это означает, что любой пользовательский интерфейс, который вы можете создать в Compose, включая рисование на Canvas, сложные анимации и компоненты Material Design, может быть захвачен и воспроизведен удаленно с производительностью нативного приложения.

Архитектура на основе документов: создание и воспроизведение

Архитектура RemoteCompose построена на четком разделении двух фаз: создания документа и воспроизведения документа. Понимание этого разделения является ключом к пониманию возможностей фреймворка.

Создание документа: захват пользовательского интерфейса как данных

Фаза создания преобразует код пользовательского интерфейса Compose в сериализованный документ. Это происходит с помощью механизма захвата, который перехватывает операции рисования на уровне Canvas, самом нижнем уровне конвейера рендеринга Android.

@Composable Content
        ↓
RemoteComposeCreationState (Tracks state and modifiers)
        ↓
CaptureComposeView (Virtual Display - no actual screen needed)
        ↓
RecordingCanvas (Intercepts every draw call)
        ↓
Operations (93+ operation types covering all drawing primitives)
        ↓
RemoteComposeBuffer (Efficient binary serialization)
        ↓
ByteArray (Network-ready, typically 10-100KB for complex UIs)

На стороне создания предоставляется полный слой интеграции с Compose. Вы пишете стандартные функции @Composable, и фреймворк захватывает всё: иерархии макетов, модификаторы, стили текста, изображения, анимации и даже обработчики касаний.

Особенность этого подхода заключается в том, что захваченный документ является самодостаточным. Он включает визуальные элементы, такие как фигуры, цвета, градиенты и тени. Он содержит текст со строками, шрифтами, размерами и стилями. Изображения могут быть встроены в виде растровых изображений или URL-адресов для отложенной загрузки. Информация о макете включает размеры, позиции, отступы и выравнивание. Взаимодействия определяют области касания, обработчики кликов и именованные действия. Переменные состояния могут обновляться в рантайме, а анимации с движениями определяются с помощью выражений, основанных на времени.

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

Воспроизведение документа: рендеринг без компиляции

На этапе воспроизведения сериализованный документ отображается на экране. Плеер выполняет операции, каждая из которых выполняется на холсте (Canvas). Концептуально это похоже на то, как видеоплеер декодирует кадры, только вместо пикселей мы декодируем инструкции отрисовки.

RemoteCompose предоставляет два бэкэнда рендеринга для удовлетворения различных архитектурных потребностей. Плеер на основе Compose рекомендуется для современных приложений:

@Composable
fun DynamicScreen(document: CoreDocument) {
    RemoteDocumentPlayer(
        document = document,
        documentWidth = screenWidth,
        documentHeight = screenHeight,
        modifier = Modifier.fillMaxSize(),
        onNamedAction = { name, value, stateUpdater ->
            // Handle named actions from the document
            when (name) {
                "addToCart" -> cartManager.addItem(value)
                "navigate" -> navController.navigate(value)
                "trackEvent" -> analytics.logEvent(value)
            }
        },
        bitmapLoader = rememberBitmapLoader()  // For lazy image loading
    )
}

Плеер, основанный на Compose, органично интегрируется с существующим пользовательским интерфейсом Compose. Это композабл функция, которую можно разместить в любом месте иерархии композиции, применить к ней модификаторы и анимировать, как и любой другой компонуемый элемент.

Для совместимости с существующими иерархиями View также доступен плеер, основанный на View:

class LegacyActivity : AppCompatActivity() {
    private lateinit var player: RemoteComposePlayer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        player = RemoteComposePlayer(this)
        setContentView(player)

        // Load document from network
        lifecycleScope.launch {
            val bytes = api.fetchDocument("home-screen")
            player.setDocument(bytes)
        }

        player.onNamedAction { name, value, stateUpdater ->
            // Handle actions
        }
    }
}

Оба плеера обеспечивают одинаковую точность рендеринга; выбор зависит от архитектуры вашего приложения. Если вы полностью используете Compose, используйте компонуемый плеер. Если вы переходите с Views или встраиваете приложение в иерархию View, используйте плеер на основе View.

Операционная модель: полный словарь для рисования

Сила RemoteCompose заключается в его всеобъемлющей операционной модели. Фреймворк определяет более 93 различных операций, охватывающих все аспекты рендеринга пользовательского интерфейса. Это не произвольное число; это полный словарь, необходимый для выражения любой операции рисования на Canvas.

Почему операции важны

Традиционный пользовательский интерфейс, управляемый сервером, отправляет высокоуровневые описания компонентов: «отобразить кнопку с текстом ‘Submit’». Затем клиент должен интерпретировать это и сопоставить с нативным компонентом. Это создает тесную связь между сервером и клиентом; оба должны согласовать, что такое «кнопка» и как она себя ведет.

RemoteCompose работает на более низком уровне: вместо «отобразить кнопку» он отправляет фактические инструкции по рисованию: «нарисовать скругленный прямоугольник в этих координатах этим цветом, затем нарисовать текст ‘Submit’ в этой позиции этим шрифтом». Клиенту не нужно знать, что такое «кнопка»; он просто выполняет операции рисования.

Этот низкоуровневый подход имеет глубокие последствия. Синхронизация схем не требуется, поскольку серверу и клиенту не нужно согласовывать определения компонентов. Сохраняется полная визуальная точность, поскольку любой возможный визуальный эффект в Compose может быть перехвачен. Обеспечена обратная совместимость, поскольку новые визуальные решения работают на старых клиентах; это просто другие операции рисования. А пользовательские компоненты работают автоматически без какой-либо регистрации.

Операции рисования

Операции рисования захватывают вызовы отрисовки Canvas, фундаментальные примитивы 2D-графики. К ним относятся DRAW_RECT для прямоугольников, используемых в кнопках, карточках и фонах; DRAW_ROUND_RECT для поверхностей Material с закругленными углами; DRAW_CIRCLE для аватаров и индикаторов; DRAW_TEXT для рендеринга текста с полным стилем; DRAW_TEXT_ON_PATH для текста вдоль кривых; DRAW_BITMAP для изображений; и DRAW_TWEEN_PATH для анимированного морфинга пути и так далее.

Каждая операция содержит всю необходимую для ее выполнения информацию: координаты, цвета, стили отрисовки и ссылки на данные, такие как текстовые строки или растровые изображения, хранящиеся в других местах документа.

Операции компоновки

Операции компоновки определяют иерархию компонентов и пространственные отношения. Операция Component объявляет компонент компоновки, Container открывает контейнер, аналогичный Column или Row, а ContainerEnd закрывает его. LoopOperation повторяет содержимое для списков. Модификаторы включают BackgroundModifier для цветов фона и рисунков, BorderModifier для стиля границ, PaddingModifier для внутреннего интервала и ClickModifier для обработки касаний.

Контейнерная модель использует подход push/pop. Когда плеер сталкивается с операцией Container, создаётся новый контекст компоновки. Все последующие операции применяются в этом контексте до тех пор, пока ContainerEnd не «выдавит» (pop) его. Это отражает принцип работы системы компоновки Compose.

Операции с состоянием и выражениями

Операции с состоянием позволяют использовать динамические значения, которые могут изменяться во время выполнения. NamedVariable объявляет именованную переменную состояния. ColorAttribute предоставляет цвета, которые можно задавать в зависимости от темы. TimeAttribute ссылается на время анимации. FloatExpression и IntegerExpression вычисляют математические выражения в каждом кадре. ConditionalOp позволяет выполнять условную отрисовку на основе состояния.

Система выражений особенно мощна. Вместо статических значений можно встраивать формулы:

// These expressions are evaluated every frame
val opacity = FloatExpression("sin(time * 2) * 0.5 + 0.5")  // Pulsing effect
val rotation = FloatExpression("time * 90 % 360")           // Continuous rotation
val position = FloatExpression("lerp(0, 100, time / 2)")    // Linear interpolation

Это позволяет создавать сложные анимации, полностью определяемые в документе — код анимации на стороне клиента не требуется.

Операции взаимодействия

Операции взаимодействия обрабатывают ввод пользователя. TouchOperation определяет область, чувствительную к касанию, а CLICK_AREA обрабатывает простые клики. ParticlesCreate инициализирует системы частиц, а ParticlesLoop управляет анимацией частиц.

Операции касания регистрируют прямоугольные области с именованными действиями. Когда пользователь касается области, проигрыватель запускает действие, которое хост-приложение обрабатывает с помощью колбеков. Такая конструкция сохраняет простоту формата документа, обеспечивая при этом богатую интерактивность.

Преимущества динамического дизайна

Теперь давайте рассмотрим конкретные преимущества, которые RemoteCompose предоставляет для динамического дизайна экрана, на реальных примерах из распространенных сценариев приложений.

Пользовательский интерфейс, управляемый сервером, без компромиссов

Традиционные подходы к пользовательскому интерфейсу, управляемые сервером, требуют компромиссов. Макеты на основе JSON предлагают ограниченную выразительность и не могут захватывать сложные анимации или пользовательскую отрисовку. WebViews создают накладные расходы на производительность, приводят к непоследовательности внешнего вида и увеличению потребления памяти. Пользовательские DSL создают дополнительную нагрузку на обслуживание, требуют длительного обучения и накладывают ограничения на предопределенные компоненты.

RemoteCompose предлагает третий путь: нативный рендеринг Compose из макетов, определяемых сервером. Вы получаете всю мощь механизма рендеринга Compose с гибкостью контента, управляемого сервером.

Рассмотрим приложение электронной коммерции, где карточки товаров нуждаются в частом обновлении, новых стилях бейджей, рекламных оверлеях или сезонных темах. С RemoteCompose серверная часть позволяет маркетинговым командам обновлять дизайн карточек без перевыпуска приложения:

// Server-side: We can update card designs without app release
@Composable
fun ProductCard(product: Product) {
    Card(
        modifier = RemoteModifier
            .fillMaxWidth()
            .clickable { namedAction("viewProduct", product.id) }
    ) {
        Box {
            // Product image with gradient overlay
            AsyncImage(
                url = product.imageUrl,
                modifier = RemoteModifier.fillMaxWidth().aspectRatio(1.5f)
            )

            // Promotional badge - can be A/B tested server-side
            if (product.hasPromotion) {
                PromotionalBadge(
                    text = product.promotionText,
                    modifier = RemoteModifier.align(Alignment.TopEnd)
                )
            }

            // Price with sale styling
            PriceTag(
                originalPrice = product.originalPrice,
                salePrice = product.salePrice,
                modifier = RemoteModifier.align(Alignment.BottomStart)
            )
        }
    }
}

Клиентская сторона просто отображает то, что отправляет сервер:

// Client-side: Just renders whatever the server sends
@Composable
fun ProductGrid(viewModel: ProductViewModel) {
    val documents by viewModel.productDocuments.collectAsState()

    LazyVerticalGrid(columns = GridCells.Fixed(2)) {
        items(documents) { document ->
            RemoteDocumentPlayer(
                document = document,
                onNamedAction = { name, value, _ ->
                    if (name == "viewProduct") {
                        navController.navigate("product/$value")
                    }
                }
            )
        }
    }
}

Теперь ваша команда может обновлять дизайн карточек продуктов, менять цвета бейджей, добавлять анимации и переставлять элементы без выпуска приложения. Пользовательский интерфейс выглядит и ощущается как нативный, потому что он нативный, отрисованный с помощью фактического конвейера отрисовки Compose.

A/B-тестирование в масштабе

Традиционно A/B-тестирование вариантов пользовательского интерфейса требует реализации всех вариантов в бинарном файле приложения, создания флагов функций для каждого варианта, выпуска приложения со всеми включенными вариантами и ожидания принятия пользователями перед измерением результатов. Этот процесс обычно занимает 2–4 недели от идеи до получения данных.

С помощью RemoteCompose вы можете тестировать различные варианты пользовательского интерфейса без развертывания нового клиентского приложения. Рассмотрим пример команды разработчиков приложения электронной коммерции, которая хочет проверить, обеспечивает ли одностраничная форма оформления заказа более высокую конверсию, чем многошаговый мастер:

// Server-side: Two completely different checkout experiences
object CheckoutExperiments {

    fun getCheckoutDocument(user: User, cart: Cart): ByteArray {
        val variant = experimentService.getVariant(user.id, "checkout-flow")

        return when (variant) {
            "single-page" -> captureSinglePageCheckout(cart)
            "multi-step" -> captureMultiStepCheckout(cart)
            "express" -> captureExpressCheckout(cart)  // New variant added without app update
            else -> captureSinglePageCheckout(cart)
        }
    }

    private fun captureSinglePageCheckout(cart: Cart): ByteArray {
        return captureRemoteDocument(context, displayInfo, profile) {
            SinglePageCheckout(
                cart = cart,
                onPlaceOrder = { namedAction("placeOrder", cart.id) },
                onUpdateQuantity = { itemId, qty ->
                    namedAction("updateQuantity", "$itemId:$qty")
                }
            )
        }
    }

    private fun captureMultiStepCheckout(cart: Cart): ByteArray {
        return captureRemoteDocument(context, displayInfo, profile) {
            MultiStepCheckout(
                cart = cart,
                steps = listOf("Shipping", "Payment", "Review"),
                onComplete = { namedAction("placeOrder", cart.id) }
            )
        }
    }
}

Клиенту совершенно безразлично, какой именно вариант будет показан:

// Client-side: Completely agnostic to which variant is shown
@Composable
fun CheckoutScreen(viewModel: CheckoutViewModel) {
    val document by viewModel.checkoutDocument.collectAsState()

    document?.let { doc ->
        RemoteDocumentPlayer(
            document = doc,
            onNamedAction = { name, value, stateUpdater ->
                when (name) {
                    "placeOrder" -> viewModel.placeOrder(value)
                    "updateQuantity" -> {
                        val (itemId, qty) = value.split(":")
                        viewModel.updateQuantity(itemId, qty.toInt())
                    }
                }
            }
        )
    }
}

Результаты мгновенные и в режиме реального времени, что означает отсутствие ожидания утверждения в магазине приложений или обновлений от пользователей. Вы даже можете добавлять совершенно новые варианты, такие как «экспресс-оплата», без каких-либо изменений в клиентской части. Эксперимент продолжается до тех пор, пока не будет достигнута статистическая значимость, после чего вы раскатываете победителя на 100% пользователей, опять же, без выпуска приложения.

Обновление контента в режиме реального времени

Приложения, ориентированные на контент, часто сталкиваются с проблемой баланса между производительностью нативного приложения и актуальностью контента. Рассмотрим новостное приложение: статьи должны иметь богатое форматирование, встроенные медиафайлы и интерактивные элементы, но они также должны обновляться в режиме реального времени по мере развития событий.

Новостной организации, освещающей крупное событие, необходимо обновлять макеты статей в режиме реального времени. Редакционная группа может корректировать макеты по мере развития событий:

// Server-side: Editorial team can update layout as story develops
class ArticleLayoutService {

    fun getArticleDocument(article: Article): ByteArray {
        return captureRemoteDocument(context, displayInfo, profile) {
            ArticleLayout(article)
        }
    }

    @Composable
    private fun ArticleLayout(article: Article) {
        Column(modifier = RemoteModifier.fillMaxSize().padding(16.dp)) {
            // Breaking news banner - can be added/removed instantly
            if (article.isBreaking) {
                BreakingNewsBanner(
                    modifier = RemoteModifier.fillMaxWidth()
                )
            }

            // Headline with dynamic styling
            Text(
                text = article.headline,
                style = if (article.isBreaking) {
                    HeadlineStyle.Breaking
                } else {
                    HeadlineStyle.Standard
                }
            )

            // Live updates indicator
            if (article.hasLiveUpdates) {
                LiveUpdatesIndicator(
                    lastUpdate = article.lastUpdate,
                    modifier = RemoteModifier.clickable {
                        namedAction("refreshArticle", article.id)
                    }
                )
            }

            // Rich content blocks - can include any Compose UI
            article.contentBlocks.forEach { block ->
                when (block) {
                    is TextBlock -> ArticleText(block)
                    is ImageBlock -> ArticleImage(block)
                    is VideoBlock -> VideoEmbed(block)
                    is LiveBlogBlock -> LiveBlogTimeline(block)
                    is InteractiveChartBlock -> DataVisualization(block)
                    is PullQuoteBlock -> PullQuote(block)
                }
            }

            // Related articles - layout can be A/B tested
            RelatedArticles(
                articles = article.relatedArticles,
                onArticleClick = { namedAction("openArticle", it.id) }
            )
        }
    }
}

Клиент просто отображает тот макет, который предоставляет сервер:

// Client-side: Renders whatever layout the server sends
@Composable
fun ArticleScreen(articleId: String, viewModel: ArticleViewModel) {
    val document by viewModel.articleDocument.collectAsState()
    val refreshing by viewModel.isRefreshing.collectAsState()

    SwipeRefresh(
        state = rememberSwipeRefreshState(refreshing),
        onRefresh = { viewModel.refresh() }
    ) {
        document?.let { doc ->
            RemoteDocumentPlayer(
                document = doc,
                onNamedAction = { name, value, _ ->
                    when (name) {
                        "openArticle" -> navController.navigate("article/$value")
                        "refreshArticle" -> viewModel.refresh()
                        "playVideo" -> videoPlayer.play(value)
                    }
                }
            )
        }
    }
}

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

Флаги функций без раздувания кода

Традиционные флаги функций требуют включения всех вариантов в бинарный файл:

// Traditional approach - all code ships, even unused variations
@Composable
fun HomeScreen() {
    when {
        featureFlags.newHomeV3 -> NewHomeLayoutV3()  // Ships always
        featureFlags.newHomeV2 -> NewHomeLayoutV2()  // Ships always
        else -> OldHomeLayout()                       // Ships always
    }
}

Это создает ряд проблем. Бинарный код увеличивается, размер приложения растет за счет всех вариантов. Неиспользуемый код попадает в систему, даже если он не используется. Риски безопасности возникают, когда неправильная настройка флага функции может раскрыть невыпущенные функции. Технический долг накапливается по мере того, как со временем накапливаются старые вариации.

С RemoteCompose передается только активная вариация:

// Server-side: Only the active variation exists on the server
class HomeScreenService {
    fun getHomeDocument(user: User): ByteArray {
        return when (featureFlags.getHomeVariant(user)) {
            "v3" -> captureHomeV3(user)
            "v2" -> captureHomeV2(user)
            else -> captureHomeDefault(user)
        }
    }
}

// Client-side: No conditional code, no dead code
@Composable
fun HomeScreen(document: CoreDocument) {
    RemoteDocumentPlayer(document = document)
    // That's it. No feature flags, no conditionals.
}

Это устраняет избыточность бинарного кода, поскольку старые варианты больше не выпускаются, удаляет неиспользуемый код, так как существует только серверный код, и снижает риски безопасности, поскольку неправильная конфигурация отображает другой пользовательский интерфейс, а не невыпущенный код.

Рассмотрим, например, приложение для социальных сетей, которое постепенно обновляет свою ленту:

// Server-side: Complete control over who sees what
class FeedLayoutService {

    fun getFeedDocument(user: User, posts: List<Post>): ByteArray {
        val variant = rolloutService.getFeedVariant(user)

        return captureRemoteDocument(context, displayInfo, profile) {
            when (variant) {
                FeedVariant.NEW_DESIGN -> NewFeedLayout(posts)
                FeedVariant.NEW_DESIGN_COMPACT -> NewFeedCompactLayout(posts)
                FeedVariant.CLASSIC -> ClassicFeedLayout(posts)
            }
        }
    }
}

// Rollout service controls the percentage
class RolloutService {
    fun getFeedVariant(user: User): FeedVariant {
        // 5% get new design, 5% get compact variant, 90% get classic
        return when {
            user.id.hashCode() % 100 < 5 -> FeedVariant.NEW_DESIGN
            user.id.hashCode() % 100 < 10 -> FeedVariant.NEW_DESIGN_COMPACT
            else -> FeedVariant.CLASSIC
        }
    }

    // Instant rollback if issues are detected
    fun emergencyRollback() {
        // All users immediately get classic layout
        // No app update needed
    }
}

Если новый дизайн вызывает проблемы — сбои, негативное взаимодействие или жалобы пользователей, — откат происходит мгновенно. Просто измените конфигурацию сервера. Экстренный релиз приложения не требуется.

Кроссплатформенная согласованность

Формат документа RemoteCompose является платформенно-независимым. Один и тот же документ может отображаться на телефоне, планшете, складном устройстве и Wear OS, при этом рендеринг будет осуществляться соответствующим платформой проигрывателем.

Рассмотрим фитнес-приложение, отображающее сводку тренировок как на мобильных устройствах, так и на часах. Одни и те же данные отображаются по-разному, оптимизированно для каждого форм-фактора:

// Server-side: Same data, different presentations
class WorkoutSummaryService {

    fun getPhoneDocument(workout: Workout): ByteArray {
        return captureRemoteDocument(context, phoneDisplayInfo, profile) {
            PhoneWorkoutSummary(workout)  // Full detailed view
        }
    }

    fun getWatchDocument(workout: Workout): ByteArray {
        return captureRemoteDocument(context, watchDisplayInfo, profile) {
            WatchWorkoutSummary(workout)  // Glanceable summary
        }
    }

    @Composable
    private fun PhoneWorkoutSummary(workout: Workout) {
        Column {
            WorkoutHeader(workout)
            HeartRateChart(workout.heartRateData)
            PaceChart(workout.paceData)
            SplitsTable(workout.splits)
            MapView(workout.route)
            ShareButton { namedAction("share", workout.id) }
        }
    }

    @Composable
    private fun WatchWorkoutSummary(workout: Workout) {
        // Optimized for small screen
        Column(modifier = RemoteModifier.fillMaxSize()) {
            Text(workout.type, style = WatchTypography.Title)
            Row {
                StatBox("Duration", workout.duration)
                StatBox("Distance", workout.distance)
            }
            MiniHeartRateIndicator(workout.avgHeartRate)
        }
    }
}

Оба устройства отображают данные о тренировках, но с оптимизированными для их форм-фактора макетами. Обновления любого из макетов развертываются мгновенно без обновления приложения на любой из платформ.

Сокращенные циклы выпуска

Наиболее значительное преимущество — операционное: изменения пользовательского интерфейса больше не требуют перевыпуска приложения. Рассмотрим цикл разработки для простой корректировки пользовательского интерфейса.

Традиционный подход занимает примерно от двух до четырех недель. Первый и второй дни посвящены внедрению разработчиками. Третий и четвертый дни — проверке и доработке кода. С пятого по седьмой день — тестированию качества. Восьмой и девятый дни — подготовке к выпуску и отправке в магазин. С десятого по четырнадцатый дни — ожидание проверки. С пятнадцатого по тридцатый дни — постепенное внедрение пользователями, при этом обычно 50–70% обновляют приложение в течение двух недель. Большинство пользователей не видят изменений в течение двух-четырех недель.

Подход RemoteCompose занимает один-два дня. Первый и второй дни — внедрение разработчиками на сервере. Развертывание занимает минуты. Все пользователи видят изменения немедленно.

Это преимущество в скорости имеет решающее значение для праздничных рекламных кампаний, где вы можете запускать сезонные темы именно в тот день, когда это необходимо, для экстренных сообщений с мгновенным обновлением пользовательского интерфейса при сбоях в работе сервиса, для быстрой итерации, когда вы быстро тестируете идеи и быстро выявляете ошибки, и для реагирования на изменения рынка в течение нескольких часов, а не недель.

Рассмотрим, например, приложение для электронной коммерции, готовящееся к Черной пятнице:

// Traditional approach: Ship all variations weeks in advance
// Problem: All promotional code ships weeks early
// Risk: Date logic bugs could show promotions early
@Composable
fun HomeScreen() {
    val today = LocalDate.now()
    when {
        today == BlackFriday -> BlackFridayHome()           // Must ship by Oct 15
        today in BlackFridayWeek -> BlackFridayWeekHome()   // Must ship by Oct 15
        today == CyberMonday -> CyberMondayHome()           // Must ship by Oct 15
        else -> RegularHome()
    }
}

// Remote approach: Deploy each promotion on the exact day
// Benefit: Each promotion deploys on the exact minute needed
// Flexibility: Can react to competitor moves in real-time
class HomeScreenService {
    fun getHomeDocument(user: User): ByteArray {
        val promotion = promotionService.getCurrentPromotion()

        return captureRemoteDocument(context, displayInfo, profile) {
            when (promotion) {
                is BlackFridayPromotion -> BlackFridayHome(promotion)
                is CyberMondayPromotion -> CyberMondayHome(promotion)
                is FlashSale -> FlashSaleHome(promotion)  // Can add new types anytime
                else -> RegularHome()
            }
        }
    }
}

Управление состоянием: за пределами статических макетов

RemoteCompose не ограничивается статическими макетами. Фреймворк включает в себя систему управления состоянием, которая позволяет создавать интерактивные, динамические пользовательские интерфейсы.

Удаленные переменные состояния

Состояние может быть встроено в документы и обновляться клиентом. Это позволяет создавать формы, счетчики, переключатели и другие интерактивные элементы:

// Creation side: Define interactive widget
@Composable
fun QuantitySelector(initialQuantity: Int) {
    var quantity by rememberRemoteState("quantity", initialQuantity)

    Row(
        modifier = RemoteModifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        IconButton(
            onClick = {
                if (quantity > 1) {
                    quantity--
                    namedAction("quantityChanged", quantity.toString())
                }
            }
        ) {
            Icon(Icons.Minus)
        }

        Text(
            text = quantity.toString(),
            style = MaterialTheme.typography.headlineMedium
        )

        IconButton(
            onClick = {
                quantity++
                namedAction("quantityChanged", quantity.toString())
            }
        ) {
            Icon(Icons.Plus)
        }
    }
}

Со стороны плеера обновление состояния осуществляется через колбек:

// Player side: Handle state updates
RemoteDocumentPlayer(
    document = document,
    onNamedAction = { name, value, stateUpdater ->
        when (name) {
            "quantityChanged" -> {
                // Update cart
                cartManager.setQuantity(itemId, value.toInt())

                // Optionally update remote state directly
                stateUpdater.updateState { state ->
                    state["quantity"] = RcInt(value.toInt())
                }
            }
        }
    }
)

Отслеживание времени анимации

Плеер отслеживает время анимации и передает его документам, что позволяет создавать анимации на основе времени без использования кода анимации на стороне клиента:

// Server side: Define animated elements
@Composable
fun PulsingNotificationBadge(count: Int) {
    // Scale pulses between 0.9 and 1.1 over 1 second
    val scale = FloatExpression("0.9 + 0.2 * sin(time * 6.28)")

    // Opacity pulses between 0.7 and 1.0
    val opacity = FloatExpression("0.7 + 0.3 * sin(time * 6.28)")

    Box(
        modifier = RemoteModifier
            .scale(scale)
            .alpha(opacity)
            .background(Color.Red, CircleShape)
            .size(24.dp)
    ) {
        Text(
            text = count.toString(),
            color = Color.White,
            modifier = RemoteModifier.align(Alignment.Center)
        )
    }
}

// The player automatically:
// 1. Tracks elapsed time since document load
// 2. Evaluates expressions each frame
// 3. Updates visual properties
// No client animation code needed

Это обеспечивает плавную и высокопроизводительную анимацию, полностью определяемую в формате документа. Выражения поддерживают математические функции, такие как sin, cos, lerp и clamp, а также арифметические операторы и ссылки на переменные.

Двусторонняя связь

Система действий обеспечивает двустороннюю связь между документом и хост-приложением:

// Document triggers actions for various purposes
@Composable
fun ProductDetailPage(product: Product) {
    Column {
        // Analytics tracking
        LaunchedEffect(Unit) {
            namedAction("analytics", "product_viewed:${product.id}")
        }

        ProductImage(product.imageUrl)

        // Navigation action
        TextButton(onClick = { namedAction("navigate", "/reviews/${product.id}") }) {
            Text("See all reviews")
        }

        // Cart action with data
        Button(onClick = { namedAction("addToCart", product.id) }) {
            Text("Add to Cart")
        }

        // State update action
        var isFavorite by rememberRemoteState("favorite", product.isFavorite)
        IconButton(
            onClick = {
                isFavorite = !isFavorite
                namedAction("toggleFavorite", "${product.id}:$isFavorite")
            }
        ) {
            Icon(if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.Favorite)
        }
    }
}

Основное приложение обрабатывает все действия единообразно:

// Host app handles all actions uniformly
RemoteDocumentPlayer(
    document = document,
    onNamedAction = { name, value, stateUpdater ->
        when (name) {
            "analytics" -> {
                val (event, id) = value.split(":")
                analytics.logEvent(event, mapOf("productId" to id))
            }
            "navigate" -> navController.navigate(value)
            "addToCart" -> {
                cartManager.add(value)
                // Update UI to show confirmation
                stateUpdater.updateState { state ->
                    state["cartCount"] = RcInt((state["cartCount"] as? RcInt)?.value?.plus(1) ?: 1)
                }
            }
            "toggleFavorite" -> {
                val (id, isFavorite) = value.split(":")
                favoritesManager.setFavorite(id, isFavorite.toBoolean())
            }
        }
    }
)

Эта двусторонняя связь означает, что серветные документы могут быть полностью интегрированы в навигацию, аналитику, управление состоянием и бизнес-логику вашего приложения, при этом документ не будет знать ничего о ваших конкретных реализациях.

Реальные архитектурные шаблоны

Давайте рассмотрим, как RemoteCompose вписывается в реальную архитектуру приложений.

Шаблон 1: Гибридная архитектура (рекомендуется)

Большинство приложений выигрывают от гибридного подхода: критически важные экраны создаются с помощью локального кода Compose, а динамические области контента используют RemoteCompose.

// Navigation: Local Compose (fast, reliable)
@Composable
fun AppNavigation() {
    NavHost(navController, startDestination = "home") {
        composable("home") { HomeScreen() }
        composable("product/{id}") { ProductScreen(it.arguments?.getString("id")) }
        composable("cart") { CartScreen() }
        composable("checkout") { CheckoutScreen() }
    }
}

// Home screen: Remote (marketing can update freely)
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val document by viewModel.homeDocument.collectAsState()

    when (val state = document) {
        is Loading -> LoadingIndicator()
        is Success -> RemoteDocumentPlayer(
            document = state.document,
            onNamedAction = { name, value, _ -> handleAction(name, value) }
        )
        is Error -> LocalFallbackHome()  // Graceful degradation
    }
}

// Product screen: Hybrid (shell is local, content is remote)
@Composable
fun ProductScreen(productId: String, viewModel: ProductViewModel = hiltViewModel()) {
    val product by viewModel.product.collectAsState()
    val contentDocument by viewModel.contentDocument.collectAsState()

    Scaffold(
        topBar = { ProductTopBar(product) },  // Local: consistent navigation
        bottomBar = { AddToCartBar(product) } // Local: critical purchase flow
    ) { padding ->
        // Remote: Rich product content, can be A/B tested
        contentDocument?.let { doc ->
            RemoteDocumentPlayer(
                document = doc,
                modifier = Modifier.padding(padding)
            )
        }
    }
}

Шаблон 2: Кэширование документов для работы в автономном режиме

Серверные документы могут быть кэшированы для доступа в автономном режиме:

class DocumentRepository @Inject constructor(
    private val api: DocumentApi,
    private val cache: DocumentCache,
    private val connectivity: ConnectivityManager
) {
    suspend fun getDocument(key: String): CoreDocument {
        // Try cache first
        cache.get(key)?.let { cached ->
            // Return cached immediately, refresh in background
            refreshInBackground(key)
            return cached
        }

        // No cache, must fetch
        return if (connectivity.isConnected) {
            fetchAndCache(key)
        } else {
            throw OfflineException("No cached document and no connectivity")
        }
    }

    private suspend fun fetchAndCache(key: String): CoreDocument {
        val bytes = api.fetchDocument(key)
        val document = RemoteComposeBuffer.deserialize(bytes)
        cache.store(key, document, ttl = 1.hours)
        return document
    }

    private fun refreshInBackground(key: String) {
        scope.launch {
            try {
                fetchAndCache(key)
            } catch (e: Exception) {
                // Silent failure, cached version is still valid
                Log.w(TAG, "Background refresh failed", e)
            }
        }
    }
}

Шаблон 3: Предварительная загрузка документов для плавной навигации

Предварительная загрузка документов для экранов, которые пользователь, скорее всего, посетит:

class DocumentPreloader @Inject constructor(
    private val repository: DocumentRepository
) {
    // Preload when user enters a screen
    fun preloadForScreen(screen: Screen) {
        val keysToPreload = when (screen) {
            is HomeScreen -> listOf("featured", "categories", "promotions")
            is CategoryScreen -> screen.subcategories.map { "category_${it.id}" }
            is ProductScreen -> listOf("reviews_${screen.productId}", "related_${screen.productId}")
            else -> emptyList()
        }

        keysToPreload.forEach { key ->
            scope.launch {
                try {
                    repository.getDocument(key)  // Caches for later
                } catch (e: Exception) {
                    // Preload failure is not critical
                }
            }
        }
    }
}

// Usage in navigation
navController.addOnDestinationChangedListener { _, destination, arguments ->
    preloader.preloadForScreen(destination.toScreen(arguments))
}

Заключение

RemoteCompose представляет собой кардинальное изменение в нашем подходе к разработке пользовательского интерфейса Android. Преобразуя макеты Compose в переносимый формат документа, он обеспечивает управляемый сервером пользовательский интерфейс, мгновенное A/B-тестирование, обновление контента в реальном времени и кроссплатформенную согласованность, сохраняя при этом производительность нативного рендеринга.

Комплексная операционная модель фреймворка, включающая более 93 операций, раскрывает всю выразительность Compose, включая анимации, состояния и взаимодействия. Разделение между созданием и воспроизведением обеспечивает гибкую архитектуру развертывания: создавайте документы на бэкэнде с полной выразительностью Compose, распространяйте их через существующую инфраструктуру и отображайте их нативно на любом устройстве Android.

Ключевым моментом является выбор правильного баланса: используйте RemoteCompose для динамических, часто изменяющихся областей контента, сохраняя при этом критически важные потоки в локальном коде Compose. Этот гибридный подход дает вам гибкость управляемого сервером пользовательского интерфейса там, где это важно, и надежность скомпилированного кода там, где это необходимо.

Независимо от того, разрабатываете ли вы приложение с большим объемом контента, требующее частых обновлений макета, платформу электронной коммерции, нуждающуюся в быстром A/B-тестировании, или корпоративный инструмент, требующий быстрых циклов итераций, RemoteCompose предоставляет архитектурную основу для действительно динамичного пользовательского интерфейса. Фреймворк справляется со сложностью сериализации, передачи и рендеринга, позволяя вам сосредоточиться на проектировании превосходного пользовательского опыта.

Вы можете посмотреть их недавний доклад на тему «Представляем RemoteCompose: выведите свой пользовательский интерфейс за пределы песочницы приложения».

Источник

Exit mobile version