Android车载显示实战:多屏分工、焦点控制与性能优化 这次接着聊 Android 车载显示。上一篇我们把车载显示的整体框架、Display 体系和窗口层级过了一遍算是把底座打好了。这一篇想往下沉一层聊点我在实际调车机和帮别人做车载项目时真正绕不开的东西车机上那么多块屏到底怎么分工、为什么车载界面里的九宫格菜单比手机上的更讲究焦点、内存和启动时间是怎么反过来卡住显示性能的、串口和蓝牙数据又是怎么一步步流到屏幕上的。如果你正打算从手机 Android 转到车载方向或者已经拿着 CarService、SurfaceFlinger 这些名词跟多屏业务搏斗了一阵子这篇内容应该对你有用。1. 多屏分工逻辑为什么车机要把一块内容拆成好几块屏幕先说一个我最早踩过的坑。从手机转过来的人下意识会觉得Android 系统只有一个屏幕所以写界面就是 setContentView弹窗就是 show。但车机上完全不是这么回事。拿一台量产车来看中控、仪表、副驾娱乐、HUD、后排屏多的能到六七块物理屏幕而系统里对应的逻辑显示器只有 displayId 不同。你要是用默认的 displayId 创建 Dialog弹窗跑哪块屏上完全是随缘的运气差一点直接弹到仪表盘上被安全策略拦下来日志里报一大串 SecurityException。1.1 逻辑显示器与物理屏幕的映射关系Android 里的显示单元叫 Display每一块逻辑显示器都有一个全局唯一的 displayId。DisplayManagerService 负责注册和管理这些逻辑显示器SurfaceFlinger 负责把它们的图层统一合成后输出到对应的物理接口。车载场景下这块逻辑显示器背后可能是一根 LVDS 线可能是 HDMI也可能只是 HUD 的一个半透明投射区域但对应用层来说它们无差别地体现为 displayId。车机上获取显示列表最直接的方式是看 DisplayManagerval displayManager context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager val displays displayManager.displays for (display in displays) { Log.d(TAG, displayId${display.displayId}, name${display.name}) }如果项目里接了 CarService更规范的是用 CarDisplayManager它把底层显示器做了分类比如仪表盘、中控、副驾、HUD 这些类型都有对应常量。拿到 carDisplays 列表之后里面的每个 CarDisplay 都带 displayId、分辨率、是否支持触摸等信息。val car Car.createCar(context) car.setCarServiceConnectionListener(...) // 等连接完成 val carDisplayManager car.getCarManager(Car.DISPLAY_SERVICE) as CarDisplayManager carDisplayManager.carDisplays.forEach { display - Log.d(TAG, type${display.type}, displayId${display.displayId}) }1.2 三种显示策略镜像、扩展与独立车机上多屏的显示策略业界基本就三种镜像Mirror、扩展Extended、独立Independent。听起来像桌面电脑的扩展屏但车机有自己的讲究。策略含义车载典型场景注意事项镜像把主屏内容原样复制到另一块屏副驾屏显示与中控相同内容、后排娱乐同步不需要关心显示差异但要注意隐私内容同步暴露扩展多块屏拼接成一个大的逻辑屏幕少数做跨屏地图的场景比如中控加副驾拼接导航对窗口管理要求高触摸坐标系要换算独立每块屏各自跑各自的任务栈仪表只显示车速转速、中控跑导航、副驾跑视频车载系统主流方案也是踩坑最多的方案量产车里的主流做法是独立显示。仪表和中控各自维护独立的 Activity 栈和窗口层级SystemUI 在某些屏幕上是共享的在某些屏幕上又是定制的。最典型的是仪表屏它通常只跑一个固定的仪表应用而且不允许乱七八糟的窗口弹上来。中控屏则是一个相对开放的显示域可以启动应用、弹窗、下拉通知。独立显示带来的一个直接问题是你必须在代码里明确指定内容往哪块屏上放。Activity 启动时用 options.setLaunchDisplayIdWindow 添加时用 WindowManager.LayoutParams 里的 displayId。不指定的话系统默认往主屏 displayId 为 DEFAULT_DISPLAY 的屏上放而主屏到底是哪一块不同车厂定义还不一样有的把中控定义为主屏有的把仪表定义为主屏。我第一次接触的项目就把仪表定义成了主屏结果通知弹窗全跑到仪表上去了现场调试的同事一度以为触屏坏了。1.3 安全区域与非安全区域为什么仪表屏不能随便弹窗车载显示不是能显示就行还得分安全区域和非安全区域。仪表屏、HUD 属于安全区域显示车速、转速、警告灯这些内容一旦被别的应用抢占就可能诱发事故。Android 在车机上的做法是引入 CarUxRestrictionsManager把驾驶状态划分为停车、低速、高速等不同等级再根据等级限制哪些应用能在哪块屏上显示。val uxRestrictionsManager car.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as CarUxRestrictionsManager uxRestrictionsManager.registerListener { restrictions - Log.d(TAG, drivingState${restrictions.activeRestrictions}) }这块的逻辑说起来很简单处于行驶状态时仪表屏只允许系统级应用显示副驾屏如果支持防窥技术可以播放视频中控屏上某些视频应用会被限制。很多从手机移植过来的应用完全没考虑这一层一上车就被限制策略拦截界面上白屏日志里只有一行 CarUxRestrictions denied。所以我一直跟团队强调做车载显示第一件事不是写界面而是先搞清楚你的内容要上哪块屏这块屏允不允许你上。2. 焦点栅格与宫格菜单车载界面比手机界面多出来的那层规则车载显示的第二个大坑是交互方式。手机靠触摸车机除了部分触摸屏外很大一部分场景还是靠物理按键、旋钮和方向盘按键来操作的。这些物理输入源落到 Android 系统里抽象出来的核心概念就是焦点。2.1 为什么车机界面必须从触摸优先转向焦点优先手机上的界面你按哪个元素响应哪个元素天然是点对点的。车机上没有鼠标也没有手指只有一组方向键和确认键系统必须知道当前焦点落在哪个控件上按下确认键时才执行对应操作。这就是 Android 的 Focus 机制。做车载界面时所有可交互控件都必须保证能通过方向键移动焦点。默认情况下Android 会根据 bounds 自动计算相邻焦点但车机界面上经常出现控件不是一个方向对齐的情况比如宫格菜单中间嵌了一个特殊卡片自动计算就会跳错。这时候就需要显式指定焦点方向Button android:idid/menu_item_1 android:focusabletrue android:nextFocusDownid/menu_item_4 android:nextFocusRightid/menu_item_2 /九宫格菜单是车机上最常见的界面形态三行三列的入口卡片。用 RecyclerView 加 GridLayoutManager 是最常规的实现但要注意几个关键点每一项都要 focusable长按事件要谨慎使用因为车上没有长按这种高频操作滑动反馈要明确焦点移动时最好有选中态高亮否则驾驶员根本不知道焦点在哪。2.2 协调布局在车载主界面里的正确使用方式热搜词里经常出现android 协调布局banner车机主界面确实大量使用这个组合因为几乎所有车机首页的形态都是顶部一块大 Banner 加下方一排功能宫格。CoordinatorLayout、AppBarLayout、CollapsingToolbarLayout 加一个 RecyclerView在手机上是很成熟的写法但直接搬到车机上会出问题。主要问题出在嵌套滚动。手机上手指滑动流畅因为焦点不在控件上车机上按键操作让焦点在控件间跳转嵌套滚动的布局很容易出现焦点跳进去就滚不回来的失控感。CoordinatorLayout 里 AppBarLayout 的滚动联动遇到按键事件的处理路径和触摸事件完全不同实测里经常出现焦点移动到顶部后CollapsingToolbarLayout 收起的一半卡住不动。我的经验是车机主页尽量简化布局层级。比较稳的结构是这样的最外层用 FrameLayout不套大滚动容器。顶部 Banner 独立成块只做轮播展示不接收焦点。下方宫格用 RecyclerViewGridLayoutManager 管理焦点在宫格内自由移动。底部 Tab 栏单独处理用方向键的 nextFocusUp 指回宫格区域。这样布局简单焦点路径清晰出问题的概率低很多。很多车机项目后期卡的掉帧和焦点丢失问题排查到最后都是因为套了太复杂的 CoordinatorLayout 嵌套结构。2.3 进度条在车机场景里的特殊语义热搜词里有android 进度条这词在车载显示场景下也有不少说道。车机上的进度条通常承担三类语义开机引导进度、OTA 升级进度、媒体加载进度。和手机不同车机上进度条的出现往往意味着用户正在等待而等待的过程里驾驶员不能长时间分心看屏幕。所以在车机上做进度条除了 ProgressBar 的基础属性外更要考虑无操作反馈。比如 OTA 下载进度到了 80% 后长时间不动界面上必须有一个明确的仍在工作的提示否则驾驶员会以为系统卡死。我见过有项目直接用系统默认的圆形 ProgressBar 放在地图界面上下载线程卡住后进度不更新用户投诉车机死机其实只是进度条停了而已。实现上建议至少用一个自定义 View 包装进度逻辑把进度更新和 UI 刷新解耦同时把长时间无更新的判定做进去到达阈值就显示提示文案。这么做看着多写了几行代码实车测试可以少流不少汗。3. 显示链路的两个硬指标内存水位与启动顺序车载显示的性能优化不能只看帧率还必须盯着内存和启动时间。这两个指标在手机上也能讲一讲但车载场景要严格得多因为车机硬件经常落后手机两个世代可运行内存长期徘徊在 2GB 到 4GB 之间而且冷启动时间是用户进车后立刻感知的体验。3.1 车机内存为什么比手机紧张挤出来的还是显示部分车机内存紧张很大一部分不是被普通应用吃掉的而是被多屏显示吃掉的。每块显示屏至少需要一层 SurfaceBuffer 才能在屏幕上呈现内容中控上叠加三层窗口更是常态。拿一块 1080p 的屏幕来算一层 Buffer 按 RGBA8888 算就是 1920 乘 1080 乘 4 字节大约 7.9MB。三层叠加就是接近 24MB再加上双缓冲三缓冲机制实际显存占用还要翻倍。如果车机上挂了四五块屏光显存这块的压力就超过了大部分手机应用。所以做车载显示从一开始就要控制窗口数量和 Surface 层级。我给自己定的准则是中控屏上可见的 Surface 层数控制在三层以内多余的半透明浮层尽量合并成一张图。仪表屏作为安全区域Surface 层数永远是最低的这是底线。内存回收方面车机上 LMKD 的淘汰策略比手机上激进得多。后台应用稍微多吃一点内存就可能被直接杀掉等用户切回来的时候重新冷启动。车载项目里常驻应用要做好内存控制尤其是桌面 Launcher绝对不能成为被 LMKD 盯上的对象因为桌面被杀了整个车机看起来就是死机。3.2 启动显示链路从点火到主界面出现的那几秒车机启动时间直接决定了用户的第一印象。从点火到中控主界面完全可用整个链路大致是这样的Bootloader → Kernel → Init → Zygote → SystemServer → SurfaceFlinger → SystemUI → CarService → Launcher这条链路上每一步都可能成为瓶颈。SurfaceFlinger 启动得晚后面的显示窗口就得等着SystemUI 初始化太慢状态栏车控栏这些基础元素就迟迟不出来CarService 没就绪很多车载应用拿不到车辆状态就只能等着。优化启动性能通常从两个方向入手。一是精简启动阶段的工作能延后到用户操作触发的都延后不要一窝蜂在开机时初始化几十个模块。二是并行化SystemUI 和 Launcher 的启动路径如果存在强依赖就改成服务发现机制谁就绪谁先上屏。调试启动链路耗时我常用的命令是adb shell dumpsys activity activities | grep -E mResumedActivity|topResumedActivity adb shell dumpsys SurfaceFlinger --latency前者看系统界面到底在哪一步后者看帧的提交时间间隔判断掉帧发生在哪个阶段。如果启动阶段出现明显卡顿再用 Perfetto 抓一次 trace按时间轴看每个进程的 CPU 占用和 Binder 等待基本就能定位到锅是谁的。3.3 掉帧问题多屏合成时的性能隐患多屏显示还有一个隐形杀手就是 SurfaceFlinger 在做多屏合成时GPU 的合成压力比单屏大得多。每一帧要在不同 displayId 之间做 Blit、缩放、旋转车规级 SoC 的 GPU 本来就不强一旦某个屏幕上的窗口频繁变化其他屏幕的帧率也会跟着被拖累。测掉帧不能只盯着中控屏仪表屏帧率更敏感。仪表显示车速表60 帧是基本要求低于这个数指针就会肉眼可见地跳动。我用 dumpsys SurfaceFlinger 的统计信息来判断掉帧位置重点看两个指标图层提交时延和合成帧间隔。实测里经常发现副驾屏播放视频时仪表屏偶发丢帧原因就是两块屏在同一个合成通道上抢 GPU 资源。解决思路是尽量降低副驾屏的帧率负载视频层用独立的硬件解码通道不让它把 SoC 的 GPU 资源占满。4. 串口、蓝牙与显示联动数据上屏的完整链路很多人以为车载显示只是 UI 那一层的事其实显示背后要处理的数据源很杂。倒车雷达距离、OBD 车辆状态、行车记录仪画面、蓝牙电话状态这些数据最终都要在屏幕上呈现出来。这一节把显示和外设数据之间的链路讲清楚。4.1 USB 串口数据怎么送进显示界面车机和外部设备通信最常用的是 USB 串口。倒车雷达模块、OBD 诊断设备、部分行车记录仪走的基本都是串口协议。Android 系统里访问 USB 串口设备先要拿到 UsbDevice然后通过 UsbManager 请求权限。val usbManager getSystemService(Context.USB_SERVICE) as UsbManager val deviceList usbManager.deviceList deviceList.values.forEach { device - if (device.vendorId TARGET_VENDOR_ID) { usbManager.requestPermission(device, pendingIntent) } }拿到权限后通常用 usb-serial-for-android 这类库打开串口设定波特率、数据位、停止位然后开一个独立线程循环读取数据。读到的是字节流要做协议解析把车速、距离这些字段提取出来再通过 Handler 或者 LiveData 发到 UI 层刷新显示。串口数据上屏有一个很关键的原则解析和 UI 刷新必须分离。串口数据可能以几十毫秒的间隔持续灌进来如果你直接在读取线程里更新 TextView界面会被频繁刷爆卡死。正确做法是读取线程只做数据解析和缓存UI 层用 Choreographer 或者固定频率比如 10Hz从缓存里采样刷新。做车载显示时这条规则我一直盯着特别是倒车影像的覆盖层稍有卡顿就会影响到驾驶安全。4.2 蓝牙状态与显示界面的联动蓝牙和显示的联动主要在两个场景蓝牙电话和蓝牙音乐。电话来电时需要在中控屏弹出来电卡片音乐播放时要显示歌曲信息和播放状态。这些都是典型的外设事件驱动 UI 变化。监听蓝牙连接状态和音频流状态常用的方式是注册 BroadcastReceiver 监听 BluetoothDevice.ACTION_ACL_CONNECTED、ACTION_ACL_DISCONNECTED以及 A2DP 连接状态变化。来电场景则要监听 TelecomManager 的呼入状态。拿到事件之后UI 层更新状态栏图标、弹出悬浮卡片或者刷新正在播放界面。这块有个容易被忽略的细节车机上很多蓝牙设备绑定是系统级的普通应用拿不到完整设备列表和状态。如果你的显示界面需要展示蓝牙设备列表最好走 CarService 的 CarBluetoothManager否则会碰到权限不足的问题。4.3 自定义串口协议的基本格式车机和外设通信各家协议差异很大但基本都逃不开帧头、长度、命令字、数据、校验这几段。我现在手里的项目用的是一个很典型的格式// 帧格式0xAA 0x55 len cmd payload crc fun parseFrame(buffer: ByteArray) { if (buffer[0] ! 0xAA.toByte() || buffer[1] ! 0x55.toByte()) { return } val len buffer[2].toInt() and 0xFF val cmd buffer[3].toInt() val payload buffer.sliceArray(4 until 4 len) val crc buffer[4 len].toInt() and 0xFF if (crc ! calcCrc(payload)) { return } // 按 cmd 分发给对应的显示逻辑 }串口数据的粘包和半包问题很常见。设备一次发来多帧或者一帧被拆成两段读取线程里要做缓冲区拼接按帧头帧尾把完整帧切出来剩下的数据留在缓冲区里等下一次读取。这个逻辑写好了能避免后面一大半的数据乱跳问题。5. 文件与权限适配车载调试绕不开的三个坑最后说几个跟显示关系不大、但会直接导致显示不出来的问题。文件访问、Content Uri、权限申请这些在手机 Android 上已经很成熟的东西放到车机环境里往往被随意处理然后现场翻车。5.1 FileProvider 与私有目录的正确用法现在不少人搜索记录里会出现一串 content:// 开头的路径比如 content://com.baidu.searchbox.fileprovider 这种格式。这些就是 Android 的 FileProvider 在跨应用共享文件时生成的 Content Uri。车载项目里最常见的使用场景是系统应用把截图、日志导出给诊断工具或者 Launcher 读取 OTA 升级包。直接传 file:// Uri 在高版本 Android 上会被立刻拦下来抛 FileUriExposedException。正确做法是配置 FileProviderprovider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /providerfile_paths 里按实际目录配置 path。我遇到过最坑的一次是车机 ROM 定制团队改了外部存储目录的映射结果按默认路径配置的 FileProvider 在实车上导出日志一直报 FileNotFound。后来把 paths 里的 external-path path 改成 / 才解决但这会让应用可以共享整个外置存储的文件权限范围过大不得已而为之。所以做车载项目拿到实车的第一件事就是确认外部存储的真实挂载路径别照着 AOSP 默认值写死。5.2 权限申请在车载场景下的特殊策略权限在车机上和手机上有完全不同的处理节奏。手机上应用冷启动时弹权限框是比较常见的做法但车机上这个写法很危险。试想用户正在开车中控屏幕上突然弹出是否允许使用蓝牙的权限对话框焦点被抢走正在导航的界面被打断这是绝对不能接受的事。车载应用的权限策略优先做预授权和白名单。系统级应用通过 privapp-permissions 白名单直接授予权限普通应用则在出厂预装时完成授权运行时不再弹框。涉及动态权限的场景比如蓝牙、定位申请时机要放在停车状态或者用户明确进入某个功能时而且弹窗出现的位置和样式也要定制不能用系统默认那种全屏对话框。权限手机端处理车载端建议BLUETOOTH_CONNECT运行时申请预授权不弹窗ACCESS_FINE_LOCATION运行时申请停车状态下申请配合禁用车内操作限制READ_EXTERNAL_STORAGE运行时申请系统应用白名单USB 设备权限USB 插入弹窗定向预授权或只在设置页弹提示车载系统的权限还有一个特点很多车厂会把关键权限绑定平台签名三方应用即使声明了也拿不到。做显示业务的同学如果发现某块数据拿不到先别猜机型兼容性先去查这个权限是不是被签名或白名单挡掉了。5.3 调试车载显示问题的常用命令最后把手头最常用的几个调试命令整理一下全是实操里反复用到的。查显示器基本信息用 dumpsys display查 SurfaceFlinger 合成情况用 dumpsys SurfaceFlinger查当前焦点窗口用 dumpsys window。每次接实车问题我基本都先跑一遍这三个命令把现场情况摸清楚再动手改代码。adb shell dumpsys display | grep -E Display|mDisplayId adb shell dumpsys SurfaceFlinger | grep -E Display|Layer adb shell dumpsys window | grep -E mCurrentFocus|mFocusedApp多屏项目里窗口焦点和显示焦点经常不一致。比如中控屏上焦点在当前应用里但 SystemUI 的某个浮层却把焦点抢走了现象就是按键按下没反应。这种问题从应用日志里完全看不出来只有 dumpsys window 能看到焦点落在了 systemui 的浮层上。把 dumpsys 当成日常工具用车载调试效率会高很多。最后说一个经验。做车载显示这几年我最大的感触是车载项目和手机项目最大的差异不在技术难度而在约束条件完全不同。手机只服务一个人车机要服务驾驶员、副驾、后排还要考虑安全、法规、硬件功耗、极端温度。很多在手机上跑得很好的方案放到车机上就要重新考量。所以入行车载建议先放下手机 App 的思维惯性从多屏、焦点、内存水位、外设链路这些底座一点点搭起来后面踩的坑会少很多。保持一个习惯也很有用每次拿到实车先跑一遍adb shell dumpsys display把当前所有 Display 的 id、分辨率、状态理清楚再去查 SurfaceFlinger。我在实际项目里靠这个习惯省掉的排查时间比什么框架优化都多。