18、View的焦点与绘制:焦点查找算法、焦点变化如何触发重绘、焦点与触摸的交互
各位同学,今天我们来聊聊焦点。说实话,焦点这个东西,很多Android开发做了两三年都未必真正搞懂。它不像触摸事件那样天天打交道,但一旦出了问题,那真是让人抓狂。我记得有一次,客户反馈说电视上的某个按钮按遥控器死活跳不过去,我排查了半天,最后发现是焦点查找算法的一个边界条件没处理好。嗯,从那以后,我就把焦点这块彻底啃了一遍。
今天这一讲,我们就从三个角度来剖析焦点:焦点是怎么找到下一个View的、焦点变化怎么触发重绘、以及焦点和触摸是怎么打架又和好的。
18.1 焦点查找算法:到底是谁拿到了焦点?
先问一个问题:当用户按下遥控器的方向键,系统怎么知道下一个该聚焦到哪个View?
你可能会说,不就是找最近的View吗?对,但也不全对。Android的焦点查找算法,核心逻辑在FocusFinder这个类里。它做的事情,说白了就是:在当前焦点的某个方向上,找一个最合适的候选者。
我们来看一下核心的查找流程:
// FocusFinder.java (简化版)
View findNextFocus(ViewGroup root, View focused, int direction) {
// 1. 收集所有可聚焦的View
ArrayList<View> focusables = root.getFocusables(direction);
// 2. 如果没有候选,返回null
if (focusables.isEmpty()) return null;
// 3. 根据方向进行筛选和排序
// 这里会用到矩形边界、距离、轴线对齐等条件
return findNextFocusInAbsoluteDirection(focusables, root, focused, rect, direction);
}
我个人习惯把这个算法拆成三步来理解:
- 收集阶段:从根View开始,递归遍历所有子View,找出所有
focusable=true且visibility=VISIBLE的View。 - 筛选阶段:根据方向(上、下、左、右),剔除掉那些不在目标方向上的View。比如你按右方向键,那当前焦点左边的View就直接被淘汰了。
- 排序阶段:对剩下的候选者,按照距离、轴线对齐度、矩形面积等维度打分,选出最优的那个。
这里有个坑:我曾经遇到过一个case,两个View在垂直方向上几乎对齐,但一个稍微偏左一点。按向下键时,焦点跳到了偏左的那个,而不是正下方的那个。为什么?因为算法优先考虑了「轴线对齐度」,而不是「中心距离」。说白了,它更看重你的重心是否在一条线上。
我们来看一张流程图,帮你理清整个查找逻辑:
18.2 焦点变化如何触发重绘?
好,现在焦点找到了。那接下来呢?焦点从View A切换到View B,屏幕上的变化是怎么发生的?
你想想看,焦点变化本质上是一个状态变化。View A失去了焦点,它需要把自己从「聚焦态」变回「普通态」;View B获得了焦点,它需要把自己从「普通态」变成「聚焦态」。这个状态变化,最终会体现在绘制上。
我们来看一下源码中的关键调用链:
// View.java
void handleFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
// 1. 更新内部状态
mHasFocus = gainFocus;
// 2. 触发焦点变化监听
if (onFocusChangeListener != null) {
onFocusChangeListener.onFocusChange(this, gainFocus);
}
// 3. 刷新Drawable状态(比如selector)
refreshDrawableState();
// 4. 请求重绘
invalidate(true);
}
这里最关键的一步是invalidate(true)。它告诉系统:这个View需要重新绘制。但注意,它只会重绘当前View,不会重绘整个窗口。这是Android的一个优化——只重绘变化的部分。
一个小技巧:如果你自定义了一个View,并且它的焦点态变化时只改变颜色或边框,那其实不需要重绘整个View。你可以重写drawableStateChanged()方法,只更新Drawable的状态,这样性能会更好。我在做TV Launcher时就用过这个优化,效果很明显。
那焦点变化到底会触发哪些绘制相关的操作呢?我整理了一个表格:
| 操作 | 触发时机 | 影响范围 |
|---|---|---|
| invalidate() | 焦点变化时立即调用 | 仅当前View |
| refreshDrawableState() | 焦点变化时调用 | 更新Drawable的state数组 |
| dispatchDraw() | 父View收到子View焦点变化 | 可能触发父View重绘 |
| onFocusChanged() | 焦点变化回调 | 可自定义重绘区域 |
18.3 焦点与触摸的交互:谁说了算?
这个问题很有意思。你想想看,一个View既可以被触摸点击,又可以被键盘/遥控器聚焦。那它们之间是怎么协调的?
说白了,触摸和焦点是两个独立的系统。触摸事件走的是onTouchEvent,焦点事件走的是onKeyEvent和onFocusChanged。但它们之间确实有交互,主要体现在以下几个方面:
18.3.1 触摸会改变焦点
当你用手指点击一个View时,系统会把这个View的焦点抢过来。这是通过View.onTouchEvent中的ACTION_DOWN事件触发的:
// View.java
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 如果View是可聚焦的,请求焦点
if (isFocusable() && isFocusableInTouchMode()) {
requestFocus();
}
break;
}
return true;
}
注意这里有个关键条件:isFocusableInTouchMode()。默认情况下,EditText是true,但Button是false。也就是说,你点击一个Button,它不会获得焦点——焦点仍然停留在原来的View上。
我曾经踩过的坑:在做一个遥控器+触摸双模交互的应用时,我发现触摸点击后,焦点没有按预期移动。排查了半天,发现是因为我在XML里给Button设置了android:focusableInTouchMode="true"。这导致触摸点击后,焦点被Button抢走了,但用户用遥控器操作时,焦点又跳到了别的地方。两种交互模式下的焦点状态不一致,体验非常割裂。
18.3.2 焦点变化不会影响触摸
反过来,焦点变化不会影响触摸事件的传递。你正在触摸一个View,突然焦点被别的View抢走了——触摸事件仍然会发给原来那个View。因为触摸事件的分发是基于坐标的,跟焦点没关系。
但有一个例外:当焦点变化导致View的布局发生变化时,触摸事件的命中区域可能会变。比如一个Button因为获得焦点而放大了,那触摸它的边缘区域时,命中结果可能跟之前不一样。
18.3.3 两者在绘制上的叠加
最后,我们来看看焦点和触摸在绘制层面是怎么叠加的。一个View可能同时处于「被触摸」和「拥有焦点」的状态。这时候它的绘制状态是什么样的?
Android的Drawable状态是一个数组,可以同时包含多个状态:
// 一个同时被触摸且有焦点的View的状态数组
int[] stateSet = {
android.R.attr.state_focused, // 有焦点
android.R.attr.state_pressed, // 被按下(触摸)
android.R.attr.state_enabled // 可用
};
你的Selector Drawable会根据这个状态数组来匹配对应的颜色或图片。所以你可以为「焦点+触摸」的组合定义一个单独的效果,比如:
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 同时有焦点和被按下 -->
<item android:state_focused="true"
android:state_pressed="true"
android:drawable="@drawable/bg_focused_pressed" />
<!-- 只有焦点 -->
<item android:state_focused="true"
android:drawable="@drawable/bg_focused" />
<!-- 只有被按下 -->
<item android:state_pressed="true"
android:drawable="@drawable/bg_pressed" />
<!-- 默认 -->
<item android:drawable="@drawable/bg_normal" />
</selector>
嗯,这里要注意状态匹配的顺序。Selector是从上到下匹配的,所以更具体的组合要放在前面。否则「只有焦点」的状态会先匹配到,后面的「焦点+触摸」就永远没机会了。
好了,关于焦点与绘制的交互,我们就聊到这里。焦点查找算法、焦点变化触发重绘的机制、以及焦点与触摸的协作方式,这三块内容在实际开发中经常会遇到。尤其是做TV端或者车载HMI的同学,焦点问题几乎是每天都要面对的。希望今天的讲解能帮你少踩几个坑。
公众号:蓝海资料掘金营,微信deep3321