20. 仪表盘与HMI系统:仪表盘显示架构、Cluster HAL实现、OpenGL仪表盘渲染、HMI性能优化

各位同学,今天我们来聊聊仪表盘。嗯,仪表盘,说白了就是驾驶员面前那块屏幕。在Android Automotive系统里,它可不是一个普通的App。它得实时、稳定、安全。我最早接触这个模块时,也觉得不就是画个指针转一转吗?后来发现,水很深。

仪表盘系统,我们通常叫它Cluster。它和中央娱乐屏最大的区别是什么?是实时性。你想想看,车速、转速、转向灯,这些信息延迟100毫秒,驾驶员就会觉得不对劲。所以,它的架构设计,从一开始就要围绕“低延迟”和“高可靠性”来展开。

核心要点: 仪表盘系统是安全关键系统,其显示架构必须独立于Android应用框架的UI线程,通常采用独立的SurfaceFlinger或直接渲染通道。

20.1 仪表盘显示架构

我个人习惯把仪表盘架构分成三层:数据层、服务层、渲染层。

  • 数据层: 负责从CAN总线、传感器、车辆信号中获取原始数据。比如车速脉冲、发动机转速、电池SOC等。
  • 服务层: 也就是Cluster HAL。它把原始数据转换成上层能理解的逻辑值。比如把脉冲频率换算成km/h。
  • 渲染层: 负责把逻辑值画到屏幕上。这里通常用OpenGL ES或者Vulkan,直接操作GPU。

为什么不用普通的Android View系统?因为View系统有VSync机制,有BufferQueue,延迟不可控。我曾经在一个项目里试过用Canvas画指针,结果发现从数据到显示,延迟超过了200毫秒。后来全部改成了OpenGL直接渲染,延迟降到了30毫秒以内。

下面这张图,是我总结的典型仪表盘显示架构。你可以看到,数据流是单向的,而且渲染层直接与HWC(Hardware Composer)交互,绕开了App框架的UI线程。

数据层 CAN总线 / 传感器 服务层 Cluster HAL 渲染层 OpenGL ES / Vulkan HWC (Hardware Composer) 物理显示屏 原始信号 逻辑值 帧缓冲区 图:仪表盘显示架构数据流 渲染层直接与HWC交互,绕过App UI线程
避坑指南: 我曾经在一个项目中,把Cluster HAL的数据处理放在了渲染线程里。结果导致渲染帧率不稳定,指针抖动。后来我把数据采集和逻辑处理单独放在一个高优先级线程里,渲染线程只负责画,问题就解决了。记住:渲染线程不要做任何阻塞操作。

20.2 Cluster HAL实现

Cluster HAL,全称是Cluster Hardware Abstraction Layer。它是连接车辆信号和上层应用的桥梁。在Android Automotive中,它通常以HIDL或AIDL服务的形式存在。

它的核心接口,我总结了一下,大概有这几个:

接口方法 功能描述 输入参数 输出参数
getVehicleSpeed() 获取当前车速 float (km/h)
getEngineSpeed() 获取发动机转速 float (rpm)
getTurnSignal() 获取转向灯状态 int (左/右/关闭)
getBatteryLevel() 获取电池电量 int (百分比)
setBrightness() 设置仪表盘亮度 int (0-255) boolean

实现Cluster HAL时,有几点要注意:

  • 数据更新频率: 车速和转速建议每10-20毫秒更新一次。转向灯可以慢一些,50毫秒就够了。
  • 数据校验: 从CAN总线来的数据,一定要做合理性校验。比如车速不可能超过300km/h,转速不可能超过10000rpm。我曾经遇到过CAN总线受到干扰,突然跳出一个负数的车速,差点把指针打飞了。
  • 线程安全: HAL服务可能被多个客户端同时访问,内部数据结构要做好同步。

下面是一个简化的Cluster HAL实现片段,用AIDL定义接口:

// IClusterHal.aidl
package com.example.automotive.cluster;

interface IClusterHal {
    float getVehicleSpeed();
    float getEngineSpeed();
    int getTurnSignal();
    int getBatteryLevel();
    boolean setBrightness(int brightness);
}

实现类里,我会用一个后台线程轮询CAN数据,然后缓存到本地变量。上层调用时,直接返回缓存值,避免阻塞。

警告: 不要在Cluster HAL的接口方法里直接做I/O操作,比如读写CAN总线。这会导致调用方卡住。正确的做法是:后台线程异步采集,接口方法只返回最新缓存。

20.3 OpenGL仪表盘渲染

好了,数据有了,接下来就是怎么画。我个人推荐用OpenGL ES 2.0或3.0。为什么?因为兼容性好,几乎所有车载GPU都支持。

仪表盘渲染的核心,其实就是画几个东西:

  • 背景: 通常是圆形或半圆形的刻度盘。
  • 刻度线和数字: 表示速度或转速的范围。
  • 指针: 根据当前值旋转角度。
  • 指示灯: 比如转向灯、远光灯、故障灯。

指针的旋转,说白了就是矩阵变换。我习惯用四元数或欧拉角来计算旋转矩阵。嗯,这里要注意,指针的旋转中心要设置在指针的根部,而不是中心点。

下面是一个简化的指针渲染代码片段:

// 指针顶点数据(以根部为原点)
float[] pointerVertices = {
    0.0f, 0.0f, 0.0f,  // 根部
    -0.02f, 0.8f, 0.0f, // 左侧
    0.02f, 0.8f, 0.0f   // 右侧
};

// 旋转矩阵计算
float angle = (currentSpeed / maxSpeed) * 270.0f; // 假设0-270度范围
Matrix.setRotateM(modelMatrix, 0, angle, 0, 0, 1);

// 在顶点着色器中应用
gl_Position = uMVPMatrix * modelMatrix * vec4(aPosition, 1.0);

这里有个小技巧:指针的旋转角度,不要直接用原始数据。因为原始数据可能有抖动。我通常会加一个低通滤波器,或者做一次线性插值。这样指针转动起来会很平滑,不会一卡一卡的。

个人经验: 我曾经用滑动平均滤波器来处理车速数据。窗口大小设为5,效果就很好了。如果窗口太大,指针会显得迟钝;太小,又会有抖动。这个需要根据实际数据调整。

20.4 HMI性能优化

仪表盘的HMI性能,直接关系到用户体验。我见过一些项目,仪表盘掉帧、卡顿,甚至黑屏。嗯,这里我总结几个优化方向:

  • 减少绘制调用: 每帧的绘制调用次数,尽量控制在50次以内。合并顶点数据,使用索引绘制。
  • 纹理压缩: 仪表盘的背景图、刻度盘图,使用ETC2或ASTC压缩格式。减少纹理上传带宽。
  • 避免CPU-GPU同步: 不要每帧都glReadPixels。这会强制CPU等待GPU完成渲染,造成流水线停顿。
  • 使用帧缓冲对象(FBO): 对于静态背景,可以预先渲染到FBO中,每帧只需要绘制动态元素。
  • 降低分辨率: 如果仪表盘是1920x720,但实际显示区域只有一部分,可以降低渲染分辨率,然后由HWC做缩放。

还有一个容易被忽略的点:内存分配。不要在渲染循环里new对象。我见过一个项目,每帧都new一个Matrix对象,结果GC频繁触发,导致掉帧。正确的做法是:在初始化时分配好所有对象,渲染时只复用。

性能目标: 仪表盘渲染帧率应稳定在60fps,最低不低于30fps。从数据采集到显示,端到端延迟应小于50毫秒。

最后,我想说一句:仪表盘系统,稳定压倒一切。功能可以少一点,但绝对不能出问题。因为驾驶员的眼睛,一直在看着它。


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