qingmei2/blogs

反思|Android 输入系统 & ANR机制的设计与实现

qingmei2 opened this issue · 1 comments

反思|Android 输入系统 & ANR机制的设计与实现

反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里

概述

对于Android开发者而言,ANR是一个老生常谈的问题,站在面试者的角度,似乎说出 「不要在主线程做耗时操作」 就算合格了。

但是,ANR机制到底是什么,其背后的原理究竟如何,为什么要设计出这样的机制?这些问题时时刻刻会萦绕脑海,而想搞清楚这些,就不得不提到Android自身的 输入系统Input System)。

Android自身的 输入系统 又是什么?一言以蔽之,任何与Android设备的交互——我们称之为 输入事件,都需要通过 输入系统 进行管理和分发;这其中最靠近上层,并且最典型的一个小环节就是View事件分发 流程。

这样看来,输入系统 本身确实是一个非常庞大复杂的命题,并且,越靠近底层细节,越容易有一种 只见树木不见树林 之感,反复几次,直至迷失在细节代码的较真中,一次学习的努力尝试付诸东流。

因此,控制住原理分析的粒度,在宏观的角度,系统地了解输入系统本身的设计理念,并引申到实际开发中的ANR现象的原理和解决思路 ,是一个非常不错的理论与实践相结合的学习方式,这也正是笔者写作本文的初衷。

本文篇幅较长,思维导图如下:

一、自顶向下探索

谈到Android系统本身,首先,必须将 应用进程系统进程 有一个清晰的认知,前者一般代表开发者依托Android平台本身创造开发的应用;后者则代表 Android系统自身创建的核心进程。

这里我们抛开 应用进程 ,先将视线转向 系统进程,因为 输入系统 本身是由后者初始化和管理调度的。

Android系统在启动的时候,会初始化zygote进程和由zygote进程fork出来的SystemServer进程;作为 系统进程 之一,SystemServer进程会提供一系列的系统服务,而接下来要讲到的InputManagerService也正是由 SystemServer 提供的。

SystemServer的初始化过程中,InputManagerService(下称IMS)和WindowManagerService(下称WMS)被创建出来;其中WMS本身的创建依赖IMS对象的注入:

// SystemServer.java
private void startOtherServices() {
 // ...
 InputManagerService inputManager = new InputManagerService(context);
 // inputManager作为WindowManagerService的构造参数
 WindowManagerService wm = WindowManagerService.main(context,inputManager, ...);
}

输入系统 中,WMS非常重要,其负责管理IMSWindowActivityManager之间的通信,这里点到为止,后文再进行补充,我们先来看IMS

顾名思义,IMS服务的作用就是负责输入模块在Java层级的初始化,并通过JNI调用,在Native层进行更下层输入子系统相关功能的创建和预处理。

JNI的调用过程中,IMS创建了NativeInputManager实例,NativeInputManager则在初始化流程中又创建了EventHubInputManager:

NativeInputManager::NativeInputManager(jobject contextObj, jobject serviceObj, const sp<Looper>& looper) : mLooper(looper), mInteractive(true) {
    // ...
    // 创建一个EventHub对象
    sp<EventHub> eventHub = new EventHub();
    // 创建一个InputManager对象
    mInputManager = new InputManager(eventHub, this, this);
}

此时我们已经处于Native层级。读者需要注意,对于整个Native层级而言,其向下负责与Linux的设备节点中获取输入,向上则与靠近用户的Java层级相通信,可以说是非常重要。而在该层级中,EventHubInputManager又是最核心的两个角色。

这两个角色的职责又是什么呢?首先来说EventHub,它是底层 输入子系统 中的核心类,负责从物理输入设备中不断读取事件(Event),然后交给InputManager,后者内部封装了InputReaderInputDispatcher,用来从EventHub中读取事件和分发事件:

InputManager::InputManager(...) {
    mDispatcher = new InputDispatcher(dispatcherPolicy);
    mReader = new InputReader(eventHub, readerPolicy, mDispatcher);
    initialize();
}

简单来看,EventHub建立了Linux与输入设备之间的通信,InputManager中的InputReaderInputDispatcher负责了输入事件的读取和分发,在 输入系统 中,两者的确非常重要。

