
16.1 起点InputDispatcher的按键分发决策上一章我们讲到InputReader把原始事件加工成KeyEvent后扔给了InputDispatcher。那InputDispatcher拿到这个KeyEvent后第一件事是什么它会先判断这个按键事件该发给谁判断依据主要有三个当前焦点窗口大部分按键事件都发给当前获得焦点的窗口系统按键比如Home键、电源键这些由系统直接处理特殊窗口比如输入法窗口、状态栏等我个人习惯把按键事件分成两类系统按键Home、Back、Recent Apps、Power、Volume等普通按键键盘上的字母、数字、方向键等系统按键的分发路径比较特殊它们会走PhoneWindowManager的拦截逻辑。我曾在项目中遇到过一个问题某个设备上的音量键偶尔会触发两次事件。查了半天发现是InputDispatcher的按键去重逻辑和PhoneWindowManager的拦截逻辑冲突了。16.2 核心路径InputDispatcher → ViewRootImpl假设现在用户按下了键盘上的A键。InputDispatcher判定这个事件应该发给当前焦点窗口。那它怎么发呢这里涉及到一个关键的数据结构——Connection。每个注册到InputDispatcher的窗口都会对应一个InputChannel和Connection。InputDispatcher通过Connection把事件发送给窗口。// InputDispatcher.cpp 中的核心分发逻辑 void InputDispatcher::dispatchKeyLocked( nsecs_t currentTime, KeyEntry* entry, const VectorInputTarget inputTargets) { for (const InputTarget inputTarget : inputTargets) { // 找到目标窗口的Connection spConnection connection getConnectionLocked(inputTarget.inputChannel-getConnectionToken()); if (connection nullptr) { continue; } // 构造DispatchEntry DispatchEntry* dispatchEntry new DispatchEntry(entry, inputTarget); // 把事件放入Connection的发送队列 connection-outboundQueue.enqueueAtTail(dispatchEntry); // 唤醒InputDispatcher的发送线程 mLooper-wake(); } }这段代码看起来简单但背后有个重要的设计思想异步分发。InputDispatcher不会阻塞等待窗口处理完事件而是把事件丢到队列里就返回了。这样即使某个窗口处理得慢也不会影响其他窗口的事件接收。嗯这里要注意InputDispatcher和窗口之间的通信是通过InputChannel完成的。InputChannel本质上是一对SocketPair一个在系统服务端一个在应用进程端。16.3 跨进程之旅从系统服务到应用进程事件从InputDispatcher到ViewRootImpl需要经历一次跨进程通信。这个过程是这样的InputDispatcher把KeyEvent写入InputChannel的发送端系统内核把数据拷贝到接收端的缓冲区应用进程的Looper检测到InputChannel有数据可读调用相应的回调函数处理事件这个过程中Looper扮演了关键角色。应用进程的主线程Looper会监听所有注册的InputChannel。一旦有事件到来Looper就会唤醒主线程执行事件处理。我曾经调试过一个性能问题某个应用在按键响应上总是有100ms左右的延迟。最后发现是因为主线程Looper被其他消息阻塞了导致InputChannel的事件无法及时处理。解决方案很简单——把耗时操作移到子线程。16.4 ViewRootImpl的接收与分发事件到达应用进程后首先被ViewRootImpl接收。ViewRootImpl内部有一个InputEventReceiver专门负责从InputChannel读取事件。// ViewRootImpl.java 中的事件接收 final class ViewRootImpl { final class WindowInputEventReceiver extends InputEventReceiver { Override public void onInputEvent(InputEvent event, int displayId) { // 把事件交给ViewRootImpl处理 enqueueInputEvent(event, this, 0, true); } } void enqueueInputEvent(InputEvent event, InputEventReceiver receiver, int flags, boolean processImmediately) { // 把事件加入队列 QueuedInputEvent q obtainQueuedInputEvent(event, receiver, flags); mPendingInputEventQueue.add(q); // 立即处理或延迟处理 if (processImmediately) { doProcessInputEvents(); } else { scheduleProcessInputEvents(); } } }这里有个细节processImmediately参数。如果是true事件会被立即处理如果是false会通过Handler发送一个消息在下一帧处理。你想想看为什么要有这个区分说白了是为了平衡响应速度和帧率。有些事件需要立即响应比如游戏中的按键有些事件可以稍微延迟比如文本输入。16.5 事件队列与处理优先级ViewRootImpl内部维护了一个事件队列。所有到达的InputEvent都会先进入这个队列然后按顺序处理。队列的处理顺序是这样的优先级事件类型处理方式最高同步事件SYNC立即处理不排队高按键事件KEY按序处理可打断中触摸事件TOUCH按序处理不可打断低其他事件排队处理嗯这个优先级设计是有原因的。按键事件如果处理慢了用户会明显感觉到卡顿。而触摸事件涉及多点触控的同步问题不能随意打断。关键点按键事件的处理优先级高于触摸事件。这意味着如果同时有按键和触摸事件到达按键事件会先被处理。这个设计保证了物理按键的响应速度。16.6 从ViewRootImpl到DecorView事件经过ViewRootImpl的处理后最终会交给DecorView。DecorView是窗口的顶层View所有的事件分发都从这里开始。ViewRootImpl会调用View.dispatchKeyEvent()方法把事件传给DecorView。然后事件就进入了我们熟悉的View事件分发流程DecorView.dispatchKeyEvent()Activity.dispatchKeyEvent()PhoneWindow.dispatchKeyEvent()DecorView.onKeyDown() / onKeyUp()ViewGroup.dispatchKeyEvent()具体View.onKeyDown() / onKeyUp()这个流程看起来很长但实际执行很快。我做过测试从InputDispatcher到View的onKeyDown回调平均耗时在1ms以内。如果超过5ms就要考虑是不是主线程被阻塞了。16.7 避坑指南常见的按键事件问题我在项目中遇到过不少按键事件相关的问题这里分享几个典型的问题1按键事件丢失我曾经遇到一个情况快速连续按键时部分按键事件丢失了。排查后发现是InputDispatcher的队列满了丢弃了后续事件。解决方案是增大队列容量或者优化事件处理速度。问题2按键事件重复有些设备在按键按下时会同时触发两次KeyEvent。这是因为硬件去抖没做好。解决方案是在应用层做去重记录上一次按键的时间戳如果间隔小于50ms就忽略。问题3按键事件延迟如果按键后要等很久才有反应通常是主线程被阻塞了。可以用Systrace抓一下看看主线程在做什么。我遇到过最离谱的情况是SharedPreferences的commit操作阻塞了主线程100ms。16.8 总结KeyEvent的完整旅程好了我们来梳理一下KeyEvent从InputDispatcher到ViewRootImpl的完整旅程InputDispatcher判断事件目标放入Connection的发送队列InputChannel跨进程传输从系统服务到应用进程ViewRootImpl接收事件加入事件队列事件队列按优先级排序按键事件优先处理DecorView事件进入View体系开始分发Activity/View最终处理按键事件这个流程看起来复杂但每个环节都有其存在的理由。你想想看如果没有InputChannel的异步机制一个窗口卡住就会导致整个系统无法响应按键。如果没有事件队列的优先级设计按键事件可能会被触摸事件阻塞。我个人建议在开发过程中遇到按键相关的问题时先理清事件当前处于哪个环节。是还没到达应用进程还是到了但没被处理还是处理了但结果不对定位到具体环节后问题就好解决了。