Android TV开发总结焦点

Posted 先知丨先觉

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android TV开发总结焦点相关的知识,希望对你有一定的参考价值。

一、焦点获取

首先,TV端的开发和我们手机端开发最大的区别就在于TV端存在焦点的概念。

如下图:

可想而知,手机端我们直接通过点击\\长按某个区域处理响应事件处,但是TV端只能通过遥控器的上下左右来操控焦点,从而选中特定的区域处理相应事件。

在TV开发中没有以前我手机端的dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent 事件来分发,而需要使用dispatchKeyEvent、onKeyDown、onKeyLisenter 等事件来分发处理焦点事件传递。

然而TV端焦点没有什么好办法可以全局控制焦点,需要我们自己来想办法规定焦点走向,一旦焦点没有处理好就会造成焦点丢失。

android提供了一些焦点相关的属性,在现有的框架层下通过设置View的属性来获得焦点:

  • android:focusable:设置一个控件能否获得焦点
  • android:nextFocusDown:(当按下键时)下一个获得焦点的控件
  • android:nextFocusDown:(当按下键时)下一个获得焦点的控件
  • android:nextFocusLeft:(当按下键时)下一个获得焦点的控件
  • android:nextFocusRight:(当按下键时)下一个获得焦点的控
    **注意:**如果按下某个方向键时,想让焦点停留在自身,可以使用android:nextFocusRight:"@null"或者android:nextFocusRight:"@id/自身id"

栗子:如下图:

我们想要实现firstView(按右键)–>secondView(按下键)–>threadView(按上键)–>firstView

步骤:

  • 第一步:让这firstView、secondView、threadView获取焦点
  • 第二步:控制这三个View的移动轨迹
  • 注意:fourthView没有涉及到焦点,我们不用做任何处理

示例:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/firstView"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:focusable="true"
        android:nextFocusDown="@null"
        android:nextFocusLeft="@null"
        android:nextFocusRight="@id/secondView"
        android:nextFocusUp="@null" />

    <View
        android:id="@+id/secondView"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:focusable="true"
        android:nextFocusDown="@id/threadView"
        android:nextFocusLeft="@null"
        android:nextFocusRight="@null"
        android:nextFocusUp="@null" />

    <View
        android:id="@+id/threadView"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:focusable="true"
        android:nextFocusDown="@null"
        android:nextFocusLeft="@null"
        android:nextFocusRight="@null"
        android:nextFocusUp="@id/firstView" />

    <View
        android:id="@+id/fourthView"
        android:layout_width="100dp"
        android:layout_height="40dp" />
</android.support.constraint.ConstraintLayout>

也可以在代码中设置:

threadView.setNextFocusLeftId(R.id.firstView);
secondView.setNextFocusDownId(R.id.threadView);

注意:

  • 开发过程中我们有时需要布局初始化就有一个View是聚焦状态,那么可以使用requestFocus()来请求焦点。

那么此时问题来了,我们肉眼如何知道焦点在哪一个View上?

此时就需要我们对焦点选中的View进行样式改变,有一下两种方法:

二、聚焦时View样式

方法一:

 android:background:设置背景的drawable
 android:textColor:设置字体颜色

对应的xml文件:
drawable的xml文件,焦点选中时显示为keyboard_add,否则显示为keyboard_add_sel

<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/keyboard_add_sel" android:state_focused="true" />
    <item android:drawable="@drawable/keyboard_add" android:state_focused="false"/>
</selector>

color的xml文件,焦点选中时显示#4194ff(蓝色),否则显示#29ffffff(灰色)

<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="#4194ff" android:state_focused="true"/>
    <item android:color="#29ffffff" android:state_focused="false"/>
</selector>

方法二:

对该View进行焦点监听(setOnFocusChangeListener),在该监听事件中进行处理

view.setOnFocusChangeListener(new View.OnFocusChangeListener()

    @Override
    public void onFocusChange(View view, boolean hasFocus)
    
        if(hasFocus)
            //获得焦点
            view.xxxxx();
        else
            //失去焦点
            view.xxxxx();
        
    
);

三、按键事件如何分发?

首先看一下经常会遇到的坑,带着问题去探究整个过程

