Compose的事件分发

7 阅读10分钟

声明:这篇文章来自自己的学习笔记,全部原创,如有错误表述请见谅,本文档来自飞书编制,所有一些高亮无法正常显示,可以直接在末尾查看原文

学习Compose的事件分发前,建议先掌握传统View下的事件分发 Android事件分发逻辑--针对事件分发相关函数的讲解这份笔记深入分析了 Android 事件分发的核心机制与源码逻辑 - 掘金

Compose对于事件分发的特殊性质

我们知道compose组件是在AndroidComposeView环境下运行的

internal class AndroidComposeView(
    context: Context,
    coroutineContext: CoroutineContext
) : ViewGroup(context), Owner, ViewRootForTest, PositionCalculator,
DefaultLifecycleObserver {}

打开AndroidComposeView我们发现它是继承于ViewGroup的,所以AndroidComposeView可以算作是一个特殊的ViewGroup,基于我们对传统View事件分发的理解,ViewGroup是作为事件分发中的一个步骤存在。

这样说的话,AndroidComposeView只是通过重写dispatchTouchEvent的一系列的方法,改变了分发流程,在外界看它仍然是一个ViewGroup,它同样要接受DOWN,MOVE,UP,CANCEL等等事件

Compose的事件分发流程

既然我们知道了AndroidComposeView事件分发处理的原理,下面我们来看看具体的分发逻辑,因为AndroidComposeView是继承于ViewGroup的,所以他会重写dispatchTouchEvent分发,我们从这里看起

AndroidComposeView的dispatchTouchEvent方法


override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
//处理悬停(Hover)退出逻辑
    if (hoverExitReceived) {
        removeCallbacks(sendHoverExitEvent)
        val lastEvent = previousMotionEvent!!
        if (motionEvent.actionMasked != ACTION_DOWN ||
            hasChangedDevices(motionEvent, lastEvent)
        ) {
            sendHoverExitEvent.run()
        } else {
            hoverExitReceived = false
        }
    }
    //基础安全检查:数据是否合法,View 是否还在窗口中
    if (isBadMotionEvent(motionEvent) || !isAttachedToWindow) {
        return false // Bad MotionEvent. Don't handle it.
    }
    
    //性能优化:如果ACTION_MOVE坐标没变,直接丢弃
    if (motionEvent.actionMasked == ACTION_MOVE && !isPositionChanged(motionEvent)) {
        return false
    }
//关键将原生事件分发给Compose内部系统
    val processResult = handleMotionEvent(motionEvent)

//如果Compose内部消费了位移阻止父容器拦截
    if (processResult.anyMovementConsumed) {
        parent.requestDisallowInterceptTouchEvent(true)
    }

    return processResult.dispatchedToAPointerInputModifier
}

我们发现dispatchTouchEvent只有这么点,对比ViewGroup的dispatchTouchEvent的200多行代码显然是太少了,这里的关键就是调用了handleMotionEvent(motionEvent)方法

AndroidComposeView的handleMotionEvent方法

