4. 事件输入流程:从硬件中断到应用层事件分发

事件输入这条路,说白了就是一条从物理世界到软件世界的通信管道。你按下一个按键,或者点一下触摸屏,这背后是硬件、内核、框架一层层接力跑完的。我最早调WMS的时候,总以为事件分发只是应用层的事,直到有一次触摸屏出现"幽灵点击"——点屏幕左边,右边却响应了。追到驱动层才发现是中断号配错了。嗯,从此我就养成了看完整条链路的习惯。

4.1 整体链路:一条事件的生命旅程

一张图比千言万语管用。下面这个svg展示了我做优化时常挂在墙上的标准流程——每个箭头都代表一次数据搬运或状态切换。

事件输入全流程 硬件中断 GPIO / I²C / USB 内核驱动 input device 注册 中断上报 Input 子系统 事件队列 / dev/input/ input_handle_event() EventHub epoll 多路复用 read() 从 /dev/input/ InputReader 原始事件 -> 关键事件 RawEvent 队列 InputDispatcher 窗口命中 / 分发 dispatch 应用层 onTouchEvent / Listener MessageQueue 注意:从硬件中断到应用层,整条链路延迟累加不可超过 16ms 才能保证 60fps 流畅度

我以前踩过一个坑:某款触摸屏在低电量下从硬件中断到内核上报花了约 12ms,加上后面的队列处理直接超了 16ms 阈值,导致画面掉帧。所以每个节点的时间戳我都在日志里打了出来。

4.2 硬件中断层:第一棒谁在跑?

你按屏幕,触控 IC 会拉高一个 GPIO 电平。CPU 检测到后暂停当前任务,跳到中断服务程序。我建议你在调试时先 cat /proc/interrupts 看看中断号对不对。

关键点:

  • 中断触发方式:边沿触发 vs 电平触发 —— 电平触发容易产生重复中断
  • 中断共享:多设备共用中断线时,需要轮询所有注册设备
  • 中断底半部(Bottom Half):用 tasklet 或 workqueue 避免在中断上下文做耗时操作

4.3 内核驱动与 Input 子系统

中断来了,驱动要做三件事:读硬件寄存器拿到坐标/按键值 -> 转换为 Linux 标准 input_event 结构体 -> 通过 input_report_key/input_report_abs 上报。你想想看,这一步如果用了 printk 来调试,整个系统的响应时间会被拖垮。我习惯用 ftrace 跟踪 input 事件时间戳。

struct input_event {
    struct timeval time;  // 硬件时间戳
    __u16 type;           // EV_KEY, EV_ABS, EV_REL ...
    __u16 code;           // 具体键值或坐标轴代码
    __s32 value;          // 0/1 或坐标值
};

内核中 /dev/input/eventX 节点的 poll/read 是同步的。多个设备同时上报时,读出的数据是交错的,必须靠 time 字段区分。我遇到过某厂商驱动忘了填充 time,导致 EventHub 解析乱序。

4.4 EventHub:用户空间的第一站

Android 里 EventHub 是一个 native 服务。它通过 epoll 同时监听所有 /dev/input/event* 节点。一旦有数据可读,它就调用 read() 获取 input_event 结构体,然后封装成 RawEvent 发给 InputReader。

曾经有个坑: 如果某个驱动输入节点在拔掉后没有正确注销,epoll 会一直返回 EPOLLHUP。我曾经被这个卡了两天,最后发现是驱动移除后没有调用 input_unregister_device。解决方案是在 EventHub 的 getEvents 中检查 fd 状态。

4.5 InputReader 和 InputDispatcher

这两个是 Android Input 系统的核心。把原始事件转换成 KeyEvent / MotionEvent,再根据窗口 Focus 分发到指定应用。

组件 职责 常见瓶颈
InputReader 解析 RawEvent,合成手势(如双指缩放) 合成算法耗时,尤其在多点触控时
InputDispatcher 窗口命中判定、策略拦截(如 Slogan) 窗口树遍历、IPC 跨进程发送

Dispatcher 有一个内部线程「InputDispatcherThread」。它从 InputReader 拿到的队列里取出事件,做 window handle 查找。

调优小技巧: 我建议在你自己的 WMS 进程监控 /proc/self/stat 里的 utime 和 stime。如果 stime 占比高说明内核态开销大,可能是驱动或 InputReader 的循环数太高。

4.6 从 Dispatcher 到应用层

Dispatcher 确定目标窗口后,通过 InputChannel(基于 socketpair)把事件发送给应用所在的进程。应用侧由 ViewRootImpl 的 InputEventReceiver 接收,然后通过 MessageQueue 抛到主线程。

这里有一个很容易被忽略的点:应用主线程如果正在做布局动画或绘制,MessageQueue 可能不会立即处理 input 消息。我见过一个 app 在 Activity 启动时做大量反射,导致点击事件延迟了 200ms。

// 应用端接收示意(简化)
InputEventReceiver receiver = new InputEventReceiver(inputChannel, looper);
receiver.setInputEventHandled(true);
// 在 dispatchInputEvent 里区分 KeyEvent 和 MotionEvent

4.7 总延迟模型与优化方向

把上面每段路的延迟加起来:

  • 硬件中断响应: ~0.5ms - 2ms(取决于 IRQ 处理函数长度)
  • 驱动上报: ~1ms - 5ms(包括读写 I²C/SPI 总线)
  • EventHub + InputReader: ~2ms - 4ms(队列拷贝和手势合成)
  • InputDispatcher: ~1ms - 3ms(窗口命中+IPC 传递)
  • 应用接收: ~0.5ms - 10ms(取决于主线程是否空闲)

整条链路如果在 16ms 以内就是合格。我自己的经验:当超过 20ms 时,用户能明显感知到「卡」。优先优化驱动 I²C 速率(从 400kHz 提到 1MHz 通常有明显收益)和减少 InputReader 里没必要的合成算法。

一句话总结: 一条事件从你手指按下去到界面响应,经历了 7 道关卡。每条关卡都可以用 perf 或 systrace 打点,找到最慢的那段开刀。别相信玄学调优,数据说话。