尽管官方提供了基本用法,但是我们开发中任然会遇到焦点相关的问题:

  • 我明明指定了焦点,为什么焦点还是丢失了?
  • onKeyDown为什么有时获取不到按键事件?
  • 没有做任何焦点处理的View会获取焦点?
  • 对RecycleView设置nextFocusDown没有效果?

接下来我们带着问题从源码角度来探究一下:

在手机端,我们通过滑动,触摸,长按等,会产生一个触摸事件(MotionEvent)。

同理:在遥控器上我们按“上”,“下”,“左”,“右”,“ok”,“返回”等按键时,会产生一个按键事件(KeyEvent),焦点的处理就在KeyEvent中分发处理。

所以此时我们需要从ViewRootImpl入手,来具体分析焦点是如何分发的?。那么此时有同学会问,为什么是从ViewRootImpl入手?

3.1 什么是ViewRootImpl?

官方定义:The top of a view hierarchy, implementing the needed protocol between View and the WindowManager.

翻译: 视图层次结构的顶部,在视图和窗口管理器之间实现所需的协议。

这里简单总结一下几点:

  • 1、ViewRootImpl是链接WindowManagerDecorView的纽带
  • 2、完成View的绘制,包括measure``、layoutdraw过程。
  • 3、向DecorView分发收到的用户发起的event事件,如按键,触屏等事件。

ViewRootImpl本身并不是一个View,可以看作是View树的管理者。而这里的成员变量mView就是DecorView,它指向的对象跟Window和Activity的mDecor指向的对象是同一个对象。所有的View组成了一个View树,每一个View都是树中的一个节点,如下图所示:


最上层的根是DecorView,中间是各ViewGroup,最下层是View。

所以我们知道知道keyevent的分发源头是ViewRootImpl,它是整个View树的管理者,首先走了mView的dispatchKeyEvent,也就是从DecorView开始进行KeyEvent的分发。

3.2 keyevent分发流程?

Android焦点事件的分发是在ViewRootImpl的内部类ViewPostImeInputStage中的processKeyEvent方法进行的,具体流程看代码:

本文以(API27)为例

(1)processKeyEvent方法的具体实现

private int processKeyEvent(QueuedInputEvent q) 
    final KeyEvent event = (KeyEvent)q.mEvent;

    //由dispatchKeyEvent进行焦点的分发,如果dispatchKeyEvent方法返回true,那么下面的焦点查找步骤就不会继续了。
    //这里mView是Activity的顶层容器DecorView,是一FrameLayout。
    //所以这里的dispatchKeyEvent方法执行的是ViewGroup的dispatchKeyEvent()方法
    if (mView.dispatchKeyEvent(event)) 
        return FINISH_HANDLED;
    

    // 是否终止事件
    // 当根视图不存在就会停止下面的步骤
    // 属于保护措施
    if (shouldDropInputEvent(q)) 
        return FINISH_NOT_HANDLED;
    

    int groupNavigationDirection = 0;

    //对TAB键做特殊处理
    //判断仅按下TAB还是TAB和其他键的组合
    //metaStateHasModifiers()方法根据指定的META状态按下指定的按键键,则返回true.如果按下不同的修改键组合,则返回false.
    //通过下面的方法判断groupNavigationDirection的方向
    if (event.getAction() == KeyEvent.ACTION_DOWN
            && event.getKeyCode() == KeyEvent.KEYCODE_TAB) 
        if (KeyEvent.metaStateHasModifiers(event.getMetaState(), KeyEvent.META_META_ON)) 
            groupNavigationDirection = View.FOCUS_FORWARD;
         else if (KeyEvent.metaStateHasModifiers(event.getMetaState(),
                KeyEvent.META_META_ON | KeyEvent.META_SHIFT_ON)) 
            groupNavigationDirection = View.FOCUS_BACKWARD;
        
    
    
    ... ... ...

    // 应用 fallback 策略
    // 具体实现见PhoneFallbackEventHandler中dispatchKeyEvent()方法
    // 主要是对媒体键,音量键,通话键等做处理,如果是这些按键则会停止下面的步骤
    if (mFallbackEventHandler.dispatchKeyEvent(event)) 
        return FINISH_HANDLED;
    

    // 自动追踪焦点
    // 该部分是重点
    if (event.getAction() == KeyEvent.ACTION_DOWN) 
        if (groupNavigationDirection != 0) 
            //如果是TAB键则groupNavigationDirection不为0,进行如下操作(这里不做重点解析)
            if (performKeyboardGroupNavigation(groupNavigationDirection)) 
                return FINISH_HANDLED;
            
         else 
            //此处是对我们按键焦点处理的重点
            //下面我们进入该方法详细去看一下,详见(2)
            if (performFocusNavigation(event)) 
                return FINISH_HANDLED;
            
        
    
    return FORWARD;


