7、显示与多屏交互:多屏显示架构、SurfaceFlinger 适配、跨屏拖拽与镜像

各位好,今天我们来聊聊车载多屏显示这块硬骨头。说实话,Android Automotive 里最让我头疼的,就是多屏交互。手机上一个屏,搞定了。车机呢?仪表盘、中控、副驾屏、后排娱乐屏……少则两块,多则五六块。每块屏的分辨率、刷新率、触控逻辑还不一样。

我刚开始做车载项目时,以为就是把手机那套显示框架搬过来。结果呢?SurfaceFlinger 直接罢工。嗯,这里面的坑,我一个个踩过。今天我把经验都倒出来。

7.1 多屏显示架构:从单屏到多屏的进化

Android 原生的显示架构,说白了就是「一个 SurfaceFlinger 管一个屏幕」。但车机需要的是「一个 SurfaceFlinger 管多个屏幕」。怎么破?

Google 在 Android 10 之后引入了 多屏显示架构(Multi-Display Support)。核心思路是:每个物理显示器对应一个 DisplayDevice 对象,SurfaceFlinger 内部维护一个显示设备列表。

核心概念:

  • DisplayDevice:代表一个物理屏幕,包含分辨率、刷新率、旋转角度等属性
  • DisplayToken:SurfaceFlinger 内部标识一个显示设备的令牌
  • LayerStack:每个显示设备有自己的 Layer 堆叠顺序

我画了一张架构图,你看一眼就明白了:

Android Automotive 多屏显示架构 应用层(App Layer) 中控应用 | 仪表应用 | 副驾应用 | 后排娱乐应用 WindowManagerService(WMS) 窗口策略管理 | 显示区域分配 | 焦点管理 SurfaceFlinger Layer 合成 | HWC 交互 | 显示设备管理 DisplayDevice[0](中控) | DisplayDevice[1](仪表) | DisplayDevice[2](副驾) Hardware Composer(HWC) 硬件合成 | 显示时序控制 | 多显示输出 物理显示器(LCD / OLED / 投影)

你看,从应用层到物理显示,中间经过了 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 层拦截拖拽事件,判断拖拽目标是否跨屏

我实现过一个方案,大致流程如下:

  1. 用户在屏幕 A 上开始拖拽,WMS 记录拖拽源窗口和起始坐标
  2. 拖拽过程中,WMS 持续监控手指位置
  3. 当手指移动到屏幕 B 的边界时,WMS 触发跨屏事件
  4. 屏幕 B 的 WindowManager 接管拖拽,显示拖拽阴影
  5. 手指抬起时,屏幕 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 编码后通过网络传输。

流程是这样的:

  1. 源端:通过 VirtualDisplay 捕获屏幕内容,输入到 MediaCodec 编码器
  2. 编码后的数据通过 Wi-Fi Direct 或 Miracast 传输
  3. 接收端:MediaCodec 解码后,渲染到 SurfaceView

我记得有一次调试无线镜像,延迟高达 300ms。查了半天,发现是编码器的 Bitrate 设置太低,导致编码器一直在丢帧。把码率从 2Mbps 调到 8Mbps 后,延迟降到了 80ms。嗯,细节决定成败。

7.5 多屏交互的避坑总结

做了这么多车载项目,我总结了几条血泪教训:

问题 现象 解决方案
屏幕闪烁 副屏内容间歇性黑屏 检查 HWC 的 vsync 信号是否稳定,必要时使用软件 vsync
触控漂移 点击中控屏,仪表盘响应 在 InputDispatcher 中根据触控坐标判断目标屏幕
镜像性能差 主屏卡顿,副屏延迟 使用 Hardware Composer 2.0DisplayContent 功能
跨屏拖拽失败 拖到边界后阴影消失 确保阴影 SurfaceControl 在迁移前已创建

多屏交互这块,没有银弹。每个车型的屏幕布局、分辨率、硬件能力都不一样。我建议你在项目初期就搭建好多屏测试环境,把各种边界情况都跑一遍。别等到路测才发现问题,那时候改起来就痛苦了。

好了,多屏显示的内容就聊到这里。记住一句话:多屏不是简单的复制粘贴,而是系统级的重构。从 SurfaceFlinger 到 WMS,从拖拽到镜像,每一层都要精心设计。

如果你在调试多屏问题时遇到 SurfaceFlinger 崩溃,先检查 /data/vendor/display/ 目录下的 dump 文件。那里记录了 HWC 的详细状态,能帮你快速定位问题。

公众号:蓝海资料掘金营,微信deep3321