暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Android TV开发之焦点初探

ChangbaDev 2017-12-27
606

背景

Android应用开发中大部分是手机App的开发。对于焦点的关注一般比较少,基本就是一些抢夺焦点、事件不响应的问题。而在一些非触摸设备比如电视的应用中,是通过遥控器的上下左右按键进行焦点的移动及选择的,所以焦点的控制就是一个非常重要的技术点了。本文就是对应用中焦点的原理及控制进行初探。(文中只对上下左右这四个方向键产生的事件进行解析,像类似tab按键的焦点移动不在本文范围内,毕竟大多数遥控器是没有类似tab按键的。参考Android8.0的源码)

本文主要内容:

  1. 初始焦点的获取。

  2. 焦点的转换。

  3. App开发中的问题及应用

初始焦点的获取


当进入一个页面即Activity打开的时候,会执行ActivityThread的 handleResumeActivity方法,这个是在Activity的启动过程当中调用的。在该方法中会调用Activity:MakeVisible(),之后是DecorView的setVisibility。DecorView是Activity的根布局,是一个FrameLayout。所以是执行的View中的方法setFlags。然后递归调用mParent.focusableViewAvailable(this),最终执行到ViewRootImpl中,调用View.requestLayout()。

之后就是requestLayout的过程了,ViewGroup调用requestLayout,根据descendantFocusability来决定是否继续调用子View的requestLayout。descendantFocusability有三个取值,FOCUSBLOCKDESCENDANTS、FOCUSBEFOREDESCENDANTS和FOCUSAFTERDESCENDANTS。FOCUSBLOCKDESCENDANTS代表子View无法获取焦点,所以结果就是当前的ViewGroup会获取了焦点(如果可以的话)。FOCUSBEFOREDESCENDANTS代表先看当前ViewGroup是否可以获取焦点,如果不可以的话再调用子View的requestLayout。FOCUSAFTERDESCENDANTS的话就是先调用子View的requestLayout,如果没有可以获取焦点的View则执行自己的super.requestLayout。最终在一个View获取焦点的时候,还会递归调用父布局requestChildFocus。父布局会保存当前获取焦点的子view。这个属性会在之后的查找中发挥作用。


焦点的转换及查找


焦点的移动是通过遥控器的按键产生keyEvent。系统通过处理各个按键事件最终改变焦点的。ViewRootImpl中包含SyntheticInputStage、ViewPostImeInputStage、NativePostImeInputStage等事件处理器。这些处理器组成责任链。如果事件没有被消耗掉,则会依次传递处理。其中ViewPostImeInputStage是处理View相关事件的。我们就从它开始说起。

ViewRootImpl.java

  1. private int processKeyEvent(QueuedInputEvent q) {


  2.    final KeyEvent event = (KeyEvent)q.mEvent;


  3.    // Deliver the key to the view hierarchy.

  4.    if (mView.dispatchKeyEvent(event)) {

  5.        return FINISH_HANDLED;

  6.    }

  7.   ...


  8.    // Handle automatic focus changes.

  9.    if (event.getAction() == KeyEvent.ACTION_DOWN) {

  10.        if (groupNavigationDirection != 0) {

  11.            if (performKeyboardGroupNavigation(groupNavigationDirection)) {

  12.                return FINISH_HANDLED;

  13.            }

  14.        } else {

  15.            if (performFocusNavigation(event)) {

  16.                return FINISH_HANDLED;

  17.            }

  18.        }

  19.    }

  20.    return FORWARD;

  21. }

整个处理过程在processKeyEvent方法中,首先是按键事件的分发dispatchKeyEvent,ViewGroup中的该方法是递归调用焦点子View的dispatchKeyEvent(上边说过,每个ViewGroup会保存有焦点的子view)。

ViewGroup.java

  1. public boolean dispatchKeyEvent(KeyEvent event) {

  2.    

  3.    if ((mPrivateFlags & (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS))

  4.            == (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS)) {

  5.        if (super.dispatchKeyEvent(event)) {

  6.            return true;

  7.        }

  8.    } else if (mFocused != null && (mFocused.mPrivateFlags & PFLAG_HAS_BOUNDS)

  9.            == PFLAG_HAS_BOUNDS) {

  10.        if (mFocused.dispatchKeyEvent(event)) {

  11.            return true;

  12.        }

  13.    }

  14.    

  15.    return false;

  16. }

可以看到按键事件是一路按照有焦点的“树枝”来传递的,mFocused就是有焦点的子View。如果事件在某一层被消耗掉了的话需要返回true。View中默认的处理就是响应keylistener和调用onKeyDown、onKeyUp等方法。

