ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android车载显示开发实战:多屏映射、SystemUI适配与性能调优

Android车载显示开发实战:多屏映射、SystemUI适配与性能调优

做Android车载显示这个方向之前,我在手机应用上写了好几年界面,自认为对Window、View、渲染这些东西都门儿清。真到了车机项目,才发现同一个Android,完全不是同一个物种。这是系列的第二篇,上一篇我们把Android Automotive OS的开发环境、模拟器和整体框架跑通了,这一篇专门讲显示:多屏怎么映射、SystemUI怎么适配、启动性能和流畅度怎么抠、实车调试怎么干。内容偏工程实践,适合正在做车载显示系统应用、或准备从手机应用转过来的工程师参考。文中提到的项目细节我都做了脱敏处理,但每个坑都是真实踩过的。

1. 车载显示与手机显示的分水岭:先弄明白车机为什么要搞一套独立显示体系

1.1 车机系统显示层次比手机多了半层"安全语义"

手机上的Android,所有窗口基本是平权的,Activity可以随便嵌套、悬浮、切换,系统把焦点给谁,谁就站在最前面。车载环境完全不是这样。车机上有一类内容是和驾驶安全强相关的:车速、挡位、故障告警、倒车影像。这些内容如果被一个普通App窗口遮住,或者被用户误触关闭,就是安全事故。

所以Android Automotive在显示架构里不是简单地把窗口叠来叠去,而是把显示区域和窗口容器做了很严格的层级划分。我最早看dumpsys window windows的时候,发现车机上窗口的DisplayId、type、region都有额外的策略限制,很多App的窗口根本不允许覆盖某些系统安全区域。这套东西在手机AOSP里虽然也有雏形,但真正启用是在车机版本里。

最典型的例子是倒车影像RVC。倒车时,整车控制器通过CAN告诉车机,车机系统会在极短时间内把中控显示切换到倒车画面。这个切换不是启动一个Activity,而是在对应Display上叠加一层专用Surface,通过HWC的硬件overlay直接送去屏幕,绕开普通App窗口调度。也就是说,屏幕上优先级最高的一层,不是你的App、不是Launcher,是这条独立的安全显示链路。理解了这一点,很多车机上的"怪异"现象就解释通了:为什么倒车时自定义View会瞬间不可见,为什么某些窗口切后台会黑一帧。

1.2 一块屏、两块屏、三块屏:各自扮演的角色完全不同

做车载显示,先得把屏幕角色捋清楚。传统座舱至少有三类屏幕,分成中控、仪表、HUD/副驾屏,它们在Android系统里的地位和实现方式天差地别。我列个表格方便你快速建立认知:

屏幕交互方式安全等级显示内容Android中的实现
中控屏触摸/旋钮中导航、音乐、空调、倒车、应用列表通常是primary display,走常规WMS窗口
仪表屏无触摸高车速、转速、挡位、告警、导航箭头通常是独立display,走Cluster专用渲染链路
HUD/副驾屏部分触摸低/中车速投影、娱乐视频、车辆状态可能是独立display或virtual display

这三块屏可能接在同一个SoC上,也可能由不同SoC驱动。我见过很多项目是座舱域控制器带着中控+仪表,Android系统里中控是主屏,仪表是一块逻辑Display,通过DRM的多个encoder输出。副驾屏和后排娱乐屏则更倾向于用virtual display,把应用内容投到特定物理接口上。

开发时最容易犯的错,是把手机那套"多窗口自由切换"直接搬到仪表屏上。仪表屏没有触摸,而且不允许用户随便关窗,所以系统会在仪表Display上锁定窗口,普通应用根本无法在仪表区域创建窗口。你在仪表上看到的任何内容,几乎都是由系统侧的ClusterService统一渲染的,不是某个普通App的View。

1.3 为什么车载显示必须"锁定"部分窗口

车机在行驶中,中控如果允许用户随便切到桌面、退出导航,这体验绝对是灾难。所以Android Automotive引入锁任务机制,系统可以指定某些应用常驻前台,setLockTaskPackages和startLockTask()就是干这个的。导航、仪表、通话这类关键显示必须常驻,不能因为用户误触Home键就跑掉。

这个设计对开发者的直接影响是:你的应用不能假设自己什么时候能拿到焦点,也不能假设窗口一直在。车机系统随时可能因为安全策略把某个应用窗口降到后台,或者把一个系统级覆盖层插到你的应用上面。很多从手机转过来的同事第一周都会骂:为什么我的Activity在车机上退到后台时连onStop都不好好走?因为系统根本没让窗口正常走完生命周期,直接以更粗暴的方式把Surface拿走了。

2. 多屏与异形屏的显示映射:SurfaceFlinger那层到底在忙什么

2.1 多个Display在车机里是怎么建立的

车机系统启动时,Kernel的DRM/KMS驱动会枚举每一个物理显示接口,一个connector对应一块屏。SurfaceFlinger读取这些信息后,给上层分配逻辑Display Id。开发者层面能感知到的,是DisplayManager里的Display对象,以及dumpsys SurfaceFlinger里那一串Display列表。

如果你要在车机上真正加一块物理屏,不是在自己App里createVirtualDisplay就行,而是要和BSP驱动同事一起,在设备树/内核cmdline里把第二个显示接口的video=参数、时序、分辨率全都配好,让SurfaceFlinger能枚举出第二块物理Display。现代座舱SoC大多支持额外接一个DSI或者DP接口,跑起来就是"中控+仪表"两块物理屏。

但实际做多屏业务时,更多场景是用virtual display。比如副驾屏想跑一个视频App,但不想让它和中控的导航抢焦点,就用VirtualDisplay把内容渲染到一个独立Surface,再由厂商中间层投到副屏物理接口上。这个方案对App透明,开发快,但有代价:virtual display的vsync和物理屏不一定对齐,画面容易撕裂,延迟也偏高。我踩过的坑是副驾屏播放视频时周期性地抽动,查到最后就是virtual display和物理屏刷新节奏不一致,后来让BSP把副驾屏也做成独立物理Display,问题才消停。

2.2 分辨率、dpi和异形屏适配:不是塞一个宽高就行

车机屏型千奇百怪,最常见的就够你喝一壶:长条仪表屏1920x720、中控竖屏1080x1920、还有圆形仪表甚至曲面屏。很多人按手机targetScreen那套来做,控件全部变形,字小得看不清。根因是密度值设置不对。

我举个例子。一块10.25英寸的1920x720长条屏,对角线像素约2052,除以10.25英寸,物理dpi大约200。如果你把系统密度强行设成400dpi,那所有dp尺寸的控件物理大小直接缩一半,本来该有两个手指宽的按钮,变成拇指尖大小,行车中根本没法点。正确做法是把密度贴近物理真实值,然后靠sw600dp、sw720dp这种限定符去区分横竖屏和长条形态,不要指望一套dpi跑天下。

异形屏还有两个隐藏问题:圆角和摄像头区域。车机中控现在很多带圆角、带挖孔或者带仪表遮罩区,内容如果不做安全区裁剪,边缘的按钮会被物理边框吃掉。系统一般会提供DisplayCutout信息,但车机厂商的定制ROM经常没把这些字段填对,导致cutout区域在车机上形同虚设。我的习惯是直接让UI设计师把核心交互控件收敛到屏幕中心安全区,不做刘海贴边。

2.3 仪表专用显示链路:Cluster和普通App毫无关系

这一点我每次带新人都会反复强调:仪表屏上看到的内容,和你的Android Activity渲染几乎没关系。Android Automotive里有一个CarInstrumentClusterManager,App可以通过它下发导航指令、箭头、里程信息,但真正把这些信息画到仪表屏幕上的是系统ClusterService,它运行在完全独立的渲染上下文里。

也就是说,你想在仪表上放一个自定义View来显示转速表?没门儿。系统安全策略不允许普通应用直接往仪表Display投递任意窗口。很多中控App开发抱怨"我的导航箭头为什么仪表上不显示",就是因为只在Activity里把那几个像素画出来了,根本没有通过跨进程接口把转向指令发给ClusterService。这是架构问题,不是画图问题。

开发车载显示,我建议一开始就把"仪表数据"和"仪表渲染"想成两个领域。App负责采集和上报高质量数据,Cluster侧负责按照车规流程把它画出来。只有系统应用、有签名权限的才能直接和ClusterService对话,普通第三方应用连接口都摸不到。

3. SystemUI与车载应用适配:状态栏、协调布局、进度条的车规改法

3.1 状态栏和导航栏:该收就收,该留就留

车机的SystemUI和手机长得有点像,但行为逻辑完全不同。手机上你做一个沉浸式看视频App,隐藏状态栏很合理;车机上如果你把状态栏一藏,时间、网络、空调温度这些重要信息也跟着没了,司机在行驶中想看一眼时间都找不到地方,这就不安全了。

