7、显示与多屏交互:多屏显示架构、SurfaceFlinger 适配、跨屏拖拽与镜像
各位好,今天我们来聊聊车载多屏显示这块硬骨头。说实话,Android Automotive 里最让我头疼的,就是多屏交互。手机上一个屏,搞定了。车机呢?仪表盘、中控、副驾屏、后排娱乐屏……少则两块,多则五六块。每块屏的分辨率、刷新率、触控逻辑还不一样。
我刚开始做车载项目时,以为就是把手机那套显示框架搬过来。结果呢?SurfaceFlinger 直接罢工。嗯,这里面的坑,我一个个踩过。今天我把经验都倒出来。
7.1 多屏显示架构:从单屏到多屏的进化
Android 原生的显示架构,说白了就是「一个 SurfaceFlinger 管一个屏幕」。但车机需要的是「一个 SurfaceFlinger 管多个屏幕」。怎么破?
Google 在 Android 10 之后引入了 多屏显示架构(Multi-Display Support)。核心思路是:每个物理显示器对应一个 DisplayDevice 对象,SurfaceFlinger 内部维护一个显示设备列表。
核心概念:
- DisplayDevice:代表一个物理屏幕,包含分辨率、刷新率、旋转角度等属性
- DisplayToken:SurfaceFlinger 内部标识一个显示设备的令牌
- LayerStack:每个显示设备有自己的 Layer 堆叠顺序
我画了一张架构图,你看一眼就明白了:
你看,从应用层到物理显示,中间经过了 WMS、SurfaceFlinger、HWC 三层。每一层都要为多屏做适配。我个人习惯把 WMS 比作「交通警察」,SurfaceFlinger 是「画师」,HWC 是「打印机」。各司其职。
7.2 SurfaceFlinger 适配:让系统认识多块屏
SurfaceFlinger 的适配,说白了就是两件事:让系统发现多块屏,以及让系统正确合成多块屏。
7.2.1 显示设备发现
系统启动时,SurfaceFlinger 会通过 HWComposer 查询可用的物理显示器。每个显示器返回一个 DisplayId。我记得第一次调试时,发现仪表盘屏幕死活不亮。查了半天,原来是 HWC 层没正确上报显示设备。
关键代码路径在 SurfaceFlinger::onHotplugReceived():
// frameworks/native/services/surfaceflinger/SurfaceFlinger.cpp
void SurfaceFlinger::onHotplugReceived(int32_t sequenceId, HWComposer& hwc,
int32_t displayId, int32_t connect) {
if (connect == HWC2::Connection::CONNECTED) {
// 创建新的 DisplayDevice
sp<DisplayDevice> display = new DisplayDevice(this, hwc, displayId);
mDisplays.add(displayId, display);
// 通知 WMS 有新的显示设备
mEventThread->onHotplugReceived(displayId, true);
} else if (connect == HWC2::Connection::DISCONNECTED) {
// 移除显示设备
mDisplays.remove(displayId);
mEventThread->onHotplugReceived(displayId, false);
}
}
避坑指南: 我曾经遇到一个问题:副驾屏热插拔后,SurfaceFlinger 崩溃。原因是 DisplayDevice 销毁时,还有 Layer 绑定在上面。解决方案是在销毁前先解绑所有 Layer。
7.2.2 显示策略配置
每块屏的用途不同,策略也不同。仪表盘需要高刷新率(60fps),中控屏需要触控响应快,副驾屏可能只需要 30fps。这些策略在 display_config.xml 中配置:
<!-- device/厂商/车型/display_config.xml -->
<displayConfiguration>
<display id="0" type="builtIn">
<name>Center Console</name>
<resolution width="1920" height="720"/>
<refreshRate>60</refreshRate>
<touchEnabled>true</touchEnabled>
</display>
<display id="1" type="builtIn">
<name>Instrument Cluster</name>
<resolution width="1280" height="480"/>
<refreshRate>60</refreshRate>
<touchEnabled>false</touchEnabled>
</display>
<display id="2" type="external">
<name>Passenger Display</name>
<resolution width="1920" height="1080"/>
<refreshRate>30</refreshRate>
<touchEnabled>true</touchEnabled>
</display>
</displayConfiguration>
你想想看,如果仪表盘也支持触控,驾驶员开车时误触一下,那画面太美我不敢看。所以仪表盘一定要关掉触控。
7.3 跨屏拖拽:从一块屏拖到另一块屏
跨屏拖拽,这是用户最直观的多屏交互。中控屏上拖一个导航卡片到仪表盘,或者拖一首歌到副驾屏。实现起来,比想象中复杂。
7.3.1 拖拽事件传递
Android 原生的拖拽框架(Drag and Drop)只支持单屏。跨屏拖拽需要我们自己扩展。核心思路是:在 WMS 层拦截拖拽事件,判断拖拽目标是否跨屏。
我实现过一个方案,大致流程如下:
- 用户在屏幕 A 上开始拖拽,WMS 记录拖拽源窗口和起始坐标
- 拖拽过程中,WMS 持续监控手指位置
- 当手指移动到屏幕 B 的边界时,WMS 触发跨屏事件
- 屏幕 B 的 WindowManager 接管拖拽,显示拖拽阴影
- 手指抬起时,屏幕 B 上的目标窗口接收 Drop 事件
关键代码片段:
// 在 WMS 中判断是否跨屏
boolean isCrossDisplayDrag(int x, int y, int sourceDisplayId) {
DisplayInfo sourceDisplay = getDisplayInfo(sourceDisplayId);
// 判断手指是否超出源屏幕边界
if (x < 0 || x > sourceDisplay.logicalWidth ||
y < 0 || y > sourceDisplay.logicalHeight) {
// 计算目标屏幕 ID
int targetDisplayId = findDisplayAtPoint(x, y);
return targetDisplayId != sourceDisplayId;
}
return false;
}
7.3.2 拖拽阴影渲染
跨屏拖拽时,阴影的渲染是个麻烦事。因为阴影本质上是一个 SurfaceControl,它只能属于一个 Display。跨屏时,阴影需要从源屏幕的 SurfaceControl 迁移到目标屏幕的 SurfaceControl。
我曾经在这里栽过跟头。阴影迁移过程中,如果时序没处理好,会出现阴影闪烁甚至消失。解决方案是:在迁移前,先在目标屏幕创建一个新的阴影 SurfaceControl,然后隐藏源屏幕的阴影,再显示目标屏幕的阴影。中间加一个 16ms 的过渡帧,人眼就看不出来了。
7.4 屏幕镜像:一块屏的内容复制到另一块屏
镜像,说白了就是把主屏的内容「复制」到副屏。车机里常见的场景:中控屏的导航信息镜像到仪表盘,或者后排娱乐屏镜像中控屏的视频。
7.4.1 基于 SurfaceFlinger 的镜像方案
最直接的方式是让 SurfaceFlinger 把同一个 Layer 同时输出到两个 DisplayDevice。但这有个问题:两个屏幕的分辨率可能不同,需要做缩放。
SurfaceFlinger 的 DisplayDevice 支持 setDisplayProjection 方法,可以设置显示区域和缩放:
// 设置镜像显示
void setupMirrorDisplay(int sourceDisplayId, int targetDisplayId) {
sp<DisplayDevice> sourceDisplay = mDisplays.get(sourceDisplayId);
sp<DisplayDevice> targetDisplay = mDisplays.get(targetDisplayId);
// 获取源屏幕的显示区域
Rect sourceRect = sourceDisplay->getDisplayRect();
// 设置目标屏幕的投影区域(自动缩放)
targetDisplay->setDisplayProjection(sourceRect.getOrientation(),
sourceRect,
targetDisplay->getDisplayRect());
}
注意: 镜像会带来性能开销。SurfaceFlinger 需要为每个镜像屏幕做一次合成。如果同时镜像多个屏幕,GPU 负载会飙升。我建议只镜像关键信息(如导航箭头、车速),不要镜像整个 UI。
7.4.2 基于 MediaCodec 的无线镜像
有些场景需要无线镜像,比如手机投屏到车机。这时候就不能用 SurfaceFlinger 了,得用 MediaCodec 编码后通过网络传输。
流程是这样的:
- 源端:通过
VirtualDisplay捕获屏幕内容,输入到 MediaCodec 编码器 - 编码后的数据通过 Wi-Fi Direct 或 Miracast 传输
- 接收端:MediaCodec 解码后,渲染到 SurfaceView
我记得有一次调试无线镜像,延迟高达 300ms。查了半天,发现是编码器的 Bitrate 设置太低,导致编码器一直在丢帧。把码率从 2Mbps 调到 8Mbps 后,延迟降到了 80ms。嗯,细节决定成败。
7.5 多屏交互的避坑总结
做了这么多车载项目,我总结了几条血泪教训:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 屏幕闪烁 | 副屏内容间歇性黑屏 | 检查 HWC 的 vsync 信号是否稳定,必要时使用软件 vsync |
| 触控漂移 | 点击中控屏,仪表盘响应 | 在 InputDispatcher 中根据触控坐标判断目标屏幕 |
| 镜像性能差 | 主屏卡顿,副屏延迟 | 使用 Hardware Composer 2.0 的 DisplayContent 功能 |
| 跨屏拖拽失败 | 拖到边界后阴影消失 | 确保阴影 SurfaceControl 在迁移前已创建 |
多屏交互这块,没有银弹。每个车型的屏幕布局、分辨率、硬件能力都不一样。我建议你在项目初期就搭建好多屏测试环境,把各种边界情况都跑一遍。别等到路测才发现问题,那时候改起来就痛苦了。
好了,多屏显示的内容就聊到这里。记住一句话:多屏不是简单的复制粘贴,而是系统级的重构。从 SurfaceFlinger 到 WMS,从拖拽到镜像,每一层都要精心设计。
如果你在调试多屏问题时遇到 SurfaceFlinger 崩溃,先检查 /data/vendor/display/ 目录下的 dump 文件。那里记录了 HWC 的详细状态,能帮你快速定位问题。