本文译自「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 的服务器和后端:
groovy
// 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 团队开发中,尚未正式发布;它仅可通过 AndroidX 快照 Maven 仓库获取。
理解核心抽象
RemoteCompose 的核心是一个框架,它支持 Compose UI 组件的远程渲染。它与传统 UI 方法的区别在于它遵循两个基本原则:声明式文档序列化和平台无关渲染。这些不仅仅是技术特性;这些架构决策从根本上改变了你对 UI 部署的思考方式。
声明式文档序列化
声明式文档序列化意味着你可以将任何 Jetpack Compose 布局捕获为紧凑的序列化格式。你可以把它想象成对 UI 进行"截图",只不过你捕获的不是像素,而是实际的绘图指令。这个捕获的文档包含了重建 UI 所需的一切:形状、颜色、文本、图像、动画,甚至还有交互式触摸区域。
kotlin
// 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")
}
}
}
结果如何?一个可以通过网络发送的字节数组。这种方法的关键在于,创建端编写的是标准的 Compose 代码。无需学习新的 DSL,无需维护 JSON 模式,也无需掌握模板语言。只要可以用 Compose 编写,就可以用 RemoteCompose 捕获。
你可以捕获一个普通的 Compose 代码,它会捕获绘制调用(这些调用非常静态)。更常见的情况是,你应该拥有镜像 Compose 的 Remote* 专用 API,这些 API 专为序列化和远程播放而设计,例如 RemoteColumn、RemoteButton、RemoteText 等。
平台无关渲染
平台无关渲染意味着捕获的文档可以通过网络传输,并在任何 Android 设备上渲染,而无需原始的 Compose 代码。客户端设备不需要你的可组合函数、视图模型或业务逻辑------它只需要文档字节和一个播放器。
kotlin
// On the client or player side
RemoteDocumentPlayer(
document = remoteDocument.document,
documentWidth = windowInfo.containerSize.width,
documentHeight = windowInfo.containerSize.height,
onAction = { actionId, value ->
// Handle user interactions
}
)
这些特性并非仅仅是为了方便;它们是架构约束,能够真正实现 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 渲染管线的最底层)拦截绘制操作。
kotlin
@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 以实现延迟加载。布局信息涵盖大小、位置、内边距和对齐方式。交互定义了触摸区域、点击处理程序和命名操作。状态变量可以在运行时更新,动画则通过基于时间的运动表达式来表达。
接收方无需访问你的代码库,只需访问文档字节即可。这与其他服务器驱动的 UI 方法有着本质区别,在其他方法中,客户端需要理解架构或拥有预构建的组件。
文档播放:无需编译即可渲染
播放阶段接收序列化的文档并将其渲染到屏幕上。播放器会遍历一系列操作,对 Canvas 执行每个操作。其概念类似于视频播放器解码帧的方式,只不过我们解码的是绘图指令而不是像素。
RemoteCompose 提供两种渲染后端以满足不同的架构需求。基于 Compose 的播放器推荐用于现代应用程序:
kotlin
@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 UI 集成。它是一个可组合的函数,你可以将其放置在组合层级结构中的任何位置,并像其他可组合函数一样对其应用修饰符和动画。
为了与现有的 View 层级结构兼容,我们还提供了一个基于 View 的播放器:
kotlin
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 种以上的不同操作,涵盖了 UI 渲染的方方面面。这并非随意设定的数字,而是表达任何 Canvas 绘图操作所需的完整词汇表。
操作的重要性
传统的服务器驱动型 UI 发送的是高级组件描述:"渲染一个带有文本'提交'的按钮"。客户端必须解析这些描述并将其映射到原生组件。这导致服务器和客户端之间紧密耦合;双方必须就"按钮"的定义及其行为达成一致。
RemoteCompose 则在更底层运行:它不发送"渲染一个按钮"这样的描述,而是发送实际的绘图指令:"在这些坐标处绘制一个带有这种颜色的圆角矩形,然后在这个位置绘制带有这种字体的文本'提交'"。客户端无需了解"按钮"的定义;它只需执行绘图操作即可。
这种底层方法意义深远。由于服务器和客户端无需就组件定义达成一致,因此无需进行模式同步。由于 Compose 中所有可能的视觉效果均可捕获,因此能够完整保留视觉保真度。由于新的视觉设计可在旧客户端上运行(它们只是不同的绘制操作),因此内置了向前兼容性。自定义组件无需注册即可自动运行。
绘制操作
绘制操作捕获 Canvas 绘制调用,这是 2D 图形的基本图元。这些图元包括:用于按钮、卡片和背景的矩形的 DRAW_RECT;用于带有圆角的 Material 曲面的 DRAW_ROUND_RECT;用于头像和指示器的 DRAW_CIRCLE;用于渲染带有完整样式的文本的 DRAW_TEXT;用于沿曲线绘制文本的 DRAW_TEXT_ON_PATH;以及用于图像的 DRAW_BITMAP。 DRAW_TWEEN_PATH 用于动画路径变形,等等。
每个操作都包含执行它所需的所有信息:坐标、颜色、绘制样式以及对文档中其他位置存储的数据(例如文本字符串或位图)的引用。
布局操作
布局操作定义组件层次结构和空间关系。Component 操作声明一个布局组件,而 Container 操作创建一个类似于 Column 或 Row 的容器,ContainerEnd 操作则关闭它。LoopOperation 操作用于循环列表内容。Modifier 包括用于背景颜色和可绘制对象的 BackgroundModifier、用于边框样式的 BorderModifier、用于内部间距的 PaddingModifier 以及用于触摸处理的 ClickModifier。
容器模型采用推送/弹出机制。当播放器遇到 Container 操作时,它会创建一个新的布局上下文。所有后续操作都将在该上下文中执行,直到 ContainerEnd 操作将其弹出。这与 Compose 的布局系统的工作方式类似。
状态和表达式操作
状态操作支持运行时可更改的动态值。NamedVariable 声明一个命名的状态变量。ColorAttribute 提供可自定义主题的颜色。TimeAttribute 引用动画时间。FloatExpression 和 IntegerExpression 每帧计算数学表达式。ConditionalOp 支持基于状态的条件渲染。
表达式系统功能强大。你可以嵌入公式,而不是静态值:
kotlin
// 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 为动态屏幕设计带来的切实优势。
服务器驱动 UI,性能毫不妥协
传统的服务器驱动 UI 方法需要权衡取舍。基于 JSON 的布局表达能力有限,无法实现复杂的动画或自定义绘制。WebView 会带来性能开销、外观不一致以及更高的内存占用。自定义 DSL 则会增加维护负担、学习曲线,并且对预定义组件有所限制。
RemoteCompose 提供了第三条路径:从服务器定义的布局进行原生 Compose 渲染。你既能充分利用 Compose 渲染引擎的强大功能,又能享受服务器驱动内容的灵活性。
例如,一个电商应用需要频繁更新产品卡片、添加新的徽章样式、促销叠加层或季节性主题。借助 RemoteCompose,服务器端允许营销团队无需发布应用即可更新卡片设计:
kotlin
// 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)
)
}
}
}
客户端只需渲染服务器发送的内容:
kotlin
// 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 的实际绘制管道渲染,因此 UI 的外观和体验与原生应用无异。
大规模 A/B 测试
传统的 A/B 测试 UI 变体需要在应用二进制文件中实现所有变体,为每个变体创建功能标志,发布包含所有变体的应用,然后等待用户采用后再衡量结果。从构思到获得数据,这个过程通常需要 2-4 周。
借助 RemoteCompose,你无需部署任何客户端即可测试 UI 变体。假设一个电商团队想要测试单页结账流程是否比多步骤向导转化率更高:
kotlin
// 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) }
)
}
}
}
客户端完全不知道显示的是哪个版本:
kotlin
// 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())
}
}
}
)
}
}
结果即时且实时,这意味着无需等待应用商店审核或用户反馈。你甚至可以添加全新的版本,例如"快速结账",而无需对客户端进行任何更改。实验会持续运行,直到获得统计学意义上的显著性,然后将获胜版本推广到所有用户,同样无需发布新应用。
实时内容更新
内容密集型应用常常需要在原生性能和内容新鲜度之间寻求平衡。以新闻应用为例:文章需要丰富的格式、嵌入式媒体和交互元素,但同时也需要随着新闻事件的进展实时更新。
一家报道重大事件的新闻机构需要实时更新文章布局。编辑团队可以根据新闻事件的进展调整布局:
kotlin
// 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) }
)
}
}
}
客户端只需渲染服务器提供的任何布局:
kotlin
// 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)
}
}
)
}
}
}
你的团队无需修改应用即可更新文章布局,添加实时博客时间线、嵌入交互式图表和更改字体。当新闻事件有进展时,他们可以立即在所有相关文章上添加"突发新闻"横幅。
避免代码膨胀的功能标志
传统的功能标志需要将所有变体都包含在二进制文件中:
kotlin
// 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,只会传输当前激活的变体:
kotlin
// 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.
}
这消除了二进制文件膨胀,因为不会传输旧的变体;由于只存在服务器端代码,因此消除了无用代码;并且由于配置错误只会显示不同的 UI 而不是未发布的代码,因此降低了安全风险。
设想一个社交媒体应用正在逐步重新设计其信息流:
kotlin
// 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 设备上渲染,并由相应的平台播放器负责渲染。
bash
Creation (Server/Backend)
↓
RemoteComposeBuffer (Platform-independent binary format)
↓
┌─────────────────────────────────────────────────────────┐
│
│
↓
↓
↓
↓
Android Phone
Android Tablet
Foldable Device
Wear OS
(Compose Player) (Compose Player) (Compose Player) (Wear Player)
假设一款健身应用在手机和手表应用上都显示锻炼总结。相同的数据会针对不同的设备尺寸进行优化,呈现不同的内容:
kotlin
// 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)
}
}
}
两款设备都显示锻炼数据,但布局针对各自的设备尺寸进行了优化。任一布局的更新都会立即生效,无需在任一平台上更新应用。
缩短发布周期
最显著的优势在于运营层面:UI 更改不再需要发布应用。考虑一下简单 UI 调整的开发周期。
传统方法大约需要两到四周。第一天和第二天是开发人员实现。第三天和第四天用于代码审查和修改。第五天到第七天用于质量保证测试。第八天和第九天处理发布准备和应用商店提交。第十天到第十四天:等待应用商店审核。第十五天到第三十天用户逐步采用,通常两周内会有 50% 到 70% 的用户更新。大多数用户在两到四周内不会看到变化。
RemoteCompose 方法只需一到两天即可完成。第一天和第二天是开发人员在服务器端实现。部署只需几分钟。所有用户都能立即看到变化。
这种速度优势对于节假日促销活动至关重要,你可以根据需要在当天部署季节性主题;对于服务中断的紧急消息,你可以即时更新 UI;对于快速迭代,你可以快速测试想法并快速失败;对于竞争响应,你可以以小时而不是几周的时间对市场变化做出反应。
以一个准备迎接黑色星期五的电商应用为例:
kotlin
// 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 不仅限于静态布局。该框架包含一个状态管理系统,可以实现交互式、动态的 UI。
远程状态变量
状态可以嵌入文档中,并由客户端更新。这使得表单、计数器、切换开关和其他交互元素成为可能:
kotlin
// 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)
}
}
}
播放器端通过操作回调处理状态更新:
kotlin
// 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())
}
}
}
}
)
动画时间跟踪
播放器跟踪动画时间并将其传递给文档,从而无需任何客户端动画代码即可实现基于时间的动画:
kotlin
// 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 之类的数学函数,以及算术运算符和变量引用。
双向通信
操作系统支持文档与宿主应用之间的双向通信:
kotlin
// 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)
}
}
}
宿主应用统一处理所有操作:
kotlin
// 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。
kotlin
// 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:文档缓存以实现离线支持
远程文档可以缓存以供离线访问:
kotlin
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:文档预加载以实现流畅导航
预加载用户可能访问的页面的文档:
kotlin
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 UI 开发思维方式的一次范式转变。通过将 Compose 布局转换为可移植文档格式,RemoteCompose 实现了服务器驱动的 UI、即时 A/B 测试、实时内容更新和跨平台一致性,同时保持了原生渲染性能。
该框架拥有包含 93 种以上操作的全面操作模型,充分展现了 Compose 的表达能力,包括动画、状态和交互。创建和播放的分离使得部署架构更加灵活:在后端生成具有完整 Compose 表达能力的文档,通过现有基础架构分发,并在任何 Android 设备上进行原生渲染。
关键在于找到合适的平衡点:对于动态、频繁变化的内容区域,使用 RemoteCompose;同时将关键流程保留在本地 Compose 代码中。这种混合方法在需要灵活性的地方提供服务器驱动 UI 的优势,在需要可靠性的地方提供编译代码的优势。
无论你是构建需要频繁更新布局的内容密集型应用、需要快速 A/B 测试的电子商务平台,还是需要快速迭代的企业级工具,RemoteCompose 都能为真正动态的 UI 提供架构基础。该框架处理了序列化、传输和渲染的复杂性,因此你可以专注于设计卓越的用户体验。
你可以观看他们最近关于RemoteCompose 简介:将你的 UI 从应用程序沙盒中解放出来的演讲。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!