Android KeyEvent分发与焦点切换

前言

2016年年底的时候,给一个App适配了D-pad,D-pad就是下图红框里的东西:


Android KeyEvent分发与焦点切换_第1张图片
诺基亚手机 D-pad

对App来说,摸触摸屏产生的是TouchEvent,按D-pad产生的是KeyEvent。由于带键盘或者D-pad的Android手机早就消失了,所以平时主要关注的是TouchEvent的分发和处理,对KeyEvent不甚了解。由于那个App的自定义View相当复杂,且不符合Android标准View结构,D-pad适配工作需要了解KeyEvent分发和处理的流程,甚至要给那些自定义View建立一套KeyEvent分发机制,这里分享一下当时的学习成果。

TouchMode与Focus

虽然现在基本看不到带键盘和D-pad的Android手机,但实际上Android是原生支持D-pad的,只是现在大家只生产触摸屏手机而已了。Android使用TouchMode区分触摸屏控制和D-pad控制,现在的触屏手机默认处于TouchMode。
TouchMode下打开App,默认没有焦点。


Android KeyEvent分发与焦点切换_第2张图片
TouchMode下打开应用抽屉,无焦点

如果按下了D-pad上的方向键,就会退出TouchMode,此时系统会在屏幕上找一个focusable的View,使其获得焦点。在非TouchMode的情况下,如果打开某个App,系统也会在App的视图里面找一个focusable的View默认授予其焦点,这一步是在ViewRootImpl#performTraversals中完成的。下图可以看到焦点在Settings上。

Android KeyEvent分发与焦点切换_第3张图片
非TouchMode下打开应用抽屉,焦点在Settings上

KeyEvent和TouchEvent的分发流程中最大的差异就在焦点上,这也是它相对简单的原因。KeyEvent的分发流程非常简单,那就是直接给当前获取了焦点的View,谁有焦点,KeyEvent就给谁。

KeyEvent的来源

对于App的Java层来说,ViewRootImplWindowInputEventReceiver#onInputEvent被回调的时候,就开始了事件的处理,TouchEvent和KeyEvent会从这里出现,随后会交给一系列InputStage处理,这里使用了职责链模式,这些InputStage以链表的形式连接,事件从链表头传递到链表尾。InputStage有好几个,其中和App联系最紧密的是ViewPreImeInputStageEarlyPostImeInputStageViewPostImeInputStage。KeyEvent也会依次经过这三个步骤,即ViewPreImeInputStage=>EarlyPostImeInputStage=>ViewPostImeInputStage

退出TouchMode

既然现在的手机默认处于TouchMode,App打开的时候也处于TouchMode,我们是怎样退出TouchMode的?
当我们按下某些按键,如D-pad上面的任意一个键时,就会退出TouchMode。
EarlyPostImeInputStage中发现两类KeyEvent经过它时,就会尝试退出TouchMode,一类是导航类的key,比如DPAD上的按钮,TAB,等等,另一类是输入类的key,比如我们键盘上打字的那些字母按钮,数字按钮。
退出TouchMode时会尝试在ViewRootImpl#mView中找一个focusable的View,搜索方向为View.FOCUS_DOWN即自上向下搜索,找到这个View后调用它的requestFocus方法,使得它获得焦点。
ViewRootImpl#mView就是DecorView

寻找这么一个View的步骤很简单:

  1. 获取所有focusable View,放入一个ArrayList
  2. 遍历ArrayList中的View,选取其中一个View,要求它的Rect最接近左上角

第二点可以暂时先这么理解,虽然它的本质是计算两个Rect在某个方向上的距离问题。显然,在下图中,Settings Rect是最接近左上角的,那么当退出TouchMode时,一定是Settings自动获得焦点。


Android KeyEvent分发与焦点切换_第4张图片
最接近左上角的Settings Rect

获取所有focusable View

要获取所有focusable View,乍一看只要遍历View,查询focusable状态就可以了,实际上并没有这么简单,因为ViewGroup有descendantFocusability属性,会影响到它和它的子View的焦点关系。
因此获取所有focusable View的过程如下:
调用mView,即DecorViewaddFocusables(ArrayList views, int direction, int focusableMode),其中views为外部传入的一个空ArrayList,调用返回后,里面就是所有的focusable View。
addFocusables是View的方法,ViewGroup重写。

ViewGroup#addFocusables(非TouchMode)

如果当前ViewGroup focusable,且设为FOCUS_BLOCK_DESCENDANTS,只需要将自己添加到ArrayList中,方法执行结束。
如果当前ViewGroup focusable,且设为FOCUS_BEFORE_DESCENDANTS,将自己添加到ArrayList中。
将所有VISIBLE的子View,按照其Rect的在父控件中的位置排序后调用其addFocusables,排序方式简单来讲就是优先从上到下,其次从左到右。
最后如果ViewGroup focusable,且设为FOCUS_AFTER_DESCENDANTS,将自己添加到ArrayList中。

