RemoteCompose:服务器驱动 UI 的全新范式

📅 2024-07-09 · ⏱ 35 min · 🚀 前沿技术

在移动应用开发中,服务器驱动 UI (Server-Driven UI, SDUI) 是一种越来越受欢迎的架构模式。它允许服务器动态控制客户端的界面结构,无需发布新版本即可更新 UI。然而,传统的 SDUI 方案面临着诸多挑战。RemoteCompose 是 AndroidX 官方库的一部分(位于 compose/remote),提供了一种全新的范式,通过 Canvas 级别的操作捕获,彻底改变了我们对 SDUI 的理解。

📦 AndroidX 官方库

RemoteCompose 是 Android 官方 Jetpack 库的一部分,源码位于 androidx/compose/remote 目录下。这意味着它将获得 Google 的长期支持和维护,与 Jetpack Compose 生态系统深度集成。

I. 传统 SDUI 的困境

1.1 传统方案的工作方式

传统的 SDUI 通常采用以下架构:

传统 Server-Driven UI 架构: ┌─────────────┐ JSON/XML ┌─────────────────────────────┐ │ Server │ ───────────────→ │ Client │ │ │ │ │ │ UI Schema │ {"type":"Card", │ ┌─────────────────────┐ │ │ Generator │ "title":"...", │ │ Schema Parser │ │ │ │ "children":[]} │ │ ↓ │ │ └─────────────┘ │ │ Component Mapper │ │ │ │ ↓ │ │ │ │ Compose UI │ │ │ └─────────────────────┘ │ └─────────────────────────────┘

服务器发送类似这样的 JSON:

{
  "type": "Column",
  "children": [
    {
      "type": "Card",
      "title": "Welcome",
      "subtitle": "Hello, User!",
      "imageUrl": "https://..."
    },
    {
      "type": "Button",
      "text": "Get Started",
      "action": "navigate://home"
    }
  ]
}

1.2 传统方案的问题

❌ 传统 SDUI 的痛点

II. RemoteCompose 的革命性设计

2.1 源码模块结构

AndroidX 源码 可以看到,RemoteCompose 采用了清晰的模块化设计:

androidx/compose/remote/ 模块结构 ├── core/ ← 核心库:定义基础类型和协议 │ ├── core-api/ 公共 API 接口 │ └── core-impl/ 内部实现 │ ├── layout/ ← 布局系统:组件树和约束 │ ├── layout-api/ 布局接口定义 │ └── layout-impl/ 布局算法实现 │ ├── player/ ← 播放器:文档解析和渲染 │ ├── player-api/ 播放器接口 │ ├── player-compose/ Compose 渲染后端 │ └── player-view/ View 渲染后端 │ ├── sender/ ← 发送端:文档生成和编码 │ └── sender-api/ 文档创建接口 │ └── samples/ ← 示例应用 └── demo-app/ 演示应用

2.2 核心理念:文档式架构

RemoteCompose 的核心突破在于:不发送组件描述,而是发送绘制指令。它将 UI 视为一个「文档」,在 Canvas 层级捕获所有绘制操作。

RemoteCompose 架构: 创建阶段 (服务端) ┌───────────────────────────────────────────────────────────┐ │ │ │ @Composable 代码 │ │ ↓ │ │ Compose Runtime │ │ ↓ │ │ Canvas 操作拦截 │ │ ↓ │ │ 二进制文档 (Binary Document) │ │ [Op1][Op2][Op3]...[Op93+] │ │ │ └───────────────────────────────────────────────────────────┘ │ ▼ 传输 播放阶段 (客户端) ┌───────────────────────────────────────────────────────────┐ │ │ │ 接收二进制文档 │ │ ↓ │ │ Document Player │ │ ↓ │ │ 遍历操作指令,在 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),按照固定格式编码:

二进制操作编码格式 ┌─────────┬──────────────┬────────────────────────────────┐ │ OpCode │ Data Length │ Operation Data │ │ (1 byte)│ (varint) │ (variable length) │ └─────────┴──────────────┴────────────────────────────────┘ 示例: DrawRect 操作编码 OpCode: 0x10 (DrawRect) ┌──────┬───────┬───────┬───────┬───────┬─────────────────┐ │ 0x10 │ left │ top │ right │ bottom│ paint_ref │ │ │(float)│(float)│(float)│(float)│ (varint) │ └──────┴───────┴───────┴───────┴───────┴─────────────────┘ 1B 4B 4B 4B 4B 1-5B Paint 引用表 Paint 数据通过引用表共享,避免重复编码: ┌─────────┬─────────────────────────────────────────────┐ │ Index 0 │ Color: #FF0000, Style: FILL, StrokeWidth: 0│ ├─────────┼─────────────────────────────────────────────┤ │ Index 1 │ Color: #00FF00, Style: STROKE, Width: 2 │ ├─────────┼─────────────────────────────────────────────┤ │ Index 2 │ LinearGradient + Shadow │ └─────────┴─────────────────────────────────────────────┘

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. 适用场景

✅ RemoteCompose 适合的场景
⚠️ 需要谨慎使用的场景

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. 总结与展望

🎯 核心要点

12.1 未来发展方向

🔮 可能的发展方向

RemoteCompose 代表了服务器驱动 UI 的一种全新思路。它通过在更低的抽象层级进行操作,解决了传统 SDUI 面临的诸多问题。作为 AndroidX 官方库的一部分,它将获得持续的维护和改进。

虽然它可能不适合所有场景,但在需要高视觉保真度、快速迭代的场景中,RemoteCompose 是一个值得考虑的选择。随着 Compose Multiplatform 的发展,这种架构也有潜力扩展到跨平台场景,让同一份文档在多个平台上都能渲染。

12.2 参考资料