private fun handleMotionEvent(motionEvent: MotionEvent): ProcessResult {
    removeCallbacks(resendMotionEventRunnable)
    try {
    //在处理点击之前,强制执行一次测量和布局,确保Compose内部的组件位置是最新的
        recalculateWindowPosition(motionEvent)
        forceUseMatrixCache = true
        measureAndLayout(sendPointerUpdate = false)
        val result = trace("AndroidOwner:onTouch") {
val action = motionEvent.actionMasked
val lastEvent = previousMotionEvent

            val wasMouseEvent = lastEvent?.getToolType(0) == TOOL_TYPE_MOUSE
            
            
            //处理从“手指触摸”切换到“鼠标点击”或“手写笔”等情况
if (lastEvent != null &&
                hasChangedDevices(motionEvent, lastEvent)
            ) {
                if (isDevicePressEvent(lastEvent)) {
                    pointerInputEventProcessor.processCancel()
                } 
                //用户之前在用手指触摸(非鼠标),现在突然改用鼠标操作,且鼠标指针直接出现在了 Compose 区域内
                else if (lastEvent.actionMasked != ACTION_HOVER_EXIT && wasMouseEvent) {
                //人为地补充一个“虚假”的触摸事件,以确保 Compose 内部的事件流(Event Stream)逻辑完整且一致
                    sendSimulatedEvent(lastEvent, ACTION_HOVER_EXIT, lastEvent.eventTime)
                }
            }

            val isMouseEvent = motionEvent.getToolType(0) == TOOL_TYPE_MOUSE

if (!wasMouseEvent &&
                isMouseEvent &&
                action != ACTION_CANCEL &&
                action != ACTION_HOVER_ENTER &&
                isInBounds(motionEvent)
            ) {
                
                sendSimulatedEvent(motionEvent, ACTION_HOVER_ENTER, motionEvent.eventTime)
            }
            lastEvent?.recycle()

            //区分“真实的鼠标移出”和“系统为了点击而伪造的移出”,从而实现性能优化并防止 UI 闪烁。 
            if (previousMotionEvent?.action == ACTION_HOVER_EXIT) {
                val previousEventDefaultPointerId =
                    previousMotionEvent?.getPointerId(0) ?: -1

                // New ACTION_HOVER_ENTER, so this should be considered a new stream
                if (motionEvent.action == ACTION_HOVER_ENTER && motionEvent.historySize == 0) {
                    if (previousEventDefaultPointerId >= 0) {
                        motionEventAdapter.endStream(previousEventDefaultPointerId)
                    }
                } else if (motionEvent.action == ACTION_DOWN && motionEvent.historySize == 0) {
                    val previousX = previousMotionEvent?.x ?: Float.NaN
                    val previousY = previousMotionEvent?.y ?: Float.NaN

                    val currentX = motionEvent.x
val currentY = motionEvent.y

val previousAndCurrentCoordinatesDoNotMatch =
                        (previousX != currentX || previousY != currentY)

                    val previousEventTime = previousMotionEvent?.eventTime ?: -1L

                    val previousAndCurrentEventTimesDoNotMatch =
                        previousEventTime != motionEvent.eventTime

// A synthetically created Hover Exit event will always have the same x,
                    // y, and timestamp as the down event it proceeds.
                    val previousHoverEventWasNotSyntheticallyProducedFromADownEvent =
                        previousAndCurrentCoordinatesDoNotMatch ||
                        previousAndCurrentEventTimesDoNotMatch

                    if (previousHoverEventWasNotSyntheticallyProducedFromADownEvent) {
                        // This should be considered a new stream, and we should
                        // reset everything.
                        if (previousEventDefaultPointerId >= 0) {
                            motionEventAdapter.endStream(previousEventDefaultPointerId)
                        }
                        pointerInputEventProcessor.clearPreviouslyHitModifierNodes()
                    }
                }
            }

            previousMotionEvent = MotionEvent.obtainNoHistory(motionEvent)
            //正式分发事件
            sendMotionEvent(motionEvent) 
        }
return result
    } finally {
        forceUseMatrixCache = false
    }
}

也就是说最后调用sendMotionEvent(motionEvent) 方法

看了这里的代码我们发现compose貌似对鼠标和手指触摸做出了很大区分,毕竟我们在传统View里面并没有看到这么多的处理,传统View的处理其实更倾向于尽量把鼠标模拟成手指这样的操作。

  1. 这是因为Compose 为了性能,引入了极强的缓存机制(HitPathTracker)
  • 当鼠标悬停(Hover)在按钮上时,Compose 已经计算出了一条“命中路径”(从根节点到该按钮的一串节点)。
  • 如果能在 ACTION_DOWN 时复用这条路径,性能会提升巨大。
  • 为了复用这个缓存,Compose 必须非常确定:刚才那个 HOVER_EXIT 是系统为了点击而合成的假事件,还是用户真的把鼠标移走了。如果是真的移走了,缓存必须清空;如果是合成的,缓存必须保留。
  • 传统 View 没有这种深度的节点路径缓存,所以它不需要费劲去比对坐标和时间来保护缓存。
  1. 对于 Android 系统来说,整个 Compose 界面只有一个 View(即 AndroidComposeView)。系统根本不知道 Compose 里面有几千个按钮还是几个列表,所以Compose需要更精细的处理
  2. Compose 从设计之初就考虑了 多平台(Multiplatform) 。同样的底层代码要运行在 Android、Windows、macOS 和 Linux 上。

所以在桌面端,鼠标的悬停、点击、右键、中键滚轮等逻辑必须极其严谨,同时也能在手机上完成鼠标切换的 完整逻辑

AndroidComposeView的sendMotionEvent方法

