借助RemoteCompose开发动态化页面

作者:稀有猿诉日期:2025/12/5

本文译自「RemoteCompose: Another Paradigm for Server-Driven UI in Jetpack Compose」,原文链接proandroiddev.com/remotecompo…,由Jaewoong Eum发布于2025年11月29日。

构建动态用户界面一直是 Android 开发中的一项根本性挑战。传统方法要求每次 UI 需要更改时都必须重新编译和重新部署整个应用程序,这给 A/B 测试、功能开关和实时内容更新带来了极大的不便。

试想一下,你的营销团队想要测试一个新的结账按钮设计:在传统模式下,这种简单的更改需要开发人员花费时间、进行代码审查、QA 测试、提交到应用商店,以及等待数周才能获得用户采纳。RemoteCompose 的出现为解决这一问题提供了一个强大的方案,它使开发人员能够在运行时创建、传输和渲染 Jetpack Compose UI 布局,而无需重新编译。

本文将探讨 RemoteCompose 的概念,理解其核心架构,并探索它如何为 Jetpack Compose 的动态页面设计带来诸多优势。本文并非库的使用教程,而是着重探讨它所代表的 Android UI 开发范式转变。

集成与依赖

在深入探讨概念之前,我们先来了解如何将 RemoteCompose 添加到你的项目中。对于运行在 JVM 上且不依赖 Android 的服务器和后端:

1// settings.gradle
2repositories {
3  maven {
4    url = uri("https://androidx.dev/snapshots/builds/14511716/artifacts/repository")
5  }
6}
7
8// JVM server - no Android dependencies
9dependencies {
10    implementation("androidx.compose.remote:remote-core:1.0.0-SNAPSHOT")
11    implementation("androidx.compose.remote:remote-creation-compose:1.0.0-SNAPSHOT")
12}
13
14// Compose-based app
15dependencies {
16    implementation("androidx.compose.remote:remote-player-compose:1.0.0-SNAPSHOT")
17    implementation("androidx.compose.remote:remote-tooling-preview:1.0.0-SNAPSHOT")
18}
19
20// View-based app
21dependencies {
22    implementation("androidx.compose.remote:remote-player-view:1.0.0-SNAPSHOT")
23}
24

请注意,RemoteCompose 仍在由 AndroidX 团队开发中,尚未正式发布;它仅可通过 AndroidX 快照 Maven 仓库获取。

理解核心抽象

RemoteCompose 的核心是一个框架,它支持 Compose UI 组件的远程渲染。它与传统 UI 方法的区别在于它遵循两个基本原则:声明式文档序列化和平台无关渲染。这些不仅仅是技术特性;这些架构决策从根本上改变了你对 UI 部署的思考方式。

声明式文档序列化

声明式文档序列化意味着你可以将任何 Jetpack Compose 布局捕获为紧凑的序列化格式。你可以把它想象成对 UI 进行“截图”,只不过你捕获的不是像素,而是实际的绘图指令。这个捕获的文档包含了重建 UI 所需的一切:形状、颜色、文本、图像、动画,甚至还有交互式触摸区域。

1// On the server or creation side
2val document = captureRemoteDocument(
3    context = context,
4    creationDisplayInfo = displayInfo,
5    profile = profile
6) {
7    RemoteColumn(modifier = RemoteModifier.fillMaxSize()) {
8        RemoteText("Dynamic Content")
9        RemoteButton(onClick = { /* action */ }) {
10            RemoteText("Click Me")
11        }
12    }
13}
14

结果如何?一个可以通过网络发送的字节数组。这种方法的关键在于,创建端编写的是标准的 Compose 代码。无需学习新的 DSL,无需维护 JSON 模式,也无需掌握模板语言。只要可以用 Compose 编写,就可以用 RemoteCompose 捕获。

你可以捕获一个普通的 Compose 代码,它会捕获绘制调用(这些调用非常静态)。更常见的情况是,你应该拥有镜像 Compose 的 Remote* 专用 API,这些 API 专为序列化和远程播放而设计,例如 RemoteColumnRemoteButtonRemoteText 等。

平台无关渲染

平台无关渲染意味着捕获的文档可以通过网络传输,并在任何 Android 设备上渲染,而无需原始的 Compose 代码。客户端设备不需要你的可组合函数、视图模型或业务逻辑——它只需要文档字节和一个播放器。

1// On the client or player side
2RemoteDocumentPlayer(
3    document = remoteDocument.document,
4    documentWidth = windowInfo.containerSize.width,
5    documentHeight = windowInfo.containerSize.height,
6    onAction = { actionId, value ->
7        // Handle user interactions
8    }
9)
10

这些特性并非仅仅是为了方便;它们是架构约束,能够真正实现 UI 定义与部署的解耦。文档格式不仅包含静态布局,还包含状态、动画和交互,从而完整地呈现了 UI 体验。

​​方法比较:为什么不选择 JSON 或 WebView?

在深入探讨之前,我们有必要了解 RemoteCompose 为什么选择这种方法而不是其他方案。

基于 JSON 的服务器端 UI,例如 Airbnb 的 Epoxy 或 Shopify 的方法,需要定义一个映射到原生组件的模式。这种方法适用于结构化内容,但难以处理复杂的动画和过渡效果、自定义绘图和图形、带有内联样式的富文本以及渐变和阴影等视觉效果。

WebView 提供了全面的灵活性,但由于其独立的渲染过程,会带来性能开销;此外,Web 样式与原生设计在外观和体验上存在不一致;每个 WebView 都会占用大量资源,造成内存压力;触摸处理也较为复杂,容易出现手势冲突。

RemoteCompose 另辟蹊径:捕获 Compose 实际执行的绘制操作。这意味着,你可以使用 Compose 构建的任何 UI,包括自定义 Canvas 绘制、复杂动画和 Material Design 组件,都可以被捕获并以原生性能远程重放。