View#addFocusables(非TouchMode)

只要当前View是focusable的,就把自己添加到ArrayList中。

KeyEvent处理流程

ViewPreImeInputStage

一般而言,输入法优先处理KeyEvent,然而实际上App可以抢在输入法之前处理KeyEvent,相关逻辑在ViewPreImeInputStage中,ViewPreImeInputStage中会调用mViewdispatchKeyEventPreIme,实际调的就是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的onKeyPreIme即可在输入法之前处理KeyEvent,如果想干预这种情况下的分发流程,可以重写对应View的dispatchKeyEventPreIme。这个所谓的"对应的View"指的是当前获取了焦点的View,比如EditText。

需要注意的是,在没有焦点的情况下,onKeyPreIme是不会被回调的。所以一般我们只会重写EditText的onKeyPreIme

EarlyPostImeInputStage

这个步骤处理退出TouchMode以及自动将焦点移交给一个View的事务,在前面已经讲了。

ViewPostImeInputStage

KeyEvent经过一系列步骤之后,没有处理的KeyEvent,最终会交给这个步骤处理,就是在这个步骤,KeyEvent被交给DecorView处理。DecorView重写了dispatchKeyEvent,将事件又交给Activity#dispatchKeyEvent处理,以前Android的MENU键,点击之后能展开ActionBar的菜单,就是在Activity里处理的,个人觉得ActionBar这个设计挺蠢的。
Activity处理了一下KEYCODE_MENU后,又调用了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不用管,直接看super.dispatchKeyEvent(event),它还是再调ViewGroup的方法,所以调了半天,又回到了ViewGroup体系里面。

    @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里面返回正确的值即可,也就是说,焦点的切换是系统帮我们做的,那么系统如何知道在什么时机切换焦点呢?关键就是dispatchKeyEvent的返回值,返回false表示KeyEvent没有被处理。
ViewPostImeInputStage中,如果事件一直得不到处理,最终会走到ViewPostImeInputStage#performFocusNavigation中,尝试进行焦点切换
焦点切换要和视觉上View的位置相匹配,如图所示,当按下DPAD_RIGHT时,焦点应该沿红色箭头移动,按下DPAD_DOWN时,焦点沿黄色箭头移动,无论按什么,都不可能沿蓝色箭头移动。

Android KeyEvent分发与焦点切换_第5张图片
focus切换与视觉位置

焦点切换流程如下:

  1. 根据D-pad按键确定焦点切换方向
  2. 根据焦点切换方向搜索移交焦点的View
  3. 将焦点移交给该View

搜索下一个焦点View

搜索下一个焦点View的过程和退出TouchMode时搜寻焦点View的过程类似:

  1. 获取所有focusable View,放入一个ArrayList
  2. 遍历ArrayList中的View,选取其中一个View,要求它的Rect最接近当前焦点所在View的Rect

对于Rect之间谁最接近谁的问题,可以看下面的图。


Android KeyEvent分发与焦点切换_第6张图片
Rect,焦点切换的依据

如果我们当前焦点在Settings上,当按下DPAD_RIGHT时,从左往右,离Settings Rect最近的是UC Rect,那么UC将获得焦点。

如果是退出TouchMode,搜寻第一个focusable的View的过程,则可以视为计算DecorView中所有focusable View的Rect与屏幕左上角看不见的一个非常小的Rect的距离的过程。


Android KeyEvent分发与焦点切换_第7张图片
第一个焦点的产生

因此,无论是搜寻第一个焦点落在哪里,还是搜寻下一个焦点落在哪里,本质上都只是某个方向上Rect距离的计算问题。
如果你看懂了上面说的东西,这里依然有一些事情需要注意。

注意点

自动焦点切换

经过上面的讨论,我们可以看到,KeyEvent的分发以及焦点的自动切换,是以ViewRootImpl为单位的,即以Window为单位,在Android中,我们的Activity是一个Window,一个Dialog也是一个Window。
焦点自动切换的前提是ViewRootImpl都能正确获取所有focusable View的坐标,这需要App内的视图遵循Android的标准View结构。
这就给自动焦点切换带来了两个限制:

  1. 不支持带有复杂结构的自绘控件
  2. 不支持单Activity结构的App

对于有复杂结构的自绘控件,对Android系统来说,这个控件只是一个View,焦点切换以View为基本单位,因此对于自绘控件内部的焦点问题,系统无法处理,需要开发者自行处理。
对于单Activity结构的App,很可能出现多个ViewGroup叠在一起的情况,而焦点自动切换中,ViewRootImpl只会获取focusable View的Rect,没有Z轴的信息,因此自动焦点切换难以应付叠放的ViewGroup。

focusableInTouchMode

以前我一直不明白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退出该页面。

你可能感兴趣的:(Android KeyEvent分发与焦点切换)