private fun sendMotionEvent(motionEvent: MotionEvent): ProcessResult {
    if (keyboardModifiersRequireUpdate) {
        keyboardModifiersRequireUpdate = false
        _windowInfo.keyboardModifiers = PointerKeyboardModifiers(motionEvent.metaState)
    }
    //把Android系统原生的MotionEvent翻译成Compose内部通用的PointerInputEvent实现Compose与Android的解耦
    val pointerInputEvent =
        motionEventAdapter.convertToPointerInputEvent(motionEvent, this)
        
    return if (pointerInputEvent != null) {
    
    
        //记录用户手指按下(down)时的本地坐标
        //Android原生系统有时会询问这个 View:“你在坐标 (x, y) 处能滑动吗?”(即调用 canScrollVertically等方法)。由于Compose的语义(Semantics)和滚动逻辑是在内部处理的,记录这个位置可以帮助 Compose 准确回答原生系统的询问
        pointerInputEvent.pointers.fastLastOrNull { it.down } ?.position?.let {
lastDownPointerPosition = it
        }
        
       //事件分发的真正执行者
 val result = pointerInputEventProcessor.process(
            pointerInputEvent,
            this,
            isInBounds(motionEvent)
        )
        
        
        val action = motionEvent.actionMasked
if ((action == ACTION_DOWN || action == ACTION_POINTER_DOWN) &&
        //如果compose没有设置点击监听或手势
            !result.dispatchedToAPointerInputModifier
        ) {
        //拒绝接受后面的滑动和抬起事件
            motionEventAdapter.endStream(motionEvent.getPointerId(motionEvent.actionIndex))
        }
        result
    } else {
        pointerInputEventProcessor.processCancel()
        ProcessResult(
            dispatchedToAPointerInputModifier = false,
            anyMovementConsumed = false
        )
    }
}

可以看到这里转换了MotionEvent并且调用了****PointerInputEventProcessor.process方法

PointerInputEventProcessor的process方法

这个方法是 Compose 触摸系统最核心的手势分发算法。它决定了哪个组件能收到触摸事件,并处理了 Compose 著名的“三阶段”分发逻辑。

fun process(
    @OptIn(InternalCoreApi::class)
    pointerEvent: PointerInputEvent,
    positionCalculator: PositionCalculator,
    isInBounds: Boolean = true
): ProcessResult {

//防止在处理一个触摸事件的过程中又触发了另一个事件的分发
    if (isProcessing) {
        return ProcessResult(
            dispatchedToAPointerInputModifier = false,
            anyMovementConsumed = false
        )
    }
    
    
    try {
        isProcessing = true

        // Gets a new PointerInputChangeEvent with the PointerInputEvent.
        @OptIn(InternalCoreApi::class)
        val internalPointerEvent =
            pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)

        var isHover = true
        for (i in 0 until internalPointerEvent.changes.size()) {
            val pointerInputChange = internalPointerEvent.changes.valueAt(i)
            if (pointerInputChange.pressed || pointerInputChange.previousPressed) {
                isHover = false
                break
            }
        }

//关键
        for (i in 0 until internalPointerEvent.changes.size()) {
            val pointerInputChange = internalPointerEvent.changes.valueAt(i)
            //只有在 Hover 状态或手指刚按下(Down)时才进行命中测试
            //如果是MOVE事件等等会直接跳过这个步骤,直接复用逻辑
            if (isHover || pointerInputChange.changedToDownIgnoreConsumed()) {
                val isTouchEvent = pointerInputChange.type == PointerType.Touch
                //root.hitTest会遍历LayoutNode树,找到触摸点所属Modifier所有带Modifier.pointerInput 的节点,存入 hitResult,这里行程的路径类似于newTouchTarget
                root.hitTest(pointerInputChange.position, hitResult, isTouchEvent)
                if (hitResult.isNotEmpty()) {
                //将命中的路径从根节点到叶子节点记录到hitPathTracker
                    hitPathTracker.addHitPath(
                        pointerId = pointerInputChange.id,
                        pointerInputNodes = hitResult,
                        prunePointerIdsAndChangesNotInNodesList =
                        pointerInputChange.changedToDownIgnoreConsumed()
                    )
                    hitResult.clear()
                }
            }
        }
        
//如果某个组件在触摸过程中突然从屏幕上消失了(比如由于状态改变被删除了),这里会将其从当前的触摸路径中剔除,防止发送事件给一个不存在的节点。
        hitPathTracker.removeDetachedPointerInputNodes()

        //利用之前生成好的路径分发事件
        val dispatchedToSomething =
            hitPathTracker.dispatchChanges(internalPointerEvent, isInBounds)

        val anyMovementConsumed = if (internalPointerEvent.suppressMovementConsumption) {
            false
        } else {
            var result = false
            for (i in 0 until internalPointerEvent.changes.size()) {
                val event = internalPointerEvent.changes.valueAt(i)
                if (event.positionChangedIgnoreConsumed() && event.isConsumed) {
                    result = true
                    break
                }
            }
            result
        }

        return ProcessResult(dispatchedToSomething, anyMovementConsumed)
    } finally {
        isProcessing = false
    }
}