这里借用网上的图对此进行一个简单的概括:

二、EventHub 与 epoll 机制

对于EventHub的具体实现,绝大多数App开发者也许并不需要去花太多时间深入——简单了解其职责,然后一笔带过似乎是笔划算的买卖。

但是在EventHub的实现细节中笔者发现,其对epoll机制的利用是一个非常经典的学习案例,因此,花时间稍微深入了解也绝对是一举两得。

上文说到,EventHub建立了Linux与输入设备之间的通信,其实这种描述是不准确的,那么,EventHub是为了解决什么问题而设计的呢,其具体又是如何实现的?

1、多输入设备与输入子系统

我们知道,Android设备可以同时连接多个输入设备,比如 屏幕键盘鼠标 等等,用户在任意设备上的输入都会产生一个中断,经由Linux内核的中断处理及设备驱动转换成一个Event,最终交给用户空间的应用程序进行处理。

Linux内核提供了一个便于将不同设备不同数据接口统一转换的抽象层,只要底层输入设备驱动程序按照这层抽象接口实现,应用就可以通过统一接口访问所有输入设备,这便是Linux内核的 输入子系统

那么 输入子系统 如何是针对接收到的Event进行的处理呢?这就不得不提到EventHub了,它是底层Event处理的枢纽,其利用了epoll机制,不断接收到输入事件Event,然后将其向上层的InputReader传递。

2、什么是epoll机制

这是常见于面试Handler相关知识点时的一道进阶题,变种问法是:「既然Handler中的Looper中通过一个死循环不断轮询,为什么程序没有因为无限死循环导致崩溃或者ANR?」

读者应该知道,Handler简单的利用了epoll机制,做到了消息队列的阻塞和唤醒。关于epoll机制,这里有一篇非常经典的解释,不了解其设计理念的读者 有必要 了解一下:

知乎:epoll或者kqueue的原理是什么?

参考上文,这里我们对epoll机制进行一个简单的总结:

epoll可以理解为event poll,不同于忙轮询和无差别轮询,在 多个输入流 的情况下,epoll只会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。

EventHub中使用epoll的恰到好处——多个物理输入设备对应了多个不同的输入流,通过epoll机制,在EventHub初始化时,分别创建mEpollFdmINotifyFd;前者用于监听设备节点是否有设备文件的增删,后者用于监听是否有可读事件,创建管道,让InputReader来读取事件:

三、事件的读取和分发

本章节将对InputReaderInputDispatcher进行系统性的介绍。

1、InputReader:读取事件

InputReader是什么?简单理解InputReader的作用,通过从EventHub获取事件后,将事件进行对应的处理,然后将事件进行封装并添加到InputDispatcher的队列中,最后唤醒InputDispatcher进行下一步的事件分发。

乍得一看,在 输入系统Native层中,InputReader似乎平凡无奇,但越是看似朴实无华的事物,在整个流程中往往占据绝对重要的作用。

首先,EventHub传过来的Event除了普通的 输入事件 外,还包含了设备本身的增、删、扫描 等事件,这些额外的事件处理并没有直接交给InputDispatcher去分发,而是在InputReader中进行了处理。

当某个时间发生——可能是用户 按键输入,或者某个 设备插入,亦或 设备属性被调整epoll_wait()返回并将Event存入。

这之后,InputReader对输入事件进行了一次读取,因为不同设备对事件的处理逻辑又各自不同,因此InputReader内部持有一系列的Mapper对事件进行 匹配 ,如果不匹配则忽略事件,反之则将Event封装成一个新的NotifyArgs数据对象,准备存入队列中,即唤醒InputDispatcher进行分发。

巧妙的是,在唤醒InputDispatcher进行分发之前,InputReader在自己的线程中先执行了一个很特殊的 拦截操作 环节。

2、输入事件的拦截和转换

读者知道,在应用开发中,一些特殊的输入事件是无法通过普通的方式进行拦截的;比如音量键,Power键,电话键,以及一些特殊的组合键,这里我们通称为 系统按键