基于文档的架构:创建与回放

RemoteCompose 的架构围绕着两个阶段的清晰分离而构建:文档创建和文档回放。理解这种分离是理解框架强大功能的关键。

文档创建:将 UI 作为数据捕获

创建阶段将 Compose UI 代码转换为序列化文档。这是通过捕获机制实现的,该机制会在 Canvas 层(Android 渲染管线的最底层)拦截绘制操作。

1@Composable Content
2        
3RemoteComposeCreationState (Tracks state and modifiers)
4        
5CaptureComposeView (Virtual Display - no actual screen needed)
6        
7RecordingCanvas (Intercepts every draw call)
8        
9Operations (93+ operation types covering all drawing primitives)
10        
11RemoteComposeBuffer (Efficient binary serialization)
12        
13ByteArray (Network-ready, typically 10-100KB for complex UIs)
14

创建端提供了一个完整的 Compose 集成层。你只需编写标准的 @Composable 函数,框架即可捕获所有内容:布局层级、修饰符、文本样式、图像、动画,甚至触摸处理程序。

其独特之处在于,捕获的文档是自包含的。它包含形状、颜色、渐变和阴影等视觉元素,以及带有字符串、字体、大小和样式的文本。图像可以嵌入为位图或 URL 以实现延迟加载。布局信息涵盖大小、位置、内边距和对齐方式。交互定义了触摸区域、点击处理程序和命名操作。状态变量可以在运行时更新,动画则通过基于时间的运动表达式来表达。

接收方无需访问你的代码库,只需访问文档字节即可。这与其他服务器驱动的 UI 方法有着本质区别,在其他方法中,客户端需要理解架构或拥有预构建的组件。

文档播放:无需编译即可渲染

播放阶段接收序列化的文档并将其渲染到屏幕上。播放器会遍历一系列操作,对 Canvas 执行每个操作。其概念类似于视频播放器解码帧的方式,只不过我们解码的是绘图指令而不是像素。

RemoteCompose 提供两种渲染后端以满足不同的架构需求。基于 Compose 的播放器推荐用于现代应用程序:

1@Composable
2fun DynamicScreen(document: CoreDocument) {
3    RemoteDocumentPlayer(
4        document = document,
5        documentWidth = screenWidth,
6        documentHeight = screenHeight,
7        modifier = Modifier.fillMaxSize(),
8        onNamedAction = { name, value, stateUpdater ->
9            // Handle named actions from the document
10            when (name) {
11                "addToCart" -> cartManager.addItem(value)
12                "navigate" -> navController.navigate(value)
13                "trackEvent" -> analytics.logEvent(value)
14            }
15        },
16        bitmapLoader = rememberBitmapLoader()  // For lazy image loading
17    )
18}
19

基于 Compose 的播放器可以自然地与你现有的 Compose UI 集成。它是一个可组合的函数,你可以将其放置在组合层级结构中的任何位置,并像其他可组合函数一样对其应用修饰符和动画。

为了与现有的 View 层级结构兼容,我们还提供了一个基于 View 的播放器:

1class LegacyActivity : AppCompatActivity() {
2    private lateinit var player: RemoteComposePlayer
3
4    override fun onCreate(savedInstanceState: Bundle?) {
5        super.onCreate(savedInstanceState)
6        player = RemoteComposePlayer(this)
7        setContentView(player)
8
9        // Load document from network
10        lifecycleScope.launch {
11            val bytes = api.fetchDocument("home-screen")
12            player.setDocument(bytes)
13        }
14
15        player.onNamedAction { name, value, stateUpdater ->
16            // Handle actions
17        }
18    }
19}
20

两种播放器提供相同的渲染保真度;选择哪种取决于你的应用程序架构。如果你完全使用 Compose,请使用可组合播放器。如果你是从 Views 迁移过来的,或者将其嵌入到 View 层级结构中,请使用基于 View 的播放器。

操作模型:一套全面的绘图词汇表

RemoteCompose 的优势在于其全面的操作模型。该框架定义了 93 种以上的不同操作,涵盖了 UI 渲染的方方面面。这并非随意设定的数字,而是表达任何 Canvas 绘图操作所需的完整词汇表。

操作的重要性

传统的服务器驱动型 UI 发送的是高级组件描述:“渲染一个带有文本‘提交’的按钮”。客户端必须解析这些描述并将其映射到原生组件。这导致服务器和客户端之间紧密耦合;双方必须就“按钮”的定义及其行为达成一致。

RemoteCompose 则在更底层运行:它不发送“渲染一个按钮”这样的描述,而是发送实际的绘图指令:“在这些坐标处绘制一个带有这种颜色的圆角矩形,然后在这个位置绘制带有这种字体的文本‘提交’”。客户端无需了解“按钮”的定义;它只需执行绘图操作即可。

这种底层方法意义深远。由于服务器和客户端无需就组件定义达成一致,因此无需进行模式同步。由于 Compose 中所有可能的视觉效果均可捕获,因此能够完整保留视觉保真度。由于新的视觉设计可在旧客户端上运行(它们只是不同的绘制操作),因此内置了向前兼容性。自定义组件无需注册即可自动运行。

绘制操作

绘制操作捕获 Canvas 绘制调用,这是 2D 图形的基本图元。这些图元包括:用于按钮、卡片和背景的矩形的 DRAW_RECT;用于带有圆角的 Material 曲面的 DRAW_ROUND_RECT;用于头像和指示器的 DRAW_CIRCLE;用于渲染带有完整样式的文本的 DRAW_TEXT;用于沿曲线绘制文本的 DRAW_TEXT_ON_PATH;以及用于图像的 DRAW_BITMAPDRAW_TWEEN_PATH 用于动画路径变形,等等。

每个操作都包含执行它所需的所有信息:坐标、颜色、绘制样式以及对文档中其他位置存储的数据(例如文本字符串或位图)的引用。