此外

val dispatchedToSomething =
            hitPathTracker.dispatchChanges(internalPointerEvent, isInBounds)

这里的分发阶段分为三个类型

阶段传递方向模拟 View 系统行为核心作用
Initial根 → 叶 (往下)onInterceptTouchEvent拦截:父组件优先抢占事件
Main叶 → 根 (往上)onTouchEvent消费:子组件处理业务逻辑
Final根 → 叶 (往下)无直接对应同步:清理状态、确认最终结果

了解了这三个流程,我们接下来来了解dispatchChanges方法

HitPathTracker的dispatchChanges方法

fun dispatchChanges(
    internalPointerEvent: InternalPointerEvent,
    isInBounds: Boolean = true
): Boolean {

//判断手指有没有发生变化(位置,数量)
    val changed = root.buildCache(
        internalPointerEvent.changes,
        rootCoordinates,
        internalPointerEvent,
        isInBounds
    )
    //如果没有任何变化不进行下面的流程
    if (!changed) {
        return false
    }
    
    //Initial和Main的分发处
    var dispatchHit = root.dispatchMainEventPass(
        internalPointerEvent.changes,
        rootCoordinates,
        internalPointerEvent,
        isInBounds
    )
    
    //Final的分发处
    dispatchHit = root.dispatchFinalEventPass(internalPointerEvent) || dispatchHit

    return dispatchHit
}

通过dispatchChanges我们发现又调用dispatchMainEventPassdispatchFinalEventPass进一步实现分发流程

我们来看看dispatchMainEventPass

HitPathTracker的dispatchMainEventPass方法

open fun dispatchMainEventPass(
    changes: LongSparseArray<PointerInputChange>,
    parentCoordinates: LayoutCoordinates,
    internalPointerEvent: InternalPointerEvent,
    isInBounds: Boolean
): Boolean {
    var dispatched = false
    children.forEach {
dispatched = it.dispatchMainEventPass(
            changes,
            parentCoordinates,
            internalPointerEvent,
            isInBounds
        ) || dispatched
    }
return dispatched
}

override fun dispatchMainEventPass(
    changes: LongSparseArray<PointerInputChange>,
    parentCoordinates: LayoutCoordinates,
    internalPointerEvent: InternalPointerEvent,
    isInBounds: Boolean
): Boolean {

    return dispatchIfNeeded {
val event = pointerEvent!!
        val size = coordinates!!.size

        //Initial
        modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Initial, size)
        }

//递归调用
        if (modifierNode.isAttached) {
            children.forEach {
it.dispatchMainEventPass(
                   
                    relevantChanges,
                    coordinates!!,
                    internalPointerEvent,
                    isInBounds
                )
            }
}
        //Main
        if (modifierNode.isAttached) {
            // Dispatch on the bubbling pass.
            modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Main, size)
            }
}
    }
}

我们之前知道Initial是自上而下的而Main是自下而上的,这里的递归很好验证了这一点

我们继续点击onPointerEvent来看

SuspendingPointerInputModifierNodeImpl的onPointerEvent方法

实际上我们点击crtl+左键查看源码来到了PointerInputModifierNode接口,实际是SuspendingPointerInputModifierNode继承了这个接口最后由SuspendingPointerInputModifierNodeImpl来实现的

我们来看看代码

通过实际代码的实现来理解这里的代码,其实这里的逻辑,就是离我们实际的操作最近的地方!