我见过一个项目,导航App为了"更沉浸",把全屏沉浸写法直接抄过来,结果系统的一个安全悬浮按钮也被顶掉了,导致用户在高速上无法一键呼出360影像。后来我们定了规矩:信息娱乐类页面原则上不隐藏系统状态栏,如果确实要全屏,必须保留一个可再唤起状态栏的物理按键或边缘手势,并且等系统确认安全之后才允许隐藏。

所以车载开发者的心态要先转变:显示沉浸感,在车上优先级低于功能可见性。你做一个地图页面,导航箭头、车速、剩余里程这些元素,比背景好不好看重要得多。

3.2 CoordinatorLayout加Banner在车机上的"异地登录"

手机端用CoordinatorLayout配合Banner做折叠头图,再嵌套列表滚动,几乎是各种App首页的标配。移植到车机上,第一个大坑就是焦点模型。

车机很多车型不是全触摸,而是靠旋钮、方向盘按键控制焦点。焦点默认情况下会落在第一个可聚焦控件上。当你的Banner是一个可滚动的轮播容器时,旋钮旋转事件会被Banner吃掉,焦点就在Banner的多个子项之间来回跳,列表里的核心内容反而没法操作。我在项目里就遇到过:司机用旋钮想选中列表里的音乐,结果页面上横幅广告不断切换,焦点在里面"鬼打墙"。

还有一个问题:Banner轮播的自动切换动画,在行驶中会不断变化屏幕局部内容,吸引司机余光,增加驾驶分心风险。车规上对这种动态内容是有排斥的。我的做法是把Banner做成静态位图,车辆挂P挡时才开始轮播,D挡立刻停止切换。如果你实在要用协调布局的联动效果,一定要把联动滚动逻辑限定在触摸输入,旋钮按键事件直接跳过Banner区域。

3.3 进度条在车上不是"转圈就行"

手机App加载中放个转圈,用户顶多骂两句。车机上如果出现一个全屏无底进度条,等于直接告诉驾驶员"系统瘫痪了"。尤其导航加载、倒车切换、音频恢复这几个场景,每多一秒无反馈,都是安全隐患。

车机加载,核心原则是"不给没有确定性的等待"。能显示百分比就不要转圈,能显示缓存数据就不要清空界面。我一个很典型的改进案例:地图启动时,原本是白屏转圈3秒,改成启动瞬间先绘制一张上次离线缓存的地图底图,同时后台拉取最新数据并显示一条横向进度条"正在更新路况,剩余12项"。用户可感知的"无内容时间"从3秒降到接近0。

技术上,我现在更倾向用LinearProgressIndicator的determinate模式,配合数据加载任务实时更新进度,而不是用MaterialProgressBar无限转圈。进度条的位置也要避开仪表侧边缘和安全区域,避免余光一直被闪烁动画干扰。还有一点:不要在所有页面都放进度条。画面内已经有大量静态信息时,一个细小的环形指示就够,把注意力留给驾驶。

4. 启动性能与显示流畅度:从开机第一帧到列表滚动的每一个毫秒

4.1 冷启动显示链路:黑屏时间怎么压

车载中控冷启动,完整路径是Bootloader→kernel→init→zygote→system_server→SurfaceFlinger→Launcher。用户真正看到中控UI,得等Launcher起来并且完成第一帧绘制。目前座舱域项目里,从整车上电到中控桌面可操作,车企要求普遍在4秒到6秒,高端项目要求4秒以内。这个要求非常紧。

优化启动显示,顺序很重要。第一是提前点亮屏幕,Kernel阶段就让DisplayPanel工作,Android起来之后系统UI的第一帧不会卡在等待背光。第二是减少黑屏白屏时间,Panel自身的上电时序往往是黑屏时间最大的不可控变量,这需要BSP驱动配合反复调。第三是把Launcher放进persistent进程,并放进开机自启动白名单、提高进程优先级,否则等zygote一个个孵化普通App,桌面要等很久。第四是首帧渲染复杂度必须低:首帧只画静态底图和当前页骨架,其余Tab、Banner、列表全部延后加载。

我实测过一个项目,优化前冷启动到桌面6.8秒,优化后4.3秒。大头就出在把Launcher进程优先级提前和面板上电时序调整这两项上,第二帧之后的页面绘制反而没怎么动。

4.2 卡顿、掉帧定位:相信数据,不要相信肉眼

车载显示掉帧,排查工具和手机一模一样:adb shell dumpsys gfxinfo <package>看App侧帧统计,adb shell dumpsys SurfaceFlinger --latency <layer>看合成侧丢帧。但在车机上,很多卡顿根因不在App代码,而在显示合成链路。

