车载多 App 同屏渲染(二):SurfaceControlViewHost 跨进程

这是「多 App 同屏渲染」系列的第二篇。第一篇用 ClassLoader 把插件 APK 加载进宿主进程,插件的 View 直接挂在宿主的视图树上------简单、体验好,但插件代码是在宿主进程里跑的。这篇换一条路:让插件在自己的进程里渲染,只把画面「递」给宿主显示。同样先在应用层落地,系统层(AAOS)的实装测试还在进行中。

上一篇留下的问题

ClassLoader 那套有个绕不开的前提:插件代码最终运行在宿主进程里。这意味着插件一崩,宿主跟着崩;插件的 Compose 版本还得和宿主对齐,不然运行时就炸。对「自家、同签名、版本可控」的生态够用,但只要插件不完全受你控制,这个耦合就危险。

想要真正的隔离------插件在自己的进程里跑、崩了不拖垮宿主、版本各管各的、宿主拿不到插件的代码------就得让两个进程各画各的,再把画面合到同一块屏上。这正是 SurfaceControlViewHost(下称 SCVH)干的事。

核心原理

一句话:宿主把自己的一个 token 交给插件,插件在自己进程里渲染好,把渲染结果打包成 SurfacePackage 通过 AIDL 回传,宿主的 SurfaceView 收下这个包就能显示。

拆开看几个关键角色:

SurfaceControlViewHost(Provider 侧)。 它在插件进程里创建一个「无窗口」的 SurfaceControl 图层,把一棵普通的 View 树挂上去,由插件自己负责测量、布局、绘制。插件画到的是自己的这块 SurfaceControl,和宿主毫无关系。

hostToken(宿主 → 插件)。 这是宿主那个 SurfaceView 提供的 token。插件用它做两件事:一是把自己那块 SurfaceControl reparent 到宿主的 surface 层级之下 (所以画面会出现在宿主界面里的正确位置),二是建立输入通道 ------宿主上的触摸事件能沿着这个 token 路由回插件的 View 树。这就是为什么嵌进来的画面是可交互的,不是一张截图。

SurfacePackage(插件 → 宿主)。 它是对插件那块 SurfaceControl(连同输入 token 等)的封装,实现了 Parcelable,所以能通过 Binder 跨进程递给宿主。宿主 SurfaceView.setChildSurfacePackage(pkg) 收下后,就把它作为子 surface 嵌到自己的 surface 之下。

最终怎么显示到一起。 两个进程各自把内容绘制到各自的 SurfaceControl,最后由系统的 SurfaceFlinger 在同一块屏上按层级合成 。不是把像素从一个进程拷到另一个进程,而是两块 surface 在合成阶段叠在一起。也正因为宿主是主动把自己的 hostToken 交出去、插件只往自己的 surface 画,不存在跨应用抓屏或注入,所以这套是零权限的。

数据流

泳道(时序)图,左道是宿主进程、右道是插件进程,箭头为跨进程的 AIDL 调用:

callback 的实现体(setChildSurfacePackage、置 VISIBLE)是宿主代码,由 Provider 跨进程触发。图需支持 Mermaid 的 Markdown 预览(GitHub / Typora / VS Code 插件)渲染。

再往底层看:token、SurfacePackage、SurfaceFlinger 各自在干嘛

这套 API 第一次看很容易懵,我按 Android 图形栈从底往上捋一遍,理解了就通了。

先垫一个前提:一块「surface」不是一张图片,而是一条图像缓冲流 ------App 用 GPU 把每一帧画进 GraphicBuffer(显存里的缓冲),直接交给系统的合成器 SurfaceFlinger。每块 surface 在 SurfaceFlinger 里对应一个图层(layer) ,由一个 SurfaceControl 句柄来引用和控制(位置、层级、裁剪、透明度)。

hostToken------我(宿主)递出去的东西。 它是宿主那个 SurfaceView 图层的身份令牌,干两件事。一是层级归属 :Provider 在自己进程里建的图层本来是「孤儿」,不知道该挂哪、显示在哪;有了它,系统就把 Provider 的图层挂成我 SurfaceView 图层的子层,跟着我一起定位、裁剪、随界面移动。二是输入通道:让落在这块区域的触摸事件从我这儿路由回 Provider 的 view 树------这正是它可交互的原因。

SurfacePackage------Provider 回给我的东西。 它是对 Provider 那块 SurfaceControl 的封装,Parcelable,能过 Binder。关键在于里面没有像素 ,传的只是一个指向 Provider 图层的句柄。我 setChildSurfacePackage(pkg),做的就是把这块图层挂进我 SurfaceView 图层之下。