@Composable
fun SpecialSwipeInterceptor() {
    // 父容器的横向偏移量
    var offsetX by remember { mutableStateOf(0f) }
    
    // 整个界面的容器
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.DarkGray),
        contentAlignment = Alignment.Center
    ) {
        // --- 父容器:特殊滑动拦截者 ---
        Box(
            modifier = Modifier
                .size(300.dp, 200.dp)
                .offset { IntOffset(offsetX.roundToInt(), 0) } // 应用位移
                .background(Color.White, RoundedCornerShape(16.dp))
                .pointerInput(Unit) {
                    awaitPointerEventScope {
                        while (true) {
                            // Initial 阶段
                            val down = awaitPointerEvent(PointerEventPass.Initial).changes.first()
                            
                            var totalDrag = 0f
                            var isIntercepting = false

                            //持续监听移动事件
                            do {
                                val event = awaitPointerEvent(PointerEventPass.Initial)
                                val change = event.changes.first()
                                
                                if (change.pressed) {
                                    //计算累计位移
                                    val dragAmount = change.positionChange().x
                                    totalDrag += dragAmount

                                 
                                    // 如果横向滑动超过 20 像素,父容器决定“抢劫”事件
                                    if (abs(totalDrag) > 20f) {
                                        isIntercepting = true
                                    }

                                    if (isIntercepting) {
                                        //执行 consume()
                                        // 这一行会让子组件(Button)在它的 Main 阶段看到                                             isConsumed = true
                                        //取消点击逻辑
                                        change.consume()
                                        
                                        //更新父容器位移
                                        offsetX += dragAmount
                                    }
                                }
                            } while (change.pressed) //指没抬起就一直循环
                            
                            //松手后回弹动画逻辑(此处简化,直接归零)
                            offsetX = 0f
                        }
                    }
                },
            contentAlignment = Alignment.Center
        ) {
            // --- 子组件:普通的 Button ---
            Button(
                onClick = { /* 正常点击逻辑 */ },
                colors = ButtonDefaults.buttonColors(backgroundColor = Color.Blue)
            ) {
                Text("我是子组件按钮", color = Color.White)
            }
        }
    }
}
fun Modifier.pointerInput(
    vararg keys: Any?,
    block: suspend PointerInputScope.() -> Unit
): Modifier = this then SuspendPointerInputElement(
    keys = keys,
    pointerInputHandler = block
)

modifier.pointerInput(){awaitPointerEventScope{}里面的区域就是我们所说的代码}

override fun onPointerEvent(
    pointerEvent: PointerEvent,
    pass: PointerEventPass,
    bounds: IntSize
) {
    boundsSize = bounds
    if (pass == PointerEventPass.Initial) {
        currentEvent = pointerEvent
    }

//协程作用域的懒启动
    // Coroutine lazily launches when first event comes in.
    if (pointerInputJob == null) {
     //普通启动 (DEFAULT) :协程会先去“排队”,等到下一帧或者 CPU 空闲时才跑。但手势分发是瞬时的,如果去排队,等你跑起来的时候,那一帧的 ACTION_DOWN 事件可能已经结束了,导致你“漏掉”了第一个事件
        //UNDISPATCHED强制协程立刻、原地、在当前线程开始运行。它能保证你的 pointerInputHandler 逻辑在第一个事件分发完成之前,就已经跑到了awaitPointerEvent()挂起点,从而完美捕捉到触发它启动的那个DOWN事件
        pointerInputJob = coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
        //这里是你写的代码即modifier.pointerInput(){}里面的相关逻辑
pointerInputHandler()
        }
}

//实际分发处
    dispatchPointerEvent(pointerEvent, pass)

    lastPointerEvent = pointerEvent.takeIf { event ->
!event.changes.fastAll { it.changedToUpIgnoreConsumed() }
}
}

我们继续查看dispatchPointerEvent方法

SuspendingPointerInputModifierNodeImpl的dispatchPointerEvent方法

private fun dispatchPointerEvent(
    pointerEvent: PointerEvent,
    pass: PointerEventPass
) {
    forEachCurrentPointerHandler(pass) {
it.offerPointerEvent(pointerEvent, pass)
    }
}
fun offerPointerEvent(event: PointerEvent, pass: PointerEventPass) {
    if (pass == awaitPass) {
        pointerAwaiter?.run {
pointerAwaiter = null
            //调用 resume(event)会让你的代码从挂起点恢复执行
            resume(event)
        }
}
}

也就是说从DOWN事件的开始,onPointerEvent方法中协程开始挂起,在offerPointerEvent恢复挂起点,执行相关代码逻辑

总结

这样我们就可以理解这些操作,其实前面的所有铺垫都在这里!

Initial和Main的分发步骤了解完后整个分发流程就完全清晰了(这里不再关注Final的流程)

到这里我们已经把事件分发的流程进行一遍了,我们并没有发现像传统View事件分发流程那样的拦截函数,这里的拦截方式,就是父View直接消费了事件,但是Initial和Main以及Final的分发并没有因为消费了事件而停止,这就是和传统View事件分发的区别了【也就是说DOWN,MOVE,UP,CANCEL都会完整经过这三个链路】

所以这两种拦截的区别

一种是直接物理拦截,一种是语义标记

前者收到CANCEL后被彻底踢出,后者前程在场只是看到“已消费”标记

原文文章链接‍‌⁠​‌⁠‬​‍​‬​‬‬​​​⁠⁠‌‍​​​‌⁠⁠​​‌​‬​​‌‌‌​​⁠‍​​​​Android--Compose事件分发机制 - 飞书云文档