(2)performFocusNavigation方法的具体实现(主要用于记录方向)

我们接下来看一下performFocusNavigation①方法:

private boolean performFocusNavigation(KeyEvent event) 
    //direction用来记录方向的值,用来进行后面的焦点查找
    int direction = 0;
    switch (event.getKeyCode()) 
        case KeyEvent.KEYCODE_DPAD_LEFT:
            //根据指定的元状态没有按下修饰符键,则返回true
            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;
    
    //给定了direction(遥控器按键按下的方向),接下来就是焦点寻找
    if (direction != 0) 
        //找到当前聚焦的View 下面会详细讲解,见(3)
        View focused = mView.findFocus();
        if (focused != null) 
            //如果focused不为空,说明找到了焦点,接着focusSearch会把direction(遥控器按键按下的方向)作为参数,找到特定方向下一个将要获取焦点的view,最后如果该view不为空,那么就让该view获取焦点。
            //后面详细介绍focusSearch()具体方法,见(4)
            View v = focused.focusSearch(direction);
            if (v != null && v != focused) 
                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;

(3)findFocus方法的具体实现(查找到当前聚焦的view)

我们来看一下详细看一下findFocus()

我们看到findFocus有viewGroup和view的:
其实就是在一层一层往下查找已经获取焦点的子View(一定要先理解视图树)

//viewGroup焦点判断
@Override
public View findFocus() 
    if (DBG) 
        System.out.println("Find focus in " + this + ": flags="
                + isFocused() + ", child=" + mFocused);
    
    
    if (isFocused()) 
        return this;
    

    if (mFocused != null) 
        return mFocused.findFocus();
    
    return null;


//view焦点判断
public View findFocus() 
    return (mPrivateFlags & PFLAG_FOCUSED) != 0 ? this : null;

说明:判断view是否获取焦点的isFocused()方法, (mPrivateFlags & PFLAG_FOCUSED) != 0 和view 的findFocus()方法是一致的。

public boolean isFocused() 
    return (mPrivateFlags & PFLAG_FOCUSED) != 0;

isFocused()方法的作用是判断view是否已经获取焦点,如果viewGroup已经获取到了焦点,那么返回本身即可,否则通过mFocused的findFocus()方法来找焦点。mFocused其实就是ViewGroup中获取焦点的子view,如果mView不是ViewGourp的话,findFocus其实就是判断本身是否已经获取焦点,如果已经获取焦点了,返回本身。

此时我们已经找到了当前获得焦点的View,接下来就是说按照给定的方向去寻找下一个即将获得焦点的view

(4)focusSearch方法的具体实现

通过View的focusSearch方法找到下一个获取焦点的View,那么到底是如何查找的?往下看:

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

View并不会直接去找,而是交给它的parent去找。

//viewGroup中
@Override
public View focusSearch(View focused, int direction) 
    if (isRootNamespace()) 
        //判断是否是顶层view,是则执行以下算法
        return FocusFinder.getInstance().findNextFocus(this, focused, direction);
     else if (mParent != null) 
        return mParent.focusSearch(focused, direction);
    
    return null;

判断是否为顶层布局(isRootNamespace()方法),若是则执行对应方法,若不是则继续向上寻找,说明会从内到外的一层层进行判断,直到最外层的布局为止。

最终会调用viewGroup的FocusFinder来找计算下一个获得焦点的view。

(5)findNextFocus方法的具体实现

// FocusFinder.java
public final View findNextFocus(ViewGroup root, View focused, int direction) 
    return findNextFocus(root, focused, null, direction);


