TV端开发之焦点处理

Android TV端开发从0到1的一点经验

Posted by lx8421bcd on December 10, 2018

前言

关于什么focus机制用来干什么这些口水话就不说了,简要一句话就是focus机制是Android用于处理键盘交互的,焦点决定哪个控件处理按键事件。在TV端开发中,我们不用再关心各种各样的触控冲突,取而代之各种莫名其妙的焦点跳转问题,要厘清这些神奇的焦点跳转,并修复它们,必须了解Android焦点处理和派发机制。此文作为学习整理和经验总结,出于篇幅考虑以及侧重点不同,本文不讨论触摸模式(TouchMode)下的焦点处理。

几个关于焦点的知识点

焦点数量

界面上的焦点数永远小于等于1,也就是说任何时候多最多只会有一个焦点,或者由于焦点丢失等因素界面上没有焦点。

手动申请焦点

需要手动请求焦点时,调用requestFocus(), 在XML的View配置中添加<requestFocus/>标签可以让View在初始化时请求焦点,不过不建议这么做,焦点的申请最好统一管理。

View能否获取焦点

并不是所有View都默认能获取焦点,特别是随系统版本不同,View默认能否获取焦点也不一样,所以最好的做法是对于能获取焦点的View,在定义时设置android:focusable:"true"setFocusable(true)

ViewGroup与焦点

焦点并没有层级制约关系,一个ViewGroup没有焦点并不代表其ChildView没有焦点。

ViewGroup中有一个mFocused变量用于存储其拥有焦点的child,注意这里并不仅仅是只存储focused view,拥有focused view的ViewGroup也会被存储于mFocused,简而言之我们能够从ViewGroup提供的方法中得知其内部是否包含focused view。
findFocus()方法就是用于递归查找获取ViewGroup内部的focused view,如果ViewGroup内部没有focus,则返回null。而getFocusedChild()方法则直接返回mFocused,注意这里的不同。

setDescendantFocusability() 方法可以用于指定ViewGroup的焦点获取策略,有以下三个值:

  • FOCUS_BLOCK_DESCENDANTS - 阻止child获取焦点,即使child调用了rquestFocus()也拿不到焦点
  • FOCUS_BEFORE_DESCENDANTS - 自己优先拿焦点
  • FOCUS_AFTER_DESCENDANTS - 默认策略,child先拿焦点,child拿不了了自己再拿

has & is

hasFocus() 方法只要ViewGroup本身有焦点或其child有焦点都返回true,而 isFocused() 只有ViwGroup自身有焦点才返回true。同理还有 isFocusable()hasFocusable() ,“is”判断自身,“has”判断自身和child。

焦点查找机制

既然不考虑触控屏,那么焦点的移动就与按键息息相关,在了解焦点查找机制时必须与按键处理结合起来。关于从按键按下到事件传递到UI层的一系列机制就不深究了,直接将分析的起点定位于 ViewRootImpl
ViewRootImpl.java - googlesource

研究一下ViewRootImpl中定义的内部类ViewPostImeInputStage,按键事件(KeyEvent)将会由processKeyEvent()方法处理,这个方法是关键方法。

final class ViewPostImeInputStage extends InputStage {

    ......