ViewRootImpl.java

  1. private boolean performFocusNavigation(KeyEvent event) {

  2.    int direction = 0;

  3.    switch (event.getKeyCode()) {//第一点

  4.        case KeyEvent.KEYCODE_DPAD_LEFT:

  5.            if (event.hasNoModifiers()) {

  6.                direction = View.FOCUS_LEFT;

  7.            }

  8.            break;

  9.        case KeyEvent.KEYCODE_DPAD_RIGHT:

  10.            if (event.hasNoModifiers()) {

  11.                direction = View.FOCUS_RIGHT;

  12.            }

  13.            break;

  14.        case KeyEvent.KEYCODE_DPAD_UP:

  15.            if (event.hasNoModifiers()) {

  16.                direction = View.FOCUS_UP;

  17.            }

  18.            break;

  19.        case KeyEvent.KEYCODE_DPAD_DOWN:

  20.            if (event.hasNoModifiers()) {

  21.                direction = View.FOCUS_DOWN;

  22.            }

  23.            break;

  24.        case KeyEvent.KEYCODE_TAB:

  25.            if (event.hasNoModifiers()) {

  26.                direction = View.FOCUS_FORWARD;

  27.            } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {

  28.                direction = View.FOCUS_BACKWARD;

  29.            }

  30.            break;

  31.    }

  32.    if (direction != 0) {

  33.        View focused = mView.findFocus();//第二点

  34.        if (focused != null) {

  35.            View v = focused.focusSearch(direction);

  36.            if (v != null && v != focused) {

  37.                

  38.                if (v.requestFocus(direction, mTempRect)) {

  39.                    playSoundEffect(SoundEffectConstants

  40.                            .getContantForFocusDirection(direction));

  41.                    return true;

  42.                }

  43.            }


  44.            // Give the focused view a last chance to handle the dpad key.

  45.            if (mView.dispatchUnhandledMove(focused, direction)) {

  46.                return true;

  47.            }

  48.        } else {

  49.            if (mView.restoreDefaultFocus()) {

  50.                return true;

  51.            }

  52.        }

  53.    }

  54.    return false;

  55. }

寻找焦点的流程,首先是将事件转换为相应的direction,获取当前的焦点View。这也是个递归调用的过程,利用父布局存储有焦点的子View。之后就是调用focusSearh来查找下一个焦点。

View.java

  1. public View focusSearch(@FocusRealDirection int direction) {

  2.    if (mParent != null) {

  3.        return mParent.focusSearch(this, direction);

  4.    } else {

  5.        return null;

  6.    }

  7. }

ViewGroup.java

  1. public View focusSearch(View focused, int direction) {

  2.    if (isRootNamespace()) {

  3.        // root namespace means we should consider ourselves the top of the

  4.        // tree for focus searching; otherwise we could be focus searching

  5.        // into other tabs.  see LocalActivityManager and TabHost for more info.

  6.        return FocusFinder.getInstance().findNextFocus(this, focused, direction);

  7.    } else if (mParent != null) {

  8.        return mParent.focusSearch(focused, direction);

  9.    }

  10.    return null;

  11. }

ViewRootImpl.java

  1. public View focusSearch(View focused, int direction) {

  2.    checkThread();

  3.    if (!(mView instanceof ViewGroup)) {

  4.        return null;

  5.    }

  6.    return FocusFinder.getInstance().findNextFocus((ViewGroup) mView, focused, direction);

  7. }

View的focusSearch一般就是调用父布局的focusSearch,直到最顶端的ViewRootImpl。当然ViewGroup中的focusSearch也有一些不同。会判断是否已经是Root层,如果是则也会调用FocusFinder来处理。反之就是View的默认实现了。

FocusFinder.java

  1. private View findNextFocus(ViewGroup root, View focused, Rect focusedRect, int direction) {

  2.    View next = null;

  3.    ViewGroup effectiveRoot = getEffectiveRoot(root, focused);

  4.    if (focused != null) {

  5.        next = findNextUserSpecifiedFocus(effectiveRoot, focused, direction);

  6.    }

  7.    if (next != null) {

  8.        return next;

  9.    }

  10.    ArrayList<View> focusables = mTempList;

  11.    try {

  12.        focusables.clear();

  13.        effectiveRoot.addFocusables(focusables, direction);

  14.        if (!focusables.isEmpty()) {

  15.            next = findNextFocus(effectiveRoot, focused, focusedRect, direction, focusables);

  16.        }

  17.    } finally {

  18.        focusables.clear();

  19.    }

  20.    return next;

  21. }

FocusFinder首先是调用findNextUserSpecifiedFocus看用户是否设置了nextFocusXXId,如果设置了则直接返回对应的View,反之则会执行findNextFocus方法来寻找。

上图是寻找nextFocusXXId对应View的调用过程。其中findViewByPredicateInsideOut就是根据nextFocusXXId来寻找相应的View。寻找的方式是从当前View开始寻找,如果是ViewGroup则递归遍历子View来寻找对应Id的View,是View的话则判断当前Id是否一致。如果没有找到对应Id的View则从父布局再继续寻找,因自己已经寻找过了,所以跳过自己。

所以寻找方向是从当前View往上逐步遍历的,直到根View。如果有相同的Id的View,那么寻找到一定是离当前View最近的一个。在应用时需注意Id相同的情况。

View.java

  1. public final <T extends View> T findViewByPredicateInsideOut(

  2.        View start, Predicate<View> predicate) {

  3.    View childToSkip = null;

  4.    for (;;) {

  5.        T view = start.findViewByPredicateTraversal(predicate, childToSkip);

  6.        if (view != null || start == this) {

  7.            return view;

  8.        }


  9.        ViewParent parent = start.getParent();

  10.        if (parent == null || !(parent instanceof View)) {

  11.            return null;

  12.        }


  13.        childToSkip = start;

  14.        start = (View) parent;

  15.    }

  16. }

  17. protected <T extends View> T findViewByPredicateTraversal(Predicate<View> predicate,

  18.        View childToSkip) {

  19.    if (predicate.test(this)) {

  20.        return (T) this;

  21.    }

  22.    return null;

  23. }  