这点无可厚非,虽然Android系统对于开发者足够的开放,但是一切都是有限制的,绝大多数的 用户按键 通常可以被应用拦截处理,但是 系统按键 绝对不行——这种限制往往能够给予用户设备安全最后的保障。

因此,在InputReader唤醒InputDispatcher进行事件分发之前,InputReader在自己的线程中进行了两轮拦截处理。

首先的第一轮拦截操作就是对 系统按键 级别的 输入事件 进行处理,对于手机而言,这个工作是在PhoneWindowManager中完成;举例来说,当用户按了Power(电源)键,Android设备本身会切唤醒或睡眠——即亮屏和息屏。

这也正是「在技术论坛中,通常对 系统按键 拦截处理的技术方案,基本都是需要修改PhoneWindowManager的源码」的原因。

接下来输入事件进入到第二轮的处理中,如果用户在Setting->Accessibility中选择打开某些功能,以 手势识别 为例,AndroidAccessbilityManagerService(辅助功能服务) 可能会根据需要转换成新的Event,比如说两根手指头捏动的手势最终会变成ZoomEvent

需要注意的是,这里的拦截处理并不会真正将事件 消费 掉,而是通过特殊的方式将事件进行标记(policyFlags),然后在InputDispatcher中处理。

至此,InputReader输入事件 完整的一轮处理到此结束,这之后,InputReader又进入了新一轮等待。

3、InputDispatcher:分发事件

wake()函数将在Looper中睡眠等待的InputDispatcher唤醒时,InputDispatcher开始新一轮事件的分发。

准确来说,InputDispatcher被唤醒时,wake()函数实际是在InputManagerService的线程中执行的,即整个流程的线程切换顺序为InputReaderThread -> InputManagerServiceThread -> InputDispatcherThread

InputDispatcher的线程负责将接收到的 输入事件 分发给 目标应用窗口,在这个过程中,InputDispatcher首先需要对上个环节中标记了需要拦截的 系统按键 相关事件进行拦截,被拦截的事件至此不再向下分发。

这之后,InputDispatcher进入了本文最关键的一个环节——调用 findFocusedWindowTargetLocked()获取当前的 焦点窗口 ,同时检测目标应用是否有ANR发生。

如果检测到目标窗口处于正常状态,即ANR并未发生时,InputDispatcher进入真正的分发程序,将事件对象进行新一轮的封装,通过SocketPair唤醒目标窗口所在进程的Looper线程,即我们应用进程中的主线程,后者会读取相应的键值并进行处理。

表面来看,整个分发流程似乎干净简洁且便于理解,但实际上InputDispatcher整个流程的逻辑十分复杂,试想一次事件分发要横跨3个线程的流程又怎会简单?

此外,InputDispatcher还负责了 ANR 的处理,这又导致整个流程的复杂度又上升了一个层级,这个流程我们在后文的ANR章节中进行更细致的分析,因此先按住不提。

接下来,我们来看看整个 输入事件 的分发流程中, 应用进程 是如何与 系统进程 建立相应的通信链接的。

4、通过Socket建立通信

关于 跨进程通信的建立 这一节,笔者最初打算作为一个大的章节来讲,但是对于整个 输入系统 而言,其似乎又只是一个 重要非必需 的知识点。最终,笔者将其放在一个小节中进行简单的描述,有兴趣的读者可以在文末的参考链接中查阅更详尽的资料。

我们知道,InputReaderInputDispatcher运行在system_server 系统进程 中,而用户操作的应用都运行在自己的 应用进程 中;这里就涉及到跨进程通信,那么 应用进程 是如何与 系统进程 建立通信的呢?

让我们回到文章最初WindowManagerService(WMS)InputManagerService(IMS)初始化的流程中来,当IMS以及其他的系统服务初始化完成之后,应用程序开始启动。

如果一个应用程序有Activity(只有Activity能够接受用户输入),那么它要将自己的Window注册到WMS中。

在这里,Android使用了Socket而不是Binder来完成。WMS中通过OpenInputChannelPair生成了两个SocketFD, 代表一个双向通道的两端:向一端写入数据,另外一端便可以读出;反之,如果一端没有写入数据,另外一端去读,则陷入阻塞等待。

