这是「多 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 据此重新生成一份新文档推回,宿主再渲染。是一次次「文档往返」,适合按钮式操作,不适合滚动、拖拽这类连续交互。
数据流
泳道(时序)图,左道宿主进程、右道插件进程,中间流动的是字节数据:

和 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、没官方教程------当练习和技术预研正合适,别急着上生产关键路径。
效果
