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 Vector<InputTarget>& inputTargets) { for (const InputTarget& inputTarget : inputTargets) { // 找到目标窗口的Connection sp<Connection> 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的异步机制,一个窗口卡住就会导致整个系统无法响应按键。如果没有事件队列的优先级设计,按键事件可能会被触摸事件阻塞。
我个人建议,在开发过程中遇到按键相关的问题时,先理清事件当前处于哪个环节。是还没到达应用进程?还是到了但没被处理?还是处理了但结果不对?定位到具体环节后,问题就好解决了。