4、窗口管理机制:窗口层级管理、焦点切换策略、跨屏拖拽实现原理。

好,咱们接着聊。窗口管理,说白了就是车机上这些“卡片”和“应用”怎么排排坐、谁先谁后的问题。你想想看,一个中控屏上同时开着导航、音乐、空调面板,副驾屏还在放视频,后排屏在玩游戏——这要是没个规矩,那不乱套了?

我个人习惯把窗口管理拆成三个核心问题:谁在上面?谁在干活?怎么扔过去? 分别对应层级、焦点和拖拽。咱们一个一个说。

4.1 窗口层级管理:Z-Order 的博弈

车机上的窗口,跟手机不一样。手机基本是“一个应用全屏”,但车机是多窗口、多屏共存。所以层级管理是基础。

我一般把窗口分为三个层级:

  • 系统层(System Layer):最底层。壁纸、桌面、系统状态栏。这层一般不参与交互,但它是背景。
  • 应用层(App Layer):中间层。导航、音乐、电话等应用窗口。它们之间可以互相覆盖、切换。
  • 浮层层(Overlay Layer):最顶层。警告弹窗、来电提醒、语音助手、空调快捷面板。这层永远在最上面,但通常半透明或可快速消失。

嗯,这里要注意:浮层不能太多。我在项目中遇到过,某个版本把“驾驶模式切换”和“电量低提醒”都做成了浮层,结果两个弹窗叠在一起,用户点不到下面的“确认”按钮。这就是典型的层级打架。

核心原则: 浮层层只放“必须立即响应”或“安全相关”的内容。其他一律放到应用层。

实现上,Android 车机用 WindowManager.LayoutParamstype 字段控制层级。比如:

// 系统级窗口
WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY

// 应用级窗口
WindowManager.LayoutParams.TYPE_APPLICATION

// 浮层窗口(需权限)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY

但光有层级不够。你还要考虑窗口栈(Window Stack)。比如用户打开了导航,又打开了音乐,再切回导航——这时候导航窗口应该回到栈顶。我建议用 LinkedHashMap 维护一个有序的窗口列表,每次焦点变化时更新顺序。

避坑指南: 我曾经在某个项目里直接用 ArrayList 存窗口,结果频繁插入删除导致性能抖动。后来换成 ArrayDeque 做双端队列,窗口切换流畅多了。

4.2 焦点切换策略:谁在“听”用户?

焦点,就是当前哪个窗口能接收按键、触摸、语音指令。车机上焦点切换比手机复杂,因为多屏多任务

举个例子:驾驶员在操作导航,副驾在调空调。这时候语音说“导航到公司”,焦点应该在导航窗口;如果语音说“温度调到26度”,焦点应该在空调窗口。但系统怎么知道用户想跟谁说话?

我常用的策略是“最后交互优先”

  1. 记录每个屏幕最后被触摸或按键的窗口。
  2. 语音指令发出时,优先匹配“最后交互窗口”所在的应用。
  3. 如果匹配不上,再广播给所有窗口,谁有能力谁响应。

代码上,我习惯维护一个 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 跨屏拖拽实现原理:把卡片“扔”到另一块屏

跨屏拖拽,是车机多屏联动的“杀手级功能”。用户可以把导航卡片从中控屏拖到仪表盘,或者把音乐列表从副驾屏拖到后排屏。

实现原理其实不复杂,核心就三步:

  1. 拖拽开始:在源屏幕捕获触摸事件,创建一个“拖拽影子”(Drag Shadow)。
  2. 跨屏传输:通过系统服务(如 DragAndDropManager)把拖拽数据序列化,发送到目标屏幕。
  3. 目标接收:目标屏幕解析数据,创建新窗口或更新现有窗口。

我画个简单的流程图(文字版):

用户触摸卡片 → 源屏幕捕获事件
    ↓
创建 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 才触发跨屏,否则视为误触。

好了,窗口管理这块就聊到这儿。说白了,层级管好“谁在上面”,焦点管好“谁在干活”,拖拽管好“怎么扔过去”。这三件事做好了,多屏联动就稳了一大半。