车载多 App 同屏渲染(三):RemoteCompose 序列化 UI

这是「多 App 同屏渲染」系列的第三篇。第一篇用 ClassLoader 把插件加载进宿主进程,插件的 View 直接挂上来------同进程、体验好,但插件崩了拖垮宿主。第二篇用 SurfaceControlViewHost 让插件在自己进程里实时渲染、把图层挂进宿主------隔离、可交互,但插件必须一直活着、还要处理黑屏和崩溃。这篇再换一条路:两个进程之间连 surface 都不共享,只传一份序列化好的 UI「文档」字节。 同样先在应用层落地,系统层(AAOS)的实装测试还在进行中。

为什么还要第三种

第二篇的 SCVH 很强,但代价实在:Provider 得一直存活(它每帧都在画),它一崩那块就黑,绑定过程还要防黑闪。对「一直在变、要实时交互」的卡片值得,但对大量只是展示、偶尔点一下的卡片(天气、状态、信息流),让每个业务都常驻一个渲染进程有点重。

RemoteCompose 换了个更轻的模型:Provider 不渲染、不持有任何 surface,它只把「这个 UI 长什么样」描述成一份数据 发出去;宿主拿到这份数据,用自己的播放器画出来。插件把字节交出去之后就算死了,宿主手里的文档还在,画面照样在------天生崩溃安全。代价是交互弱:它不是一块活画布,而是一份静态文档,只能通过预声明的动作做有限往返。

核心原理:bytes in, ints out

一句话:Provider 把 UI 描述序列化成一份紧凑的二进制文档(字节流),通过 AIDL 传给宿主;宿主用一个播放器解释这份文档、渲染出来。 传的既不是 View,也不是像素,而是一份「怎么画」的指令数据。

几个关键点:

它是一份文档,不是代码,也不是图层。 Provider 用带 Remote 前缀的可组合项描述界面(RemoteColumn/RemoteText...),captureSingleRemoteDocument{...} 把它「拍平」成 ByteArray。这段字节里是一串绘制/布局指令加一个很小的状态机------所谓 bytes in, ints out :播放器把文档字节喂进去,像跑一个极小虚拟机一样得到布局数值和绘制操作,再画到屏上。宿主跑的是自己的播放器,插件的代码一行都没在宿主进程里执行。

只能用 Remote 前缀的东西。 创建端用定制的捕获机制,只认 @RemoteComposable + Remote* 组件、RemoteModifier、以及远程类型(24.rdp、"x".rs、颜色用 RemoteColor 等)。混一个普通 Column 是进不了文档的------能被序列化的只有可声明的描述。

崩溃安全来自「数据 vs 进程」解耦。 SCVH 传的是指向 Provider 活图层的句柄,进程死画面就停;RemoteCompose 传的是自包含数据,和 Provider 进程死活无关。也因此隔离、安全:宿主只是在解释数据,不加载、不运行外部代码。

交互靠「预声明动作 + 往返重生成」。 静态文档没法把任意触摸透到远端。做法是:给元素声明一个动作;宿主渲染后用户点它,宿主把 actionId 回传给 Provider;Provider 据此重新生成一份新文档推回,宿主再渲染。是一次次「文档往返」,适合按钮式操作,不适合滚动、拖拽这类连续交互。

数据流

