FocusParkingView清除旧Window焦点

在实现RotaryController的功能时,官方文档 开发应用 | Android Open Source Project 有如下这段描述:

需要使用 FocusParkingView 的原因如下:

  1. 当焦点被设置在其他窗口中时,Android 不会自动清除焦点。如果您尝试清除上一个窗口中的焦点,Android 会重新聚焦该窗口中的某个视图,从而导致两个窗口同时聚焦。为每个窗口添加一个 FocusParkingView 就可以解决此问题。此视图是透明的,其默认焦点突出显示标志已停用,因此无论此视图是否聚焦,对用户都不可见。此视图可以获得焦点,使 RotaryService 能够将焦点"停"在它上面,从而移除焦点突出显示标志。

看到官方文档的这个描述,虽然能解决问题但是还是不知道如何实现的。针对这段描述,我产生了一个疑问:

  1. 为什么添加了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

  1. 收集候选区域:首先在同一窗口内查找其他 FocusArea,从候选列表中移除当前的 FocusArea(因为不能 nudge 到自己)
  2. 扩展搜索范围:如果需要,还会在指定方向上查找其他Window中的 FocusArea
  3. 过滤无效区域:移除没有可聚焦后代的 FocusArea
  4. 选择最佳目标:根据几何位置和方向选择最合适的 FocusArea,使用chooseBestNudgeCandidate(..) 方法从候选列表中选择最适合的 FocusArea 这种方法实现了RotaryContoller的焦点自动导航功能,能够跨窗口寻找合适的焦点目标,提供流畅的用户体验。

chooseBestNudgeCandidate(..)Navigator 类中的一个方法。该方法使用了以下策略来选择最佳候选:

  1. 获取源节点和源 FocusArea 的边界信息
  2. 遍历所有候选节点
  3. 使用 isCandidate(...) 方法判断候选节点是否符合方向要求
  4. 使用 FocusFinder.isBetterCandidate(...) 方法比较候选节点的优劣
  5. 返回最佳候选节点
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的焦点,那么这样会带来什么问题呢?

  1. 用户可以看到界面上同时有两个Window有焦点,会有焦点突出显示HighLight效果
  2. 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 在窗口焦点切换时清除前一个窗口焦点状态的机制如下:

  1. 避免直接清除焦点

    • 当需要清除当前Window的焦点时,系统不会直接清除焦点,因为这可能导致 Android 自动重新聚焦同一Window中的某个View,造成当前Window和目标Window同时处于聚焦状态。
    • 为了解决这个问题,系统采用"停放焦点"的方式,将焦点转移到FocusParkingView上。
  2. FocusParkingView 的特性

    • FocusParkingView是一个特殊的View,无论是否处于聚焦状态,它都是透明的,对用户不可见。
    • 这使得它成为停放焦点的理想选择,因为它不会影响用户的视觉体验。
  3. 清除焦点的具体实现

    • clearFocusInCurrentWindow()方法中,系统会查找当前窗口中的FocusParkingView

      java 复制代码
      private 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 操作将焦点转移到它上面:

      java 复制代码
      boolean 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 判断逻辑:

  1. 检查候选区域(rect*)是否在beam

    • 检查 rect1rect2 是否分别在源区域的 beam 范围内;
    • 如果 rect1beam 内而 rect2 不在,则 rect1 更优;
  2. 方向性检查(两个rect都在 beam 内)

    • 对于beam 范围如果都是Right的话,此时如果 rect2beam范围的下方,而 rect1beam的正右方,那么rect1 更优;
  3. 对于水平方向移动(LEFT/RIGHT),在 beam 内的总是更优

  4. 对于垂直方向移动(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):沿导航方向的距离
    1. 水平移动:X轴距离(左右移动)
    2. 垂直移动:Y轴距离(上下移动)
    3. 使用 majorAxisDistance 方法计算
  • 次轴距离(Minor Axis Distance):垂直于导航方向的距离
    1. 水平移动:Y轴距离(上下偏移)
    2. 垂直移动:X轴距离(左右偏移)
    3. 使用 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操作中,焦点能够智能地移动到用户期望的目标区域,提供流畅自然的导航体验,符合用户对焦点移动的直觉预期。


至此分析完毕,过程分析中如有错漏欢迎批评指正!

相关推荐
auxor2 小时前
Android 窗口管理 - 窗口添加过程分析Client端
android
雨白3 小时前
HTTP协议详解(一):工作原理、请求方法与状态码
android·http
Yang-Never3 小时前
Kotlin -> object声明和object表达式
android·java·开发语言·kotlin·android studio
小白马丶4 小时前
Jetpack Compose开发框架搭建
android·前端·android jetpack
狂浪天涯4 小时前
Android 16 显示系统 | 从View 到屏幕系列 - 8 | SurfaceFlinger 合成 (一)
android
Wgllss4 小时前
完整案例:Kotlin+Compose+Multiplatform跨平台之桌面端实现(二)
android·架构·android jetpack
深盾安全5 小时前
Android 16KB页面对齐介绍
android
智江鹏5 小时前
Android 之 Kotlin 和 MVVM 架构的 Android 登录示例
android·开发语言·kotlin
凛_Lin~~6 小时前
2025-08 安卓开发面试拷打记录(面试题)
android