2016年年底的时候,给一个App适配了D-pad,D-pad就是下图红框里的东西:
诺基亚手机 D-pad对App来说,摸触摸屏产生的是TouchEvent,按D-pad产生的是KeyEvent。由于带键盘或者D-pad的Android手机早就消失了,所以平时主要关注的是TouchEvent的分发和处理,对KeyEvent不甚了解。由于那个App的自定义View相当复杂,且不符合Android标准View结构,D-pad适配工作需要了解KeyEvent分发和处理的流程,甚至要给那些自定义View建立一套KeyEvent分发机制,这里分享一下当时的学习成果。
虽然现在基本看不到带键盘和D-pad的Android手机,但实际上Android是原生支持D-pad的,只是现在大家只生产触摸屏手机而已了。Android使用TouchMode区分触摸屏控制和D-pad控制,现在的触屏手机默认处于TouchMode。 TouchMode下打开App,默认没有焦点。
TouchMode下打开应用抽屉,无焦点如果按下了D-pad上的方向键,就会退出TouchMode,此时系统会在屏幕上找一个focusable的View,使其获得焦点。在非TouchMode的情况下,如果打开某个App,系统也会在App的视图里面找一个focusable的View默认授予其焦点,这一步是在
中完成的。下图可以看到焦点在Settings上。 非TouchMode下打开应用抽屉,焦点在Settings上
- ViewRootImpl#performTraversals
KeyEvent和TouchEvent的分发流程中最大的差异就在焦点上,这也是它相对简单的原因。KeyEvent的分发流程非常简单,那就是直接给当前获取了焦点的View,谁有焦点,KeyEvent就给谁。
对于App的Java层来说,
中
- ViewRootImpl
被回调的时候,就开始了事件的处理,TouchEvent和KeyEvent会从这里出现,随后会交给一系列
- WindowInputEventReceiver#onInputEvent
处理,这里使用了职责链模式,这些
- InputStage
以链表的形式连接,事件从链表头传递到链表尾。
- InputStage
有好几个,其中和App联系最紧密的是
- InputStage
,
- ViewPreImeInputStage
,
- EarlyPostImeInputStage
。KeyEvent也会依次经过这三个步骤,即
- ViewPostImeInputStage
=>
- ViewPreImeInputStage
=>
- EarlyPostImeInputStage
- ViewPostImeInputStage
既然现在的手机默认处于TouchMode,App打开的时候也处于TouchMode,我们是怎样退出TouchMode的?
当我们按下某些按键,如D-pad上面的任意一个键时,就会退出TouchMode。
中发现特定的KeyEvent经过它时,就会尝试退出TouchMode。 退出TouchMode时会尝试在
- EarlyPostImeInputStage
中找一个focusable的View,搜索方向为
- ViewRootImpl#mView
即自上向下搜索,找到这个View后调用它的
- View.FOCUS_DOWN
方法,使得它获得焦点。
- requestFocus
就是
- ViewRootImpl#mView
。
- DecorView
寻找这么一个View的步骤很简单:
第二点可以暂时先这么理解,虽然它的本质是计算两个Rect在某个方向上的距离问题。显然,在下图中,Settings Rect是最接近左上角的,那么当退出TouchMode时,一定是Settings自动获得焦点。
最接近左上角的Settings Rect要获取所有focusable View,乍一看只要遍历View,查询focusable状态就可以了,实际上并没有这么简单,因为ViewGroup有
属性,会影响到它和它的子View的焦点关系。 因此获取所有focusable View的过程如下: 调用
- descendantFocusability
,即
- mView
的
- DecorView
,其中
- addFocusables(ArrayList<View> views, int direction, int focusableMode)
为外部传入的一个空ArrayList,调用返回后,里面就是所有的focusable View。
- views
是View的方法,ViewGroup重写。
- addFocusables
如果当前ViewGroup focusable,且设为
,只需要将自己添加到ArrayList中,方法执行结束。 如果当前ViewGroup focusable,且设为
- FOCUS_BLOCK_DESCENDANTS
,将自己添加到ArrayList中。 将所有VISIBLE的子View,按照其Rect的在父控件中的位置排序后调用其
- FOCUS_BEFORE_DESCENDANTS
,排序方式简单来讲就是优先从上到下,其次从左到右。 最后如果ViewGroup focusable,且设为
- addFocusables
,将自己添加到ArrayList中。
- FOCUS_AFTER_DESCENDANTS
只要当前View是focusable的,就把自己添加到ArrayList中。
一般而言,输入法优先处理KeyEvent,然而实际上App可以抢在输入法之前处理KeyEvent,相关逻辑在
中,
- ViewPreImeInputStage
中会调用
- ViewPreImeInputStage
的
- mView
,实际调的就是
- dispatchKeyEventPreIme
的方法
- ViewGroup
- @Override
- public boolean dispatchKeyEventPreIme(KeyEvent event) {
- if ((mPrivateFlags & (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS))
- == (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS)) {
- return super.dispatchKeyEventPreIme(event);
- } else if (mFocused != null && (mFocused.mPrivateFlags & PFLAG_HAS_BOUNDS)
- == PFLAG_HAS_BOUNDS) {
- return mFocused.dispatchKeyEventPreIme(event);
- }
- return false;
- }
这个方法默认直接向当前获取了焦点的View派发事件,如果没有获取了焦点的View,则什么都不做。
App开发者只需要重写对应View的
即可在输入法之前处理KeyEvent,如果想干预这种情况下的分发流程,可以重写对应View的
- onKeyPreIme
。这个所谓的"对应的View"指的是当前获取了焦点的View,比如EditText。
- dispatchKeyEventPreIme
需要注意的是,在没有焦点的情况下,
是不会被回调的。所以一般我们只会重写EditText的
- onKeyPreIme
。
- onKeyPreIme
这个步骤处理退出TouchMode以及自动将焦点移交给一个View的事务,在前面已经讲了。
KeyEvent经过一系列步骤之后,没有处理的KeyEvent,最终会交给这个步骤处理,就是在这个步骤,KeyEvent被交给
处理。
- DecorView
重写了
- DecorView
,将事件又交给
- dispatchKeyEvent
处理,以前Android的MENU键,点击之后能展开ActionBar的菜单,就是在Activity里处理的,个人觉得ActionBar这个设计挺蠢的。 Activity处理了一下KEYCODE_MENU后,又调用了
- Activity#dispatchKeyEvent
,这玩意儿又直接调用了
- PhoneWindow#superDispatchKeyEvent
- DecorView#superDispatchKeyEvent
- public boolean superDispatchKeyEvent(KeyEvent event) {
- // Give priority to closing action modes if applicable.
- if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
- final int action = event.getAction();
- // Back cancels action modes first.
- if (mPrimaryActionMode != null) {
- if (action == KeyEvent.ACTION_UP) {
- mPrimaryActionMode.finish();
- }
- return true;
- }
- }
- return super.dispatchKeyEvent(event);
- }
这里ActionMode不用管,直接看
,它还是再调ViewGroup的方法,所以调了半天,又回到了ViewGroup体系里面。
- super.dispatchKeyEvent(event)
- @Override
- public boolean dispatchKeyEvent(KeyEvent event) {
- ......
- if ((mPrivateFlags & (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS))
- == (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS)) {
- if (super.dispatchKeyEvent(event)) {
- return true;
- }
- } else if (mFocused != null && (mFocused.mPrivateFlags & PFLAG_HAS_BOUNDS)
- == PFLAG_HAS_BOUNDS) {
- if (mFocused.dispatchKeyEvent(event)) {
- return true;
- }
- }
- ......
- return false;
- }
可以看到KeyEvent的分发非常简单,那就是直接给有焦点的View,如果没有任何有焦点的View,则不处理。至此KeyEvent是如何从ViewRootImpl来到我们的View中的问题就清楚了。
我们平时写代码,从来没有碰过焦点相关的问题,只需要按照系统的要求,在
里面返回正确的值即可,也就是说,焦点的切换是系统帮我们做的,那么系统如何知道在什么时机切换焦点呢?关键就是
- onKeyXXX
的返回值,返回false表示KeyEvent没有被处理。
- dispatchKeyEvent
中,如果事件一直得不到处理,最终会走到
- ViewPostImeInputStage
中,尝试进行焦点切换 焦点切换要和视觉上View的位置相匹配,如图所示,当按下DPAD_RIGHT时,焦点应该沿红色箭头移动,按下DPAD_DOWN时,焦点沿黄色箭头移动,无论按什么,都不可能沿蓝色箭头移动。 focus切换与视觉位置
- ViewPostImeInputStage#performFocusNavigation
焦点切换流程如下:
搜索下一个焦点View的过程和退出TouchMode时搜寻焦点View的过程类似:
对于Rect之间谁最接近谁的问题,可以看下面的图。
如果我们当前焦点在Settings上,当按下DPAD_RIGHT时,从左往右,离Settings Rect最近的是UC Rect,那么UC将获得焦点。
如果是退出TouchMode,搜寻第一个focusable的View的过程,则可以视为计算DecorView中所有focusable View的Rect与屏幕左上角看不见的一个非常小的Rect的距离的过程。
第一个焦点的产生因此,无论是搜寻第一个焦点落在哪里,还是搜寻下一个焦点落在哪里,本质上都只是某个方向上Rect距离的计算问题。 如果你看懂了上面说的东西,这里依然有一些事情需要注意。
经过上面的讨论,我们可以看到,KeyEvent的分发以及焦点的自动切换,是以ViewRootImpl为单位的,即以Window为单位,在Android中,我们的Activity是一个Window,一个Dialog也是一个Window。
焦点自动切换的前提是ViewRootImpl都能正确获取所有focusable View的坐标,这需要App内的视图遵循Android的标准View结构。
这就给自动焦点切换带来了两个限制:
对于有复杂结构的自绘控件,对Android系统来说,这个控件只是一个View,焦点切换以View为基本单位,因此对于自绘控件内部的焦点问题,系统无法处理,需要开发者自行处理。 对于单Activity结构的App,很可能出现多个ViewGroup叠在一起的情况,而焦点自动切换中,ViewRootImpl只会获取focusable View的Rect,没有Z轴的信息,因此自动焦点切换难以应付叠放的ViewGroup。
以前我一直不明白focusable和focusableInTouchMode有什么区别,因为在触屏手机的时代,我们很难察觉到他们之间的区别,如果我们尝试给一个触屏App适配D-pad,使用触屏手机开发,使用D-pad手机测试,就会发现他们之间的区别。
比如一个Button,在D-pad手机上,需要通过高亮来告诉用户焦点在它上面,而在触屏机上,用户想点什么直接点就行了,Button完全不需要有焦点,因此,Button的focusable属性默认为true,focusableInTouchMode默认为false(为true也没关系),这样它在两种机器上都可以正常工作。
对于EditText,即便在触屏机上,也需要获取焦点,主要是输入法需要,因此它的focusable和focusableInTouchMode都为true。如果EditText的focusableInTouchMode为false,触屏上就没法输入了。
最后分享一个bug,在适配D-pad的时候,有一个界面中有一个特殊的View,focusable为true,且会requestFocus,它负责监听BACK按键并退出,但在触屏手机上,在这个页面点击BACK键无法退出。检查之后发现那个View的focusableInTouchMode默认为false,导致在触屏机上它无法获取焦点,BACK事件被忽略掉,没有分发给它处理。解决方案自然就是将它的focusableInTouchMode改为true,使得在触屏机上能按BACK退出该页面。
来源: https://juejin.im/entry/5a2d64ae6fb9a0450407d0ea