最终InputDispatcher中建立了目标应用的Connection对象,代表与远端应用的窗口建立了链接;同样,应用进程中的ViewRootImpl创建了WindowInputEventReceiver用于接受InputDispatchor传过来的事件:

这里我们对该次 跨进程通信建立流程 有了初步的认知,对于Android系统而言,Binder是最广泛的跨进程通信的应用方式,但是Android系中跨进程通信就仅仅只用到了Binder吗?答案是否定的,至少在 输入系统 中,除了Binder之外,Socket同样起到了举足轻重的作用。

那么新的问题就来了,这里为什么选择Socket而不是选择Binder呢,关于这个问题的解释,笔者找到了一个很好的版本:

Socket可以实现异步的通知,且只需要两个线程参与(Pipe两端各一个),假设系统有N个应用程序,跟输入处理相关的线程数目是 N+1 (1Input Dispatcher线程)。然而,如果用Binder实现的话,为了实现异步接收,每个应用程序需要两个线程,一个Binder线程,一个后台处理线程(不能在Binder线程里处理输入,因为这样太耗时,将会堵塞住发送端的调用线程)。在发送端,同样需要两个线程,一个发送线程,一个接收线程来接收应用的完成通知,所以,N个应用程序需要 2(N+1)个线程。相比之下,Socket还是高效多了。

现在,应用进程 能够收到由InputDispatcher处理完成并分发过来的 输入事件 了。至此,我们来到了最熟悉的应用层级事件分发流程。对于这之后 应用层级的事件分发,可以阅读下述笔者的另外两篇文章,本文不赘述。

反思|Android 输入系统 & ANR机制的设计与实现

四、ANR机制的设计与实现

输入系统 有了更初步整体的认知之后,接下来本文将针对ANR机制进行更深一步的探索。

通常来讲,ANR的来源分为Service、Broadcast、Provider以及Input两种。

这样区分的原因是,首先,前者发生在 应用进程 组件中的ANR问题通常是相对好解决的,若ANR本身容易复现,开发者通常仅需要确定组件的代码中是否在 主线程中做了耗时处理;而后者ANR发生的原因为 输入事件 分发超时,包括按键和屏幕的触摸事件,通过阅读上一章节,读者知道 输入系统 中负责处理ANR问题的是处于 系统进程 中的InputDispatcher,其整个流程相比前者而言逻辑更加复杂。

简单理解了之后,读者需要知道,「组件类ANR发生原因通常是由于 主线程中做了耗时处理」这种说法实际上是笼统的,更准确的讲,其本质的原因是 组件任务调度超时,而在设备资源紧凑的情况下,ANR的发生更多是综合性的原因。

Input类型的ANR相对于Service、Broadcast、Provider,其内部的机制又截然不同。

1、第一类原理概述

具体不同在哪里呢,对于Service、Broadcast、Provider组件类的ANR而言,Gityuan这篇文章 中做了一个非常精妙的解释:

ANR是一套监控Android应用响应是否及时的机制,可以把发生ANR比作是 引爆炸弹,那么整个流程包含三部分组成:

  • 埋定时炸弹:中控系统(system_server进程)启动倒计时,在规定时间内如果目标(应用进程)没有干完所有的活,则中控系统会定向炸毁(杀进程)目标。
  • 拆炸弹:在规定的时间内干完工地的所有活,并及时向中控系统报告完成,请求解除定时炸弹,则幸免于难。
  • 引爆炸弹:中控系统立即封装现场,抓取快照,搜集目标执行慢的罪证(traces),便于后续的案件侦破(调试分析),最后是炸毁目标。

将组件的ANR机制比喻为 定时炸弹 非常贴切,以Service为例,对于Android系统而言,启动一个服务其本质是进程间的异步通信,那么,如何判断Service是否启动成功,如果一直没有成功,那么如何处理?

因此Android设计了一个 置之死地而后生 的机制,在尝试启动Service时,让中控系统system_server埋下一个 定时炸弹 ,当Service完成启动,拆掉炸弹;否则在system_serverActivityManager线程中引爆炸弹,这就是组件类ANR机制的原理:

接下来简单了解一下 输入系统 流程中ANR机制的原理。

2、第二类原理概述

Input类型的ANR在日常开发中更为常见且更复杂,比如用户或者测试反馈,点击屏幕中的UI元素导致「卡死」。

少数情况下开发者能够很快定位到问题,但更常见的情况是,该问题是 随机难以复现 的,导致该问题的原因也更具有综合性,比如低端设备的系统本身资源已非常紧张,或者多线程相互持有彼此需要的资源导致 死锁 ,亦或其它复杂的情况,因此处理这类型问题就需要开发者对 输入系统 中的ANR机制有一定的了解。

与组件类ANR不同的是,Input类型的超时机制并非时间到了一定就会爆炸,而是处理后续上报事件的过程才会去检测是否该爆炸,所以更像是 扫雷 的过程。

什么叫做 扫雷 呢,对于 输入系统 而言,即使某次事件执行时间超过预期的时长,只要用户后续没有再生成输入事件,那么也不需要ANR

而只有当新一轮的输入事件到来,此时正在分发事件的窗口(即App应用本身)迟迟无法释放资源给新的事件去分发,这时InputDispatcher才会根据超时时间,动态的判断是否需要向对应的窗口提示ANR信息。

这也正是用户在第一次点击屏幕,即使事件处理超时,也没有弹出ANR窗口,而当用户下意识再次点击屏幕时,屏幕上才提示出了ANR信息的原因。

由此可见,组件类ANRInput ANR原理上确实有所不同;除此之外,前者是在ActivityManager线程中处理的ANR信息,后者则是在InputDispatcher线程中处理的ANR,这里通过一张图简单了解一下后者的整体流程:

现在我们对Input类型的ANR机制有了一个简单的了解,下文将针对其更深入性的细节实现进行探讨。

3、事件分发的异步机制

我们再次将目光转回到InputDispatcher的实现细节。

先抛出一个新的问题,对处于system_server进程Native层级的 事件分发 而言,其向下与 应用进程 的通信的过程应该是同步还是异步的?

对于读者而言,不难得出答案是异步的,因为两者之间双向通信的建立是通过SocketPair,并且,因为system_serverInputDispatcher对事件的分发实际上是一对多的,如果是同步的,那么一旦其中一个应用分发超时,那么InputDispatcher线程自然被卡住,其永远都不可能进入到下一轮的事件分发中,扫雷 机制更是无从谈起。

因此,与应用进程中事件分发不同的是,后者我们通常可以认为是在主线程中同步的,而对于整个 输入系统 而言,因为涉及到 系统进程 与多个 应用进程 之间异步的通信,因此其内部的实现更为复杂。

因为事件分发涉及到异步回调机制,因此InputDispatcher需要对事件进行维护和管理,那么问题就变成了,使用什么样的数据结构去维护这些输入事件比较合适。

4、三个队列

InputDispatcher的源码实现中,整体的事件分发流程共使用到3个事件队列:

  • mInBoundQueue:用于记录InputReader发送过来的输入事件;
  • outBoundQueue:用于记录即将分发给目标应用窗口的输入事件;
  • waitQueue:用于记录已分发给目标应用,且应用尚未处理完成的输入事件。

下文,笔者通过2轮事件分发的示例,对三个队列的作用进行简单的梳理。

4.1 第一轮事件分发

首先InputReader线程通过EventHub监听到底层的输入事件上报,并将其放入了mInBoundQueue中,同时唤醒了InputDispatcher线程。

然后InputDispatcher开始了第一轮的事件分发,此时并没有正在处理的事件,因此InputDispatchermInBoundQueue队列头部取出事件,并重置ANR的计时,并检查窗口是否就绪,此时窗口准备就绪,将该事件转移到了outBoundQueue队列中,因为应用管道对端连接正常,因此事件从outBoundQueue取出,然后放入了waitQueue队列,因为Socket双向通信已经建立,接下来就是 应用进程 接收到新的事件,然后对其进行分发。

如果 应用进程 事件分发正常,那么会通过Socketsystem_server通知完成,则对应的事件最终会从waitQueue队列中移除。

4.2 第二轮事件分发