泳道(时序)图,左道宿主进程、右道插件进程,中间流动的是字节数据:
Provider 插件进程(captureSingleRemoteDocument) Host 宿主进程(RemoteComposePlayer) Provider 插件进程(captureSingleRemoteDocument) Host 宿主进程(RemoteComposePlayer) #mermaid-svg-g510Ig6AdZJ75UbZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-g510Ig6AdZJ75UbZ .error-icon{fill:#552222;}#mermaid-svg-g510Ig6AdZJ75UbZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-g510Ig6AdZJ75UbZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-g510Ig6AdZJ75UbZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-g510Ig6AdZJ75UbZ .marker.cross{stroke:#333333;}#mermaid-svg-g510Ig6AdZJ75UbZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-g510Ig6AdZJ75UbZ p{margin:0;}#mermaid-svg-g510Ig6AdZJ75UbZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-g510Ig6AdZJ75UbZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-g510Ig6AdZJ75UbZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-g510Ig6AdZJ75UbZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-g510Ig6AdZJ75UbZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-g510Ig6AdZJ75UbZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-g510Ig6AdZJ75UbZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-g510Ig6AdZJ75UbZ .sequenceNumber{fill:white;}#mermaid-svg-g510Ig6AdZJ75UbZ #sequencenumber{fill:#333;}#mermaid-svg-g510Ig6AdZJ75UbZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-g510Ig6AdZJ75UbZ .messageText{fill:#333;stroke:none;}#mermaid-svg-g510Ig6AdZJ75UbZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-g510Ig6AdZJ75UbZ .labelText,#mermaid-svg-g510Ig6AdZJ75UbZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-g510Ig6AdZJ75UbZ .loopText,#mermaid-svg-g510Ig6AdZJ75UbZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-g510Ig6AdZJ75UbZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-g510Ig6AdZJ75UbZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-g510Ig6AdZJ75UbZ .noteText,#mermaid-svg-g510Ig6AdZJ75UbZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-g510Ig6AdZJ75UbZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-g510Ig6AdZJ75UbZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-g510Ig6AdZJ75UbZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-g510Ig6AdZJ75UbZ .actorPopupMenu{position:absolute;}#mermaid-svg-g510Ig6AdZJ75UbZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-g510Ig6AdZJ75UbZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-g510Ig6AdZJ75UbZ .actor-man circle,#mermaid-svg-g510Ig6AdZJ75UbZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-g510Ig6AdZJ75UbZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Stub.asInterface(binder) 拿到代理 主线程协程 captureSingleRemoteDocument { Remote* }→ CapturedDocument.bytes player.setDocument(bytes) 解释并渲染 按 action 更新状态,重新 capture player.setDocument(newBytes) 重新渲染 bindService(显式 Intent)1onServiceConnected(binder)2requestDocument() (后台线程,阻塞 binder 调用)3返回文档字节(byte\[\])4用户点击 → onAction(type, payload, callback)5callback.onDocument(newBytes)6

和 SCVH 最大的不同:两条道之间传的是字节,不是图层句柄。Provider 交完字节就能退场,宿主手里的文档仍能渲染。

实现步骤

1. 依赖(两端都要,alpha)

RemoteCompose 在 androidx.compose.remote:* 下,是 Jetpack 官方库但仍 alpha (本文用 1.0.0-alpha15),包名/API 会变,以官方发布页为准。

kotlin 复制代码
// 插件(创建端)
implementation("androidx.compose.remote:remote-creation-compose:1.0.0-alpha15")

// 宿主(播放端)
implementation("androidx.compose.remote:remote-core:1.0.0-alpha15")
implementation("androidx.compose.remote:remote-player-view:1.0.0-alpha15")

2. AIDL 契约(两端逐字相同,中性包)

传的是字节,byte[] 是 AIDL 原生类型,很自然:

aidl 复制代码
interface IRemoteProvider {
    byte[] requestDocument();
    void onAction(String type, String payload, IDocCallback callback);
}
interface IDocCallback { void onDocument(in byte[] bytes); }

3. Provider:把 UI 拍成字节(注意 capture 是 suspend)

captureSingleRemoteDocument 是 suspend 、且内部要跑一次组合(主线程),而 AIDL requestDocument() 是同步方法。不能在同步 AIDL 里直接调 suspend 。解法:主线程协程生成并缓存,requestDocument() 返回缓存(首帧用 CompletableDeferred 等一下),onAction 重新生成后回推。

kotlin 复制代码
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
@Volatile private var cachedBytes = ByteArray(0)
private val firstDoc = CompletableDeferred<ByteArray>()

override fun requestDocument(): ByteArray =
    if (firstDoc.isCompleted) cachedBytes else runBlocking { firstDoc.await() } // 阻塞 Binder 线程,安全

private suspend fun buildDocument(): ByteArray =
    captureSingleRemoteDocument(context = this) {
        RemoteColumn(RemoteModifier.fillMaxWidth().padding(16.rdp)) {
            RemoteText("Remote Compose", color = RemoteColor(Color.White))
            RemoteText("来自插件进程 · $counter", color = RemoteColor(Color.White))
        }
    }.bytes

onAction 里改状态后 scope.launch { cachedBytes = buildDocument(); callback.onDocument(cachedBytes) }。Service 记得 Manifest 注册 + exported=true。

4. Host:用播放器渲染字节

remote-player-view 的 RemoteComposePlayer 是个 View,塞进区域容器,喂字节即可。requestDocument() 是阻塞 binder 调用,放后台线程,回主线程 setDocument:

kotlin 复制代码
@SuppressLint("RestrictedApi")
private fun renderDocument(bytes: ByteArray) {
    val c = container ?: return
    val player = RemoteComposePlayer(c.context)
    c.removeAllViews(); c.addView(player, MATCH_PARENT, MATCH_PARENT)
    player.setOnClickListener { sendAction("refresh", "") }  // 动作回传(白名单)
    player.setDocument(bytes)
}

宿主 <queries> 要能看见插件包,否则 bindService 静默失败。

几个真踩到的坑

