每个 Display 都拥有自己独立且完整的生态,具体表现为各自管理着一组 Window。
1. 引子:从一个拖拽 Bug 说起
用户在 Launcher 中进行拖拽操作时,如果发生了 Display 焦点切换,拖拽的 View 会卡在屏幕上无法消失。
2. Android Input 管道全景
网上已有大量关于此主题的文章可供参考,本文仅作简要概述。
Android 输入事件的处理管道可分为五个层次:
- 硬件层:负责接收原始的触摸、按键等输入信号(例如,手指触摸屏幕产生一个触摸事件)。
- Linux 内核层:接收硬件信号,并将其封装成
input_event结构,写入到如/dev/input/eventX之类的设备节点。 - Android Native 层:负责从设备节点(如
/dev/input/eventX)读取原始的输入事件。 - Android Service 层:进行事件的解析、分发,并将其派发给上层的应用。
- 应用层:最终接收并消费处理输入事件。
3. TouchState:每个 Display 独立记账
3.1 mTouchStatesByDisplay 数据结构
mTouchStatesByDisplay是一个std::unordered_map,其键(key)为displayId。这意味着:
每个显示屏拥有自己独立的触摸状态,彼此互不干扰。
std::unordered_map<int32_t, TouchState> mTouchStatesByDisplay GUARDED_BY(mLock);3.2 TouchState 结构体
struct TouchState { bool down; // 是否有手指按下? bool split; // 是否开启了分屏触摸? int32_t deviceId; // 是哪个输入设备的触摸?(防止多设备冲突) uint32_t source; // 输入源(触摸屏?鼠标?) int32_t displayId; // 是哪个显示屏的触摸? std::vector<TouchedWindow> windows; // 当前触摸了哪些窗口? std::vector<sp<InputWindowHandle>> portalWindows; // 穿过了哪些 portal window? std::vector<TouchedMonitor> gestureMonitors; // 手势监听器 };4. 根因分析:findTouchedWindowTargetsLocked
void InputDispatcher::dispatchOnceInnerLocked(nsecs_t* nextWakeupTime) { // ... done = dispatchMotionLocked(currentTime, typedEntry, &dropReason, nextWakeupTime); } int32_t InputDispatcher::dispatchMotionLocked(...) { injectionResult = findTouchedWindowTargetsLocked(...); }当后续事件携带的displayId在mTouchStatesByDisplay中不存在对应的TouchState时,find(displayId)会返回end(),此时oldState不会从该 Display 获取到已有触摸状态
Step 1:拷贝旧状态
const TouchState* oldState = nullptr; TouchState tempTouchState; auto oldStateIt = mTouchStatesByDisplay.find(displayId); // 使用 displayId 查询 map if (oldStateIt != mTouchStatesByDisplay.end()) { oldState = &(oldStateIt->second); tempTouchState.copyFrom(*oldState); }Step 2:判断是否是新手势
bool newGesture = (maskedAction == AMOTION_EVENT_ACTION_DOWN || maskedAction == AMOTION_EVENT_ACTION_SCROLL || isHoverAction);Step 3:设备切换检测
bool switchedDevice = tempTouchState.deviceId >= 0 && tempTouchState.displayId >= 0 && (tempTouchState.deviceId != entry.deviceId || tempTouchState.source != entry.source || tempTouchState.displayId != displayId); // ← 此处 displayId 不同会触发!如果switchedDevice == true且当前有手指按下(tempTouchState.down == true),则会执行以下逻辑:
if (switchedDevice && tempTouchState.down && !down && !isHoverAction) { ALOGI("Dropping event because a pointer for a different device is already down " "in display %" PRId32, displayId); injectionResult = INPUT_EVENT_INJECTION_FAILED; goto Failed; // ← 直接丢弃事件! } Failed: // 1. 检查权限 if (injectionPermission != INJECTION_PERMISSION_GRANTED) { return injectionResult; // ← 直接返回,什么都不做 } // 2. 更新状态(但 wrongDevice 时不更新) if (!wrongDevice) { // 更新 mTouchStatesByDisplay // 但没有发送 CANCEL 事件! } return injectionResult;问题所在:当后续事件的displayId与当前TouchState中记录的displayId不一致时,会使switchedDevice为true。如果此时tempTouchState.down仍为true,且当前事件不是新的DOWN、也不是 Hover 事件,则该事件会进入Failed路径,并以INPUT_EVENT_INJECTION_FAILED结束处理。
进一步影响:该路径不会按照正常的触摸事件分发流程继续处理当前事件,因此原有拖拽手势无法正常完成后续的事件状态转换。如果拖拽 View 依赖后续的CANCEL事件清理自身状态,而该CANCEL又没有在后续流程中产生或送达应用层,就可能出现拖拽 View 残留在屏幕上的现象。
5. 调试方法
- dumpsys input