SurfaceFlinger------最后上屏的人。 这里要纠正一个直觉:我的 SurfaceView 从头到尾没拿到 Provider 的像素 。Provider 一直把每帧画进它自己的缓冲,直接作为「Provider 图层」送进 SurfaceFlinger;SurfaceFlinger 每个 vsync 收集所有图层(我的 UI、挂在我坑位里的 Provider 子层、状态栏、别的 App......),按各自的位置 / 层级 / 透明度叠合,合成出最终这一帧的屏幕画面再输出。所谓「合成到一屏」,不是 App 意义上的画内容,而是把大家画好的缓冲拼起来、送到屏幕

把角色对上,一句一个:

  • 宿主 SurfaceView:占一块独立图层,提供坑位(位置 / 大小 / 裁剪 / 层级)+ 输入通道(hostToken)。
  • 插件:有自己的渲染目标(SurfaceControl),自己往里渲染,把句柄(SurfacePackage)回传。
  • 宿主收到句柄 :setChildSurfacePackage 把插件图层挂成 SurfaceView 图层的子层,坐进坑位。
  • SurfaceFlinger:每帧把插件图层和别的图层合成上屏,插件画面就显示在宿主指定的位置。

一句话:宿主给「坑位 + 输入线」,插件给「一个自渲染的图层句柄」,宿主把它挂进坑位,SurfaceFlinger 每帧把它和别的图层合成上屏;像素始终是插件自己画、直接进合成器,不经过宿主。

由此自然成立两点:插件图层的位置和大小是继承 宿主 SurfaceView 那块坑位的(改宿主 SurfaceView 的尺寸,插件画面跟着变);整条链路是零拷贝的(宿主不碰像素)------这也是它比「截图传图」高效的根本原因。

实现步骤

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

两个 App 的 applicationId 不同,但 AIDL 接口的全限定名必须一字不差 ------Binder 靠接口描述符匹配。所以放一个中性包 com.example.scvh,两边照抄同一份,并各自在 build.gradle.kts 里开 buildFeatures { aidl = true }(AGP 8+ 默认关闭)。

aidl 复制代码
// IProviderService.aidl
interface IProviderService {
    void requestEmbed(IBinder hostToken, int width, int height, IEmbedCallback callback);
}
// IEmbedCallback.aidl
interface IEmbedCallback {
    void onSurfacePackageReady(in SurfaceControlViewHost.SurfacePackage pkg);
    void onContentReady();   // 真正画过一帧再通知,配合防黑屏
}

2. Provider:Service + SCVH

kotlin 复制代码
override fun onBind(intent: Intent) = object : IProviderService.Stub() {
    override fun requestEmbed(hostToken: IBinder, w: Int, h: Int, cb: IEmbedCallback) {
        main.post {                                   // Binder 线程 → 切主线程
            val display = getSystemService(DisplayManager::class.java)
                .getDisplay(Display.DEFAULT_DISPLAY)
            val scvh = SurfaceControlViewHost(this@ProviderService, display, hostToken)
            val content = buildContentView()          // 自己那棵 View 树(可用 ComposeView)
            scvh.setView(content, w, h)
            content.viewTreeObserver.addOnDrawListener { cb.onContentReady() }
            cb.onSurfacePackageReady(scvh.surfacePackage)
        }
    }
}

ProviderService 要在插件 Manifest 里注册,并且 android:exported="true"(跨应用绑定必须)。

3. Host:SurfaceView + 绑定 + 收包

kotlin 复制代码
val surfaceView = SurfaceView(context).apply {
    setZOrderOnTop(true)                              // 不设的话 surface 在窗口后面,被卡片背景盖住 → 黑屏
}
container.addView(surfaceView, MATCH_PARENT, MATCH_PARENT)

val conn = object : ServiceConnection {
    override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
        val provider = IProviderService.Stub.asInterface(binder)   // 拿到的是代理,不是 Service 实例
        surfaceView.post {                                         // attach 后 hostToken 才有效
            provider.requestEmbed(surfaceView.hostToken, surfaceView.width, surfaceView.height, callback)
        }
    }
    override fun onServiceDisconnected(name: ComponentName?) {}
}
val intent = Intent().apply {
    setClassName("com.example.surfacecontrolplugin",
                 "com.example.surfacecontrolplugin.ProviderService")   // 跨应用必须显式
}
context.bindService(intent, conn, Context.BIND_AUTO_CREATE)

val callback = object : IEmbedCallback.Stub() {
    override fun onSurfacePackageReady(pkg: SurfaceControlViewHost.SurfacePackage) {
        surfaceView.setChildSurfacePackage(pkg)
    }
    override fun onContentReady() { surfaceView.visibility = View.VISIBLE }
}

SCVH 里跑 Compose 的坑

SCVH 是个「没有 Activity 的窗口」,而 ComposeView 需要视图树上有 ViewTreeLifecycleOwner / ViewTreeViewModelStoreOwner / ViewTreeSavedStateRegistryOwner。平时这些由 Activity 提供,这里没有,必须手动挂一个最小 Owner(同时实现这三者)并把生命周期推到 RESUMED,否则 setContent 会崩或不绘制。想先把跨进程链路跑通,可以先用一个普通 TextView 当内容,通了再换 Compose。