如果第一轮事件分发尚未接收到回调通知,第二轮事件分发抵达又是如何处理的呢?

第二轮事件到达InputDispatcher时,此时InputDispatcher发现有事件正在处理,因此不会从mInBoundQueue取出新的事件,而是直接检查窗口是否就绪,若未就绪,则进入ANR检测状态。

以下几种情况会导致进入ANR检测状态:

1、目标应用不会空,而目标窗口为空。说明应用程序在启动过程中出现了问题;
2、目标Activity的状态是Pause,即不再是Focused的应用;
3、目标窗口还在处理上一个事件。

读者需要理解,并非所有「目标窗口还在处理上一个事件」都会抛出ANR,而是需要通过检测时间,如果未超时,那么直接中止本轮事件分发,反之,如果事件分发超时,那么才会确定ANR的发生。

这也正是将Input类型的ANR描述为 扫雷 的原因:这里的扫雷是指当前输入系统中正在处理着某个耗时事件的前提下,后续的每一次input事件都会检测前一个正在处理的事件是否超时(进入扫雷状态),检测当前的时间距离上次输入事件分发时间点是否超时。如果前一个输入事件,则会重置ANRtimeout,从而不会爆炸。

至此,输入系统 检测到了ANR的发生,并向上层抛出了本次ANR的相关信息。

小结

本文旨在对Android 输入系统 进行一个系统性的概述,读者不应将本文作为唯一的学习资料,而应该通过本文对该知识体系进行初步的了解,并根据自身要求进行单个方向细节性的突破。而已经掌握了骨骼架构的读者而言,更细节性的知识点也不过是待丰富的血肉而已。

本文从立题至发布,整个流程耗时近1个半月,在这个过程中,笔者参考了较本文内容数十倍的资料,受益颇深,也深感以 举重若轻 为写文目标之艰难——内容铺展容易,但通过 简洁连贯 的语言来对一个庞大复杂的知识体系进行收拢,需要极强的 克制力 ,在这种严苛的要求下,每一句的描述都需要极高的 精确性 ,这对笔者而言是一个挑战,但真正完成之后,对整个知识体系的理解程度同样也是极高的。

而这也正是 反思 系列的初衷,希望你能喜欢。

参考 & 扩展阅读

正如上文所言,输入系统ANR 本身都是一个非常大的命题,除了宽广的知识体系,还需要亲身去实践和总结,下文列出若干相关参考资料,读者可根据自身需求选择性进行扩展阅读:

1、彻底理解安卓应用无响应机制 @Gityuan
2、Input系统—ANR原理分析 @Gityuan
3、理解Android ANR的触发原理 @Gityuan

深入学习ANR机制资料,GityuanANR博客系列绝对是先驱级别的,尤其是第1篇文章中,其对于 定时炸弹扫雷 的形容,贴切且易理解,这种 举重若轻 的写作风格体现了作者本身对整个知识体系的深度掌握;而后两篇文章则针对两种类型的ANR分别进行了源码级别的分析,非常下饭。

4、图解Android-Android的 Event Input System @漫天尘沙

笔者曾经想写一个 图解Android 系列,后来因为种种原因放弃了,没想到若干年前已经有先驱进行过了这样的尝试,并且,内容质量极高。笔者相信,能够花费非常大精力总结的文章一定不会被埋没,而这篇文章,注定会成为经典中的经典。

5、Android Input系列 @Stan_Z

一个笔者最近关注非常优秀的作者,文章非常具有深度,其Input系列针对整个输入系统进行了更细致源码级别的分析,非常值得收藏。

6、Android 信号处理面面观 之 信号定义、行为和来源 @rambo2188

如果读者对「Android系统信号处理的行为」感兴趣,那么这篇文章绝对不能错过。

7、Android开发高手课 @张绍文

实战中的经典之作,该课程每一小结都极具深度,价值不可估量。因或涉及到利益相关,而且推荐了也从张老师那里拿不到钱,因此本文不加链接并放在最下面(笑)。


关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub

如果您觉得文章还差了那么点东西,也请通过 关注 督促我写出更好的文章——万一哪天我进步了呢?