这是「多 App 同屏渲染」系列的第二篇。第一篇用 ClassLoader 把插件 APK 加载进宿主进程,插件的 View 直接挂在宿主的视图树上——简单、体验好,但插件代码是在宿主进程里跑的。这篇换一条路:让插件在自己的进程里渲染,只把画面「递」给宿主显示。同样先在应用层落地,系统层(AAOS)的实装测试还在进行中。上一篇留下的问题ClassLoader 那套有个绕不开的前提:插件代码最终运行在宿主进程里。这意味着插件一崩,宿主跟着崩;插件的 Compose 版本还得和宿主对齐,不然运行时就炸。对「自家、同签名、版本可控」的生态够用,但只要插件不完全受你控制,这个耦合就危险。想要真正的隔离——插件在自己的进程里跑、崩了不拖垮宿主、版本各管各的、宿主拿不到插件的代码——就得让两个进程各画各的,再把画面合到同一块屏上。这正是SurfaceControlViewHost(下称 SCVH)干的事。核心原理一句话:宿主把自己的一个 token 交给插件,插件在自己进程里渲染好,把渲染结果打包成SurfacePackage通过 AIDL 回传,宿主的SurfaceView收下这个包就能显示。拆开看几个关键角色:SurfaceControlViewHost(Provider 侧)。它在插件进程里创建一个「无窗口」的 SurfaceControl 图层,把一棵普通的 View 树挂上去,由插件自己负责测量、布局、绘制。插件画到的是自己的这块 SurfaceControl,和宿主毫无关系。hostToken(宿主 → 插件)。这是宿主那个SurfaceView提供的 token。插件用它做两件事:一是把自己那块 SurfaceControlreparent 到宿主的 surface 层级之下(所以画面会出现在宿主界面里的正确位置),二是建立输入通道——宿主上的触摸事件能沿着这个 token 路由回插件的 View 树。这就是为什么嵌进来的画面是可交互的,不是一张截图。SurfacePackage(插件 → 宿主)。它是对插件那块 SurfaceControl(连同输入 token 等)的封装,实现了Parcelable,所以能通过 Binder 跨进程递给宿主。宿主SurfaceView.setChildSurfacePackage(pkg)收下后,就把它作为子 surface 嵌到自己的 surface 之下。最终怎么显示到一起。两个进程各自把内容绘制到各自的 SurfaceControl,最后由系统的 SurfaceFlinger 在同一块屏上按层级合成。不是把像素从一个进程拷到另一个进程,而是两块 surface 在合成阶段叠在一起。也正因为宿主是主动把自己的 hostToken 交出去、插件只往自己的 surface 画,不存在跨应用抓屏或注入,所以这套是零权限的。数据流泳道(时序)图,左道是宿主进程、右道是插件进程,箭头为跨进程的 AIDL 调用:Provider 插件进程(ProviderService)Host 宿主进程(SurfaceView)Provider 插件进程(ProviderService)Host 宿主进程(SurfaceView)Stub.asInterface(binder) 拿到代理SurfaceView attach 后取 hostTokennew SurfaceControlViewHost(ctx, display, hostToken)scvh.setView(content, w, h) 自己进程渲染surfaceView.setChildSurfacePackage(pkg)surfaceView.visibility VISIBLESurfaceFlinger 把两块 surface 合成到同一屏bindService(显式 Intent)1onServiceConnected(binder)2requestEmbed(hostToken, w, h, callback)3callback.onSurfacePackageReady(surfacePackage)4callback.onContentReady() 首帧后5callback的实现体(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)hostToken 的作用是把插件的无窗口界面登记到宿主窗口名下主要服务于输入、焦点和生命周期。newSurfaceControlViewHost(ctx,display,hostToken)→WindowlessWindowManager(...,hostInputToken)setView(view,w,h)→ addToDisplay →WMS.grantInputChannel(displayId,surfaceControl,window,hostInputToken,...)WMS:→ 创建一个EmbeddedWindow嵌入式窗口根据 hostToken 找到宿主的WindowState作为它的宿主 → 给插件创建一个独立的InputChannel插件:有自己的渲染目标(SurfaceControl),自己往里渲染,把句柄(SurfacePackage)回传。宿主收到句柄:setChildSurfacePackage把插件图层挂成 SurfaceView 图层的子层,坐进坑位。 surfacePackge 的作用是把插件的 layer 挂载到宿主上。否则SF 无法找到插件的这个layer图层没有SurfacePackage时插件的图层是孤儿插件进程里SurfaceControlViewHost会创建一个SurfaceControl插件的View树就画到这上面。但是这个Layer没有父节点不在任何屏幕的Layer树里offscreen layer。插件画得再起劲SF合成时也遍历不到它所以屏幕上看不到。SurfacePackage里装的是什么 内容 作用 插件根Layer的SurfaceControl核心。它就是我们最开始聊的那种 handle通过Parcel跨进程传过去后宿主拿到的是同一个SFLayer的句柄 输入 token、无障碍连接、远程接口 支持焦点、无障碍以及把配置变化通知给插件 里面没有像素只有句柄。 宿主拿到之后做了什么// SurfaceView.setChildSurfacePackage(pkg)大致等价于t.reparent(pkg.getSurfaceControl(),mSurfaceControl)// 把插件的 Layer 挂到 SurfaceView 的 Layer 下面.show(pkg.getSurfaceControl()).apply();// 直接发给 SF挂上之后Layer树变成这样Display└─ 宿主窗口 └─SurfaceView的Layer← 宿主决定位置、大小、裁剪、层级 └─ 插件根Layer← 通过SurfacePackage挂进来 └─ 插件的BBQbuffer 层 ← 插件每帧自己提交 buffer 这样一来SF合成时就能遍历到插件的Layer并且它的位置和裁剪都继承自SurfaceView。SurfaceFlinger:每帧把插件图层和别的图层合成上屏,插件画面就显示在宿主指定的位置。一句话:宿主给「坑位 输入线」,插件给「一个自渲染的图层句柄」,宿主把它挂进坑位,SurfaceFlinger 每帧把它和别的图层合成上屏;像素始终是插件自己画、直接进合成器,不经过宿主。由此自然成立两点:插件图层的位置和大小是继承宿主 SurfaceView 那块坑位的(改宿主 SurfaceView 的尺寸,插件画面跟着变);整条链路是零拷贝的(宿主不碰像素)——这也是它比「截图传图」高效的根本原因。实现步骤1. AIDL 契约(两端逐字相同,放中性包)两个 App 的 applicationId 不同,但 AIDL 接口的全限定名必须一字不差——Binder 靠接口描述符匹配。所以放一个中性包com.example.scvh,两边照抄同一份,并各自在build.gradle.kts里开buildFeatures { aidl true }(AGP 8 默认关闭)。// 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 SCVHoverridefunonBind(intent:Intent)object:IProviderService.Stub(){overridefunrequestEmbed(hostToken:IBinder,w:Int,h:Int,cb:IEmbedCallback){main.post{// Binder 线程 → 切主线程valdisplaygetSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)valscvhSurfaceControlViewHost(thisProviderService,display,hostToken)valcontentbuildContentView()// 自己那棵 View 树(可用 ComposeView)scvh.setView(content,w,h)content.viewTreeObserver.addOnDrawListener{cb.onContentReady()}cb.onSurfacePackageReady(scvh.surfacePackage)}}}ProviderService要在插件 Manifest 里注册,并且android:exportedtrue(跨应用绑定必须)。3. Host:SurfaceView 绑定 收包valsurfaceViewSurfaceView(context).apply{setZOrderOnTop(true)// 不设的话 surface 在窗口后面,被卡片背景盖住 → 黑屏}container.addView(surfaceView,MATCH_PARENT,MATCH_PARENT)valconnobject:ServiceConnection{overridefunonServiceConnected(name:ComponentName?,binder:IBinder?){valproviderIProviderService.Stub.asInterface(binder)// 拿到的是代理,不是 Service 实例surfaceView.post{// attach 后 hostToken 才有效provider.requestEmbed(surfaceView.hostToken,surfaceView.width,surfaceView.height,callback)}}overridefunonServiceDisconnected(name:ComponentName?){}}valintentIntent().apply{setClassName(com.example.surfacecontrolplugin,com.example.surfacecontrolplugin.ProviderService)// 跨应用必须显式}context.bindService(intent,conn,Context.BIND_AUTO_CREATE)valcallbackobject:IEmbedCallback.Stub(){overridefunonSurfacePackageReady(pkg:SurfaceControlViewHost.SurfacePackage){surfaceView.setChildSurfacePackage(pkg)}overridefunonContentReady(){surfaceView.visibilityView.VISIBLE}}SCVH 里跑 Compose 的坑SCVH 是个「没有 Activity 的窗口」,而ComposeView需要视图树上有ViewTreeLifecycleOwner/ViewTreeViewModelStoreOwner/ViewTreeSavedStateRegistryOwner。平时这些由 Activity 提供,这里没有,必须手动挂一个最小 Owner(同时实现这三者)并把生命周期推到RESUMED,否则setContent会崩或不绘制。想先把跨进程链路跑通,可以先用一个普通TextView当内容,通了再换 Compose。几个真踩到的坑bindService返回 false。不是要开前台服务,而是Android 11 的包可见性:宿主queries里没声明能看到插件包,系统就当这个服务不存在。宿主 Manifest 加queriespackage android:namecom.example.surfacecontrolplugin//queries即可。附带确认插件确实装了、Serviceexportedtrue、setClassName的类名没写错。黑屏。分两种:一是内容根本没送到(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 嵌入最合适的一站。效果
阅读完成 · 觉得有帮助?