在实现RotaryController
的功能时,官方文档 开发应用 | Android Open Source Project 有如下这段描述:
需要使用
FocusParkingView
的原因如下:
- 当焦点被设置在其他窗口中时,
Android
不会自动清除焦点。如果您尝试清除上一个窗口中的焦点,Android
会重新聚焦该窗口中的某个视图,从而导致两个窗口同时聚焦。为每个窗口添加一个FocusParkingView
就可以解决此问题。此视图是透明的,其默认焦点突出显示标志已停用,因此无论此视图是否聚焦,对用户都不可见。此视图可以获得焦点,使RotaryService
能够将焦点"停"在它上面,从而移除焦点突出显示标志。
看到官方文档的这个描述,虽然能解决问题但是还是不知道如何实现的。针对这段描述,我产生了一个疑问:
- 为什么添加了
FocusParkingView
这个之后Window
焦点切换的时候就可以清除上一个Window
的焦点状态?
那么带着疑问,我们来源码(基于Android12源码
)中寻找答案:
java
// packages/apps/Car/RotaryController/src/com/android/car/rotary/RotaryService.java
// 当用户将旋钮上下左右轻推时会回调该方法
private void handleNudgeEvent(@View.FocusRealDirection int direction, int action) {
if (!isValidAction(action)) {
return;
}
// If the focused node is in direct manipulation mode, manipulate it directly.
// 判断是否在DM模式,在DM模式下就走如下逻辑
if (mInDirectManipulationMode) {
// 当前节点只支持旋转事件,不支持NugeEvent事件
if (DirectManipulationHelper.supportRotateDirectly(mFocusedNode)) {
L.d("Ignore nudge events because we're in DM mode and the focused node only "
+ "supports rotate directly");
} else {
// 支持NudgeEvent事件
injectKeyEventForDirection(direction, action);
}
return;
}
// We're done with ACTION_UP event.
// 事件已经处理完成了
if (action == ACTION_UP) {
return;
}
// 获取当前屏幕的Window列表,通过AccessibilityWindowManager获取的
List<AccessibilityWindowInfo> windows = getWindows();
// 在处理ACTION_UP nudge 事件时不要调用 initFocus(),因为当我们将焦点委托给 FocusArea 时,此nudge事件通常会在TYPE_VIEW_FOCUSED事件之前到达,并且当我们发现 mFocusedNode 不再聚焦时,会导致我们聚焦附近的视图
if (initFocus(windows, direction)) {
Utils.recycleWindows(windows);
return;
}
// 如果当前聚焦 HUN,则应仅处理与 HUN 微移方向相反方向的微移事件
if (mFocusedNode != null && mNavigator.isHunWindow(mFocusedNode.getWindow())
&& direction != mHunEscapeNudgeDirection) {
Utils.recycleWindows(windows);
return;
}
// 如果焦点节点未处于DM模式,尝试将焦点移动到另一个节点
nudgeTo(windows, direction);
// 清除AccessibilityWindowInfo记录当前窗口的相关信息
Utils.recycleWindows(windows);
}
正常我们不会处于DM模式,而是在我们轻推的时候会导航到下一个FocusArea
,如下代码所示:
java
@VisibleForTesting
void nudgeTo(@NonNull List<AccessibilityWindowInfo> windows,
@View.FocusRealDirection int direction) {
....
// 如果没有非 FocusParkingView 聚焦,请执行屏幕外微移作(如果指定)
// 我们分析是的从A Window切换到B Window, 这里的mFocusedNode不会为null,
if (mFocusedNode == null) {
L.d("mFocusedNode is null");
handleOffScreenNudge(direction);
return;
}
...
// 微移快捷方式,这个可以参考官方文档
// [FocusArea 自定义](https://source.android.com/docs/automotive/hmi/rotary_controller/app_developers?hl=zh-cn#focusarea-custom)
Bundle arguments = new Bundle();
arguments.putInt(NUDGE_DIRECTION, direction);
if (mFocusArea.performAction(ACTION_NUDGE_SHORTCUT, arguments)) {
L.d("Nudge to shortcut view");
AccessibilityNodeInfo root = mNavigator.getRoot(mFocusArea);
if (root != null) {
updateFocusedNodeAfterPerformingFocusAction(root);
root.recycle();
}
return;
}
// 没有快捷键节点,因此将焦点移动到给定方向
// 首先,尝试对 mFocusArea 执行ACTION_NUDGE以轻移到另一个 FocusArea
// 但是如果当前布局只有一个FocusArea的时候就会找不到
arguments.clear();
arguments.putInt(NUDGE_DIRECTION, direction);
if (mFocusArea.performAction(ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA, arguments)) {
L.d("Nudge to user specified FocusArea");
AccessibilityNodeInfo root = mNavigator.getRoot(mFocusArea);
if (root != null) {
updateFocusedNodeAfterPerformingFocusAction(root);
root.recycle();
}
return;
}
// 方向上没有指定的 FocusArea 或缓存的 FocusArea,因此 mFocusArea 不知道要轻推到哪个 FocusArea
// 在本例中,我们将使用几何的方式找到目标 FocusArea,下面介绍一下这个方法,详细看下面
// 注释①
AccessibilityNodeInfo targetFocusArea =
mNavigator.findNudgeTargetFocusArea(windows, mFocusedNode, mFocusArea, direction);
// 如果没有找到可以聚焦的FocusArea
if (targetFocusArea == null) {
L.d("Failed to find nearest FocusArea for nudge");
// 如果窗口是一个PopupWindow,并且用户试图通过nudge操作离开Window
// 系统会尝试执行 ACTION_DISMISS_POPUP_WINDOW 动作来关闭弹窗
AccessibilityWindowInfo sourceWindow = mFocusArea.getWindow();
if (sourceWindow != null) {
Rect sourceBounds = new Rect();
sourceWindow.getBoundsInScreen(sourceBounds);
// 如果Window不是可以被dismiss的类型返回的是false
if (mNavigator.isDismissible(sourceWindow, sourceBounds, direction)) {
// 查找FocusParkingView对象
AccessibilityNodeInfo fpv = mNavigator.findFocusParkingView(mFocusedNode);
if (fpv != null) {
// 执行dismiss Popup的逻辑
if (fpv.performAction(ACTION_DISMISS_POPUP_WINDOW)) {
L.v("Performed ACTION_DISMISS_POPUP_WINDOW successfully");
fpv.recycle();
sourceWindow.recycle();
return;
}
L.v("The overlay window doesn't support dismissing by nudging "
+ sourceBounds);
fpv.recycle();
} else {
L.e("No FocusParkingView in " + sourceWindow);
}
} // isDismissible括号
sourceWindow.recycle();
}
// 如果用户在屏幕边缘进行nudge操作(即没有更多可聚焦的区域)
// 系统会执行屏幕边缘的特殊处理,可能触发全局操作、按键事件或启动特定intent。
handleOffScreenNudge(direction);
return;
}
// 如果用户正在轻推退出 IME, 会将mFocusedNode设置为正在编辑的节点(应已聚焦)并隐藏 IME
if (mEditNode != null && mFocusArea.getWindowId() != targetFocusArea.getWindowId()){
...
}
// targetFocusArea 是一个显式 FocusArea(即 FocusArea 类的实例),因此请对其执行ACTION_FOCUS
// FocusArea 将通过聚焦其子View之一来处理此问题
// 一般都能找到这个FocusArea对象,即targetFocusArea这个是FocusArea的实例
if (Utils.isFocusArea(targetFocusArea)) {
arguments.clear();
arguments.putInt(NUDGE_DIRECTION, direction);
// 真正执行的地方
boolean success = performFocusAction(targetFocusArea, arguments);
L.successOrFailure("Nudging to the nearest FocusArea " + targetFocusArea, success);
targetFocusArea.recycle();
return;
}
...
}
注释①解释:
该方法是 Navigator
类中的 findNudgeTargetFocusArea(..)
方法,用于查找最适合接收焦点的 FocusArea
。 方法通过以下步骤实现查找最合适的 FocusArea
:
- 收集候选区域:首先在同一窗口内查找其他
FocusArea
,从候选列表中移除当前的FocusArea
(因为不能nudge
到自己) - 扩展搜索范围:如果需要,还会在指定方向上查找其他
Window
中的FocusArea
- 过滤无效区域:移除没有可聚焦后代的
FocusArea
- 选择最佳目标:根据几何位置和方向选择最合适的
FocusArea
,使用chooseBestNudgeCandidate(..)
方法从候选列表中选择最适合的FocusArea
这种方法实现了RotaryContoller
的焦点自动导航功能,能够跨窗口寻找合适的焦点目标,提供流畅的用户体验。
chooseBestNudgeCandidate(..)
是Navigator
类中的一个方法。该方法使用了以下策略来选择最佳候选:
- 获取源节点和源
FocusArea
的边界信息- 遍历所有候选节点
- 使用
isCandidate(...)
方法判断候选节点是否符合方向要求- 使用
FocusFinder.isBetterCandidate(...)
方法比较候选节点的优劣- 返回最佳候选节点
java
// /packages/apps/Car/RotaryController/src/com/android/car/rotary/Navigator.java
private AccessibilityNodeInfo chooseBestNudgeCandidate(
@NonNull AccessibilityNodeInfo sourceNode,
@NonNull List<AccessibilityNodeInfo> candidates,
int direction) {
if (candidates.isEmpty()) {
return null;
}
// 获取源节点和源FocusArea的边界信息
Rect sourceBounds = Utils.getBoundsInScreen(sourceNode);
AccessibilityNodeInfo sourceFocusArea = getAncestorFocusArea(sourceNode);
Rect sourceFocusAreaBounds = Utils.getBoundsInScreen(sourceFocusArea);
sourceFocusArea.recycle();
AccessibilityNodeInfo bestNode = null;
Rect bestBounds = new Rect();
// 遍历所有候选节点
for (AccessibilityNodeInfo candidate : candidates) {
// 判断候选节点的方法是否符合要求
if (isCandidate(sourceBounds, sourceFocusAreaBounds, candidate, direction)) {
Rect candidateBounds = Utils.getBoundsInScreen(candidate);
// 通过isBetterCandidate找到最优Node
if (bestNode == null || FocusFinder.isBetterCandidate(
direction, sourceBounds, candidateBounds, bestBounds)) {
bestNode = candidate;
bestBounds.set(candidateBounds);
}
}
}
return copyNode(bestNode);
}
看到这里,我产生了一个疑问,这个最优
Node
是怎么产生和确认的?留个疑问我们后续再来分析。
我们继续分析原来的逻辑,就是真正执行的地方performFocusAction
,代码如下:
java
// RotaryService.java类中方法
private boolean performFocusAction(
@NonNull AccessibilityNodeInfo targetNode, @Nullable Bundle arguments) {
// 如果直接使用mFocusedNode传递到performFocusActionInternal,则 mFocusedNode 可能会被回收
// 此时如果我们使用 mFocusedNode,可能会导致Crash, 所以此处传递的是Copy的对象
AccessibilityNodeInfo copyNode = copyNode(targetNode);
// 执行
boolean success = performFocusActionInternal(copyNode, arguments);
// 回收Node
copyNode.recycle();
return success;
}
java
private boolean performFocusActionInternal(
@NonNull AccessibilityNodeInfo targetNode, @Nullable Bundle arguments) {
...
// 包含WebView 或 ComposeViews的情况下返回true;不包含返回false
boolean isInVirtualHierarchy = mNavigator.isInVirtualNodeHierarchy(targetNode);
if (!Utils.isFocusArea(targetNode) && Utils.hasFocus(targetNode) && !isInVirtualHierarchy) {
// targetNode 的子View已经被聚焦,因此无法直接对 targetNode 执行ACTION_FOCUS,除非它是 FocusArea
// 解决方法是先清除焦点(将焦点转移到FocusParkingView),然后targetNode获取到焦点了
// 禁止聚焦具有焦点的节点不适用于 WebView 或 ComposeViews
L.d("One of targetNode's descendants is already focused: " + targetNode);
if (!clearFocusInCurrentWindow()) {
return false;
}
}
// 现在我们可以对 targetNode 执行ACTION_FOCUS,因为它没有焦点,它的后代焦点已被清除,或者它是一个 FocusArea
// 这里targetNode如果是FocusParkingView那么就会请求到焦点,从而让原来窗口中聚焦的对象失去焦点,触发onFocusChange
boolean result = targetNode.performAction(ACTION_FOCUS, arguments);
if (!result) {
L.w("Failed to perform ACTION_FOCUS on node " + targetNode);
return false;
}
L.d("Performed ACTION_FOCUS on node " + targetNode);
// 如果我们在 FocusArea 上执行ACTION_FOCUS,找到焦点具体需要传递到的子View并聚焦
if (Utils.isFocusArea(targetNode)) {
if (updateFocusedNodeAfterPerformingFocusAction(targetNode)) {
return true;
} else {
L.w("Unable to find focus after performing ACTION_FOCUS on a FocusArea");
}
}
// Update mFocusedNode and mPendingFocusedNode.
// 下面就是核心方法,添加FocusParkingView能够主动清除原来窗口的焦点
// 如果是FocusParkingView那么传入的就是null
setFocusedNode(Utils.isFocusParkingView(targetNode) ? null : targetNode);
// 如果没有TYPE_VIEW_FOCUSED,那么为最后一个聚焦对象;否则设置为null
setPendingFocusedNode(targetNode);
return true;
}
java
// 这里传入focusedNode的是null
@VisibleForTesting
void setFocusedNode(@Nullable AccessibilityNodeInfo focusedNode) {
// Android doesn't clear focus automatically when focus is set in another window, so we need to do it explicitly.
// 当在另一个窗口中设置焦点时,Android 不会自动清除焦点,因此我们需要显式执行此作。
// focusedNode = null 传入
boolean clearedFocus = maybeClearFocusInCurrentWindow(focusedNode);
// 将当前聚焦对象设置为null,即mFocusedNode=null, mFocusArea=null
setFocusedNodeInternal(focusedNode);
if (mFocusedNode != null && mLastTouchedNode != null) {
setLastTouchedNodeInternal(null);
}
// 如果上面清除了焦点,则会将焦点显示记录设置为上一个窗口
// 待需要恢复该窗口的焦点时对聚焦节点执行ACTION_FOCUS,将聚焦显示恢复到存储的聚焦对象上
// 此逻辑对于处理显示在虚拟显示器上的 ActivityView 是必需的
// 这仅适用于应用程序Window和 ActivityView 窗口
// 系统窗口不需要它,因为系统不会将事件注入其中
if (clearedFocus && focusedNode != null) {
// 这里会为FocusParkingView请求焦点
focusedNode.performAction(ACTION_FOCUS);
}
}
根据源码中的注释也能看到,当焦点从A Window
切换到B Window
时候,焦点会传递到B Window
但是Android
系统不会主动清除A Window
的焦点,那么这样会带来什么问题呢?
- 用户可以看到界面上同时有两个
Window
有焦点,会有焦点突出显示HighLight
效果 A Window
中设置的onFocusChange(...)
焦点监听不会生效,因为此时A Window
并没有清除Focus
那么我们继续看,FocusParkingView
如何实现"清除焦点"这个效果的。
java
// FocusParkingView清除原来Window的逻辑
private boolean maybeClearFocusInCurrentWindow(@Nullable AccessibilityNodeInfo targetFocus) {
// 中间的逻辑均不满足
// mFocusedNode !=null && mFocusedNode.isFocused()=true && targetFocus == null
...
return clearFocusInCurrentWindow();
}
java
// 清除焦点的逻辑
private boolean clearFocusInCurrentWindow() {
if (mFocusedNode == null) {
L.e("Don't call clearFocusInCurrentWindow() when mFocusedNode is null");
return false;
}
// 找到当前布局的根节点对象
AccessibilityNodeInfo root = mNavigator.getRoot(mFocusedNode);
// 找到FocusParkingView请求焦点,如果找到了就请求焦点
boolean result = clearFocusInRoot(root);
root.recycle();
return result;
}
java
private boolean clearFocusInRoot(@NonNull AccessibilityNodeInfo root) {
// fpv就是FocusParkingView对象
AccessibilityNodeInfo fpv = mNavigator.findFocusParkingViewInRoot(root);
// Refresh the node to ensure the focused state is up to date. The node came directly from
// the node tree but it could have been cached by the accessibility framework. fpv = Utils.refreshNode(fpv);
if (fpv == null) {
L.e("No FocusParkingView in the window that contains " + root);
return false;
}
// 已经有焦点就返回
if (fpv.isFocused()) {
L.d("FocusParkingView is already focused " + fpv);
fpv.recycle();
return true;
}
// 没有焦点就请求焦点
boolean result = performFocusAction(fpv);
if (!result) {
L.w("Failed to perform ACTION_FOCUS on " + fpv);
}
fpv.recycle();
return result;
}
至此,回答了为什么FocusParkingView
能够解决Window
焦点切换时,原来的Window
焦点不显示的情况。因为在用户轻推的时候,旧Window
会让其FocusParkingView
请求焦点,而FocusParkingView
本身是可聚焦但是透明且没有焦点突出显示的效果
的。所以会给用户一种旧Window
失去焦点而新Window
获取到焦点了(新Window获取焦点的逻辑不是本文重点)。
现在我们总结一下FocusParkingView
清除旧Window
焦点的机制。
FocusParkingView
在窗口焦点切换时清除前一个窗口焦点状态的机制如下:
-
避免直接清除焦点
- 当需要清除当前
Window
的焦点时,系统不会直接清除焦点,因为这可能导致Android
自动重新聚焦同一Window
中的某个View
,造成当前Window
和目标Window
同时处于聚焦状态。 - 为了解决这个问题,系统采用"停放焦点"的方式,将焦点转移到
FocusParkingView
上。
- 当需要清除当前
-
FocusParkingView 的特性
FocusParkingView
是一个特殊的View
,无论是否处于聚焦状态,它都是透明的,对用户不可见。- 这使得它成为停放焦点的理想选择,因为它不会影响用户的视觉体验。
-
清除焦点的具体实现
-
在
clearFocusInCurrentWindow()
方法中,系统会查找当前窗口中的FocusParkingView
:javaprivate boolean clearFocusInCurrentWindow() { if (mFocusedNode == null) { L.e("Don't call clearFocusInCurrentWindow() when mFocusedNode is null"); return false; } AccessibilityNodeInfo fpv = mNavigator.findFocusParkingViewInRoot(root); // ... }
-
如果找到
FocusParkingView
并且它还没有聚焦,则执行ACTION_FOCUS
操作将焦点转移到它上面:javaboolean result = performFocusAction(fpv);
-
通过这种方式,FocusParkingView
充当了一个"焦点停放区",在Window
间切换焦点时,将前一个Window
的焦点状态"停放"在这里,从而有效地清除了前一个Window
的焦点状态,避免了多个Window
同时聚焦的问题。
现在再来回到前面的挑选候选FocusArea
优劣的机制分析:
FocusFinder.isBetterCandidate(...)
方法判断候选FocusArea
优劣的判断机制
1. Beam 比较优先策略
java
// /packages/apps/Car/RotaryController/src/com/android/car/rotary/FocusFinder.java
// If rect1 is better by beam, it wins.
if (beamBeats(direction, source, rect1, rect2)) {
return true;
}
// If rect2 is better by beam, then rect1 can't be.
if (beamBeats(direction, source, rect2, rect1)) {
return false;
}
首先使用 beamBeats(...)
方法进行优先比较。这个方法基于"Beam"
概念判断哪个候选更优,判断在焦点搜索过程中,一个候选矩形 (rect1
) 是否比另一个候选矩形 (rect2
) 更适合作为下一个焦点目标,是焦点导航算法中的核心比较方法之一。
java
@VisibleForTesting
static boolean beamBeats(int direction, Rect source, Rect rect1, Rect rect2) {
final boolean rect1InSrcBeam = beamsOverlap(direction, source, rect1);
final boolean rect2InSrcBeam = beamsOverlap(direction, source, rect2);
// 如果rect1不在beam内或rect2在beam内,则rect1不胜出
if (rect2InSrcBeam || !rect1InSrcBeam) {
return false;
}
// 如果rect2不在指定方向上,则rect1胜出
if (!isToDirectionOf(direction, source, rect2)) {
return true;
}
// 水平方向,beam内直接胜出
if ((direction == View.FOCUS_LEFT || direction == View.FOCUS_RIGHT)) {
return true;
}
// 垂直方向,比较主要轴距离
return majorAxisDistance(direction, source, rect1)
< majorAxisDistanceToFarEdge(direction, source, rect2);
}
Beam
概念解释:
Beam
范围:在移动方向(View.FOCUS_RIGHT
等)的轴线上,从source
区域无限延伸出的一个范围
beamBeats
判断逻辑:
-
检查候选区域(
rect*
)是否在beam
中- 检查
rect1
和rect2
是否分别在源区域的beam
范围内; - 如果
rect1
在beam
内而rect2
不在,则rect1
更优;
- 检查
-
方向性检查(两个
rect
都在beam
内)- 对于
beam
范围如果都是Right
的话,此时如果rect2
在beam
范围的下方,而rect1
在beam
的正右方,那么rect1
更优;
- 对于
-
对于水平方向移动(
LEFT/RIGHT
),在beam
内的总是更优 -
对于垂直方向移动(
UP/DOWN
),还需要考虑距离因素
2. 加权距离比较
如果 beam
比较无法确定优劣,则使用加权距离比较:
java
return getWeightedDistanceFor(
majorAxisDistance(direction, source, rect1),
minorAxisDistance(direction, source, rect1))
< getWeightedDistanceFor(
majorAxisDistance(direction, source, rect2),
minorAxisDistance(direction, source, rect2));
距离计算方式:
- 主轴距离(
Major Axis Distance
):沿导航方向的距离- 水平移动:
X
轴距离(左右移动) - 垂直移动:
Y
轴距离(上下移动) - 使用
majorAxisDistance
方法计算
- 水平移动:
- 次轴距离(
Minor Axis Distance
):垂直于导航方向的距离- 水平移动:
Y
轴距离(上下偏移) - 垂直移动:
X
轴距离(左右偏移) - 使用
minorAxisDistance
方法计算
- 水平移动:
- 加权计算
java
// 其中 MAJOR_AXIS_BIAS = 13,强调主轴距离的重要性
private static long getWeightedDistanceFor(long majorAxisDistance, long minorAxisDistance) {
return MAJOR_AXIS_BIAS * majorAxisDistance * majorAxisDistance
+ minorAxisDistance * minorAxisDistance;
}
3. 具体判断流程
- 方向正确性:候选区域必须在正确的方向上(使用
isInDirection
判断) Beam
优先:在移动方向的beam
范围内的候选区域优先- 距离优化:主轴距离更重要,次轴距离作为辅助参考
- 综合评估:通过加权距离公式综合判断
4. 实际应用示例
在 RotaryService
中,当用户进行 nudge
操作时,系统会:
- 找到所有可能的候选
FocusArea
- 使用
isBetterCandidate
方法逐一比较 - 选择最优的
FocusArea
作为焦点移动目标
这种方法确保了在RotaryController
操作中,焦点能够智能地移动到用户期望的目标区域,提供流畅自然的导航体验,符合用户对焦点移动的直觉预期。
至此分析完毕,过程分析中如有错漏欢迎批评指正!