ViewGroup.java

  1. protected <T extends View> T findViewByPredicateTraversal(Predicate<View> predicate,

  2.        View childToSkip) {

  3.    if (predicate.test(this)) {

  4.        return (T) this;

  5.    }


  6.    final View[] where = mChildren;

  7.    final int len = mChildrenCount;


  8.    for (int i = 0; i < len; i++) {

  9.        View v = where[i];


  10.        if (v != childToSkip && (v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {

  11.            v = v.findViewByPredicate(predicate);


  12.            if (v != null) {

  13.                return (T) v;

  14.            }

  15.        }

  16.    }


  17.    return null;

  18. }

自动寻找焦点的过程首先是确定一个focusedRect,就是原焦点的区域。如果没有原焦点,则会根据direction确定一个区域。方向为上左的时候区域为右下的点,方向为右下的时候怎区域为左上的点。然后,依次比较所有的可获取焦点的View,找出最合适成为下一个焦点的View。

FocusFinder.java

  1. boolean isBetterCandidate(int direction, Rect source, Rect rect1, Rect rect2) {


  2.    // to be a better candidate, need to at least be a candidate in the first

  3.    // place :)

  4.    if (!isCandidate(source, rect1, direction)) {

  5.        return false;

  6.    }


  7.    // we know that rect1 is a candidate.. if rect2 is not a candidate,

  8.    // rect1 is better

  9.    if (!isCandidate(source, rect2, direction)) {

  10.        return true;

  11.    }


  12.    // if rect1 is better by beam, it wins

  13.    if (beamBeats(direction, source, rect1, rect2)) {

  14.        return true;

  15.    }


  16.    // if rect2 is better, then rect1 cant' be :)

  17.    if (beamBeats(direction, source, rect2, rect1)) {

  18.        return false;

  19.    }


  20.    // otherwise, do fudge-tastic comparison of the major and minor axis

  21.    return (getWeightedDistanceFor(

  22.                    majorAxisDistance(direction, source, rect1),

  23.                    minorAxisDistance(direction, source, rect1))

  24.            < getWeightedDistanceFor(

  25.                    majorAxisDistance(direction, source, rect2),

  26.                    minorAxisDistance(direction, source, rect2)));

  27. }

比较算法(其中rect2代表当前最合适的区域,初始是一个最不可能的区域):

1.rect1是否在对应的方向上。rect1是则继续。如果不是则淘汰。 

2.如果rect1满足方向上的要求,同样检查下rect2是否满足。一般都会满足,除了初始情况。

3.如果都满足条件,则根据方向上是否重叠及距离来判断谁更合适。

beamBeats比较: 

1.如果rect2在对应的方向上,或者rect1不在。则rect1不比rect2合适。 

2.如果rect2和原区域在该方向上重合,则rect1胜出。  

3.如果左右方向则rect1胜出。

4.竖直方向上,最后则是距离的比较。比如up方向上,是rect1的bottom和src的top之前的距离比较于rect2的top和src的top之间的距离。小的会胜出。对于重合的情况则距离为0。

4.两次交换rect1和rect2通过第3步的比较。如果还不能确定谁胜出则进行距离的比较。比如direction为up,rect1的bottom和src的距离为a,rect1和src的左右方向上的中心点的距离为b。则rect1的距离就是 13*a*a+b*b;

  1. int getWeightedDistanceFor(int majorAxisDistance, int minorAxisDistance) {

  2.    return 13 * majorAxisDistance * majorAxisDistance

  3.            + minorAxisDistance * minorAxisDistance;

  4. }


其中13就是一个计算因子,个人理解是direction方向上的距离在整个距离的计算中占据更大的比重。

以上,就是焦点寻找的具体过程了。如果有疑惑的话,可以对照Android的源码一步步,应该会比较好理解一点。

App开发中的问题及解决


一,我们在项目中使用TabWidget +TabHost来实现多fragment的切换。在TabWidget上进行焦点切换的时候会切换fragment(在Android8.0之后只有焦点的变化)。这样在焦点从content切换到TabWidget的时候,如果查找到的焦点不是当前tab的时候,就是出现fragment切换的情况,而这不是我们希望看到的。


比如上图,当前tab为tab1,当前焦点为btn2。这时按up键时,根据焦点查找算法,tab2会成为下一个焦点。而当焦点获取焦点时,会自动切换当前fragment。而我们的需求是下一个焦点是tab1。这里就要进行一些特殊处理。

方式一:在TabWidget内注册整个view tree的焦点变化的监听,通过焦点变化来重新设置tab。这种方式在没有其他变化的时候是可以实现效果。但是如果有比如放大等效果的话,就是出现两个同时放大的tab。所以我们弃用了。

方式二:重写tabhost的focusSearch方法。根据上边所讲的焦点寻找过程。会先调用持有焦点这个“树枝”上所有View的focusSearch方法。

MyTabhost.java

  1. @Override

  2. public View focusSearch(View focused, int direction) {

  3.    for (WeakReference<IFocusSearchListener> weakReference : mListenerList) {

  4.        IFocusSearchListener listener = weakReference.get();

  5.        if (listener != null) {

  6.            view = listener.focusSearchSelf(focused, direction);

  7.            if (view != null) {

  8.                return view;

  9.            }

  10.        }

  11.    }


  12.    return super.focusSearch(focused, direction);

  13. }

MyTabWidget.java

  1. public View focusSearchSelf(View focused, int direction) {

  2.    if (mLastFocusView != null) {

  3.        View view = FocusFinder.getInstance().findNextFocus((ViewGroup) getRootView(), focused, direction);

  4.        if (ViewUtils.isChildInHierarchy(this, view) && !ViewUtils.isChildInHierarchy(this, focused)) {

  5.                return mLastFocusView;

  6.        }

  7.    }

  8.    return null;

  9. }

看代码MyTabhost中使用了监听模式,MyTabWidget中的focusSearchSelf是具体的实现。依然是使用FocusFinder先寻找一遍,如果找到的焦点是TabWidget的子View,而原焦点不是子View,则直接返回当前tab。这种方式主要是在焦点确认之前来干扰焦点的寻找。这是一个特别重要的方式,同样用于解决下个问题。

二,默认焦点不符合需求的问题

比如上图,右边是一个逻辑上的整体,一个listview+viewgroup的布局。当前焦点是btn3。当按up键时,按逻辑上的关系,我们需要btn2获取焦点,但是根据默认寻找算法,btn1获得了焦点。如果用设置nextFocusUpId的方式,对于listview则非常麻烦,毕竟item的view是会变化的。那么我们同样可以用重写focusSearch方法的方式来解决。 我们需要设置btn3的父布局的focusUpId为ListView的id。默认情况下两个父布局的nextFocusXXId的设置对子view是不管作用的。我们可以这样重写整个布局根view的 focusSearch。

  1. public View focusSearch(View focused, int direction) {

  2.    View next = findNextUserSpecifiedFocus(focused, direction);

  3.    if (next != null &&  next.getVisibility() == View.VISIBLE) {

  4.        ViewGroup group = getNearestSameParent(next, focused);

  5.        if (group != null) {

  6.            next = FocusFinder.getInstance().findNextFocus(group, focused, direction);

  7.        }

  8.    } else {

  9.        next = null;

  10.    }

  11.    return next;

  12. }

首先是通过当前焦点递归寻找看是否有某一层的父布局有设置nextFocusId。如果有则寻找该view和当前焦点共同的最近的父布局。然后用该布局作为FocusFinder的根布局来寻找下个焦点。这么做的目的就是尽量缩小寻找的范围。尽可能的契合逻辑上的合理。

结尾


本文主要就是对焦点的获取和转换进行了原理的阐述,然后根据其中的过程(尤其是focusSearch方法),我们进行控制处理,来达到我们项目中的需求。其中如果有错误,欢迎指证。谢谢~


文章转载自ChangbaDev,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论