没有官方教程,只有 release notes。 而且整套创建/播放 API 都是 @RestrictTo(LIBRARY_GROUP),不进公开 API 参考 ,所以你在 developer.android.com 上搜类和方法基本是空的------只能靠读源码 + 社区文章。用这些类都得 @SuppressLint("RestrictedApi"),并接受它随时会变。

有两份实现,别引错。 androidx.compose.remote.*(Jetpack,发到 Maven,App 用这份 )和 com.android.internal.widget.remotecompose.*(平台 frameworks/base 内置,@hide,App 不能用 )。后者在 sdk/sources/android-37.0/... 里能读到源码,但那只是可读源码,不在 android.jar 里、也过不了 hidden-API------读它理解原理可以,import 用它不行。

capture 是 suspend,同步 AIDL 里不能直调。 用主线程协程生成 + 缓存 + CompletableDeferred 等首帧;requestDocument() 只在首次用 runBlocking 等一下(阻塞的是 Binder 线程,不是主线程,安全)。

版本别用太旧。 网上很多示例是早期 alpha(如 alpha06),captureSingleRemoteDocument 那套要 alpha14/15;旧版没有或签名不同。两端版本要一致。

小细节。 RemoteText 默认颜色不一定可见,要显式给 RemoteColor(...);remote-player-view 播放器有 minSdk 要求(实测按需抬到 29)。

三种方式怎么选

ClassLoader(同进程) SurfaceControl(跨进程) RemoteCompose(序列化)
进程 同进程 跨进程 跨进程
传的是什么 View 对象 图层句柄(SurfacePackage) UI 文档(字节流)
隔离 / 崩溃安全 无,插件崩宿主崩 有,插件崩只黑那块 有,且插件死了画面还在
交互 完整 完整(输入跨进程路由) 弱,只能预声明动作往返
Provider 是否需存活 ---(同进程) 必须存活,实时渲染 不需要,交完字节就能退场
适合 自家、同签名、版本可控 实时、可交互、要隔离 展示型卡片、崩溃安全优先

总结

RemoteCompose 的思路可以浓缩成一句:Provider 把 UI 描述成一份序列化文档(bytes),AIDL 传给宿主,宿主用播放器解释这份文档渲染出来;交互靠回传 actionId、由 Provider 重新生成文档来完成。 它传的是数据,不是图层,也不是代码,所以插件交完就能退场、画面还在,天生崩溃安全、进程隔离、宿主不跑任何外部代码。

放到三种方式里看:ClassLoader 共享的是进程和代码 ,SCVH 共享的是图层 ,RemoteCompose 共享的只是一份数据------耦合一路降到最低,代价是交互能力也一路变弱。选择因此很清楚:要极致体验且可控用 ClassLoader;要实时可交互又隔离用 SCVH;要展示型、崩溃安全、进程彻底解耦用 RemoteCompose。理解这三种后,再面对任何「多 App 怎么同屏」的需求,就能按「共享什么(进程/图层/数据)、要不要实时、要不要隔离」快速判断该用哪一种。

只是要记住:RemoteCompose 现在还是 alpha、API 全是 restricted、没官方教程------当练习和技术预研正合适,别急着上生产关键路径。

效果

相关推荐
声光界9 天前
《立足中国,面向全球,打造面向全球车载与音频设备的下一代互联音频》
audio·车载·音频设备·vehicle·audio devices·音频互联
老猿讲编程1 个月前
【Eclipse OpenSOVD学习之十四】差距清单与演进建议
学习·rust·eclipse·车载·sovd
老猿讲编程1 个月前
【Eclipse OpenSOVD学习之十】客户端 SDK
学习·eclipse·嵌入式软件·车载·sovd
老猿讲编程2 个月前
【Eclipse OpenSOVD学习之六】 数据访问层(DataProvider)
学习·eclipse·嵌入式软件·车载·sovd
老猿讲编程2 个月前
【Eclipse OpenSOVD学习之二】 SOVD 协议原理与标准背景
学习·eclipse·嵌入式软件·车载·sovd
千里马学框架2 个月前
安卓开机性能优化:如何安全高效地裁剪 SystemService
android·智能手机·framework·wms·手机·性能·车载
齊家治國平天下3 个月前
AAOS 电源管理深度解析:休眠/唤醒/功耗优化
android·车载系统·aaos·aosp·电源管理·休眠唤醒·carpowermanager
齊家治國平天下3 个月前
Android 14 Vehicle HAL (VHAL) 设计与实现深度解析
android·aaos·carservice·车载开发·android 14·vhal·vehicle hal
齊家治國平天下3 个月前
CarService 深度解析 —— 基于 Android 14
android·车载系统·aaos·carservice·电源管理·android 14·vhal