- 移动开发
- 智能硬件
- 音视频
【免费下载链接】DiPlay
Independent CarPlay receiver for compatible Android head units. Wired and wireless public preview.
DiPlay 的「CarPlay 旁侧边面板」(Side panel,实验性功能)让 iPhone 把 CarPlay 画在主屏幕的三分之二,而 DiPlay 在剩下的三分之一里显示自己的 Android 面板——横屏时位于乘客一侧,竖屏时位于屏幕底部,且随时可切回全屏 CarPlay 而无需重连。本文以仓库文档 docs/SIDE_PANEL.md 为主线,结合 CarPlayViewAreas.kt 与 CarPlayHostActivity.kt 的源码实现,讲清这一功能的设置方式、view area 原理、面板的布局与刷新机制,以及同一轮研究中验证的 iAP2 协议边界。读完你可以掌握:如何在 DiPlay 中开启并切换该功能、它依赖的updateViewArea调用链,以及 CarPlay 流上叠放自定义 Android 视图的通用做法。
功能概览与开启方式
功能的行为在文档中描述得很直接:CarPlay 与 DiPlay 共享车机主屏,iPhone 在屏幕的三分之二内绘制 CarPlay,DiPlay 在剩余区域显示自己的面板。具体布局规则为:
- 横屏:CarPlay 保持位于驾驶员一侧,面板占据乘客一侧的三分之一——左舵车在右侧,开启Right-hand drive(右舵)后换到左侧;
- 竖屏:面板是屏幕底部的一条横幅(band);
- 车机可以随时在这两种形态(面板/全屏 CarPlay)之间切换,无需重连;
- 功能默认关闭,入口在Settings → Display and performance → Side panel next to CarPlay;
- 开启后,会话内的菜单会出现Side panel按钮,面板自身则有一个Full screen按钮用于切回全屏。
从字符串资源 strings.xml 可以看到该功能的官方描述(含"实验性"标注与"下次连接时生效"的适用前提):
<string name="side_panel">Side panel next to CarPlay (experimental)</string> <string name="side_panel_description">CarPlay can move to two thirds of the screen while DiPlay shows a panel in the rest: on the passenger's side of a landscape screen, at the bottom of a portrait one (clock and, where it is read, the battery). Show and hide it from the in-session menu. Applies at the next connection.</string> <string name="side_panel_show">Side panel</string> <string name="side_panel_full_screen">⤢ Full screen</string>注意字符串中两个关键前提:面板显示时钟,以及"已读取到时的电量"(battery 一项依赖车机侧数据能否读到);设置变更在下一次连接时生效。对应的持久化实现在 SidePanel.kt:
object SidePanelSettings { private const val PREFS = "diplay_side_panel" private const val KEY_ENABLED = "enabled" fun enabled(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_ENABLED, false) fun setEnabled(context: Context, enabled: Boolean) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_ENABLED, enabled).apply() }即独立的 SharedPreferences 文件diplay_side_panel,键enabled,默认值false。会话建立时该开关被传入 view area 的构建参数(CarPlayHostActivity.kt 第 3529–3541 行 中sidePanel = SidePanelSettings.enabled(this)),因此关闭状态下本次会话根本不会声明侧边面板区域,也就没有对应的菜单按钮——这解释了"下次连接生效"的说法。
工作原理:基于 View Areas 的面积声明与切换
该功能构建在 DiPlay 的 view areas 机制之上(详见 docs/VIEW_AREAS.md)。CarPlay accessory 可以在主屏视频流里声明多个 view area,并告诉 iPhone 当前画在哪一个。开启侧边面板后,主屏在每种屏幕方向、每个 dock 边缘下额外声明一个占屏幕三分之二大小的区域:横屏取左侧(或右舵时的右侧),竖屏取顶部。
源码侧的核心是 CarPlayViewAreas.kt。其中区域按用途分成三类,SIDE_PANEL只在用户请求时才会被选中:
/** What an area is for; SIDE_PANEL is CarPlay beside DiPlay's panel and is only chosen on request. */ enum class Kind { FULL_SCREEN, SPLIT_SCREEN, SIDE_PANEL }build()构造侧边面板区域时(第 148–158 行):
if (sidePanel) for (screen in screens) { // CarPlay keeps two thirds: the driver's side of a landscape screen (the panel on the // passenger's side) or the top of a portrait one (the panel at the bottom). val width = if (screen.portrait) screen.width else (screen.width * 2 / 3) and 1.inv() val height = if (screen.portrait) (screen.height * 2 / 3) and 1.inv() else screen.height val originX = if (rightHandDrive) screen.width - width else 0 for (edge in edges) { areas += AirPlayViewArea(width, height, originX = originX, dockEdge = edge) slots += Slot(Kind.SIDE_PANEL, screen.portrait) } }要点:横屏面积为屏幕宽的2/3(向下取整到偶数,and 1.inv()),竖屏为高的2/3;originX由rightHandDrive决定,实现左舵/右舵镜像。index()/sidePanel()负责按"当前方向 + dock 边缘"查回对应区域索引,use()记录当前使用的区域,整个类不依赖任何 Android 类型(除偏好设置外),因此可完整单元测试(见 CarPlayViewAreasTest.kt)。
切换动作走 CarPlay 事件通道的updateViewArea。AirPlayInfoPlist.kt 第 170 行附近 记录了在 Tang 车 + iOS 27 上实测到的消息形态:iPhone 只有在带上了动画时长(animationDurationMillis)和相邻区域(adjacentViewAreas)时才响应切换。消息最终经 AirPlaySession.showViewArea() 发出,编排层 CarPlayController.showViewArea() 透传。
关键在于"iPhone 只知道自己被要求用更小的区域",对面板内容一无所知。因此 DiPlay 采取的做法是:
- 画布按整块屏幕布局:
layoutArea()对SIDE_PANEL类区域返回对应的全屏区域(第 63–66 行),保证流内容不变、iPhone 继续发送整帧; - 普通 Android 视图盖住黑条:
panelRect()计算 CarPlay 留下的黑色条带(流坐标),第 72–84 行 分别处理横屏左右侧与竖屏底部三种情况; - 触摸分区:面板内触摸留在 DiPlay,其余区域照旧交给 iPhone;
- 窗口变化收场:屏幕转向、车机分屏等窗口变化会结束面板并按常规选择匹配区域;关闭 DiPlay 或会话结束则隐藏面板并停止其刷新。
面板的构建、定位与刷新(源码走读)
面板内容:时钟、电量与全屏按钮
CarPlayHostActivity.kt 的buildSidePanel()(第 1510–1539 行) 构造了一个垂直LinearLayout,包含三部分:
- 一个
TextClock(24 小时制HH:mm/ 12 小时制h:mm,72sp 白字); - 一个电量
TextView(sidePanelBattery,30sp); - 一个Full screen按钮,点击调用
showSidePanel(false)切回全屏。
面板背景为深色(rgb(16,16,18)),且isClickable = true——这使落在面板上的触摸被其消费,不再穿透到下方的手势层/CarPlay 流。
定位:把流坐标映射到视频布局
iPhone 持续发送整帧,而视频视图在窗口内可能被 letterbox(黑边缩放)。placeSidePanel()(第 1542–1557 行)把面板条带从流像素坐标换算到窗口坐标:
private fun placeSidePanel(viewWidth: Int, viewHeight: Int) { val panel = sidePanel ?: return val display = sessionDisplay ?: return val strip = display.viewAreas?.let { it.panelRect(it.current) } ?: return val content = contentRect(viewWidth, viewHeight) val scaleX = content.width / display.width val scaleY = content.height / display.height val left = Math.round(content.left + strip.originX * scaleX) val top = Math.round(content.top + strip.originY * scaleY) panel.layoutParams = FrameLayout.LayoutParams(...).apply { // Stream coordinates are physical pixels, independent of the UI's reading direction. leftMargin = left; topMargin = top // Gravity.TOP or Gravity.LEFT } }两个值得注意的设计:
- letterbox 跟随:
contentRect是视频内容在窗口内的实际矩形,因此面板始终精确盖在流的黑条上,而不是简单地贴窗口边缘; - 物理坐标不受 RTL 影响:
Gravity.TOP or Gravity.LEFT+ 流像素坐标,保证界面切到右起书写语言(如阿拉伯语、希伯来语)时面板不会翻到 CarPlay 上面去。这一点由测试 SidePanelLayoutTest.letterboxedPanelKeepsItsPhysicalPassengerSideInEitherLayoutDirection 覆盖:2560×1440 流放入 1200×1200 窗口(正方形 letterbox),在 LTR/RTL × 左舵/右舵四种组合下,面板left恒为 800(左舵)或 0(右舵),且尺寸一致(400×675,top=263)。另一个测试portraitPanelCoversTheBottomStripOfTheSquareStream验证了竖屏(旋屏方形流)下面板覆盖底部条带。
显示、隐藏与状态恢复
showSidePanel(show)(第 1559–1574 行)是切换入口:
- 显示时取
areas.sidePanel(portrait),隐藏时取areas.indexFor(...)(当前窗口形状匹配的全屏/分屏区域); - 通过
controller?.showViewArea(target)下发updateViewArea,只有下发成功才更新状态(sent为 false 时直接返回),避免 UI 状态与实际区域不一致; - 记录日志
Side panel shown/hidden: view area $target sent=$sent; - 显示时启动周期刷新
sidePanelTick,隐藏/重置时移除回调; - 最后
updateVideoLayout(...)让画布按整屏重新布局。
会话菜单中的按钮同样以区域是否存在为前提——sessionDisplay?.viewAreas?.sidePanel() != null时才渲染Side panel / Full screen切换按钮(第 1612–1621 行)。
状态收场时机:源码中多处调用resetSidePanel()——新会话开始时"不带侧边面板启动"(第 4060 行)、屏幕转向或进入车机分屏时"结束面板"(第 4246 行)。窗口大小变化时若面板仍显示则重新placeSidePanel(第 4327 行)。
电量刷新的条件:refreshSidePanel()(第 1582–1588 行)只在 DiPlay 已为 iPhone 读取电量的场景(BydOutputSettings.batteryToIphoneActive)下才显示"🔋 xx% · xx km",否则留空——即字符串资源中"where it is read"的实现。刷新周期为常量SIDE_PANEL_REFRESH_MILLIS = 5_000L(第 4941 行),由sidePanelTick在主线程上自调度,面板关闭即停止。
会话接管(adoption):当新的宿主 Activity 接管一个面板已打开的存活会话时,adoptBackgroundSession会恢复面板视图与刷新回调,且不改变 view area(区域索引current在CarPlayViewAreas内跨 Activity 共享)。这一行为由 SidePanelLayoutTest.adoptingAnOpenSidePanelRestoresItsViewAndRefreshWithoutChangingTheArea 验证:接管后areas.current仍指向侧边面板区域,sidePanelShown为 true、面板可见、sidePanelTick已挂入 Handler。生命周期另有 SidePanelLifecycleTest.kt 覆盖。
实测结果
文档记录的实测环境:2024 款 BYD Tang(DiLink 5.0,2560×1440 可旋转中控屏)+ iOS 27 iPhone,无线 CarPlay(同设计的实验室构建):
- CarPlay 在横屏(左侧 2/3)与竖屏(顶部 2/3)之间移动并切回,耗时不到一秒,全程无需重连;
- CarPlay 区域内的触摸全部正确命中;
- 面板显示时钟与车辆电量和续航里程;
- 一个 dock 相关发现:声明了多个区域后,CarPlay 在 Automatic dock 下会把 dock 放到屏幕底部;需要通过 dock 设置来指定位置(与 docs/VIEW_AREAS.md 中"声明第二个区域后 Automatic dock 停靠底部"的结论一致)。
演进方向:从投屏到 CarPlay 外围的启动器
由于面板就是一个普通 Android 视图,它能显示 DiPlay 可读取或操作的任何东西。文档列出的方向包括:
- 经车机 adb 读取的车辆数据:空调、胎压、车门状态;
- 对车辆的控制(例如 BYD autoservice 中已记录的空调 fid);
- 小组件、BYD 应用快捷方式,或其他地图源的导航图。
再补上几块拼图——方向盘按键、驻车时自动显示面板的规则、按屏幕尺寸区分的布局、面板侧边选择——DiPlay 就能作为 CarPlay 外围的一个"launcher"而不仅仅是一块全屏投屏。这一方向与仓库中已存在的车辆数据桥接模块(如 BydNavigationOutputs.kt、BydHudBridge.kt 等 hud 包实现)在数据链路上是衔接的:面板当前的电量读数正是复用了为 iPhone 供电量的同一数据源。
同一轮研究的其他 iAP2 发现
在同一台车、同一实验室构建上,参考 Apple iAP2 消息规范(Xcode CarPlay Simulator 的iap2messages-internal.i2mspecarchive,仅作为数据读取),还验证了以下边界:
- VehicleStatus 附加项:在 VehicleStatus 组件中声明
OutsideTemperature、InsideTemperature、WiperStatus、BarometricPressure、Alerts与PassengerSeatStatus均被接受;iPhone 随后订阅了车外温度、雨刮、气压、警报与乘客座椅状态(唯独没有车内温度)。从车端发送实时值(车外温度、雨刮、双闪)也被接受,但 CarPlay 与 Apple Maps 界面上没有任何可见变化; - 道路物体检测(identification 参数 33,供车辆摄像头报告路牌、车道与物体):iPhone 拒绝了该 identification(对 0x0021 返回
IdentificationRejected),推测保留给经过认证的车辆; - 应用发现(对所有 CarPlay 应用发起带图标的
StartAppDiscoveryUpdates):iPhone 以AppDiscoveryUpdate应答,CarPlayAppListAvailable = 0(Unknown)且没有列表。
这些结果说明:CarPlay 侧对 accessory 的声明是"选择性信任"的——接受更多声明不等于这些能力会在 UI 中兑现,而部分能力(如道路物体检测)则存在明确的准入限制。理解这一点,有助于正确评估类似"车端数据注入 CarPlay"这类功能的实际边界。
小结
DiPlay 的侧边面板是一个以"最小改动撬动最大效果"为特点的实验性功能:它没有发明新的传输能力,而是复用 CarPlay 既有的多 view area 声明 +updateViewArea无重连切换机制,再用一个定位精确(流坐标 → 视频布局映射)、触摸隔离、5 秒周期刷新的 Android 视图填满 CarPlay 让出的黑色条带。设置入口在 Display and performance 页,默认关闭、下次连接生效;核心逻辑集中在 CarPlayViewAreas.kt(区域计算)、CarPlayHostActivity.kt(面板构建/定位/刷新/收场)与 SidePanel.kt(开关持久化),并有 SidePanelLayoutTest.kt、SidePanelLifecycleTest.kt、CarPlayViewAreasTest.kt 三组测试对定位数学、RTL 稳定性、会话接管与区域选择行为提供验证。若要在此基础上扩展面板内容(车辆数据、控制、小组件),面板就是一个普通的 Android 视图树,可任意替换buildSidePanel()的构建逻辑,而 view area 侧完全不用改动。
- 移动开发
- 智能硬件
- 音视频
【免费下载链接】DiPlay
Independent CarPlay receiver for compatible Android head units. Wired and wireless public preview.
相关推荐
Screego屏幕共享服务:低延迟高清屏幕共享解决方案解析
Screego屏幕共享服务:低延迟高清屏幕共享解决方案解析 项目背景与定位 在现代远程协作环境中,屏幕共享已成为日常工作不可或缺的功能。然而,许多企业级聊天解决
音视频后端VueUse 实战指南:useDisplayMedia 实现响应式屏幕共享与录屏
VueUse 实战指南:useDisplayMedia 实现响应式屏幕共享与录屏 useDisplayMedia 是 VueUse 中基于 Vue 3 Comp
前端MiroTalk P2P中的实时协作功能:白板、文件共享与屏幕共享
MiroTalk P2P中的实时协作功能:白板、文件共享与屏幕共享 在当今远程协作的时代,拥有强大实时协作功能的视频会议平台变得至关重要。MiroTalk P2
后端前端音视频即时通讯
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考