包一层 Box 影响了事件分发?记 Compose 事件分发的一次踩坑

背景

看到 通过调用栈快速探究 Compose 中 touch 事件的处理原理 一文,想到之前踩过相关的坑,在此记录一下。

先想一个问题,在 Compose 组件外面包一层 Box 组件,会影响点击事件的分发吗?乍一想似乎不影响,我们平时写 UI 的时候,Row、Column、Box 随手用,没有碰到会影响点击事件的情况。但凑巧的是,若干个条件同时发生,导致在包了一层 Box 之后,原本正常分发的点击事件不再正常工作了......

复现场景

在一个主体使用 Compose 编写的页面之上,有一个之前使用 View 写的浮层,通过 AndroidView 组件接入了当前页面。为了方便排布,浮层与页面其他主体元素,共同作为 ConstraintLayout 的子元素,进行约束布局。在升级到 1.4.0 之后,出现了 AndroidView 在设置部分 Modifier 时布局异常的问题(I4dc77, b/274797771)。为了解决布局问题,临时在 AndroidView 组件外包裹了一层 Box 组件。

简化以后的场景复现如下,首先使用 AndroidView 引入原有的 View,FloatingView 点击一次后切换 visibility 为 GONE,模拟点击叉叉关闭了这个浮层:

ps:掘金编辑器解析后缩进都混乱了,没精力一个个修改了。。

kotlin 复制代码
@Composable
fun FloatingView() {
    AndroidView(
        factory = { context -> FloatingView(context) }
)
}

// FloatingView
private fun init() {
    // 点击浮层后,弹出 Toast,并且切换 FloatingView 的 visibility 为 GONE
    setOnClickListener {
Toast.makeText(context, "Floating View Clicked!", Toast.LENGTH_SHORT).show()
        visibility = GONE
 }
}

在同一个 Box 中,分别放置了页面主体元素和浮层,预期在关闭浮层以后,再点击这个区域,能够显示背景被点击的 Toast,代表点击事件分发到了页面主体元素:

kotlin 复制代码
@Composable
fun FloatingDemo() {
Surface(modifier = Modifier.fillMaxSize()) {
        // 页面主体元素
Box(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
Toast
                        .makeText(context, "Background View Clicked!", Toast.LENGTH_SHORT)
                        .show()
                }
)
        // 浮层
        FloatingView()
    }
}

后用 Box 包裹 FloatingView 组件,替换原来的 FloatingView 组件:

kotlin 复制代码
@Composable
fun WrappedFloatingView() {
    Box {
FloatingView()
    }
}

@Composable
fun FloatingDemo() {
Surface(modifier = Modifier.fillMaxSize()) { 
// 页面主体元素
Box(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
Toast
                        .makeText(context, "Background View Clicked!", Toast.LENGTH_SHORT)
                        .show()
                }
)
        // 替换为包裹了 Box 之后的浮层
        WrappedFloatingView()
    }
}

