4、窗口管理机制:窗口层级管理、焦点切换策略、跨屏拖拽实现原理。
好,咱们接着聊。窗口管理,说白了就是车机上这些“卡片”和“应用”怎么排排坐、谁先谁后的问题。你想想看,一个中控屏上同时开着导航、音乐、空调面板,副驾屏还在放视频,后排屏在玩游戏——这要是没个规矩,那不乱套了?
我个人习惯把窗口管理拆成三个核心问题:谁在上面?谁在干活?怎么扔过去? 分别对应层级、焦点和拖拽。咱们一个一个说。
4.1 窗口层级管理:Z-Order 的博弈
车机上的窗口,跟手机不一样。手机基本是“一个应用全屏”,但车机是多窗口、多屏共存。所以层级管理是基础。
我一般把窗口分为三个层级:
- 系统层(System Layer):最底层。壁纸、桌面、系统状态栏。这层一般不参与交互,但它是背景。
- 应用层(App Layer):中间层。导航、音乐、电话等应用窗口。它们之间可以互相覆盖、切换。
- 浮层层(Overlay Layer):最顶层。警告弹窗、来电提醒、语音助手、空调快捷面板。这层永远在最上面,但通常半透明或可快速消失。
嗯,这里要注意:浮层不能太多。我在项目中遇到过,某个版本把“驾驶模式切换”和“电量低提醒”都做成了浮层,结果两个弹窗叠在一起,用户点不到下面的“确认”按钮。这就是典型的层级打架。
核心原则: 浮层层只放“必须立即响应”或“安全相关”的内容。其他一律放到应用层。
实现上,Android 车机用 WindowManager.LayoutParams 的 type 字段控制层级。比如:
// 系统级窗口
WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY
// 应用级窗口
WindowManager.LayoutParams.TYPE_APPLICATION
// 浮层窗口(需权限)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
但光有层级不够。你还要考虑窗口栈(Window Stack)。比如用户打开了导航,又打开了音乐,再切回导航——这时候导航窗口应该回到栈顶。我建议用 LinkedHashMap 维护一个有序的窗口列表,每次焦点变化时更新顺序。
避坑指南: 我曾经在某个项目里直接用 ArrayList 存窗口,结果频繁插入删除导致性能抖动。后来换成 ArrayDeque 做双端队列,窗口切换流畅多了。
4.2 焦点切换策略:谁在“听”用户?
焦点,就是当前哪个窗口能接收按键、触摸、语音指令。车机上焦点切换比手机复杂,因为多屏多任务。
举个例子:驾驶员在操作导航,副驾在调空调。这时候语音说“导航到公司”,焦点应该在导航窗口;如果语音说“温度调到26度”,焦点应该在空调窗口。但系统怎么知道用户想跟谁说话?
我常用的策略是“最后交互优先”:
- 记录每个屏幕最后被触摸或按键的窗口。
- 语音指令发出时,优先匹配“最后交互窗口”所在的应用。
- 如果匹配不上,再广播给所有窗口,谁有能力谁响应。
代码上,我习惯维护一个 FocusManager:
class FocusManager {
// 每个屏幕的焦点窗口
private val screenFocusMap = mutableMapOf<Int, String>()
fun requestFocus(screenId: Int, windowId: String) {
screenFocusMap[screenId] = windowId
// 通知系统更新焦点
updateWindowFocus(screenId, windowId)
}
fun getCurrentFocus(screenId: Int): String? {
return screenFocusMap[screenId]
}
}
但这里有个坑:安全窗口的焦点抢占。比如倒车影像启动时,必须强制获取焦点,不管用户之前在看什么。我建议给安全相关窗口一个“高优先级焦点请求”,系统层直接拦截并切换。
注意: 焦点切换不能太频繁。我曾经见过一个 demo,每次触摸都触发焦点重排,结果导致界面闪烁。后来加了 200ms 的去抖,问题解决。
4.3 跨屏拖拽实现原理:把卡片“扔”到另一块屏
跨屏拖拽,是车机多屏联动的“杀手级功能”。用户可以把导航卡片从中控屏拖到仪表盘,或者把音乐列表从副驾屏拖到后排屏。
实现原理其实不复杂,核心就三步:
- 拖拽开始:在源屏幕捕获触摸事件,创建一个“拖拽影子”(Drag Shadow)。
- 跨屏传输:通过系统服务(如
DragAndDropManager)把拖拽数据序列化,发送到目标屏幕。 - 目标接收:目标屏幕解析数据,创建新窗口或更新现有窗口。
我画个简单的流程图(文字版):
用户触摸卡片 → 源屏幕捕获事件
↓
创建 DragShadow(半透明卡片跟随手指)
↓
手指移动到屏幕边缘 → 触发跨屏事件
↓
序列化数据(窗口ID、位置、内容URI)
↓
通过 Binder 发送到目标屏幕的 WindowService
↓
目标屏幕解析 → 创建新窗口 / 更新现有窗口
代码上,Android 原生提供了 View.startDragAndDrop(),但车机上需要扩展。我一般自己封装一个 CrossScreenDragHelper:
class CrossScreenDragHelper(private val context: Context) {
fun startDrag(view: View, data: DragData) {
// 1. 创建拖拽影子
val shadow = DragShadowBuilder(view)
// 2. 启动拖拽
view.startDragAndDrop(
ClipData.newPlainText("drag_data", data.toJson()),
shadow,
null,
0
)
// 3. 监听拖拽结束,判断是否跨屏
view.setOnDragListener { _, event ->
if (event.action == DragEvent.ACTION_DRAG_ENDED) {
if (event.result) {
// 跨屏成功,通知目标屏幕
sendToTargetScreen(data)
}
}
true
}
}
private fun sendToTargetScreen(data: DragData) {
// 通过系统服务发送
val service = context.getSystemService(WindowService::class.java)
service?.createWindowOnScreen(data.targetScreenId, data)
}
}
嗯,这里要注意:拖拽过程中的视觉反馈。用户把卡片拖到屏幕边缘时,应该有个“吸附”或“高亮”效果,告诉用户“你可以扔过去了”。我习惯在屏幕边缘设置一个 50dp 的“热区”,手指进入热区后,目标屏幕边缘亮起一个半透明的接收区域。
关键点: 跨屏拖拽的延迟必须控制在 100ms 以内。超过这个时间,用户会感觉“卡了一下”。我建议用 SharedMemory 传输数据,避免序列化开销。
最后,说一个我踩过的坑:拖拽冲突。有一次,用户在中控屏拖拽卡片时,手指不小心滑到了副驾屏的边缘,结果两个屏幕同时响应了拖拽事件。后来我加了“屏幕边界锁定”——只有手指离开源屏幕超过 30px 才触发跨屏,否则视为误触。
好了,窗口管理这块就聊到这儿。说白了,层级管好“谁在上面”,焦点管好“谁在干活”,拖拽管好“怎么扔过去”。这三件事做好了,多屏联动就稳了一大半。