布局操作

布局操作定义组件层次结构和空间关系。Component 操作声明一个布局组件,而 Container 操作创建一个类似于 ColumnRow 的容器,ContainerEnd 操作则关闭它。LoopOperation 操作用于循环列表内容。Modifier 包括用于背景颜色和可绘制对象的 BackgroundModifier、用于边框样式的 BorderModifier、用于内部间距的 PaddingModifier 以及用于触摸处理的 ClickModifier

容器模型采用推送/弹出机制。当播放器遇到 Container 操作时,它会创建一个新的布局上下文。所有后续操作都将在该上下文中执行,直到 ContainerEnd 操作将其弹出。这与 Compose 的布局系统的工作方式类似。

状态和表达式操作

状态操作支持运行时可更改的动态值。NamedVariable 声明一个命名的状态变量。ColorAttribute 提供可自定义主题的颜色。TimeAttribute 引用动画时间。FloatExpressionIntegerExpression 每帧计算数学表达式。ConditionalOp 支持基于状态的条件渲染。

表达式系统功能强大。你可以嵌入公式,而不是静态值:

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

这使得完全在文档中定义丰富的动画成为可能——无需客户端动画代码。 ​​交互操作

交互操作处理用户输入。TouchOperation 定义触摸区域,而 CLICK_AREA 处理简单的点击操作。ParticlesCreate 初始化粒子系统,ParticlesLoop 驱动粒子动画。

触摸操作注册带有命名操作的矩形区域。当用户点击某个区域时,播放器会触发相应的操作,宿主应用程序会通过回调函数来处理这些操作。这种设计既保持了文档格式的简洁性,又实现了丰富的交互功能。

动态屏幕设计的优势

现在,让我们通过常见应用场景中的真实案例,来探讨 RemoteCompose 为动态屏幕设计带来的切实优势。

服务器驱动 UI,性能毫不妥协

传统的服务器驱动 UI 方法需要权衡取舍。基于 JSON 的布局表达能力有限,无法实现复杂的动画或自定义绘制。WebView 会带来性能开销、外观不一致以及更高的内存占用。自定义 DSL 则会增加维护负担、学习曲线,并且对预定义组件有所限制。

RemoteCompose 提供了第三条路径:从服务器定义的布局进行原生 Compose 渲染。你既能充分利用 Compose 渲染引擎的强大功能,又能享受服务器驱动内容的灵活性。

例如,一个电商应用需要频繁更新产品卡片、添加新的徽章样式、促销叠加层或季节性主题。借助 RemoteCompose,服务器端允许营销团队无需发布应用即可更新卡片设计:

1// Server-side: We can update card designs without app release
2@Composable
3fun ProductCard(product: Product) {
4    Card(
5        modifier = RemoteModifier
6            .fillMaxWidth()
7            .clickable { namedAction("viewProduct", product.id) }
8    ) {
9        Box {
10            // Product image with gradient overlay
11            AsyncImage(
12                url = product.imageUrl,
13                modifier = RemoteModifier.fillMaxWidth().aspectRatio(1.5f)
14            )
15
16            // Promotional badge - can be A/B tested server-side
17            if (product.hasPromotion) {
18                PromotionalBadge(
19                    text = product.promotionText,
20                    modifier = RemoteModifier.align(Alignment.TopEnd)
21                )
22            }
23
24            // Price with sale styling
25            PriceTag(
26                originalPrice = product.originalPrice,
27                salePrice = product.salePrice,
28                modifier = RemoteModifier.align(Alignment.BottomStart)
29            )
30        }
31    }
32}
33

客户端只需渲染服务器发送的内容:

1// Client-side: Just renders whatever the server sends
2@Composable
3fun ProductGrid(viewModel: ProductViewModel) {
4    val documents by viewModel.productDocuments.collectAsState()
5
6    LazyVerticalGrid(columns = GridCells.Fixed(2)) {
7        items(documents) { document ->
8            RemoteDocumentPlayer(
9                document = document,
10                onNamedAction = { name, value, _ ->
11                    if (name == "viewProduct") {
12                        navController.navigate("product/$value")
13                    }
14                }
15            )
16        }
17    }
18}
19

现在,你的团队无需发布任何应用即可更新产品卡片设计,更改徽章颜色、添加动画和重新排列元素。由于它是原生应用,通过 Compose 的实际绘制管道渲染,因此 UI 的外观和体验与原生应用无异。

大规模 A/B 测试

传统的 A/B 测试 UI 变体需要在应用二进制文件中实现所有变体,为每个变体创建功能标志,发布包含所有变体的应用,然后等待用户采用后再衡量结果。从构思到获得数据,这个过程通常需要 2-4 周。

借助 RemoteCompose,你无需部署任何客户端即可测试 UI 变体。假设一个电商团队想要测试单页结账流程是否比多步骤向导转化率更高:

1// Server-side: Two completely different checkout experiences
2object CheckoutExperiments {
3
4    fun getCheckoutDocument(user: User, cart: Cart): ByteArray {
5        val variant = experimentService.getVariant(user.id, "checkout-flow")
6
7        return when (variant) {
8            "single-page" -> captureSinglePageCheckout(cart)
9            "multi-step" -> captureMultiStepCheckout(cart)
10            "express" -> captureExpressCheckout(cart)  // New variant added without app update
11            else -> captureSinglePageCheckout(cart)
12        }
13    }
14
15    private fun captureSinglePageCheckout(cart: Cart): ByteArray {
16        return captureRemoteDocument(context, displayInfo, profile) {
17            SinglePageCheckout(
18                cart = cart,
19                onPlaceOrder = { namedAction("placeOrder", cart.id) },
20                onUpdateQuantity = { itemId, qty ->
21                    namedAction("updateQuantity", "$itemId:$qty")
22                }
23            )
24        }
25    }
26
27    private fun captureMultiStepCheckout(cart: Cart): ByteArray {
28        return captureRemoteDocument(context, displayInfo, profile) {
29            MultiStepCheckout(
30                cart = cart,
31                steps = listOf("Shipping", "Payment", "Review"),
32                onComplete = { namedAction("placeOrder", cart.id) }
33            )
34        }
35    }
36}
37