分别点击看看效果。在切换 FloatingView 的 visibility 为 GONE 之后,再次点击浮层区域,未包裹 Box 的例子中,背景的 Box 能接收到点击事件,而包裹了 Box 的例子中却不能:
```

|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
| | |
| FloatingView 未包裹 Box | FloatingView 包裹了 Box |

Box 在这里看起来只起到辅助布局的作用,没有对点击事件做任何额外的处理,为什么就影响了事件分发呢?

分析

Compose 事件分发流程

首先简单介绍 Compose 事件分发的流程。Compose 事件分发的流程与 View 系统相差不多,遍历 LayoutNode 的 Modifier 判断是否有处理点击事件的 PointerInputModifierNode,若有,再判断 MotionEvent 的坐标是否在对应 LayoutNode 的范围内,若符合则收集起来,并做下一步的分发。

Compose 通过 ComposeView 挂接到传统 View 视图体系中,ComposeView 是一个 ViewGroup ,它的直接子 View 是一个 AndroidComposeView 对象(它也是一个 ViewGroup ),然后在 AndroidComposeView 中管理着一棵由 LayoutNode 组成的 UI 树,每个 Composable 最终都对应着 LayoutNode 树中的一个节点。

我们使用 setContent 方法将 Compose 布局设置到了 AndroidComposeView 中。AndroidComposeView 重写了 ViewGroup 的 dispatchTouchEvent 方法分发 Android 的点击事件给 Compose,通过 handleMotionEvent 方法、sendMotionEvent 方法,调用到 PointerInputEventProcessor 的 process 方法:

kotlin 复制代码
@OptIn(InternalCoreApi::class, ExperimentalComposeUiApi::class)
// 1. root 为 AndroidComposeView 传进来的根节点
internal class PointerInputEventProcessor(val root: LayoutNode) {
    fun process(
        pointerEvent: PointerInputEvent,
        positionCalculator: PositionCalculator,
        isInBounds: Boolean = true
    ): ProcessResult {
        internalPointerEvent.changes.values.forEach { pointerInputChange ->
            // 2. 是否为 down 事件,如果是的话,则需要记录命中路径
            if (isHover || pointerInputChange.changedToDownIgnoreConsumed()) {
                val isTouchEvent = pointerInputChange.type == PointerType.Touch
                // 3. 获取命中的 PointerInputModifierNode ,添加到 hitResult 集合
                root.hitTest(pointerInputChange.position, hitResult, isTouchEvent)
                if (hitResult.isNotEmpty()) {
                    // 4. 添加到命中路径,转换成链表 hitPathTracker
                    hitPathTracker.addHitPath(pointerInputChange.id, hitResult)
                    hitResult.clear()
                }
            }
        }
        // 5. 分发事件
        val dispatchedToSomething =
            hitPathTracker.dispatchChanges(internalPointerEvent, isInBounds)

        return ProcessResult(dispatchedToSomething, anyMovementConsumed)
    }
}

Compose 如何获得命中路径 hitResult

在 Compose 的时间分发流程中,包裹一层 Box 导致哪里发生了变化,最终致使点击事件没有按照预期传递呢?经过调试发现,在步骤3"获取命中的 PointerInputModifierNode ,添加到 hitResult 集合"之后,hitResult 的结果有所不同。包裹了 Box 时,hitResult 中少了两个 Node,而这两个 Node 恰好是 Compose 中调用 clickable 修饰符后,会增加的两个 Node。

  • 没有 Box 包裹
  • 有 Box 包裹

推测是这个原因导致背景 Box 没有被分发到点击事件,所以继续追踪步骤3处的代码,可以看到继续调用了 hitTest 方法,能够猜到这是一个遍历操作,但是具体的流程是什么样的呢?

kotlin 复制代码
// LayoutNode

@OptIn(ExperimentalComposeUiApi::class)
internal fun hitTest(
    pointerPosition: Offset,
    hitTestResult: HitTestResult<PointerInputModifierNode>,
    isTouchEvent: Boolean = false,
    isInLayer: Boolean = true
) {
    val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition)
    // 从 outerCoordinator 开始遍历 NodeCoordinator,调用 NodeCoordinator.hitTest 检测
    outerCoordinator.hitTest(
        // 这里传入 NodeCoordinator.PointerInputSource,之后用于筛选 PointerInputModifierNode
        NodeCoordinator.PointerInputSource,
        positionInWrapped,
        hitTestResult,
        isTouchEvent,
        isInLayer
    )
}

先看下遍历的大致流程:

  1. Modifier.Node 以 NodeChain 双向链表的形式挂在 LayoutNode 上
  2. 查找命中路径时,从 outerCoordinator 开始遍历 NodeCoordinator 链,如果有 PointerInputModifierNode 且点击坐标在当前 LayoutNode 范围内,将其加入 hitTestResult
  3. 遍历到 innerCoordinator 后,再继续查找子 LayoutNode 节点

遍历的3个步骤的对应代码如下:

  1. Modifier.Node 以 NodeChain 双向链表的形式挂在 LayoutNode 上
kotlin 复制代码
// LayoutNode

internal val nodes = NodeChain(this)
internal val innerCoordinator: NodeCoordinator
    get() = nodes.innerCoordinator
internal val outerCoordinator: NodeCoordinator
    get() = nodes.outerCoordinator

// 每次添加新的 Modifier 时,都会整理 NodeChain
override var modifier: Modifier = Modifier
    set(value) { ..
        nodes.updateFrom(value)
    }
  1. 查找命中路径时,从 outerCoordinator 开始遍历 NodeCoordinator 链,如果有 PointerInputModifierNode 且点击坐标在当前 LayoutNode 范围内,将其加入 hitTestResult
kotlin 复制代码
// NodeCoordinator

fun <T : DelegatableNode> hitTest(
    hitTestSource: HitTestSource<T>,
    pointerPosition: Offset,
    hitTestResult: HitTestResult<T>,
    isTouchEvent: Boolean,
    isInLayer: Boolean
) {
    val head = headUnchecked(hitTestSource.entityType())
    if (!withinLayerBounds(pointerPosition)) { ...
    } else if (head == null) {
        // 2.1. 对应的 Modifier.Node 不是 PointerInputModifierNode,继续遍历 NodeCoordinator 链
        hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)
    } else if (isPointerInBounds(pointerPosition)) {
        // 2.2. 对应的 Modifier.Node 是 PointerInputModifierNode,并且 pointer 坐标在 layoutNode 范围
        // 内,记录这一个 PointerInputModifierNode 到 hitTestResult
        head.hit(
            hitTestSource,
            pointerPosition,
            hitTestResult,
            isTouchEvent,
            isInLayer
        )
    } else { ... }
}

open fun <T : DelegatableNode> hitTestChild(
    hitTestSource: HitTestSource<T>,
    pointerPosition: Offset,
    hitTestResult: HitTestResult<T>,
    isTouchEvent: Boolean,
    isInLayer: Boolean
) {
    val wrapped = wrapped
    if (wrapped != null) {
        val positionInWrapped = wrapped.fromParentPosition(pointerPosition)
        // 2.3. 继续对下一个 NodeCoordinator 进行 hitTest 判断
        wrapped.hitTest(
            hitTestSource,
            positionInWrapped,
            hitTestResult,
            isTouchEvent,
            isInLayer
        )
    }
}
  1. 遍历到 innerCoordinator 后,再继续查找子 LayoutNode 节点
kotlin 复制代码
// InnerNodeCoordinator

@OptIn(ExperimentalComposeUiApi::class)
override fun <T : DelegatableNode> hitTestChild(
    hitTestSource: HitTestSource<T>,
    pointerPosition: Offset,
    hitTestResult: HitTestResult<T>,
    isTouchEvent: Boolean,
    isInLayer: Boolean
) {
    ...
    // 3.1. 按 z 轴放置的顺序,逆序遍历子 LayoutNode,一旦返回 true 就停止遍历
    layoutNode.zSortedChildren.reversedAny { child ->
        if (child.isPlaced) {
            // 3.2. 调用 NodeCoordinator.PointerInputSource 对象的 childHitTest 方法
            // 即跳转到3.4步骤
            hitTestSource.childHitTest(
                child,
                pointerPosition,
                hitTestResult,
                isTouchEvent,
                inLayer
            )
            val wasHit = hitTestResult.hasHit()
            val continueHitTest: Boolean
            if (!wasHit) {
                continueHitTest = true
            } else if (
                // 3.3. 检查刚才命中的 LayoutNode 是否与其他兄弟 LayoutNode 共享点击事件,
                // 如果共享,则继续遍历,反之则结束遍历
                child.outerCoordinator.shouldSharePointerInputWithSiblings()
            ) {
                hitTestResult.acceptHits()
                continueHitTest = true
            } else {
                continueHitTest = false
            }
            !continueHitTest
        } else {
            false
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
val PointerInputSource =
    object : HitTestSource<PointerInputModifierNode> {
        override fun entityType() = Nodes.PointerInput

        override fun childHitTest(
            layoutNode: LayoutNode,
            pointerPosition: Offset,
            hitTestResult: HitTestResult<PointerInputModifierNode>,
            isTouchEvent: Boolean,
            isInLayer: Boolean
        // 3.4. 开始子 LayoutNode 的遍历
        ) = layoutNode.hitTest(pointerPosition, hitTestResult, isTouchEvent, isInLayer)
    }

是否和兄弟 LayoutNode 共享点击事件

梳理 Compose 获得命中路径 hitResult 的过程后,可以推断包裹 Box 之后,影响了遍历过程的 3.3 步骤。3.3 步骤决定是否继续遍历的关键在于 PointerInputModifier 中 sharePointerInputWithSiblings() 方法的返回值,该方法在 BackwardsCompatNode 被重写,返回值取决于 pointerInputFilter 的 shareWithSiblings 属性:

kotlin 复制代码
// NodeCoordinator
fun shouldSharePointerInputWithSiblings(): Boolean {
    val start = headNode(Nodes.PointerInput.includeSelfInTraversal) ?: return false
    start.visitLocalDescendants(Nodes.PointerInput) {
if (it.sharePointerInputWithSiblings()) return true
    }
return false
}

// PointerInputModifierNode
fun sharePointerInputWithSiblings(): Boolean = false

// BackwardsCompatNode
override fun sharePointerInputWithSiblings(): Boolean {
    return with(element as PointerInputModifier) {
        pointerInputFilter.shareWithSiblings
    }
}

PointerInputFilter 抽象类中定义 shareWithSiblings 变量为 false:

kotlin 复制代码
abstract class PointerInputFilter {
    @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
    open val shareWithSiblings: Boolean
        get() = false
}

PointerInputModifier 接口有一个实现类 PointerInteropFilter,其中重写了 pointerInputFilter 对象,它的 shareWithSiblings 属性重写为 true:

kotlin 复制代码
// PointerInteropFilter

@ExperimentalComposeUiApi
internal class PointerInteropFilter : PointerInputModifier {
    override val pointerInputFilter =
        object : PointerInputFilter() {
            override val shareWithSiblings
                get() = true
        }
}

AndroidView 的点击事件会通过 pointerInteropFilter 修饰符创建一个 PointerInteropFilter 对象,添加到 BackwardsCompatNode:

kotlin 复制代码
// AndroidViewHolder
val layoutNode: LayoutNode = run {
    val layoutNode = LayoutNode()
    val coreModifier = Modifier
        // 使用 pointerInteropFilter 修饰符处理点击事件
        .pointerInteropFilter(this)
    layoutNode.modifier = modifier.then(coreModifier)
    layoutNode
}

// PointerInteropFilter
@ExperimentalComposeUiApi
internal fun Modifier.pointerInteropFilter(view: AndroidViewHolder): Modifier {
    // 创建了 PointerInputModifier 接口的实现类 PointerInteropFilter 对象
    val filter = PointerInteropFilter()
    filter.onTouchEvent = {...}
    return this.then(filter)
}

因此,在 Compose 遍历寻找命中路径 hitResult 的过程中,如果命中 AndroidView,将与其兄弟 LayoutNode 分享点击事件,继续进行遍历。

而 Compose 其他组件的点击事件,在 1.4.x 版本,都收拢到 pointerInput 修饰符,添加 PointerInputModifier 接口的另一个实现类 SuspendingPointerInputFilter 对象。SuspendingPointerInputFilter 中没有重写 shareWithSiblings 属性,因此并不会跟兄弟 LayoutNode 共享点击事件。在 1.5.0 版本及以后,取消了 SuspendingPointerInputFilter 实现类,Compose 组件的点击事件,在遍历获得命中路径 hitResult 的过程中,在步骤 3.3 也不会进行共享,而是直接结束遍历。

在这个场景里发生了什么

梳理完了事件分发过程、遍历获得命中路径、是否和兄弟 LayoutNode 共享点击事件,回头看看在这个具体场景里发生了什么:

kotlin 复制代码
@Composable
fun FloatingDemo() {
Surface(modifier = Modifier.fillMaxSize()) {
        // 页面主体元素
Box(
            modifier = Modifier
                .fillMaxSize()
                .clickable {
Toast
                        .makeText(context, "Background View Clicked!", Toast.LENGTH_SHORT)
                        .show()
                }
)
        // 浮层
        FloatingView()
        // 替换为包裹了 Box 之后的浮层
        // WrappedFloatingView()
    }
}
  • 如果 AndroidView 没有包裹 Box,首先命中 AndroidView,返回到父节点 Surface 时,由于 AndroidView 和兄弟节点分享点击事件,继续进入页面主体元素进行遍历。
  • 如果 AndroidView 包裹了 Box,首先命中 AndroidView,返回到父节点 Box 时,虽然 AndroidView 和兄弟节点分享点击事件,但该 Box 下没有其他子节点,所以继续返回;返回到父节点 Surface 时,由于 Box 不和兄弟节点分享点击事件,因此不会再进入页面主体元素进行遍历。

结语

分析了这么久,不知道有没有朋友心里觉得有点奇怪的?是的,设置 FloatingView 的 visibility 为 GONE 之后,为什么它的宽高不是0,反而还能看到它的布局边界呢?有人在 IssueTracker 提出了这个问题,看起来在 1.6.0 版本已经修复了(b/324429692)。所以总结起来,这一次预期之外的事件分发,是由两个 bug 扎堆引起的意外(另一个是前文提到的 AndroidView 在设置部分 Modifier 时布局异常的问题(I4dc77, b/274797771),从 1.4.0 开始出现,在 1.4.3 已经修复)。因此如果使用最新的 1.6.3,是无法复现本文的问题的。

虽然这个坑已经踩完了,但如果在使用 1.6.0 以前的版本,同时还有和 View 系统有互操作的状况,可以检查一下有没有类似的情况。以及升版本的时候,还是要检查一下有没有突然坑了的地方,修复的时候也不要修了一个又搞出另一个。。。

相关内容跨越版本和时间都很久,有问题欢迎讨论和指正~

相关推荐
网络研究院2 小时前
Android 安卓内存安全漏洞数量大幅下降的原因
android·安全·编程·安卓·内存·漏洞·技术
凉亭下2 小时前
android navigation 用法详细使用
android
小比卡丘5 小时前
C语言进阶版第17课—自定义类型:联合和枚举
android·java·c语言
前行的小黑炭6 小时前
一篇搞定Android 实现扫码支付:如何对接海外的第三方支付;项目中的真实经验分享;如何高效对接,高效开发
android
落落落sss7 小时前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
代码敲上天.7 小时前
数据库语句优化
android·数据库·adb
GEEKVIP10 小时前
手机使用技巧:8 个 Android 锁屏移除工具 [解锁 Android]
android·macos·ios·智能手机·电脑·手机·iphone
model200512 小时前
android + tflite 分类APP开发-2
android·分类·tflite
彭于晏68912 小时前
Android广播
android·java·开发语言
与衫13 小时前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql