在移动应用开发中,服务器驱动 UI (Server-Driven UI, SDUI) 是一种越来越受欢迎的架构模式。它允许服务器动态控制客户端的界面结构,无需发布新版本即可更新 UI。然而,传统的 SDUI 方案面临着诸多挑战。RemoteCompose 是 AndroidX 官方库的一部分(位于 compose/remote),提供了一种全新的范式,通过 Canvas 级别的操作捕获,彻底改变了我们对 SDUI 的理解。
RemoteCompose 是 Android 官方 Jetpack 库的一部分,源码位于 androidx/compose/remote 目录下。这意味着它将获得 Google 的长期支持和维护,与 Jetpack Compose 生态系统深度集成。
I. 传统 SDUI 的困境
1.1 传统方案的工作方式
传统的 SDUI 通常采用以下架构:
服务器发送类似这样的 JSON:
{
"type": "Column",
"children": [
{
"type": "Card",
"title": "Welcome",
"subtitle": "Hello, User!",
"imageUrl": "https://..."
},
{
"type": "Button",
"text": "Get Started",
"action": "navigate://home"
}
]
}
1.2 传统方案的问题
- Schema 同步:服务器和客户端必须就组件定义达成一致,版本管理复杂
- 组件注册:每个新组件都需要在客户端预先注册和实现
- 有限的视觉能力:只能使用预定义的组件,自定义样式受限
- 前向兼容问题:新组件在旧客户端上无法显示
- 自定义组件困难:复杂的自定义 UI 难以通过 Schema 描述
II. RemoteCompose 的革命性设计
2.1 源码模块结构
从 AndroidX 源码 可以看到,RemoteCompose 采用了清晰的模块化设计:
2.2 核心理念:文档式架构
RemoteCompose 的核心突破在于:不发送组件描述,而是发送绘制指令。它将 UI 视为一个「文档」,在 Canvas 层级捕获所有绘制操作。
2.2 Canvas 级别的操作捕获
RemoteCompose 在 Android 渲染管道的最低层级进行拦截,捕获所有绘制操作:
// RemoteCompose 捕获的操作类型 (93+ 种)
// 基础绘制操作
sealed class DrawOperation {
data class DrawRect(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val paint: PaintData
) : DrawOperation()
data class DrawCircle(
val cx: Float,
val cy: Float,
val radius: Float,
val paint: PaintData
) : DrawOperation()
data class DrawPath(
val path: PathData,
val paint: PaintData
) : DrawOperation()
data class DrawText(
val text: String,
val x: Float,
val y: Float,
val font: FontData,
val paint: PaintData
) : DrawOperation()
data class DrawImage(
val imageData: ByteArray,
val srcRect: RectData,
val dstRect: RectData
) : DrawOperation()
// 变换操作
data class Translate(val dx: Float, val dy: Float) : DrawOperation()
data class Scale(val sx: Float, val sy: Float) : DrawOperation()
data class Rotate(val degrees: Float) : DrawOperation()
// 裁剪操作
data class ClipRect(val rect: RectData) : DrawOperation()
data class ClipPath(val path: PathData) : DrawOperation()
// 状态保存/恢复
object Save : DrawOperation()
object Restore : DrawOperation()
// ... 更多操作
}
2.3 文档结构
捕获的操作被编码为紧凑的二进制格式:
// 文档结构示意
class RemoteComposeDocument {
// 文档头
val version: Int
val width: Float
val height: Float
// 资源表 (图片、字体等)
val resources: List<ResourceEntry>
// 操作序列
val operations: List<DrawOperation>
// 交互区域
val clickableAreas: List<ClickableArea>
// 动画定义
val animations: List<AnimationDef>
}
III. Wire Protocol 与二进制编码
3.1 WireBuffer:高效的二进制协议
RemoteCompose 使用自定义的 WireBuffer 进行二进制编码,而非 JSON 或 Protobuf。这种设计选择是为了最大化性能和最小化文档体积:
// WireBuffer 核心实现原理
class WireBuffer {
private val buffer: ByteArray
private var position: Int = 0
// 变长整数编码 (类似 Protobuf varint)
fun writeVarInt(value: Int) {
var v = value
while (v >= 0x80) {
buffer[position++] = ((v and 0x7F) or 0x80).toByte()
v = v ushr 7
}
buffer[position++] = v.toByte()
}
// 浮点数编码 (IEEE 754)
fun writeFloat(value: Float) {
val bits = value.toBits()
buffer[position++] = (bits shr 24).toByte()
buffer[position++] = (bits shr 16).toByte()
buffer[position++] = (bits shr 8).toByte()
buffer[position++] = bits.toByte()
}
// 字符串编码 (长度前缀 + UTF-8)
fun writeString(value: String) {
val bytes = value.encodeToByteArray()
writeVarInt(bytes.size)
bytes.copyInto(buffer, position)
position += bytes.size
}
}
3.2 操作码编码格式
每个操作都有唯一的操作码 (OpCode),按照固定格式编码:
IV. 操作模型深入解析
4.1 93+ 种操作的分类
| 类别 | 操作示例 | 说明 |
|---|---|---|
| 形状绘制 | DrawRect, DrawCircle, DrawOval, DrawRoundRect, DrawArc | 基础几何形状 |
| 路径绘制 | DrawPath, DrawLine, DrawPoints | 复杂路径和线条 |
| 文本渲染 | DrawText, DrawTextOnPath | 包含字体、样式信息 |
| 图像绘制 | DrawImage, DrawImageRect | 图片数据内嵌 |
| 变换 | Translate, Scale, Rotate, Skew, Concat | 矩阵变换 |
| 裁剪 | ClipRect, ClipPath, ClipRoundRect | 裁剪区域 |
| 状态 | Save, Restore, SaveLayer | Canvas 状态管理 |
| 效果 | BlendMode, Shader, MaskFilter, ColorFilter | 视觉效果 |
4.2 ContentType 系统
RemoteCompose 定义了丰富的 ContentType 枚举,用于语义化地描述内容类型:
// 源码参考: ContentType.kt
enum class ContentType(val id: Int) {
// 基础类型
UNKNOWN(0),
TEXT(1),
IMAGE(2),
ICON(3),
BUTTON(4),
// 容器类型
ROW(10),
COLUMN(11),
BOX(12),
CARD(13),
// 列表类型
LIST(20),
LIST_ITEM(21),
GRID(22),
// 导航类型
TAB_BAR(30),
TAB_ITEM(31),
NAVIGATION_BAR(32),
// 媒体类型
VIDEO(40),
AUDIO(41),
ANIMATION(42),
// 自定义类型 (1000+)
CUSTOM(1000)
}
4.3 Paint 数据的完整性
// Paint 包含完整的样式信息
data class PaintData(
// 颜色
val color: Int,
val alpha: Float,
// 样式
val style: PaintStyle, // Fill, Stroke, FillAndStroke
val strokeWidth: Float,
val strokeCap: StrokeCap,
val strokeJoin: StrokeJoin,
// 渐变
val shader: ShaderData?, // LinearGradient, RadialGradient, etc.
// 阴影
val shadow: ShadowData?,
// 混合模式
val blendMode: BlendMode,
// 抗锯齿
val isAntiAlias: Boolean
)
V. Layout 模块:组件树系统
5.1 ComponentTree 结构
与纯绘制指令不同,Layout 模块引入了更高层的抽象——组件树。这允许更智能的布局计算和语义化操作:
// 组件树节点定义
sealed class ComponentNode {
abstract val id: Int
abstract val contentType: ContentType
abstract val bounds: Rect
abstract val semanticActions: List<SemanticAction>
// 容器节点
data class Container(
override val id: Int,
override val contentType: ContentType,
override val bounds: Rect,
override val semanticActions: List<SemanticAction>,
val children: List<ComponentNode>,
val layoutParams: LayoutParams
) : ComponentNode()
// 叶子节点 (绘制内容)
data class Leaf(
override val id: Int,
override val contentType: ContentType,
override val bounds: Rect,
override val semanticActions: List<SemanticAction>,
val drawOperations: List<DrawOperation>
) : ComponentNode()
}
5.2 SemanticAction 语义动作
语义动作是 RemoteCompose 处理交互的核心机制,比简单的点击区域更强大:
// 语义动作定义
sealed class SemanticAction {
abstract val id: String
// 导航动作
data class Navigate(
override val id: String,
val route: String,
val params: Map<String, String>
) : SemanticAction()
// 打开 URL
data class OpenUrl(
override val id: String,
val url: String,
val openInBrowser: Boolean
) : SemanticAction()
// 发送事件到服务器
data class SendEvent(
override val id: String,
val eventName: String,
val payload: Map<String, Any>
) : SemanticAction()
// 执行本地动作 (预定义)
data class LocalAction(
override val id: String,
val actionType: LocalActionType,
val params: Map<String, String>
) : SemanticAction()
// 复合动作 (多个动作串联)
data class Composite(
override val id: String,
val actions: List<SemanticAction>
) : SemanticAction()
}
// 本地动作类型
enum class LocalActionType {
SHARE, // 分享
COPY_TO_CLIPBOARD, // 复制到剪贴板
SHOW_TOAST, // 显示 Toast
HAPTIC_FEEDBACK, // 触觉反馈
ANALYTICS_EVENT // 埋点事件
}
5.3 布局约束系统
// 布局参数
data class LayoutParams(
val direction: LayoutDirection, // HORIZONTAL, VERTICAL
val mainAxisAlignment: Alignment,
val crossAxisAlignment: Alignment,
val spacing: Float,
val padding: Padding
)
// 布局解析器根据约束计算最终位置
class LayoutResolver {
fun resolve(
node: ComponentNode.Container,
availableWidth: Float,
availableHeight: Float
): ResolvedLayout {
// 1. 测量子节点
val childSizes = node.children.map { measure(it) }
// 2. 根据布局参数计算位置
val positions = calculatePositions(
childSizes,
node.layoutParams,
availableWidth,
availableHeight
)
// 3. 返回解析后的布局
return ResolvedLayout(node, positions)
}
}
VI. 双端播放器
6.1 Compose 播放器
作为 Composable 函数,与现有 Compose UI 无缝集成:
@Composable
fun RemoteComposeView(
document: RemoteComposeDocument,
modifier: Modifier = Modifier,
onClickEvent: (ClickEvent) -> Unit = {}
) {
Canvas(modifier = modifier.fillMaxSize()) {
// 遍历并执行所有绘制操作
document.operations.forEach { op ->
when (op) {
is DrawOperation.DrawRect -> {
drawRect(
color = Color(op.paint.color),
topLeft = Offset(op.left, op.top),
size = Size(op.right - op.left, op.bottom - op.top)
)
}
is DrawOperation.DrawText -> {
drawText(
textMeasurer = textMeasurer,
text = op.text,
topLeft = Offset(op.x, op.y)
)
}
// ... 处理其他操作
}
}
}
}
// 使用示例
@Composable
fun RemoteScreen(viewModel: RemoteViewModel) {
val document by viewModel.document.collectAsState()
Scaffold(
topBar = { TopAppBar(title = { Text("Remote UI") }) }
) { padding ->
document?.let { doc ->
RemoteComposeView(
document = doc,
modifier = Modifier.padding(padding),
onClickEvent = { event ->
viewModel.handleClick(event)
}
)
}
}
}
6.2 View 播放器
兼容传统 View 层次结构:
class RemoteComposePlayerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private var document: RemoteComposeDocument? = null
fun setDocument(doc: RemoteComposeDocument) {
this.document = doc
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
document?.let { doc ->
doc.operations.forEach { op ->
executeOperation(canvas, op)
}
}
}
private fun executeOperation(canvas: Canvas, op: DrawOperation) {
when (op) {
is DrawOperation.DrawRect -> {
canvas.drawRect(
op.left, op.top, op.right, op.bottom,
op.paint.toAndroidPaint()
)
}
is DrawOperation.Save -> canvas.save()
is DrawOperation.Restore -> canvas.restore()
// ...
}
}
}
VII. 交互处理
7.1 点击区域定义
RemoteCompose 通过定义可点击区域来支持交互:
// 点击区域数据
data class ClickableArea(
val id: String,
val bounds: RectData,
val action: ClickAction
)
sealed class ClickAction {
data class Navigate(val route: String) : ClickAction()
data class SendEvent(val eventId: String, val data: Map<String, Any>) : ClickAction()
data class OpenUrl(val url: String) : ClickAction()
}
7.2 触摸检测
@Composable
fun RemoteComposeView(
document: RemoteComposeDocument,
onClickEvent: (ClickAction) -> Unit
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { offset ->
// 查找点击的区域
val clickedArea = document.clickableAreas
.find { area ->
offset.x >= area.bounds.left &&
offset.x <= area.bounds.right &&
offset.y >= area.bounds.top &&
offset.y <= area.bounds.bottom
}
clickedArea?.let { area ->
onClickEvent(area.action)
}
}
}
) {
// 绘制操作...
}
}
VIII. 动画支持
8.1 动画定义
// 动画可以内嵌在文档中
data class AnimationDef(
val id: String,
val targetOperationId: Int, // 要动画的操作
val property: AnimatableProperty, // 动画属性
val fromValue: Float,
val toValue: Float,
val duration: Long,
val easing: EasingType,
val repeatMode: RepeatMode
)
enum class AnimatableProperty {
ALPHA,
TRANSLATE_X,
TRANSLATE_Y,
SCALE_X,
SCALE_Y,
ROTATION
}
8.2 动画执行
@Composable
fun AnimatedRemoteComposeView(document: RemoteComposeDocument) {
// 为每个动画创建动画状态
val animationStates = document.animations.associate { anim ->
anim.id to animateFloatAsState(
targetValue = anim.toValue,
animationSpec = tween(
durationMillis = anim.duration.toInt(),
easing = anim.easing.toComposeEasing()
)
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
document.operations.forEachIndexed { index, op ->
// 检查是否有动画应用到此操作
val animValue = findAnimationValue(index, animationStates)
withTransform({
animValue?.let { applyAnimation(it) }
}) {
executeOperation(op)
}
}
}
}
IX. 与传统 SDUI 的对比
| 特性 | 传统 SDUI | RemoteCompose |
|---|---|---|
| 协议类型 | 高层组件描述 (JSON/XML) | 低层绘制指令 (Binary) |
| Schema 同步 | 需要服务端和客户端同步 | 不需要 |
| 组件注册 | 每个组件需预先注册 | 不需要 |
| 视觉保真度 | 受限于预定义组件 | 100% 保真 |
| 自定义组件 | 需要额外支持 | 自动支持 |
| 前向兼容 | 新组件需要客户端更新 | 完全兼容 |
| 文档大小 | 较小 (仅描述) | 较大 (含完整绘制数据) |
| 动态内容 | 易于更新数据 | 需要重新生成文档 |
X. 适用场景
- 营销活动页面:设计师可以自由创作,无需客户端开发配合
- A/B 测试:快速测试不同的视觉设计
- 个性化 UI:为不同用户群体提供定制化界面
- 富文本内容:复杂的图文混排展示
- 动态广告:高保真的广告创意渲染
- 预览功能:设计稿实时预览
- 高频更新的数据:如实时股票、聊天消息
- 复杂交互:如拖拽排序、手势识别
- 表单输入:需要双向数据绑定
- 网络受限环境:文档体积可能较大
XI. 实现建议
11.1 文档缓存策略
class RemoteComposeCache(
private val maxSize: Int = 50
) {
private val cache = LruCache<String, RemoteComposeDocument>(maxSize)
suspend fun getDocument(
url: String,
version: String
): RemoteComposeDocument {
val cacheKey = "$url:$version"
// 检查内存缓存
cache[cacheKey]?.let { return it }
// 检查磁盘缓存
loadFromDisk(cacheKey)?.let { doc ->
cache.put(cacheKey, doc)
return doc
}
// 从网络获取
val doc = fetchFromNetwork(url)
cache.put(cacheKey, doc)
saveToDisk(cacheKey, doc)
return doc
}
}
11.2 增量更新
// 支持 Diff 更新,只传输变化的操作
data class DocumentPatch(
val baseVersion: String,
val deletedOperations: List<Int>,
val modifiedOperations: Map<Int, DrawOperation>,
val addedOperations: List<DrawOperation>
)
fun RemoteComposeDocument.applyPatch(patch: DocumentPatch): RemoteComposeDocument {
val newOps = operations.toMutableList()
// 删除
patch.deletedOperations.sortedDescending().forEach { idx ->
newOps.removeAt(idx)
}
// 修改
patch.modifiedOperations.forEach { (idx, op) ->
newOps[idx] = op
}
// 添加
newOps.addAll(patch.addedOperations)
return copy(operations = newOps)
}
11.3 安全性考虑
// 文档签名验证
class SecureDocumentLoader(
private val publicKey: PublicKey
) {
fun loadAndVerify(
documentBytes: ByteArray,
signature: ByteArray
): RemoteComposeDocument? {
// 1. 验证签名
val verifier = Signature.getInstance("SHA256withRSA")
verifier.initVerify(publicKey)
verifier.update(documentBytes)
if (!verifier.verify(signature)) {
return null // 签名无效
}
// 2. 解析文档
return RemoteComposeDocument.parse(documentBytes)
}
// 限制允许的操作类型 (白名单)
fun sanitize(doc: RemoteComposeDocument): RemoteComposeDocument {
val allowedOps = doc.operations.filter { op ->
op !is DrawOperation.ExecuteScript // 禁止脚本执行
}
return doc.copy(operations = allowedOps)
}
}
XII. 总结与展望
- AndroidX 官方:作为 Jetpack 库的一部分,获得 Google 长期支持
- 范式转变:从「组件描述」转向「绘制指令」
- 模块化设计:core、layout、player、sender 清晰分离
- 二进制协议:WireBuffer 高效编码,最小化文档体积
- 组件树系统:支持语义化操作和智能布局
- 双端播放:Compose 和 View 两种渲染后端
- 语义动作:丰富的交互处理机制
- 完全解耦:服务端和客户端无需 Schema 同步
12.1 未来发展方向
- Compose Multiplatform 支持:同一份文档在 Android、iOS、Web、Desktop 渲染
- 实时协作:多人同时编辑和预览 UI
- AI 生成:通过 AI 生成 RemoteCompose 文档
- 设计工具集成:Figma 等设计工具直接导出 RemoteCompose 格式
- 低代码平台:可视化拖拽生成 RemoteCompose 文档
RemoteCompose 代表了服务器驱动 UI 的一种全新思路。它通过在更低的抽象层级进行操作,解决了传统 SDUI 面临的诸多问题。作为 AndroidX 官方库的一部分,它将获得持续的维护和改进。
虽然它可能不适合所有场景,但在需要高视觉保真度、快速迭代的场景中,RemoteCompose 是一个值得考虑的选择。随着 Compose Multiplatform 的发展,这种架构也有潜力扩展到跨平台场景,让同一份文档在多个平台上都能渲染。