客户端完全不知道显示的是哪个版本:

1// Client-side: Completely agnostic to which variant is shown
2@Composable
3fun CheckoutScreen(viewModel: CheckoutViewModel) {
4    val document by viewModel.checkoutDocument.collectAsState()
5
6    document?.let { doc ->
7        RemoteDocumentPlayer(
8            document = doc,
9            onNamedAction = { name, value, stateUpdater ->
10                when (name) {
11                    "placeOrder" -> viewModel.placeOrder(value)
12                    "updateQuantity" -> {
13                        val (itemId, qty) = value.split(":")
14                        viewModel.updateQuantity(itemId, qty.toInt())
15                    }
16                }
17            }
18        )
19    }
20}
21

结果即时且实时,这意味着无需等待应用商店审核或用户反馈。你甚至可以添加全新的版本,例如“快速结账”,而无需对客户端进行任何更改。实验会持续运行,直到获得统计学意义上的显著性,然后将获胜版本推广到所有用户,同样无需发布新应用。

实时内容更新

内容密集型应用常常需要在原生性能和内容新鲜度之间寻求平衡。以新闻应用为例:文章需要丰富的格式、嵌入式媒体和交互元素,但同时也需要随着新闻事件的进展实时更新。

一家报道重大事件的新闻机构需要实时更新文章布局。编辑团队可以根据新闻事件的进展调整布局:

1// Server-side: Editorial team can update layout as story develops
2class ArticleLayoutService {
3
4    fun getArticleDocument(article: Article): ByteArray {
5        return captureRemoteDocument(context, displayInfo, profile) {
6            ArticleLayout(article)
7        }
8    }
9
10    @Composable
11    private fun ArticleLayout(article: Article) {
12        Column(modifier = RemoteModifier.fillMaxSize().padding(16.dp)) {
13            // Breaking news banner - can be added/removed instantly
14            if (article.isBreaking) {
15                BreakingNewsBanner(
16                    modifier = RemoteModifier.fillMaxWidth()
17                )
18            }
19
20            // Headline with dynamic styling
21            Text(
22                text = article.headline,
23                style = if (article.isBreaking) {
24                    HeadlineStyle.Breaking
25                } else {
26                    HeadlineStyle.Standard
27                }
28            )
29
30            // Live updates indicator
31            if (article.hasLiveUpdates) {
32                LiveUpdatesIndicator(
33                    lastUpdate = article.lastUpdate,
34                    modifier = RemoteModifier.clickable {
35                        namedAction("refreshArticle", article.id)
36                    }
37                )
38            }
39
40            // Rich content blocks - can include any Compose UI
41            article.contentBlocks.forEach { block ->
42                when (block) {
43                    is TextBlock -> ArticleText(block)
44                    is ImageBlock -> ArticleImage(block)
45                    is VideoBlock -> VideoEmbed(block)
46                    is LiveBlogBlock -> LiveBlogTimeline(block)
47                    is InteractiveChartBlock -> DataVisualization(block)
48                    is PullQuoteBlock -> PullQuote(block)
49                }
50            }
51
52            // Related articles - layout can be A/B tested
53            RelatedArticles(
54                articles = article.relatedArticles,
55                onArticleClick = { namedAction("openArticle", it.id) }
56            )
57        }
58    }
59}
60

客户端只需渲染服务器提供的任何布局:

1// Client-side: Renders whatever layout the server sends
2@Composable
3fun ArticleScreen(articleId: String, viewModel: ArticleViewModel) {
4    val document by viewModel.articleDocument.collectAsState()
5    val refreshing by viewModel.isRefreshing.collectAsState()
6
7    SwipeRefresh(
8        state = rememberSwipeRefreshState(refreshing),
9        onRefresh = { viewModel.refresh() }
10    ) {
11        document?.let { doc ->
12            RemoteDocumentPlayer(
13                document = doc,
14                onNamedAction = { name, value, _ ->
15                    when (name) {
16                        "openArticle" -> navController.navigate("article/$value")
17                        "refreshArticle" -> viewModel.refresh()
18                        "playVideo" -> videoPlayer.play(value)
19                    }
20                }
21            )
22        }
23    }
24}
25

你的团队无需修改应用即可更新文章布局,添加实时博客时间线、嵌入交互式图表和更改字体。当新闻事件有进展时,他们可以立即在所有相关文章上添加“突发新闻”横幅。

避免代码膨胀的功能标志

传统的功能标志需要将所有变体都包含在二进制文件中:

1// Traditional approach - all code ships, even unused variations
2@Composable
3fun HomeScreen() {
4    when {
5        featureFlags.newHomeV3 -> NewHomeLayoutV3()  // Ships always
6        featureFlags.newHomeV2 -> NewHomeLayoutV2()  // Ships always
7        else -> OldHomeLayout()
8
9        // Ships always
10    }
11}
12

这会带来几个问题。二进制文件会因为包含所有变体而增加应用程序的大小。即使未使用,也会包含无用代码。当功能标志配置错误时,可能会暴露未发布的功能,从而带来安全风险。随着时间的推移,旧的变体不断累积,导致技术债务不断增加。

使用 RemoteCompose,只会传输当前激活的变体:

1// Server-side: Only the active variation exists on the server
2class HomeScreenService {
3    fun getHomeDocument(user: User): ByteArray {
4        return when (featureFlags.getHomeVariant(user)) {
5            "v3" -> captureHomeV3(user)
6            "v2" -> captureHomeV2(user)
7            else -> captureHomeDefault(user)
8        }
9    }
10}
11
12// Client-side: No conditional code, no dead code
13@Composable
14fun HomeScreen(document: CoreDocument) {
15    RemoteDocumentPlayer(document = document)
16    // That's it. No feature flags, no conditionals.
17}
18

这消除了二进制文件膨胀,因为不会传输旧的变体;由于只存在服务器端代码,因此消除了无用代码;并且由于配置错误只会显示不同的 UI 而不是未发布的代码,因此降低了安全风险。

设想一个社交媒体应用正在逐步重新设计其信息流:

1// Server-side: Complete control over who sees what
2class FeedLayoutService {
3
4    fun getFeedDocument(user: User, posts: List<Post>): ByteArray {
5        val variant = rolloutService.getFeedVariant(user)
6
7        return captureRemoteDocument(context, displayInfo, profile) {
8            when (variant) {
9                FeedVariant.NEW_DESIGN -> NewFeedLayout(posts)
10                FeedVariant.NEW_DESIGN_COMPACT -> NewFeedCompactLayout(posts)
11                FeedVariant.CLASSIC -> ClassicFeedLayout(posts)
12            }
13        }
14    }
15}
16
17// Rollout service controls the percentage
18class RolloutService {
19    fun getFeedVariant(user: User): FeedVariant {
20        // 5% get new design, 5% get compact variant, 90% get classic
21        return when {
22            user.id.hashCode() % 100 < 5 -> FeedVariant.NEW_DESIGN
23            user.id.hashCode() % 100 < 10 -> FeedVariant.NEW_DESIGN_COMPACT
24            else -> FeedVariant.CLASSIC
25        }
26    }
27
28    // Instant rollback if issues are detected
29    fun emergencyRollback() {
30        // All users immediately get classic layout
31        // No app update needed
32    }
33}
34

如果新设计导致问题(例如崩溃、用户互动度下降或用户投诉),可以立即回滚。只需更改服务器配置即可。无需紧急发布应用。

跨平台一致性

RemoteCompose 的文档格式与平台无关。同一文档可以在手机、平板电脑、折叠屏设备和 Wear OS 设备上渲染,并由相应的平台播放器负责渲染。

1Creation (Server/Backend)  
2      
3RemoteComposeBuffer (Platform-independent binary format)  
4      
5┌─────────────────────────────────────────────────────────┐  
6
7
8
9
10
11
12
13
14
15
16Android Phone
17
18Android Tablet
19
20Foldable Device
21
22Wear OS
23(Compose Player) (Compose Player)  (Compose Player)   (Wear Player)
24

假设一款健身应用在手机和手表应用上都显示锻炼总结。相同的数据会针对不同的设备尺寸进行优化,呈现不同的内容:

1// Server-side: Same data, different presentations
2class WorkoutSummaryService {
3
4    fun getPhoneDocument(workout: Workout): ByteArray {
5        return captureRemoteDocument(context, phoneDisplayInfo, profile) {
6            PhoneWorkoutSummary(workout)  // Full detailed view
7        }
8    }
9
10    fun getWatchDocument(workout: Workout): ByteArray {
11        return captureRemoteDocument(context, watchDisplayInfo, profile) {
12            WatchWorkoutSummary(workout)  // Glanceable summary
13        }
14    }
15
16    @Composable
17    private fun PhoneWorkoutSummary(workout: Workout) {
18        Column {
19            WorkoutHeader(workout)
20            HeartRateChart(workout.heartRateData)
21            PaceChart(workout.paceData)
22            SplitsTable(workout.splits)
23            MapView(workout.route)
24            ShareButton { namedAction("share", workout.id) }
25        }
26    }
27
28    @Composable
29    private fun WatchWorkoutSummary(workout: Workout) {
30        // Optimized for small screen
31        Column(modifier = RemoteModifier.fillMaxSize()) {
32            Text(workout.type, style = WatchTypography.Title)
33            Row {
34                StatBox("Duration", workout.duration)
35                StatBox("Distance", workout.distance)
36            }
37            MiniHeartRateIndicator(workout.avgHeartRate)
38        }
39    }
40}
41

两款设备都显示锻炼数据,但布局针对各自的设备尺寸进行了优化。任一布局的更新都会立即生效,无需在任一平台上更新应用。

缩短发布周期

最显著的优势在于运营层面:UI 更改不再需要发布应用。考虑一下简单 UI 调整的开发周期。

传统方法大约需要两到四周。第一天和第二天是开发人员实现。第三天和第四天用于代码审查和修改。第五天到第七天用于质量保证测试。第八天和第九天处理发布准备和应用商店提交。第十天到第十四天:等待应用商店审核。第十五天到第三十天用户逐步采用,通常两周内会有 50% 到 70% 的用户更新。大多数用户在两到四周内不会看到变化。

RemoteCompose 方法只需一到两天即可完成。第一天和第二天是开发人员在服务器端实现。部署只需几分钟。所有用户都能立即看到变化。

这种速度优势对于节假日促销活动至关重要,你可以根据需要在当天部署季节性主题;对于服务中断的紧急消息,你可以即时更新 UI;对于快速迭代,你可以快速测试想法并快速失败;对于竞争响应,你可以以小时而不是几周的时间对市场变化做出反应。

以一个准备迎接黑色星期五的电商应用为例:

1// Traditional approach: Ship all variations weeks in advance
2// Problem: All promotional code ships weeks early
3// Risk: Date logic bugs could show promotions early
4@Composable
5fun HomeScreen() {
6    val today = LocalDate.now()
7    when {
8        today == BlackFriday -> BlackFridayHome()
9
10        // Must ship by Oct 15
11        today in BlackFridayWeek -> BlackFridayWeekHome()   // Must ship by Oct 15
12        today == CyberMonday -> CyberMondayHome()
13
14        // Must ship by Oct 15
15        else -> RegularHome()
16    }
17}
18
19// Remote approach: Deploy each promotion on the exact day
20// Benefit: Each promotion deploys on the exact minute needed
21// Flexibility: Can react to competitor moves in real-time
22class HomeScreenService {
23    fun getHomeDocument(user: User): ByteArray {
24        val promotion = promotionService.getCurrentPromotion()
25
26        return captureRemoteDocument(context, displayInfo, profile) {
27            when (promotion) {
28                is BlackFridayPromotion -> BlackFridayHome(promotion)
29                is CyberMondayPromotion -> CyberMondayHome(promotion)
30                is FlashSale -> FlashSaleHome(promotion)  // Can add new types anytime
31                else -> RegularHome()
32            }
33        }
34    }
35}
36

状态管理:超越静态布局

RemoteCompose 不仅限于静态布局。该框架包含一个状态管理系统,可以实现交互式、动态的 UI。

远程状态变量

状态可以嵌入文档中,并由客户端更新。这使得表单、计数器、切换开关和其他交互元素成为可能:

1// Creation side: Define interactive widget
2@Composable
3fun QuantitySelector(initialQuantity: Int) {
4    var quantity by rememberRemoteState("quantity", initialQuantity)
5
6    Row(
7        modifier = RemoteModifier.fillMaxWidth(),
8        horizontalArrangement = Arrangement.SpaceBetween
9    ) {
10        IconButton(
11            onClick = {
12                if (quantity > 1) {
13                    quantity--
14                    namedAction("quantityChanged", quantity.toString())
15                }
16            }
17        ) {
18            Icon(Icons.Minus)
19        }
20
21        Text(
22            text = quantity.toString(),
23            style = MaterialTheme.typography.headlineMedium
24        )
25
26        IconButton(
27            onClick = {
28                quantity++
29                namedAction("quantityChanged", quantity.toString())
30            }
31        ) {
32            Icon(Icons.Plus)
33        }
34    }
35}
36

播放器端通过操作回调处理状态更新:

1// Player side: Handle state updates
2RemoteDocumentPlayer(
3    document = document,
4    onNamedAction = { name, value, stateUpdater ->
5        when (name) {
6            "quantityChanged" -> {
7                // Update cart
8                cartManager.setQuantity(itemId, value.toInt())
9
10                // Optionally update remote state directly
11                stateUpdater.updateState { state ->
12                    state["quantity"] = RcInt(value.toInt())
13                }
14            }
15        }
16    }
17)
18

动画时间跟踪

播放器跟踪动画时间并将其传递给文档,从而无需任何客户端动画代码即可实现基于时间的动画:

1// Server side: Define animated elements
2@Composable
3fun PulsingNotificationBadge(count: Int) {
4    // Scale pulses between 0.9 and 1.1 over 1 second
5    val scale = FloatExpression("0.9 + 0.2 * sin(time * 6.28)")
6
7    // Opacity pulses between 0.7 and 1.0
8    val opacity = FloatExpression("0.7 + 0.3 * sin(time * 6.28)")
9
10    Box(
11        modifier = RemoteModifier
12            .scale(scale)
13            .alpha(opacity)
14            .background(Color.Red, CircleShape)
15            .size(24.dp)
16    ) {
17        Text(
18            text = count.toString(),
19            color = Color.White,
20            modifier = RemoteModifier.align(Alignment.Center)
21        )
22    }
23}
24
25// The player automatically:
26// 1. Tracks elapsed time since document load
27// 2. Evaluates expressions each frame
28// 3. Updates visual properties
29// No client animation code needed
30

这使得完全在文档格式中定义的流畅、高性能动画成为可能。表达式支持诸如 sincoslerpclamp 之类的数学函数,以及算术运算符和变量引用。

双向通信

操作系统支持文档与宿主应用之间的双向通信:

1// Document triggers actions for various purposes
2@Composable
3fun ProductDetailPage(product: Product) {
4    Column {
5        // Analytics tracking
6        LaunchedEffect(Unit) {
7            namedAction("analytics", "product_viewed:${product.id}")
8        }
9
10        ProductImage(product.imageUrl)
11
12        // Navigation action
13        TextButton(onClick = { namedAction("navigate", "/reviews/${product.id}") }) {
14            Text("See all reviews")
15        }
16
17        // Cart action with data
18        Button(onClick = { namedAction("addToCart", product.id) }) {
19            Text("Add to Cart")
20        }
21
22        // State update action
23        var isFavorite by rememberRemoteState("favorite", product.isFavorite)
24        IconButton(
25            onClick = {
26                isFavorite = !isFavorite
27                namedAction("toggleFavorite", "${product.id}:$isFavorite")
28            }
29        ) {
30            Icon(if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.Favorite)
31        }
32    }
33}
34

宿主应用统一处理所有操作:

1// Host app handles all actions uniformly
2RemoteDocumentPlayer(
3    document = document,
4    onNamedAction = { name, value, stateUpdater ->
5        when (name) {
6            "analytics" -> {
7                val (event, id) = value.split(":")
8                analytics.logEvent(event, mapOf("productId" to id))
9            }
10            "navigate" -> navController.navigate(value)
11            "addToCart" -> {
12                cartManager.add(value)
13                // Update UI to show confirmation
14                stateUpdater.updateState { state ->
15                    state["cartCount"] = RcInt((state["cartCount"] as? RcInt)?.value?.plus(1) ?: 1)
16                }
17            }
18            "toggleFavorite" -> {
19                val (id, isFavorite) = value.split(":")
20                favoritesManager.setFavorite(id, isFavorite.toBoolean())
21            }
22        }
23    }
24)
25

这种双向通信意味着远程文档可以完全集成到你应用的导航、分析、状态管理和业务逻辑中,而文档本身无需了解你的具体实现。

