// On the server or creation sidevaldocument=captureRemoteDocument(context=context,creationDisplayInfo=displayInfo,profile=profile){RemoteColumn(modifier=RemoteModifier.fillMaxSize()){RemoteText("Dynamic Content")RemoteButton(onClick={/* action */}){RemoteText("Click Me")}}}
// On the client or player sideRemoteDocumentPlayer(document=remoteDocument.document,documentWidth=windowInfo.containerSize.width,documentHeight=windowInfo.containerSize.height,onAction={actionId,value->// Handle user interactions})
@ComposablefunDynamicScreen(document:CoreDocument){RemoteDocumentPlayer(document=document,documentWidth=screenWidth,documentHeight=screenHeight,modifier=Modifier.fillMaxSize(),onNamedAction={name,value,stateUpdater->// Handle named actions from the documentwhen(name){"addToCart"->cartManager.addItem(value)"navigate"->navController.navigate(value)"trackEvent"->analytics.logEvent(value)}},bitmapLoader=rememberBitmapLoader()// For lazy image loading)}
// Server-side: We can update card designs without app release@ComposablefunProductCard(product:Product){Card(modifier=RemoteModifier.fillMaxWidth().clickable{namedAction("viewProduct",product.id)}){Box{// Product image with gradient overlayAsyncImage(url=product.imageUrl,modifier=RemoteModifier.fillMaxWidth().aspectRatio(1.5f))// Promotional badge - can be A/B tested server-sideif(product.hasPromotion){PromotionalBadge(text=product.promotionText,modifier=RemoteModifier.align(Alignment.TopEnd))}// Price with sale stylingPriceTag(originalPrice=product.originalPrice,salePrice=product.salePrice,modifier=RemoteModifier.align(Alignment.BottomStart))}}}
客户端只需渲染服务器发送的内容:
123456789101112131415161718
// Client-side: Just renders whatever the server sends@ComposablefunProductGrid(viewModel:ProductViewModel){valdocumentsbyviewModel.productDocuments.collectAsState()LazyVerticalGrid(columns=GridCells.Fixed(2)){items(documents){document->RemoteDocumentPlayer(document=document,onNamedAction={name,value,_->if(name=="viewProduct"){navController.navigate("product/$value")}})}}}
// Server-side: Two completely different checkout experiencesobjectCheckoutExperiments{fungetCheckoutDocument(user:User,cart:Cart):ByteArray{valvariant=experimentService.getVariant(user.id,"checkout-flow")returnwhen(variant){"single-page"->captureSinglePageCheckout(cart)"multi-step"->captureMultiStepCheckout(cart)"express"->captureExpressCheckout(cart)// New variant added without app updateelse->captureSinglePageCheckout(cart)}}privatefuncaptureSinglePageCheckout(cart:Cart):ByteArray{returncaptureRemoteDocument(context,displayInfo,profile){SinglePageCheckout(cart=cart,onPlaceOrder={namedAction("placeOrder",cart.id)},onUpdateQuantity={itemId,qty->namedAction("updateQuantity","$itemId:$qty")})}}privatefuncaptureMultiStepCheckout(cart:Cart):ByteArray{returncaptureRemoteDocument(context,displayInfo,profile){MultiStepCheckout(cart=cart,steps=listOf("Shipping","Payment","Review"),onComplete={namedAction("placeOrder",cart.id)})}}}
客户端完全不知道显示的是哪个版本:
1234567891011121314151617181920
// Client-side: Completely agnostic to which variant is shown@ComposablefunCheckoutScreen(viewModel:CheckoutViewModel){valdocumentbyviewModel.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())}}})}}
// Server-side: Editorial team can update layout as story developsclassArticleLayoutService{fungetArticleDocument(article:Article):ByteArray{returncaptureRemoteDocument(context,displayInfo,profile){ArticleLayout(article)}}@ComposableprivatefunArticleLayout(article:Article){Column(modifier=RemoteModifier.fillMaxSize().padding(16.dp)){// Breaking news banner - can be added/removed instantlyif(article.isBreaking){BreakingNewsBanner(modifier=RemoteModifier.fillMaxWidth())}// Headline with dynamic stylingText(text=article.headline,style=if(article.isBreaking){HeadlineStyle.Breaking}else{HeadlineStyle.Standard})// Live updates indicatorif(article.hasLiveUpdates){LiveUpdatesIndicator(lastUpdate=article.lastUpdate,modifier=RemoteModifier.clickable{namedAction("refreshArticle",article.id)})}// Rich content blocks - can include any Compose UIarticle.contentBlocks.forEach{block->when(block){isTextBlock->ArticleText(block)isImageBlock->ArticleImage(block)isVideoBlock->VideoEmbed(block)isLiveBlogBlock->LiveBlogTimeline(block)isInteractiveChartBlock->DataVisualization(block)isPullQuoteBlock->PullQuote(block)}}// Related articles - layout can be A/B testedRelatedArticles(articles=article.relatedArticles,onArticleClick={namedAction("openArticle",it.id)})}}}
客户端只需渲染服务器提供的任何布局:
123456789101112131415161718192021222324
// Client-side: Renders whatever layout the server sends@ComposablefunArticleScreen(articleId:String,viewModel:ArticleViewModel){valdocumentbyviewModel.articleDocument.collectAsState()valrefreshingbyviewModel.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)}})}}}
// Server-side: Only the active variation exists on the serverclassHomeScreenService{fungetHomeDocument(user:User):ByteArray{returnwhen(featureFlags.getHomeVariant(user)){"v3"->captureHomeV3(user)"v2"->captureHomeV2(user)else->captureHomeDefault(user)}}}// Client-side: No conditional code, no dead code@ComposablefunHomeScreen(document:CoreDocument){RemoteDocumentPlayer(document=document)// That's it. No feature flags, no conditionals.}
// Server-side: Complete control over who sees whatclassFeedLayoutService{fungetFeedDocument(user:User,posts:List<Post>):ByteArray{valvariant=rolloutService.getFeedVariant(user)returncaptureRemoteDocument(context,displayInfo,profile){when(variant){FeedVariant.NEW_DESIGN->NewFeedLayout(posts)FeedVariant.NEW_DESIGN_COMPACT->NewFeedCompactLayout(posts)FeedVariant.CLASSIC->ClassicFeedLayout(posts)}}}}// Rollout service controls the percentageclassRolloutService{fungetFeedVariant(user:User):FeedVariant{// 5% get new design, 5% get compact variant, 90% get classicreturnwhen{user.id.hashCode()%100<5->FeedVariant.NEW_DESIGNuser.id.hashCode()%100<10->FeedVariant.NEW_DESIGN_COMPACTelse->FeedVariant.CLASSIC}}// Instant rollback if issues are detectedfunemergencyRollback(){// All users immediately get classic layout// No app update needed}}
// Server-side: Same data, different presentationsclassWorkoutSummaryService{fungetPhoneDocument(workout:Workout):ByteArray{returncaptureRemoteDocument(context,phoneDisplayInfo,profile){PhoneWorkoutSummary(workout)// Full detailed view}}fungetWatchDocument(workout:Workout):ByteArray{returncaptureRemoteDocument(context,watchDisplayInfo,profile){WatchWorkoutSummary(workout)// Glanceable summary}}@ComposableprivatefunPhoneWorkoutSummary(workout:Workout){Column{WorkoutHeader(workout)HeartRateChart(workout.heartRateData)PaceChart(workout.paceData)SplitsTable(workout.splits)MapView(workout.route)ShareButton{namedAction("share",workout.id)}}}@ComposableprivatefunWatchWorkoutSummary(workout:Workout){// Optimized for small screenColumn(modifier=RemoteModifier.fillMaxSize()){Text(workout.type,style=WatchTypography.Title)Row{StatBox("Duration",workout.duration)StatBox("Distance",workout.distance)}MiniHeartRateIndicator(workout.avgHeartRate)}}}
// Traditional approach: Ship all variations weeks in advance// Problem: All promotional code ships weeks early// Risk: Date logic bugs could show promotions early@ComposablefunHomeScreen(){valtoday=LocalDate.now()when{today==BlackFriday->BlackFridayHome()// Must ship by Oct 15todayinBlackFridayWeek->BlackFridayWeekHome()// Must ship by Oct 15today==CyberMonday->CyberMondayHome()// Must ship by Oct 15else->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-timeclassHomeScreenService{fungetHomeDocument(user:User):ByteArray{valpromotion=promotionService.getCurrentPromotion()returncaptureRemoteDocument(context,displayInfo,profile){when(promotion){isBlackFridayPromotion->BlackFridayHome(promotion)isCyberMondayPromotion->CyberMondayHome(promotion)isFlashSale->FlashSaleHome(promotion)// Can add new types anytimeelse->RegularHome()}}}}
// Player side: Handle state updatesRemoteDocumentPlayer(document=document,onNamedAction={name,value,stateUpdater->when(name){"quantityChanged"->{// Update cartcartManager.setQuantity(itemId,value.toInt())// Optionally update remote state directlystateUpdater.updateState{state->state["quantity"]=RcInt(value.toInt())}}}})
动画时间跟踪
播放器跟踪动画时间并将其传递给文档,从而无需任何客户端动画代码即可实现基于时间的动画:
1234567891011121314151617181920212223242526272829
// Server side: Define animated elements@ComposablefunPulsingNotificationBadge(count:Int){// Scale pulses between 0.9 and 1.1 over 1 secondvalscale=FloatExpression("0.9 + 0.2 * sin(time * 6.28)")// Opacity pulses between 0.7 and 1.0valopacity=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
// Document triggers actions for various purposes@ComposablefunProductDetailPage(product:Product){Column{// Analytics trackingLaunchedEffect(Unit){namedAction("analytics","product_viewed:${product.id}")}ProductImage(product.imageUrl)// Navigation actionTextButton(onClick={namedAction("navigate","/reviews/${product.id}")}){Text("See all reviews")}// Cart action with dataButton(onClick={namedAction("addToCart",product.id)}){Text("Add to Cart")}// State update actionvarisFavoritebyrememberRemoteState("favorite",product.isFavorite)IconButton(onClick={isFavorite=!isFavoritenamedAction("toggleFavorite","${product.id}:$isFavorite")}){Icon(if(isFavorite)Icons.Filled.FavoriteelseIcons.Outlined.Favorite)}}}
宿主应用统一处理所有操作:
123456789101112131415161718192021222324
// Host app handles all actions uniformlyRemoteDocumentPlayer(document=document,onNamedAction={name,value,stateUpdater->when(name){"analytics"->{val(event,id)=value.split(":")analytics.logEvent(event,mapOf("productId"toid))}"navigate"->navController.navigate(value)"addToCart"->{cartManager.add(value)// Update UI to show confirmationstateUpdater.updateState{state->state["cartCount"]=RcInt((state["cartCount"]as?RcInt)?.value?.plus(1)?:1)}}"toggleFavorite"->{val(id,isFavorite)=value.split(":")favoritesManager.setFavorite(id,isFavorite.toBoolean())}}})
classDocumentRepository@Injectconstructor(privatevalapi:DocumentApi,privatevalcache:DocumentCache,privatevalconnectivity:ConnectivityManager){suspendfungetDocument(key:String):CoreDocument{// Try cache firstcache.get(key)?.let{cached->// Return cached immediately, refresh in backgroundrefreshInBackground(key)returncached}// No cache, must fetchreturnif(connectivity.isConnected){fetchAndCache(key)}else{throwOfflineException("No cached document and no connectivity")}}privatesuspendfunfetchAndCache(key:String):CoreDocument{valbytes=api.fetchDocument(key)valdocument=RemoteComposeBuffer.deserialize(bytes)cache.store(key,document,ttl=1.hours)returndocument}privatefunrefreshInBackground(key:String){scope.launch{try{fetchAndCache(key)}catch(e:Exception){// Silent failure, cached version is still validLog.w(TAG,"Background refresh failed",e)}}}}
模式 3:文档预加载以实现流畅导航
预加载用户可能访问的页面的文档:
12345678910111213141516171819202122232425262728
classDocumentPreloader@Injectconstructor(privatevalrepository:DocumentRepository){// Preload when user enters a screenfunpreloadForScreen(screen:Screen){valkeysToPreload=when(screen){isHomeScreen->listOf("featured","categories","promotions")isCategoryScreen->screen.subcategories.map{"category_${it.id}"}isProductScreen->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 navigationnavController.addOnDestinationChangedListener{_,destination,arguments->preloader.preloadForScreen(destination.toScreen(arguments))}