几个真踩到的坑

bindService 返回 false。 不是要开前台服务,而是 Android 11+ 的包可见性 :宿主 <queries> 里没声明能看到插件包,系统就当这个服务不存在。宿主 Manifest 加 <queries><package android:name="com.example.surfacecontrolplugin"/></queries> 即可。附带确认插件确实装了、Service exported=truesetClassName 的类名没写错。

黑屏。 分两种:一是内容根本没送到(bindService 失败 / hostToken 为 null / 尺寸为 0 / Provider 崩了),挨个打 log 看断在哪;二是内容送到了但被盖住------SurfaceView 默认在窗口后面,会被卡片的深色背景挡住,setZOrderOnTop(true) 提到上层就好。再配合「先 INVISIBLE,收到 onContentReady 再 VISIBLE」避免绑定过程中的黑闪。

hostToken 取到 null。 SurfaceView.getHostToken() 在部分 ROM 是 @hide,直接调拿不到,需要反射;而且必须等 SurfaceView attach 到 window 之后才有效,所以取它的动作放在 surfaceView.post {} 里。

尺寸为 0。 attach 时容器还没布局完,width/height 是 0。真正 requestEmbed 要放在布局完成之后(post / doOnLayout)再取尺寸。

Provider 必须活着

这是 SCVH 和另外两种方案最本质的差别:画面是插件进程每一帧实时画 的,宿主只是把它的 surface 贴在自己界面上。所以插件进程一旦被杀或崩溃,那块 surface 就停更 → 黑屏/冻住(宿主不受影响)。BIND_AUTO_CREATE 会在绑定期间抬高插件进程优先级、尽量保活,但它仍是独立的可回收进程,所以要 linkToDeath 监听死亡、做降级。

适合它的场景就是「别的进程拥有渲染权,但要在你界面里内联、实时、可交互」:车机 Launcher 里各业务自绘的实时卡片(媒体正在播放、EV 电量、导航)、Privacy Sandbox 里广告 SDK 在独立进程渲染的广告位、智能家居各设备自绘的控件、别的 App 的实时视频/摄像头预览。这些要么需要实时刷新、要么需要真触摸交互、要么需要进程隔离,静态方案做不到。

三种方案怎么选

ClassLoader(同进程) SurfaceControl(跨进程) RemoteCompose(序列化)
进程 同进程 跨进程 跨进程
隔离 / 崩溃安全 无,插件崩宿主崩 有,插件崩只黑那块 有,且插件死了画面还在
交互 完整 完整(输入跨进程路由)
Provider 是否需存活 ---(同进程) 必须存活,实时渲染 不需要,只递字节
代价 版本要对齐、共享代码 黑屏/崩溃/token 都要自己处理 交互能力受限

总结

SCVH 的思路很干净:AIDL 通信,宿主把 hostToken 传给 Provider,Provider 在自己进程里用 SurfaceControlViewHost 渲染,把结果封成 SurfacePackage 回传,宿主的 SurfaceView 收下这个包就显示。 两个进程各画各的 surface,最后交给 SurfaceFlinger 合成到同一屏;hostToken 顺带把输入通道建起来,所以画面是活的、能点的;整个过程零权限。

它拿「Provider 必须存活 + 黑屏/崩溃要自己兜」换来了「进程隔离 + 实时 + 可交互」。相比第一篇的 ClassLoader,它把耦合从「共享进程和代码」降到了「只共享一份 AIDL 契约」------这也是往系统层(TaskView、Scalable UI)走之前,理解跨进程 UI 嵌入最合适的一站。

效果

相关推荐
樊小肆1 小时前
离谱,每轮请求 25% 的 token,竟在重发模型想完就扔的内心独白
前端·人工智能·agent
小妖现世1 小时前
前端面试复习笔记:React 高频题 + JS 手写题
前端
holidaypenguin1 小时前
Windows 本地 HTTPS 证书生成指南
前端
酷酷的逗逗乐1 小时前
前端转 Agent 开发 · 第六节
前端·程序员
回家吃饭去吧1 小时前
WebAssembly 深度解析:前端性能的最后一块拼图
前端
爱勇宝1 小时前
没有 Fn 键关触控板?我做了一个双击即用的 Windows 小工具
前端·后端·程序员
尤小小1 小时前
Vite-SSG 实践:Vue项目预渲染落地完整方案
前端·vue.js
默_笙2 小时前
🍕 AI 的嘴巴装了水管(上):从"等它说完"到"边说边听"的流式输出指南
前端·javascript
lichenyang4532 小时前
让 VK 小程序调用 HarmonyOS 原生能力:壳子 SDK 的实现思路
前端