实际应用架构模式

让我们来探讨一下 RemoteCompose 如何融入实际应用架构。

模式 1:混合架构(推荐)

大多数应用都能从混合架构中获益:关键页面使用本地 Compose 代码构建,而动态内容区域则使用 RemoteCompose。

1// Navigation: Local Compose (fast, reliable)
2@Composable
3fun AppNavigation() {
4    NavHost(navController, startDestination = "home") {
5        composable("home") { HomeScreen() }
6        composable("product/{id}") { ProductScreen(it.arguments?.getString("id")) }
7        composable("cart") { CartScreen() }
8        composable("checkout") { CheckoutScreen() }
9    }
10}
11
12// Home screen: Remote (marketing can update freely)
13@Composable
14fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
15    val document by viewModel.homeDocument.collectAsState()
16
17    when (val state = document) {
18        is Loading -> LoadingIndicator()
19        is Success -> RemoteDocumentPlayer(
20            document = state.document,
21            onNamedAction = { name, value, _ -> handleAction(name, value) }
22        )
23        is Error -> LocalFallbackHome()  // Graceful degradation
24    }
25}
26
27// Product screen: Hybrid (shell is local, content is remote)
28@Composable
29fun ProductScreen(productId: String, viewModel: ProductViewModel = hiltViewModel()) {
30    val product by viewModel.product.collectAsState()
31    val contentDocument by viewModel.contentDocument.collectAsState()
32
33    Scaffold(
34        topBar = { ProductTopBar(product) },  // Local: consistent navigation
35        bottomBar = { AddToCartBar(product) } // Local: critical purchase flow
36    ) { padding ->
37        // Remote: Rich product content, can be A/B tested
38        contentDocument?.let { doc ->
39            RemoteDocumentPlayer(
40                document = doc,
41                modifier = Modifier.padding(padding)
42            )
43        }
44    }
45}
46

模式 2:文档缓存以实现离线支持

远程文档可以缓存以供离线访问:

1class DocumentRepository @Inject constructor(
2    private val api: DocumentApi,
3    private val cache: DocumentCache,
4    private val connectivity: ConnectivityManager
5) {
6    suspend fun getDocument(key: String): CoreDocument {
7        // Try cache first
8        cache.get(key)?.let { cached ->
9            // Return cached immediately, refresh in background
10            refreshInBackground(key)
11            return cached
12        }
13
14        // No cache, must fetch
15        return if (connectivity.isConnected) {
16            fetchAndCache(key)
17        } else {
18            throw OfflineException("No cached document and no connectivity")
19        }
20    }
21
22    private suspend fun fetchAndCache(key: String): CoreDocument {
23        val bytes = api.fetchDocument(key)
24        val document = RemoteComposeBuffer.deserialize(bytes)
25        cache.store(key, document, ttl = 1.hours)
26        return document
27    }
28
29    private fun refreshInBackground(key: String) {
30        scope.launch {
31            try {
32                fetchAndCache(key)
33            } catch (e: Exception) {
34                // Silent failure, cached version is still valid
35                Log.w(TAG, "Background refresh failed", e)
36            }
37        }
38    }
39}
40

模式 3:文档预加载以实现流畅导航

预加载用户可能访问的页面的文档:

1class DocumentPreloader @Inject constructor(
2    private val repository: DocumentRepository
3) {
4    // Preload when user enters a screen
5    fun preloadForScreen(screen: Screen) {
6        val keysToPreload = when (screen) {
7            is HomeScreen -> listOf("featured", "categories", "promotions")
8            is CategoryScreen -> screen.subcategories.map { "category_${it.id}" }
9            is ProductScreen -> listOf("reviews_${screen.productId}", "related_${screen.productId}")
10            else -> emptyList()
11        }
12
13        keysToPreload.forEach { key ->
14            scope.launch {
15                try {
16                    repository.getDocument(key)  // Caches for later
17                } catch (e: Exception) {
18                    // Preload failure is not critical
19                }
20            }
21        }
22    }
23}
24
25// Usage in navigation
26navController.addOnDestinationChangedListener { _, destination, arguments ->
27    preloader.preloadForScreen(destination.toScreen(arguments))
28}
29

结论

RemoteCompose 代表了我们对 Android UI 开发思维方式的一次范式转变。通过将 Compose 布局转换为可移植文档格式,RemoteCompose 实现了服务器驱动的 UI、即时 A/B 测试、实时内容更新和跨平台一致性,同时保持了原生渲染性能。

该框架拥有包含 93 种以上操作的全面操作模型,充分展现了 Compose 的表达能力,包括动画、状态和交互。创建和播放的分离使得部署架构更加灵活:在后端生成具有完整 Compose 表达能力的文档,通过现有基础架构分发,并在任何 Android 设备上进行原生渲染。

关键在于找到合适的平衡点:对于动态、频繁变化的内容区域,使用 RemoteCompose;同时将关键流程保留在本地 Compose 代码中。这种混合方法在需要灵活性的地方提供服务器驱动 UI 的优势,在需要可靠性的地方提供编译代码的优势。

无论你是构建需要频繁更新布局的内容密集型应用、需要快速 A/B 测试的电子商务平台,还是需要快速迭代的企业级工具,RemoteCompose 都能为真正动态的 UI 提供架构基础。该框架处理了序列化、传输和渲染的复杂性,因此你可以专注于设计卓越的用户体验。

你可以观看他们最近关于RemoteCompose 简介:将你的 UI 从应用程序沙盒中解放出来的演讲。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!


借助RemoteCompose开发动态化页面》 是转载文章,点击查看原文


相关推荐


当OpenAI内部命名乱成“GPT-5.1a-beta-v3-rev2”,Gateone.ai 已为你筑起一道“多模态智能的稳定防线”。
gaetoneai2025/12/2