我处理过一个问题:中控列表滑动掉帧,60Hz屏幕本该16.6ms一帧,实测帧间隔跑到33ms。CPU和主线程都没压力,最后查出来是GPU带宽满载——那块车机屏是4K@60 HDR,页面里多个surface带半透明效果,还有一个全屏实时模糊背景,HWC无法拆成多个overlay layer,只能全部丢给GPU合成。一帧要合成这么多特效,带宽直接被打满。解决办法是去掉全屏模糊,把能设成opaque的Surface尽量设成opaque,让HWC走硬件overlay,帧间隔立刻回到16.6ms。这件事让我学会了一句话:显示优化,不止是动画代码优化,还是合成策略优化。

另外要说内存。车机进程一旦被LMKD杀掉,用户感知就是"当前界面闪黑、回到桌面、重新启动App"。把导航这种关键App做成不可杀,需要在lmkd配置里对关键进程设置protection,同时从源头压内存占用。像图片多、缓存多的显示类场景,用Bitmap复用和三缓冲策略能省出不少内存,这也会直接影响显示稳定性。

4.3 多屏vsync与刷新率:撕裂往往不是App的锅

多屏车机还有一个手机场景很难遇到的坑:主屏和副屏刷新率不一致时的画面同步问题。SurfaceFlinger默认以主屏vsync为基准,副屏如果刷新率不同,App生成的帧可能来不及在副屏垂直消隐期完成交换,结果就是撕裂和周期性抽动。

现在的Android版本支持per-display vsync,但很多车机项目并没有在BSP层把副屏的vsync源和刷新率配好。我参与的项目里,副驾屏是60Hz,中控是60Hz还好,等到仪表屏改成120Hz后,中控App的动画偶尔出现半屏撕裂,查到最后是SurfaceFlinger把仪表的高频vsync当成了全局节奏,中控侧的渲染缓存跟不上。

这个问题的处理基本在BSP和SurfaceFlinger配置层面。作为应用层开发,能做的就是确认你的Surface是否和物理Display绑定,避免在virtual display上执行对时序敏感的动画,因为virtual display没有可靠的物理vsync保障。

5. 权限与外设协同显示:蓝牙、USB串口、文件访问这些隐藏雷区

5.1 权限清单别照搬手机,运行时权限弹窗在车机上很危险

车载应用开发,有一个非常常见但又很隐蔽的坑:直接把手机项目的AndroidManifest.xml权限列表搬过来。结果应用在车机上跑起来,正当用户操作时,系统突然弹出一个"允许获取位置信息吗?"的对话框。司机在开车,怎么点?

所以在车机项目里,运行时权限弹窗基本是禁区。所有需要的危险权限,要么在出厂阶段做预授权,要么通过系统级签名应用做白名单配置。如果你的应用走三方市场渠道,必须在Manifest里声明权限的同时,和车厂确认权限授予策略。

还有一个Android 11之后的大坑:/sdcard/Android/data目录访问限制。很多车机预装App用FileProvider提供文件,路径类似content://...fileprovider/external_path/android/data/...,但Android 11开始,普通App无法用File类直接访问另一个App的Android/data目录。车机上因为涉及系统级签名应用和SAF框架的定制,行为更是五花八门。我遇到过文件管理器打开图片预览直接报"No such file"的问题,最后查出来就是厂商定制SAF没按新版本的存储限制适配。开发车载显示文件相关功能前,一定先确认目标Android版本和厂商的文件访问策略。

5.2 蓝牙状态和显示联动:不要直接监听系统蓝牙

车机屏幕上"蓝牙已连接某某手机"的状态展示,是一个典型的显示联动功能。但如果你直接监听系统的BluetoothAdapter.ACTION_ACL_CONNECTED,大概率会翻车。因为车载蓝牙栈往往不是Android侧的Bluetooth协议栈,而是独立的蓝牙MCU/蓝牙SoC在管,Android只通过抽象接口拿到部分状态。

正确做法是通过CarBluetoothManager这类车辆抽象层去获取蓝牙连接状态,同时监听整车蓝牙模块上报的状态变化。Android侧状态和整车蓝牙模块状态不一致时,表现就是界面显示"已连接",但实际音频通路没建立;或者断开车载蓝牙后,界面还停留在"连接中"。

我写这段是想提醒:车载显示的内容,本质上是整车多个模块状态的投影。你的界面状态机要以整车状态为准,而不是以某一个App内部的落点状态为准。

5.3 USB串口调试:显示出问题时屏幕帮不了你

