第三讲: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队列
}
优化小技巧:InputReader 的处理频率由 EventHub::getEvents() 阻塞时长决定。如果你在低端设备上遇到输入延迟,可以尝试调整 epoll 超时时间,但别改太小,否则 CPU 空转。

3.2 InputDispatcher — 事件分发的决策者

InputDispatcher 是 Input 系统的大脑。它从 Reader 的队列里取出事件,然后决定「发给谁」。这里的核心数据是 焦点窗口(focused window) 和 触摸目标(touch targets)。

它的工作流程可以分为三大步:

  1. 窗口查询:从 WindowManagerService 拿到当前所有可见窗口的 Z-order 和焦点信息。
  2. 命中测试:如果是触摸事件,根据坐标计算出哪一个窗口应该接收。这一步涉及 HitTestResult,其实是个简单的矩形相交判断。
  3. 序列化分发:通过 InputChannel 把事件发送给目标窗口的 Input 接收线程。

记得我之前排查过一个「偶尔点不到按钮」的 bug 吗?最后发现是因为 Dispatcher 在做命中测试时,忽略了窗口的缩放变换。系统窗口有 scale 属性,但 Dispatcher 没有考虑,所以点击偏移了。那是 Android 10 之前的一个坑,后来的版本修了。嗯,这里要注意。

分发优先级

事件类型 分发策略 常见问题
按键 KeyEvent 始终发给焦点窗口 焦点丢失导致按键未被处理
触摸 MotionEvent 按坐标命中测试,多个窗口可同时接收 坐标变换错误、遮挡判断不准确
鼠标/触控板 类似触摸,但需处理悬浮悬停 某些设备上报的坐标范围不一致
避坑指南:我曾经在自定义 Launcher 上遇到一次 ANR,原因是 Dispatcher 向一个已死掉的窗口的 InputChannel 发送事件,但该 Channel 已经没有对端读取,导致事件无法入队,最终超时。解决办法:监控 WindowManagerService 的窗口移除广播,及时解绑 Channel。

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  // ← 这个数字越大,说明积压越严重
      ...
优化实战:如果 app 经常出现 pendingEvents 超过 20,说明主线程的 input 处理瓶颈。可以考虑异步处理事件,或者把部分触摸逻辑放到 RenderThread 上。但注意:大多数事件的响应必须在主线程,否则会破坏输入顺序。

3.4 三者协作 — 一张图看懂全流程

下面这张图完整描述了 InputReader、InputDispatcher 和 InputChannel 之间的数据流动和依赖关系,你拿起手机按一下,背后就是这么跑的:

Input系统架构:Reader → Dispatcher → Channel EventHub epoll监听/dev/input/* 原始事件 (RawEvents) InputReader 解析、归一化、去抖动 放入InputEventQueue InputDispatcher 窗口查询+命中测试 序列化、分发策略 ANR监控 InputChannel Unix domain socket 环形缓冲区 App进程 InputEventReceiver 数据流方向:内核 → Reader → Dispatcher → Channel → App 性能关键指标 • Reader轮询延迟:一般 < 2ms,超过就说明系统负载过高 • Dispatcher命中测试时间:单窗口 < 1ms,多窗口叠加需要小心 • InputChannel缓冲积压数:pendingEvents > 10 时需预警 • ANR阈值:单次分发等待超过 5 秒(受窗口超时策略影响)

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
这个链条里,任何一处阻塞都会让用户体验变糟。