当OpenAI内部命名乱成“GPT-5.1a-beta-v3-rev2”,Gateone.ai 已为你筑起一道“多模态智能的稳定防线”。 Transformer 作者最新爆料震动开发者社区: GPT-5.1 在内部疯狂迭代,命名混乱如“代码草稿”OpenAI 正从“大版本发布”转向“高频实验 + 快速试错”真正的下一爆点,是 多模态推理 × 具身智能 的融合——AI不再只是聊天,而是要看、说、想、做但随之而来的是:接口频繁变更、模型行为不可预测、安全边界模糊 这不是进步的终点,而是混乱的


图像处理中的投影变换(单应性变换)
涤生8432025/11/29

参考链接:参考链接 投影变换是将图像从一个视角变向另一个视角,实现不同视角之间的图像变换。例如将一个正方形经过透视变换,转为一个梯形。 透视变换的通用公式为: 其中(u,v)是原始图片(即需要变换的图片)中的像素坐标。这里的 [u, v, w]是原始点的齐次坐标。在图像处理中,当我们有一个二维点 (u, v) 时,通常会将其表示为齐次坐标 [u, v, 1]。也就是说,在输入时,我们默认了 w=1。 原始图片中的像素点坐标(u,v)经过变换后,对应变换后的图片中的坐标(x,y),其中。


node.js和nest.js做智能体开发需要会哪些东西
光影少年2025/11/27

✅ 一、学习路线图(从零到上线) 我把大模型应用开发拆成 6 个阶段: 阶段 1:基础能力(Node.js + TS) JavaScript ES6+ TypeScript(类型、泛型、类、装饰器) Node.js 基础:async/await、fs、path、events npm / pnpm 基本指令 阶段 2:Nest.js 后端基础 项目创建(CLI) 模块、控制器、服务 依赖注入(DI) DTO + Pipe Inter


三小时上线,七天破千刀:AI 出海订阅站的 0-1 全流程复盘
孟健AI编程2025/11/24

大家好,我是孟健。 上周末,创业的第二个月,我收到了第一笔客户订单。 到现在,短短一周内已经破千刀,并且收入还在刷新新高。 我给自己的 0-1 目标是:3 个月内赚到 1,000 美元,走出“新手村”。 现在提前达成,内心非常激动。 当然,最激动的应该还是第一个有收入的晚上,享受到了躺赚的快乐,也很符合“边际递减”的原理。 这篇总结原本几天前就想写,无奈竞争太激烈,我连续工作了十天应对各种变化和危机。 今天终于在休息的空档把整个过程和方法论写出来,希望对大家有启发。 由于竞争关系,产品本身暂


Tree of Thoughts:让大语言模型像人类一样思考
ToTensor2025/11/23

文章目录 前言引言什么是 Tree of Thoughts?传统方法的局限ToT 的优势 Game24:一个完美的例子ToT 的核心工作流程整体流程图1. 生成(Generation)2. 评估(Evaluation)3. 选择(Selection) 完整流程示例问题说明整体流程图详细步骤说明步骤 1/4:从初始状态开始步骤 2/4:对选中的候选继续生成步骤 3/4:继续搜索步骤 4/4:生成最终答案 完整路径总结 代码实现核心函数生成函数示例评估函数评估提示词示例


win11上wsl本地安装版本ubuntu25.10
Livingbody2025/11/21

1.安装wsl2 dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart wsl --set-default-version 2 2.下载镜像 从163镜像下载 mi


Lua 的 assert 函数
IMPYLH2025/11/19

Lua 的 assert 函数 是一个内置的错误检查函数,主要用于验证条件并在条件不满足时抛出错误。其语法格式为: assert(condition [, error_message]) 参数说明: condition:要检查的条件表达式error_message(可选):当条件为假时要显示的错误信息 工作原理: 当 condition 为真时,assert 会返回所有传入的参数;当 condition 为假时,assert 会抛出错误。如果提供了 error_message,则使用该


增强现实与物联网融合在智慧城市交通管理中的智能优化应用探索
while(努力):进步2025/11/18

随着智慧城市建设的推进,交通管理系统面临着车辆数量激增、道路资源有限和实时交通信息复杂多变等挑战。传统交通管理依赖摄像头监控、信号灯定时控制和人工调度,已难以满足现代城市对高效、智能、低碳交通的需求。增强现实(AR)技术与物联网(IoT)设备的深度融合,为城市交通管理提供了新的解决方案。通过实时感知、数据分析和可视化指引,交通系统能够实现智能优化与动态决策。 在这一系统中,AR 用于将交通信息以直观可视化形式呈现给管理者或驾驶者,IoT 设备则提供实时路况、车辆定位、空气质量和交通流量等多维数


Python 的内置函数 slice
IMPYLH2025/11/17

Python 内建函数列表 > Python 的内置函数 slice Python 的内置函数 slice() 用于创建切片对象,可以应用于序列类型(如列表、字符串、元组)的切片操作。这个函数提供了一种更灵活的方式来定义切片,特别适合在需要动态生成切片参数的情况下使用。 基本语法 slice(stop) slice(start, stop[, step]) 参数说明 start(可选):切片的起始索引,默认为 None,表示从序列开头开始。stop:切片的结束索引(不包含该索引对应的


linux上gitlab runner部署文档
艾迪王2025/11/16

2025年11月16日 背景 平常使用的CI/CD主要是用Jenkins,git的本地hook,但是对于代码上传后执行差异代码优化这个技术场景流程场景来说: Jenkins流程只会做到全量排查,如果中途遇到问题代码导致失败,得不偿失,且一个仓库可能会有不再维护代码与无关代码,造成资源浪费 git本地hook问题在于,更新时每个组员都需要做,并且git commit的时候可以通过--no-verify 规避本地check,同时如果直接在gitlab上面IDE直接修改,则本地git hook脚本不

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2025 聚合阅读