ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SurfaceControlViewHost:Android 13跨进程UI共享实战

SurfaceControlViewHost:Android 13跨进程UI共享实战 如果你做过跨进程 UI 共享一定对被 RemoteViews 限制得死死的体验记忆犹新。写一个卡片只能用系统规定的几种 View点击事件只能走 PendingIntent数据更新得整棵子树重建交互稍微复杂一点就捉襟见肘。Android 13 推出的SurfaceControlViewHost把“一个应用把自己的真实 View 渲染到另一个应用的界面里”变成了公开 API宿主拿到的是一块真正的、由对方进程实时绘制的 Surface而不是一张静态快照。这篇文章我按照实际落地项目的顺序来写先讲它解决什么问题再拆核心对象然后是宿主端和客户端各自的接入步骤最后把我实测过程中踩过的坑和选型边界一并交代清楚。适合正在做跨应用/跨进程 UI 共享的 Android 开发也适合想搞清楚大屏、车载、系统 UI 里那些“内嵌第三方界面”是怎么实现的人。1. 为什么需要这个 APIRemoteViews 做不到的那些事1.1 RemoteViews 的墙RemoteViews是 Android 很早就在用的跨进程 UI 方案桌面小部件、通知栏、SystemUI 里的媒体卡片都在用它。它解决的是“远程进程拉取一个界面描述并渲染”的问题但代价是界面能力被严重压缩只能用 FrameLayout、LinearLayout、RelativeLayout 这几个容器再加 TextView、ImageView、Button、ProgressBar 等固定控件。不能自定义 View不能持有业务对象不能注册点击回调只能通过setOnClickPendingIntent发一个 PendingIntent 出去。整个视图树是不可变的想更新某个文本得用setTextViewText这类方法一层层去改复杂一点的逻辑全堆在那一堆 set 方法里。本质上它是“宿主进程根据描述文件直接创建 View”远程进程并没有真正参与渲染。这些限制在小卡片场景下还能忍但一旦遇到“车机中控上嵌入一个完整的音乐控制面板”“聊天应用里嵌入地图卡片并支持手势拖动”这种需要复杂交互的需求RemoteViews 直接不够用。以前大家只能退回去做截图下发或者干脆起一个悬浮窗体验都很别扭。1.2 跨进程渲染的早期解法与它们的局限在SurfaceControlViewHost出来之前想做跨进程 UI 大体有三条路用WindowManager在另一个进程里直接 addView 一个窗口然后通过坐标计算把它“贴”在宿主界面上。问题很明显窗口层级和宿主 Activity 不在同一棵视图树里动画、转场、遮挡关系都难对齐多窗口模式下更是灾难。用Presentation投到第二块屏幕上适合外接屏展示但无法嵌入到宿主应用自身的布局流里。用RemoteViews能力受限前面已经说过了。也就是说Android 一直缺一个“能把进程 A 的 View 树完整渲染到进程 B 的某个 Surface 上并且两边还能正常交互”的官方方案。SurfaceControlViewHost补上的就是这个缺口。1.3 SurfaceControlViewHost 的定位一句话概括它允许一个进程客户端持有自己的 View 树把渲染结果通过 Surface 跨进程传给另一个进程宿主并嵌进宿主的视图层级中。对宿主来说它只看到一个 Surface 大小的“洞”对这个洞里的内容负责的完全是远程进程自己的 View 系统。它和 SurfaceView 是黄金搭档。宿主侧通常就是一个普通 SurfaceView客户端侧是任意真实 View。两边通过SurfaceControlViewHost建立联系后远程进程的 UI 就成了宿主界面上一个可以交互的活区域。这套机制最早被 Android 系统内部用来做 SystemUI 与三方应用的交互Android 13 把它公开之后第三方应用之间也能用了。车载、大屏、折叠屏、穿戴设备这些场景里它的价值会越来越明显。2. 核心对象速通HostToken、SurfacePackage 与 SurfaceControl 各自扮演的角色2.1 三个类一句话概括这三个类是理解SurfaceControlViewHost的钥匙我习惯用一句话分别记住它们SurfaceControlViewHost.HostToken宿主签发的“门禁卡”。客户端必须拿着它才能创建自己的SurfaceControlViewHost实例而且这张卡绑定的是宿主进程的某个显示区域。SurfaceControlViewHost.SurfacePackage客户端打包好的“画”。它封装了客户端 View 渲染目标 Surface 的关键信息通过 Binder 传到宿主后宿主可以把它挂到自己的显示树上。SurfaceControlSurfaceFlinger 里一个显示节点的句柄。SurfacePackage 内部就是包了一个SurfaceControl宿主拿到它之后的操作本质上是把节点挂到层级树里。2.2 一次完整跨进程渲染的链路整个过程可以拆成四步宿主进程通过SurfaceView.getHostToken()拿到一个HostToken这个 token 通过 Binder/AIDL 传到客户端进程。客户端进程拿着 token构造自己的SurfaceControlViewHost调用setView(view, width, height)再通过getSurfacePackage()拿到SurfacePackage。SurfacePackage通过 Binder 回传给宿主进程。宿主进程拿到SurfacePackage后调用surfaceView.setChildSurfacePackage(pkg)把它挂到 SurfaceView 对应节点下。这个顺序不能乱。客户端必须在拿到 HostToken 之后才能创建实例宿主必须在拿到 SurfacePackage 之后才能挂载。token 的方向是宿主到客户端surface 的方向是客户端到宿主一进一出链路就通了。2.3 它为什么敢说“性能可控”很多人第一次接触SurfaceControlViewHost会担心跨进程渲染的性能损耗。实际上它的渲染链路和普通 View 没有本质区别客户端进程的 View 树照常 measure/layout/draw绘制结果通过 BufferQueue 交给 SurfaceFlingerSurfaceFlinger 再负责合成。宿主进程只是多了一个 Surface 节点引用并不参与具体绘制。这里的开销主要是跨 BufferQueue 的拷贝和合成层数增加。和 RemoteViews 那种“宿主直接构建 View 树”相比它多了合成开销但换来的是完整 UI 能力。实际测试下来静态卡片级别的界面完全无感知列表滚动这种高频刷新场景会有一定开销但可接受。3. 工程准备API 等级、Manifest 主题与跨进程通道3.1 API 等级与基本依赖SurfaceControlViewHost、SurfaceView.getHostToken()、SurfaceView.setChildSurfacePackage()都是 Android 13API 33新增的公开 API。最小版本直接定到 33没有兼容库可以回补。工程上我建议用 Kotlin 和最新的 Android Gradle Plugin避免老版本编译期找不到符号。不需要额外引入第三方依赖全是 framework API。跨进程传输部分可以自己写 AIDL也可以用现成的 Binder 通道。3.2 Manifest 与主题配置这里有几个容易漏的配置点我逐个说明客户端进程如果是一个独立进程需要正常声明 Service 或 Provider 来承载。如果客户端不要求独立进程同进程也能玩但那就失去跨进程意义了一般不建议。客户端应用不需要注册 Activity只需要一个带 Looper 的上下文。实际开发里我把它放在一个后台 Service 进程里一样能正常渲染。客户端进程的 Context 建议用createConfigurationContext(Configuration(config).apply { densityDpi hostDisplay.densityDpi })来保证尺寸和密度一致否则容易出现 DPI 漂移问题后面踩坑章节会细说。宿主端如果不想用默认主题注意给 Activity 配上后台透明主题避免 SurfaceView 挂载前出现闪白。3.3 跨进程通信通道设计HostToken 和 SurfacePackage 都是 Parcelable可以由 AIDL 直接传递。我在工程里定义了一个精简的接口// IRemoteUi.aidl package com.example.remoteui; import android.view.SurfaceControlViewHost; interface IRemoteUi { void attachHostToken(SurfaceControlViewHost.HostToken token); void deliverSurfacePackage(SurfaceControlViewHost.SurfacePackage pkg); }这里有个小坑AIDL 里引用android.view.SurfaceControlViewHost需要显式 import否则编译不过。如果 AIDL 传递 Parcelable 遇到问题也可以退一步用IBinder裸传拿到后再包回具体类型。4. 宿主端接入用 SurfaceView 接住远端画面4.1 布局与 SurfaceView宿主端的核心就是一个 SurfaceView。布局文件里在要嵌入的位置放好它宽高按实际设计来SurfaceView android:idid/remoteSurface android:layout_width240dp android:layout_height120dp /注意 SurfaceView 默认是异步的它的 Surface 实际创建时机在主视图 attach 之后。所以第一步不是立刻去拿 HostToken而是要等到 SurfaceView 的 Surface 真正创建完成。我在代码里用SurfaceHolder.Callback来保证时序surfaceView.holder.addCallback(object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) { // Surface 已可用此时才能安全获取 HostToken hostToken surfaceView.getHostToken() remoteUiService.attachHostToken(hostToken) } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { } override fun surfaceDestroyed(holder: SurfaceHolder) { // 释放或解绑 surfaceView.setChildSurfacePackage(null) } })4.2 HostToken 下发拿到 HostToken 的时机非常关键。如果 SurfaceView 的 Surface 还没创建就调用getHostToken()拿到的 token 可能无法对应到实际显示节点后续客户端渲染结果挂不上来。我在最初调试时就是在这里翻了车直接在onCreate里取 token结果另一端的 Surface 始终黑屏。token 拿到后立刻发给客户端进程。因为 token 是 Parcelable直接走 AIDL 即可。这里要注意attachHostToken应当在每次surfaceCreated都重新发送一次因为 Surface 销毁重建后 token 可能也变了。4.3 接收 SurfacePackage 并挂载客户端处理完自己的视图后会通过 AIDL 把 SurfacePackage 回传。宿主端拿到后调用setChildSurfacePackage挂载override fun deliverSurfacePackage(pkg: SurfaceControlViewHost.SurfacePackage?) { runOnUiThread { if (pkg null) { surfaceView.setChildSurfacePackage(null) returnrunOnUiThread } surfaceView.setChildSurfacePackage(pkg) } }挂载操作必须在 UI 线程而且 SurfaceView 必须仍然处于有效状态。如果宿主界面已经退到后台或者 SurfaceView 被移除此时调用setChildSurfacePackage会直接抛异常。5. 客户端接入在后台进程渲染真正的 View 并寄出去5.1 创建客户端 SurfaceControlViewHost客户端进程收到 HostToken 后先构造自己的SurfaceControlViewHost。构造时传 token 表示它是客户端角色val clientHost SurfaceControlViewHost( appContext, displayManager.getDisplay(Display.DEFAULT_DISPLAY), hostToken )这里的appContext用 Application Context 就够了不需要 Activity。display一般用系统默认 display但如果宿主的界面在副屏上就要传对应的 Display 对象否则纹理尺寸和刷新率都对不上。有个细节创建SurfaceControlViewHost的线程必须有 Looper因为它内部会创建 ViewRootImpl。在 Service 的onCreate里直接调用没问题但如果放在普通线程里就要先Looper.prepare()。5.2 setView 与尺寸的细节接下来把自己要展示的 View 设置进去val view LayoutInflater.from(appContext).inflate(R.layout.remote_panel, null) clientHost.setView(view, widthPx, heightPx)widthPx和heightPx是渲染缓冲的像素尺寸这个值决定了客户端 View 的 measure 范围也直接影响宿主端看到的清晰度。这里有几个关键注意点如果宿主 SurfaceView 的布局尺寸是 dp必须通过 density 换算成 px否则在 2x/3x 屏幕上会出现内容被拉伸或压缩的问题。宽高不一定要和宿主 SurfaceView 完全一致系统会将客户端 surface 缩放到 SurfaceView 的实际大小但比例不一致会出现画面变形一般保持一致更稳妥。如果宿主 SurfaceView 的大小会动态变化客户端应重新创建 SurfacePackage 并回传。我目前的方案是宿主侧监听尺寸变化后通知客户端重新setView。5.3 返回 SurfacePackagesetView之后调用getSurfacePackage()就能拿到打包好的 Surfaceval pkg clientHost.getSurfacePackage() remoteUiService.deliverSurfacePackage(pkg)拿到SurfacePackage之后即使客户端进程后续崩溃宿主端显示的画面也不会立刻消失因为 Surface 的所有者实际上已经移交给了 SurfaceFlinger。但如果客户端进程是主动销毁的一定要调clientHost.release()。5.4 生命周期与 release客户端进程结束时要调用clientHost.release()来释放 Surface、View 树和内部 BufferQueue。不调 release 的话Surface 会被 SurfaceFlinger 保留一段时间宿主端画面会卡在最后一帧不消失。我的做法是在 Service 的onDestroy和 Binder 死亡回调里都加上 releaseoverride fun onDestroy() { clientHost?.release() clientHost null super.onDestroy() }有一个坑是重复 release。release()之后再次调用getSurfacePackage()或setView()会抛异常所以最好用一个 nullable 字段持有实例释放后置空。6. 实测踩坑记录黑屏、点不动、尺寸漂移与进程释放6.1 画面一直黑屏黑屏是遇到最多的现象而且原因往往不在同一处。我逐个排过之后总结出最常见的三个原因宿主端 SurfaceView 的 Surface 尚未创建就发送了 HostToken。解决方案就是前面说的必须在surfaceCreated回调之后再发。客户端setView的宽高传了 0。传 0 不会直接崩但 SurfaceFlinger 不会生成有效的 BufferQueue宿主端就一直黑屏。Display 传错。副屏场景容易踩这个坑客户端创建时传的 Display 必须和宿主最终显示的目标一致。排查这类问题我建议先在宿主端打日志确认setChildSurfacePackage收到的 pkg 不为空再看客户端getSurfacePackage返回值基本能定位到是哪一侧没有正确执行。6.2 画面出来了但触控失灵画面显示正常但点不动这个坑比黑屏更隐蔽。我查过源码才明白setChildSurfacePackage虽然把 Surface 挂到了宿主节点下但输入事件的通道是独立建立的触摸事件能否正确路由到客户端 View取决于宿主 SurfaceView 是不是可聚焦、可点击的。默认情况下 SurfaceView 并不消费触摸事件需要给 SurfaceView 设置isFocusable true和合适的isClickable属性。我按下面的配置调整之后点击、滚动、手势都正常了surfaceView.isFocusable true surfaceView.isFocusableInTouchMode true surfaceView.isClickable true另外客户端那边 View 自身的 clickable 也要正常设置。如果客户端 View 本身就是纯展示型没有设置点击监听事件会被系统判定为不消费看起来就像宿主 SurfaceView 把事件吃了。6.3 尺寸和 density 对不齐尺寸问题表现为内容显示出来偏大或偏小、文字模糊、图片边缘有锯齿。本质原因是客户端 Context 的 density 和宿主不一致。举个例子客户端进程如果是一个默认配置的普通进程它的resources.displayMetrics.densityDpi可能和宿主 Activity 所在屏幕不一致。在setView传宽高时如果直接用 dp 转 px算出来的像素尺寸和 SurfaceView 实际大小不匹配。我的解决方法是客户端在创建 Context 时强制继承宿主的 density 配置val config Configuration(appContext.resources.configuration).apply { densityDpi hostDensityDpi } val displayContext appContext.createConfigurationContext(config)再基于这个 displayContext 去 inflate View整个显示效果就正常了。6.4 release 时机与 Binder 死亡处理客户端进程被杀掉或宿主进程被杀掉时如果没有正确处理释放逻辑会有两个问题一个是宿主端画面卡在最后一帧一个是内存泄漏。建议在跨进程通道里监听死亡回调。AIDL 可以通过linkToDeath监听对方进程终止触发后主动解绑binder.linkToDeath({ runOnUiThread { surfaceView.setChildSurfacePackage(null) } }, 0)宿主侧解除挂载后SurfaceFlinger 会回收对应节点客户端如果还活着也能通过后续重建去恢复。7. 选型边界它和 RemoteViews、嵌入模式、悬浮窗的差异7.1 各方案的适用场景对比我把常见的跨进程 UI 方案放在一起做过对比结论很清晰SurfaceControlViewHost适合“宿主界面内部嵌入一个可交互的远程 View”但并不是所有场景都需要上它。方案跨进程自定义 View交互能力嵌入宿主布局适用场景RemoteViews支持不支持弱支持桌面卡片、通知栏Presentation支持支持强不支持第二屏幕展示悬浮窗/WindowManager支持支持强不支持全局悬浮窗Activity Embedding不支持支持强弱分屏式多任务同时展示SurfaceControlViewHost支持支持强支持应用内嵌远程 UI7.2 什么场景我推荐用它我推荐的优先级从高到低是车机/大屏上的三方卡片第三方 App 用自己的 UI 渲染宿主只占位安全边界清晰。聊天/IM 里的嵌入式动态卡片比如地图卡片、商品卡片需要完整的手势交互。系统 UI 内的安全控件比如支付密码键盘键盘 UI 由独立可信进程渲染。7.3 什么场景我建议慎用SurfaceControlViewHost不适合做超大列表或高频刷新界面因为每次刷新都多了一层合成开销。如果宿主进程里只是简单展示一张图片或一段文本RemoteViews 仍然是更轻量的选择。另外它要求两端都运行在 Android 13 及以上老机型用户覆盖不到的时候还是得保留降级方案。最后说一点个人经验SurfaceControlViewHost的上手难度不在于 API 本身而在于它对时序非常敏感。HostToken 的发放时机、SurfaceView 的回调顺序、客户端渲染线程的 Looper 准备任何一个环节含糊表现出来都是黑屏这种让人摸不着头脑的表面现象。调试的时候建议先把链路拆开每一端单独验证再合起来联调会省很多时间。这个方向后续还能延伸到安全 surface、多屏协调等更深的玩法但先把基础链路打通后面就顺了。
返回列表