做车载显示,串口调试是绕不开的求生技能。很多系统级的显示问题,比如SurfaceFlinger挂掉、屏完全黑掉、Launcher起不来,屏幕是不会给你任何线索的。这时候一条UART串口线就是唯一的救命通道。

车机上通常会引出调试串口,波特率常见115200或1500000。通过串口可以看Kernel、init、SurfaceFlinger的启动日志,定位是panel驱动没起来,还是SurfaceFlinger初始化失败。我在项目里用串口抓过一次开不了屏的问题,一开机日志里全是failed to find panel,直接定位到设备树panel节点配置错误,省了一下午的瞎猜。

USB口方面,常见的是U盘文件显示和调试连接。用SAF框架打开U盘文件时要小心车机厂商只做了部分MIME过滤,图片格式明明支持却在文件选择器里显示灰的。真要做深度文件预览,建议走MediaStore API,而不是老旧的Environment.getExternalStorageDirectory()。分屏、多用户、多Display环境下,老API拿到的路径经常不准,显示出来的往往不是你插入的那块U盘。

6. 从Android Studio到实车:调试链路与几个让我印象深刻的实战问题

6.1 AAOS模拟器能信几分,不能信几分

Android Studio里创建Automotive Emulator,日常开发逻辑、UI布局、基本交互验证都能干。AAOS模拟器也支持多Display模拟,但和实车差距还是很大:模拟器没有真正的HWC多屏overlay,仪表Cluster链路根本模拟不出来,触摸坐标映射和panel时序也和真机无关。

我的工作流是:代码逻辑和UI在模拟器里过,涉及多屏、启动性能、安全链路、触摸偏差的,一定实车验收。曾经有个动画在模拟器上丝滑流畅,实车上因为缺少GPU带宽直接掉到20帧,这种事只有实车才能暴露。

模拟器还有一个常见问题:wm size和wm density改了之后,很多布局在模拟器上看正常,但实车因为物理尺寸不同,控件显得过大或过小。所以模拟器适合定逻辑,不适合定视觉尺寸。

6.2 实车调试的日常命令:先看窗口,再看图层,最后看数据

实车调试显示问题,我一般按这套顺序操作:

adb devices adb shell dumpsys window windows adb shell dumpsys SurfaceFlinger --list adb shell dumpsys SurfaceFlinger --latency <layer> adb shell wm size adb shell wm density adb shell dumpsys activity top

黑屏时,第一件事看SurfaceFlinger里有没有那个Surface。Surface不存在,查App进程为什么没起来;Surface存在但黑,查App渲染侧有没有真正提交帧;Surface正常但画面被盖住,查窗口层级和系统安全窗口。窗口错位就看dumpsys window里bounds和DisplayId对不对。触摸偏差就用getevent对比触摸上报坐标和屏幕显示坐标。

实车另一个常见情况:倒车或摄像头抢占画面后,你的App窗口被系统置为不可见,回来时闪黑一帧。原因是抢回窗口时Surface被重建了。处理办法是监听onWindowFocusChanged,在焦点恢复前不要销毁绘制Surface,保留最后一帧缓存用来过渡。这个体验问题在实车调试里特别典型,模拟器完全复现不了。

6.3 显示异常根因排查清单

这些是我在项目里沉淀下来的排查表,遇到问题可以先对照:

现象常见根因排查方向
中控黑屏但声音正常SurfaceFlinger或App未绘制查gfxinfo、surface状态、App是否存活
仪表显示错乱、残留Cluster渲染链路问题查ClusterService日志和GPU合成
触摸偏移或反向显示屏与触摸屏坐标映射错误getevent对比坐标,调HAL校准
花屏、条带HWC overlay/DRM mode配置问题检查带宽、format,尝试禁用overlay
副屏撕裂、延迟vsync不同步或virtual display改为物理Display或启用per-display vsync
应用被杀瞬间闪黑LMKD内存压力调整omm adj,保护关键进程
返回界面闪一帧黑安全窗口抢Surface保留缓存帧过渡,避免Surface重建

最后再做一个小补充:车载显示和手机显示最本质的差别,是它永远服务于"正在开车的人"。你做的每一个动画、每一次隐藏状态栏、每一个进度条样式,都要先在脑子里过一遍:这个画面在驾驶员余光里会是什么效果。我这些年最大的体会是,显示工程师在车机项目里,不只是写代码,还要和整车交互、安全策略、硬件BSP甚至结构设计反复拉齐。如果你真要入门车载显示,我建议先找一个实车启动黑屏的问题盯几天,那比看一百篇框架文章都管用。

返回列表