声明:这篇文章来自自己的学习笔记,全部原创,如有错误表述请见谅,本文档来自飞书编制,所有一些高亮无法正常显示,可以直接在末尾查看原文
学习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的处理其实更倾向于尽量把鼠标模拟成手指这样的操作。
- 这是因为Compose 为了性能,引入了极强的缓存机制(HitPathTracker) 。
- 当鼠标悬停(Hover)在按钮上时,Compose 已经计算出了一条“命中路径”(从根节点到该按钮的一串节点)。
- 如果能在
ACTION_DOWN时复用这条路径,性能会提升巨大。 - 为了复用这个缓存,Compose 必须非常确定:刚才那个
HOVER_EXIT是系统为了点击而合成的假事件,还是用户真的把鼠标移走了。如果是真的移走了,缓存必须清空;如果是合成的,缓存必须保留。 - 传统 View 没有这种深度的节点路径缓存,所以它不需要费劲去比对坐标和时间来保护缓存。
- 对于 Android 系统来说,整个 Compose 界面只有一个 View(即
AndroidComposeView)。系统根本不知道 Compose 里面有几千个按钮还是几个列表,所以Compose需要更精细的处理 - 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我们发现又调用dispatchMainEventPass和dispatchFinalEventPass进一步实现分发流程
我们来看看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事件分发机制 - 飞书云文档