第三讲:Input系统架构 — InputReader、InputDispatcher、InputChannel
Android 的 Input 系统,说白了就是一套「事件流水线」。从你手指点下去那一刻,到 App 里的 onClick 回调被触发,背后经历了三个核心角色:InputReader 负责读取原始事件,InputDispatcher 负责分发给目标窗口,InputChannel 则是它们之间传递数据的管道。这三个家伙协同工作,任何一个环节出问题,都会导致卡顿、丢事件甚至 ANR。
我之前在优化一个视频播放器的投屏场景时,就遇到过因为 InputChannel 缓冲区太小导致事件丢失的 bug。查了整整两天,最后发现是进程间通信时队列满了,直接丢弃了后续的触摸事件。所以咱们今天就把它彻底搞清楚。
3.1 InputReader — 从内核到用户态的第一站
InputReader 运行在 system_server 进程的 InputReaderThread 里。它的任务就是不断从 EventHub 中读取原始输入事件(比如触摸坐标、按键码、滚轮值),然后解析成标准化的 RawEvent。
它的工作流其实很直观:
- 轮询 EventHub:通过
epoll监听 /dev/input/* 设备节点。每次有数据到达,EventHub 就会返回一批事件。 - 加工事件:把原始事件转换成框架能识别的结构,比如计算触摸点坐标、映射按键码、处理多点触控协议。
- 放入队列:加工好的事件不会直接分发,而是先扔进一个内部的
InputEventQueue,等待 Dispatcher 取走。
你可能会问:「为啥不直接让 Dispatcher 读呢?」嗯,这里有个设计考量——解耦。Reader 只管「读」和「翻译」,Dispatcher 只管「派」。二者之间用队列缓冲,可以平滑突发流量。我曾经在游戏机上碰到过手指狂点屏幕的场景,没有队列缓冲的话,Dispatcher 根本扛不住那瞬间的高频事件。
关键代码路径(简化)
// EventHub.cpp
while (true) {
size_t count = getEvents(&mPendingEventItems, ...);
for (size_t i = 0; i < count; i++) {
// 原始事件 -> RawEvent
}
}
// InputReader.cpp
void loopOnce() {
RawEvent events[MAX_QUEUE_SIZE];
size_t count = mEventHub->getEvents(&events, ...);
for (size_t i = 0; i < count; i++) {
process(&events[i]); // 解析、去抖动、插值等
}
mQueuedListener->flush(); // 推送到Dispatcher队列
}
EventHub::getEvents() 阻塞时长决定。如果你在低端设备上遇到输入延迟,可以尝试调整 epoll 超时时间,但别改太小,否则 CPU 空转。
3.2 InputDispatcher — 事件分发的决策者
InputDispatcher 是 Input 系统的大脑。它从 Reader 的队列里取出事件,然后决定「发给谁」。这里的核心数据是 焦点窗口(focused window) 和 触摸目标(touch targets)。
它的工作流程可以分为三大步:
- 窗口查询:从
WindowManagerService拿到当前所有可见窗口的 Z-order 和焦点信息。 - 命中测试:如果是触摸事件,根据坐标计算出哪一个窗口应该接收。这一步涉及
HitTestResult,其实是个简单的矩形相交判断。 - 序列化分发:通过
InputChannel把事件发送给目标窗口的 Input 接收线程。
记得我之前排查过一个「偶尔点不到按钮」的 bug 吗?最后发现是因为 Dispatcher 在做命中测试时,忽略了窗口的缩放变换。系统窗口有 scale 属性,但 Dispatcher 没有考虑,所以点击偏移了。那是 Android 10 之前的一个坑,后来的版本修了。嗯,这里要注意。
分发优先级
| 事件类型 | 分发策略 | 常见问题 |
|---|---|---|
| 按键 KeyEvent | 始终发给焦点窗口 | 焦点丢失导致按键未被处理 |
| 触摸 MotionEvent | 按坐标命中测试,多个窗口可同时接收 | 坐标变换错误、遮挡判断不准确 |
| 鼠标/触控板 | 类似触摸,但需处理悬浮悬停 | 某些设备上报的坐标范围不一致 |
3.3 InputChannel — 跨进程通信的管道
InputChannel 本质上是 一对 Unix domain socket 的封装。一端是 InputDispatcher(在 system_server),另一端是目标应用的 ViewRootImpl(在 App 进程)。它们通过 SocketPair() 创建两个 fd,一个用于发送,一个用于接收。
它的设计意图很明确:
- 无锁通信:写入端只管写,读取端只管读,没有共享内存的同步问题。
- 带缓冲队列:每个 Channel 内部维护一个环形缓冲区(
InputMessage的数组),默认大小一般几百字节,够放几十个事件。 - 超时机制:如果发送端等待对方读取超过 5 秒(默认),就会触发 ANR。
说白了,InputChannel 就是一条单向的公路。Dispatcher 是卡车司机,不停把事件打包扔到公路上。App 端的 InputEventReceiver 是卸货员,不停从公路上取货。如果卸货员动作慢(比如主线程在绘制动画),卡车就会在公路上堵住,最终导致整条路报废(ANR)。
缓冲区压力测试
我曾在某款视频 App 上做过一个实验:快速滑动列表,看看事件会不会被丢弃。结果发现当 App 的 UI 线程卡顿超过 100ms 时,InputChannel 的缓冲区就开始积压,后续的触摸事件被丢掉,用户会感觉到「滑一下没反应」。
// 查看当前 InputChannel 的缓冲状态(adb shell dumpsys input)
Input Dispatcher State:
...
Channels:
'Window #42 com.example.app/com.example.MainActivity':
fd: 134
isServer: true
pendingEvents: 12 // ← 这个数字越大,说明积压越严重
...
3.4 三者协作 — 一张图看懂全流程
下面这张图完整描述了 InputReader、InputDispatcher 和 InputChannel 之间的数据流动和依赖关系,你拿起手机按一下,背后就是这么跑的:
3.5 三个常见优化场景
光讲理论没用,咱们看看实际优化中遇到的三个典型场景。
- 场景一:快速滑动掉帧 —— 原因是 InputChannel 缓冲区太小 + App 主线程处理帧不及时。我曾经把一个视频流应用的 InputChannel 缓冲区从 16 个事件提升到 32 个,丢帧率从 12% 降到 3%。但注意不是越大越好,太大会增加内存和 ANR 风险。
- 场景二:多点触控乱序 —— 发生在某些定制手机的内核修改之后,Reader 得到的原始事件里,手指的 id 被重用了。解决方法是在 Reader 的 process 函数里增加手指 ID 的校验逻辑。
- 场景三:后台 App 突然 ANR —— 我查过一个案子:App 被切到后台后,WMS 把焦点窗口移走了,但 InputChannel 没有关闭,Dispatcher 还在往里面塞事件。App 的 InputEventReceiver 已经停止读取,导致队列撑满,最终 Dispatcher 报 ANR。修复方案:在 App 进入后台时主动解除 InputChannel 绑定。
手指触摸 -> 内核生成驱动事件
-> EventHub 通过 epoll 唤醒 InputReader
-> InputReader 解析成 MotionEvent (含所有坐标点)
-> 放入 Dispatcher 的队列
-> Dispatcher 查找到目标窗口 (通过 SurfaceFlinger 的 Layer 信息)
-> 通过 InputChannel 发送给 App 的 Socket
-> App 的 InputEventReceiver 从 fd 读取
-> ViewRootImpl 把事件交给 DecorView
-> 最终传到你的 OnTouchListener
这个链条里,任何一处阻塞都会让用户体验变糟。