    @Override
    protected int onProcess(QueuedInputEvent q) {
        if (q.mEvent instanceof KeyEvent) {
            return processKeyEvent(q); // 按键事件交由processKeyEvent()方法处理
        } else {
            final int source = q.mEvent.getSource();
            if ((source & InputDevice.SOURCE_CLASS_POINTER) != 0) {
                return processPointerEvent(q);
            } else if ((source & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) {
                return processTrackballEvent(q);
            } else {
                return processGenericMotionEvent(q);
            }
        }
    }
    private int processKeyEvent(QueuedInputEvent q) {
        final KeyEvent event = (KeyEvent)q.mEvent;
        if (mUnhandledKeyManager.preViewDispatch(event)) {
            return FINISH_HANDLED;
        }
        // 将KeyEvent派发到布局树上看看有没有View消耗,如果被消耗,返回FINISH_HANDLED
        if (mView.dispatchKeyEvent(event)) {
            return FINISH_HANDLED;
        }

        ......

        // 默认情况下的焦点处理
        // Handle automatic focus changes.
        if (event.getAction() == KeyEvent.ACTION_DOWN) {
            if (groupNavigationDirection != 0) {
                if (performKeyboardGroupNavigation(groupNavigationDirection)) {
                    return FINISH_HANDLED;
                }
            } else {
                if (performFocusNavigation(event)) {
                    return FINISH_HANDLED;
                }
            }
        }
        return FORWARD;
    }
    ......
}

按键与direction

在上面的代码中可以看到默认的焦点处理调用了performKeyboardGroupNavigation()performFocusNavigation()两个方法,其中最主要的处理在于performFocusNavigation()方法

private boolean performFocusNavigation(KeyEvent event) {
    int direction = 0;
    //direction 判断
    switch (event.getKeyCode()) {
        case KeyEvent.KEYCODE_DPAD_LEFT:
            if (event.hasNoModifiers()) {
                direction = View.FOCUS_LEFT;
            }
            break;
        case KeyEvent.KEYCODE_DPAD_RIGHT:
            if (event.hasNoModifiers()) {
                direction = View.FOCUS_RIGHT;
            }
            break;
        case KeyEvent.KEYCODE_DPAD_UP:
            if (event.hasNoModifiers()) {
                direction = View.FOCUS_UP;
            }
            break;
        case KeyEvent.KEYCODE_DPAD_DOWN:
            if (event.hasNoModifiers()) {
                direction = View.FOCUS_DOWN;
            }
            break;
        case KeyEvent.KEYCODE_TAB:
            if (event.hasNoModifiers()) {
                direction = View.FOCUS_FORWARD;
            } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
                direction = View.FOCUS_BACKWARD;
            }
            break;
    }
    if (direction != 0) {
        View focused = mView.findFocus();
        if (focused != null) {
            View v = focused.focusSearch(direction);
            if (v != null && v != focused) {
                // do the math the get the interesting rect
                // of previous focused into the coord system of
                // newly focused view
                focused.getFocusedRect(mTempRect);
                if (mView instanceof ViewGroup) {
                    ((ViewGroup) mView).offsetDescendantRectToMyCoords(
                            focused, mTempRect);
                    ((ViewGroup) mView).offsetRectIntoDescendantCoords(
                            v, mTempRect);
                }
                if (v.requestFocus(direction, mTempRect)) {
                    playSoundEffect(SoundEffectConstants
                            .getContantForFocusDirection(direction));
                    return true;
                }
            }
            // Give the focused view a last chance to handle the dpad key.
            if (mView.dispatchUnhandledMove(focused, direction)) {
                return true;
            }
        } else {
            if (mView.restoreDefaultFocus()) {
                return true;
            }
        }
    }
    return false;
}

direction值表示了键盘的按键方向,只有上、下、左、右四个枚举值。平时遥控器也不存在tab和shift,这两个可以不用管,这里需要注意的是KeyEvent的direction和View的focus direction是不一样的,所以这里才需要用一个switch语句转换。判断完direction后走到焦点处理,这里我们就可以看到焦点传递的几个关键方法了,其中我们最需要关注的是View的focusSearch()方法。

focusSearch()

View的focusSearch()方法内容比较简单,请求它上一层的focusSearch(),嵌套调用查焦点,查不到就返回null。
View.java - googlesource

    public View focusSearch(@FocusRealDirection int direction) {
        if (mParent != null) {
            return mParent.focusSearch(this, direction);
        } else {
            return null;
        }
    }

mParent是ViewParent接口的实例,在这里可以认为是ViewGroup,所以来看下ViewGroup的focusSearch()
ViewGroup.java - googlesource

    @Override
    public View focusSearch(View focused, int direction) {
        if (isRootNamespace()) {
            // root namespace means we should consider ourselves the top of the
            // tree for focus searching; otherwise we could be focus searching
            // into other tabs.  see LocalActivityManager and TabHost for more info.
            return FocusFinder.getInstance().findNextFocus(this, focused, direction);
        } else if (mParent != null) {
            return mParent.focusSearch(focused, direction);
        }
        return null;
    }

可以看到仍然是个逐级上查的逻辑,只不过多了一点,当ViewGroup是根布局时,将会调用FocusFinder去查找焦点。

FocusFinder

FocusFinder是一个全局单例,通过之前的代码我们可以确定绝大部分默认的焦点查找最后都会交由FocusFinder来处理。
FocusFinder.java - googlesource
调用FocusFinder.findeNextFocus()最后会执行到这个方法:

private View findNextFocus(ViewGroup root, View focused, Rect focusedRect, int direction) {
    View next = null;
    ViewGroup effectiveRoot = getEffectiveRoot(root, focused);
    if (focused != null) {
        next = findNextUserSpecifiedFocus(effectiveRoot, focused, direction);
    }
    if (next != null) {
        return next;
    }
    ArrayList<View> focusables = mTempList;
    try {
        focusables.clear();
        effectiveRoot.addFocusables(focusables, direction);
        if (!focusables.isEmpty()) {
            next = findNextFocus(effectiveRoot, focused, focusedRect, direction, focusables);
        }
    } finally {
        focusables.clear();
    }
    return next;
}

从这里可以看到FocusFinder将会优先查找有没有特定ID的View,也就是XML中的android:NextFocusUp="..."之类的标识。
如果没有查找到特殊ID的View,则将会进入下一个finNextFocus()

private View findNextFocus(ViewGroup root, View focused, Rect focusedRect,
        int direction, ArrayList<View> focusables) {
    if (focused != null) {
        if (focusedRect == null) {
            focusedRect = mFocusedRect;
        }
        // fill in interesting rect from focused
        focused.getFocusedRect(focusedRect);
        root.offsetDescendantRectToMyCoords(focused, focusedRect);
    } else {
        if (focusedRect == null) {
            focusedRect = mFocusedRect;
            // make up a rect at top left or bottom right of root
            switch (direction) {
                case View.FOCUS_RIGHT:
                case View.FOCUS_DOWN:
                    setFocusTopLeft(root, focusedRect);
                    break;
                case View.FOCUS_FORWARD:
                    if (root.isLayoutRtl()) {
                        setFocusBottomRight(root, focusedRect);
                    } else {
                        setFocusTopLeft(root, focusedRect);
                    }
                    break;
                case View.FOCUS_LEFT:
                case View.FOCUS_UP:
                    setFocusBottomRight(root, focusedRect);
                    break;
                case View.FOCUS_BACKWARD:
                    if (root.isLayoutRtl()) {
                        setFocusTopLeft(root, focusedRect);
                    } else {
                        setFocusBottomRight(root, focusedRect);
                    break;
                }
            }
        }
    }
    switch (direction) {
        case View.FOCUS_FORWARD:
        case View.FOCUS_BACKWARD:
            return findNextFocusInRelativeDirection(focusables, root, focused, focusedRect,
                    direction);
        case View.FOCUS_UP:
        case View.FOCUS_DOWN:
        case View.FOCUS_LEFT:
        case View.FOCUS_RIGHT:
            return findNextFocusInAbsoluteDirection(focusables, root, focused,
                    focusedRect, direction);
        default:
            throw new IllegalArgumentException("Unknown direction: " + direction);
    }
}

这个方法的主要内容可以概括为根据传入的焦点、方向、可获取焦点的控件列表等参数,通过坐标运算计算出指定方向上的下一个焦点。

结论

通过以上的研究可以概括出几点:

  1. 当按键事件触发焦点查找流程时,始于ViewRootImpl调用processKeyEvent()方法内默认的焦点处理,如果有焦点则调用当前焦点view的focusSearch()方法,如果当前页面没有焦点,将会调用根布局的restoreDefaultFocus()方法。
  2. 与Touch事件的传递不同,焦点查找流程开始于当前焦点View,逐级请求父布局的focusSearch()方法,如无其它情况将会一直请求到根布局的focusSearch()方法,然后调用FocusFinder得到下一个焦点View,是一个由内到外的过程。
  3. 整个焦点查找流程均在focusSearch()方法的嵌套调用中完成,当focusSearch()执行完毕返回结果后,下一焦点View就已经确定。

如何指定焦点派发

了解过焦点查找流程,就可以去找两个切入点,我们要做的就是在上面这些流程中,找到可以继承、重载的内容,在整个调用流程中做出拦截,返回我们想要的结果。

dispatchKeyEvent()

在ViewRootImpl调用processKeyEvent()方法时,我们可以看到按键事件是优先交由根布局的dispatchKeyEvent()方法处理的,如果这个事件被消费,那后面也就没有焦点查找什么事了。
也就是说,我们可以在dispatchKeyEvent()方法中,根据入参判断我们要的特殊焦点是哪个,然后requestFocus就行了。并不需要走一道焦点查找流程。

focusSearch()

我们知道整个查找流程是在focusSearch()的逐级嵌套调用中走完的,既然是逐级向上请求查找,那么我们在外层ViewGroup的focusSearch()方法中作出拦截即可。事实上Google也是这么干的,在Google的android-support-v17-leanback库中,BrowseFrameLayout控件就是在内部定义了Listener并在focusSearch阶段调用Listener让外部返回指定的focus。

BrowseFrameLayout.java - googlesource

当我们需要在外层指定焦点时,可以参考BrowseFrameLayout的方式写出自定义ViewGroup进行焦点查找拦截。

一些坑

某些自定义控件内部调用了requestFocus()

当你的界面走着走着发现焦点不知道飞到哪里去,自己又找不到原因时,就该考虑有没有这方面的问题存在了,Android手机开发为主的习惯让很多SDK开发者比较漠视焦点的存在,为了实现某些效果直接在其View初始化时requestFocus,这些埋在SDK代码里的requestFocus我们不容易搜索到,但是一旦出现View预加载(比如ViewPager滑动)等情况时,这些requestFocus就将会成为导致焦点乱飞的元凶,而且很难处理。

对于内部包含requestFocus的SDK控件,我们能做的只有反编译其源码copy一份出来,注释掉requestFocus,如果这一步很难做到的话,只有找SDK提供方怼一顿了。😄

按键长按飞焦点

此问题主要发生于RecyclerView的使用中,目前比较通行的解决方案是限制按键频率 RecyclerView在TV端的使用