//root是上面isRootNamespace()为true的ViewGroup
//focused是当前焦点视图
private View findNextFocus(ViewGroup root, View focused, Rect focusedRect, int direction) 
    View next = null;
    ViewGroup effectiveRoot = getEffectiveRoot(root, focused);
    if (focused != null) 
        // 优先从xml或者代码中指定focusid的View中找
        next = findNextUserSpecifiedFocus(effectiveRoot, focused, direction);
    
    if (next != null) 
        return next;
    
    ArrayList<View> focusables = mTempList;
    try 
        focusables.clear();
        effectiveRoot.addFocusables(focusables, direction);
        if (!focusables.isEmpty()) 
            //其次,根据算法去找,原理就是找在方向上最近的View
            next = findNextFocus(effectiveRoot, focused, focusedRect, direction, focusables);
        
     finally 
        focusables.clear();
    
    return next;

从上面可以看出

  • (1)优先找开发者指定的下一个focus的视图 ,就是在xml或者代码中指定NextFocusDirection Id的视图
  • (2)其次,根据算法去找,原理就是找在方向上最近的视图

我们这里分开两个方法看findNextUserSpecifiedFocus()findNextFocus()

(6)findNextUserSpecifiedFocus() 从指定focusid的View中找

//FocusFinder.java
private View findNextUserSpecifiedFocus(ViewGroup root, View focused, int direction) 
    // 寻找用户定义的下一个焦点View
    View userSetNextFocus = focused.findUserSetNextFocus(root, direction);
    View cycleCheck = userSetNextFocus;
    boolean cycleStep = true; // we want the first toggle to yield false
    while (userSetNextFocus != null) 
        if (userSetNextFocus.isFocusable()
                && userSetNextFocus.getVisibility() == View.VISIBLE
                && (!userSetNextFocus.isInTouchMode()
                        || userSetNextFocus.isFocusableInTouchMode())) 
            return userSetNextFocus;
        
        userSetNextFocus = userSetNextFocus.findUserSetNextFocus(root, direction);
        if (cycleStep = !cycleStep) 
            cycleCheck = cycleCheck.findUserSetNextFocus(root, direction);
            if (cycleCheck == userSetNextFocus) 
                // found a cycle, user-specified focus forms a loop and none of the views
                // are currently focusable.
                break;
            
        
    
    return null;

findNextUserSpecifiedFocus()方法会执行focused(即当前获取焦点的View)的findUserSetNextFocus方法,如果该方法返回的View不为空,且isFocusable = true && isInTouchMode()=true的话,FocusFinder找到的焦点就是findNextUserSpecifiedFocus()返回的View。

//View.java 
findUserSetNextFocus(View root, @FocusDirection int direction) 
    switch (direction) 
        case FOCUS_LEFT:
            if (mNextFocusLeftId == View.NO_ID) return null;
            return findViewInsideOutShouldExist(root, mNextFocusLeftId);
        case FOCUS_RIGHT:
            if (mNextFocusRightId == View.NO_ID) return null;
            return findViewInsideOutShouldExist(root, mNextFocusRightId);
        case FOCUS_UP:
            if (mNextFocusUpId == View.NO_ID) return null;
            return findViewInsideOutShouldExist(root, mNextFocusUpId);
        case FOCUS_DOWN:
            if (mNextFocusDownId == View.NO_ID) return null;
            return findViewInsideOutShouldExist(root, mNextFocusDownId);
        case FOCUS_FORWARD:
            if (mNextFocusForwardId == View.NO_ID) return null;
            return findViewInsideOutShouldExist(root, mNextFocusForwardId);
        case FOCUS_BACKWARD: 
            if (mID == View.NO_ID) return null;
            final int id = mID;
            return root.findViewByPredicateInsideOut(this, new Predicate<View>() 
                @Override
                public boolean test(View t) 
                    return t.mNextFocusForwardId == id;
                
            );
        
    
    return null;

findUserSetNextFocus就是通过设置的id去找view,比如:按了“左”方向键,如果设置了mNextFocusLeftId,则会通过findViewInsideOutShouldExist去找这个View。

来看看findViewInsideOutShouldExist做了什么?

//View.java
private View findViewInsideOutShouldExist(View root, int id) 
    if (mMatchIdPredicate == null) 
        // 可以理解为一个判定器,如果id匹配则判定成功
        mMatchIdPredicate = new MatchIdPredicate();
    
    mMatchIdPredicate.mId = id;
    View result = root.findViewByPredicateInsideOut(this, mMatchIdPredicate);
    ...
    return result;


public final View findViewByPredicateInsideOut(View start, Predicate<View> predicate) 
    View childToSkip = null;
    for (;;) 
		
        // 从当前起始节点开始寻找(ViewGroup是遍历自己的child),寻找id匹配的View
        View view = start.findViewByPredicateTraversal(predicate, childToSkip);
        if (view != null || start == this) 
            return view;
        

        ViewParent parent = start.getParent();
        if (parent == null || !(parent instanceof View)) 
            return null;
        

        // 如果如果当前节点没有,则往上一级,从自己的parent中查找,并跳过自己
        childToSkip = start;
        start = (View) parent;
    


protected View findViewByPredicateTraversal(Predicate<View> predicate, View childToSkip) 
    if (predicate.apply(this)) 
        return this;
    
    return null;

// ViewGroup

@Override
protected View findViewByPredicateTraversal(Predicate<View> predicate, View childToSkip) 
    if (predicate.apply(this)) 
        return this;
    

    final View[] where = mChildren;
    final int len = mChildrenCount;

    for (int i = 0; i < len; i++) 
        View v = where[i];

        if (v != childToSkip && (v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) 
            v = v.findViewByPredicate(predicate);

            if (v != null) 
                return v;
            
        
    

    return null;

可以看到,findViewInsideOutShouldExist这个方法从当前指定视图去寻找指定id的视图。首先从自己开始向下遍历,如果没找到则从自己的parent开始向下遍历,直到找到id匹配的视图为止。

(7)findNextFocus()根据算法去找

如果开发者没有指定nextFocusId,则用findNextFocus找指定方向上最近的视图
看一下这里的用法:

//FocusFinder.java
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();
        //找到所有isFocusable的View
        effectiveRoot.addFocusables(focusables, direction);
        if (!focusables.isEmpty()) 
            next = findNextFocus(effectiveRoot, focused, focusedRect, direction, focusables);
        
     finally 
        focusables.clear();
    
    return next;

这里就不对findNextFocus()具体展开了,大概讲一下步骤:

findNextFocus():

  • (1)遍历找出所有isFocusable的视图
  • (2)将focused视图的坐标系,转换到root的坐标系中,统一坐标,以便进行下一步的计算
  • (3)进行一次遍历比较,得到最“近”的视图作为下一个焦点视图

3.3 keyevent分发流程总结

  • 1、ViewRootImpl的processKeyEvent方法获取按键事件
  • 2、判断ViewGroup的dispatchKeyEvent()方法是否消费了事件是则不往下分发,终止。
  • 3、判断是否是一些特殊按键如:接听,挂断,音量等
  • 4、如果没有消费事件,那么焦点就会交给系统来处理
  • 5、Android底层先会记录按键的方向
  • 6、DecorView会从顶部一层一层往下调用findFocus方法找到当前获取焦点的View
  • 7、通过focusSearch从内到外层层寻找下一个焦点view,直到顶层为止,具体算法在FocusFinder
  • 8、FocusFinder会根据用户设置的id,优先查找,如果没有设置则通过系统算法找到最近的焦点view

3.4 处理焦点的时机

结合KeyEvent事件的流转,对处理焦点的时机做了如下排序:

  • 1、dispatchKeyEvent
  • 2、mOnKeyListener.onKey回调
  • 3、onKeyDown/onKeyUp
  • 4、focusSearch
  • 5、指定nextFocusId
  • 6、系统自动从所有isFocusable的视图中找下一个焦点视图
    以上任一处都可以指定焦点,一旦使用了就不再往下走。

扫码关注公众号“伟大程序猿的诞生“,更多干货新鲜文章等着你~

公众号回复“资料获取”,获取更多干货哦~

有问题添加本人微信号“fenghuokeji996” 或扫描博客导航栏本人二维码

以上是关于Android TV开发总结焦点的主要内容,如果未能解决你的问题,请参考以下文章

Android TV开发焦点移动源码分析

Android TV开发总结构建一个TV Metro界面(仿泰捷视频TV版)

Android TV 焦点原理源码解析

Android TV-电视开发知识点速览

Android TV 开发焦点处理 ( 父容器与子组件焦点获取关系处理 | 不同电视设备上的兼容问题 | 触摸获取焦点 | 按键获取焦点 )

Android TV - 在细节片段中失去焦点