一、前言
Compose 的事件分发机制彻底抛弃了安卓传统的 dispatchTouchEvent -> onInterceptTouchEvent -> onTouchEvent 这一套基于继承和布尔返回值的分发逻辑。取而代之的是基于节点的树形遍历、三阶段分发(Initial、Main、Final)以及与 Kotlin 协程深度绑定的挂起式事件流。
本文将顺着事件的真实流转路径,从安卓dispatchTouchEvent 开始,把涉及到的核心源码全部贴出,并结合具体例子,一步步为您详细拆解 Compose 事件分发的全貌。
二、现实世界的入口:AndroidComposeView.dispatchTouchEvent
无论Compose的内部机制多么精妙,它在安卓平台上最终还是依托于一个 ViewGroup,这就是 AndroidComposeView。当用户的手指触摸屏幕时,安卓系统最先调用的是它的 dispatchTouchEvent。
2、1 AndroidComposeView.android.kt
// AndroidComposeView.android.kt
override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
// 1. 如果系统禁用了触摸(比如有些无障碍模式下),直接返回
if (isHovered) { ... }
try {
// 2. 核心转换:将 Android 原生的 MotionEvent 转换为 Compose 跨平台的 PointerInputEvent
val pointerInputEvent = motionEventAdapter.convertToPointerInputEvent(motionEvent, this)
if (pointerInputEvent != null) {
// 3. 将转换后的事件交给 Compose 的事件核心枢纽 (PointerInputEventProcessor) 去处理
val result = pointerInputEventProcessor.process(pointerInputEvent)
// 4. 判断事件是否被 Compose 树中的某个 Modifier 消费或处理了
if (result.dispatchedToAPointerInputModifier) {
// 如果处理了,请求系统父 View(比如外层的原生 ScrollView)不要拦截后续事件
parent.requestDisallowInterceptTouchEvent(true)
return true
}
} else {
// 事件没有发生有意义的变化(比如过滤掉了一些无用的 ACTION_HOVER)
return super.dispatchTouchEvent(motionEvent)
}
} finally { ... }
return false
}
这里是连接安卓原生与 Compose 跨平台体系的桥梁。AndroidComposeView 作为一个容器,截获了所有的触摸事件。它不做具体的业务处理,只做两件事:用 MotionEventAdapter 转换格式,然后将控制权移交给 PointerInputEventProcessor。
三、跨平台事件转换:MotionEventAdapter
Compose是跨平台的(支持 iOS、桌面端等),它不能让内部的节点直接依赖特定于安卓的 MotionEvent。因此需要 MotionEventAdapter 进行解耦转换。
3、1 MotionEventAdapter.android.kt
下面是简化后的代码:
// MotionEventAdapter.android.kt 简化后的代码
internal class MotionEventAdapter {
fun convertToPointerInputEvent(
motionEvent: MotionEvent,
positionCalculator: PositionCalculator
): PointerInputEvent? {
val action = motionEvent.actionMasked
val pointers = mutableListOf<PointerInputEventData>()
// 1. 遍历 MotionEvent 中的所有多点触控(多根手指)
for (i in 0 until motionEvent.pointerCount) {
val pointerId = motionEvent.getPointerId(i)
// 2. 将屏幕物理坐标转换为 Compose 的局部相对坐标
val position = positionCalculator.screenToLocal(
Offset(motionEvent.getX(i), motionEvent.getY(i))
)
// 3. 判断每根手指当前的状态:是按下、移动还是抬起?
val isPressed = !isHover && i != upIndex && (!isScroll || motionEvent.buttonState != 0)
// 4. 封装成跨平台的数据结构 PointerInputEventData
pointers.add(
PointerInputEventData(
id = PointerId(pointerId.toLong()), // 统一为跨平台的 PointerId
uptime = motionEvent.eventTime, // 事件时间
position = position, // 转换后的坐标
down = isPressed, // 是否按下
pressure = motionEvent.getPressure(i),
type = PointerType.Touch // 触摸类型(鼠标、触摸、触控笔等)
)
)
}
// 5. 返回包含所有手指状态的统一事件对象 PointerInputEvent
return PointerInputEvent(motionEvent.eventTime, pointers, motionEvent)
}
}
MotionEventAdapter 会遍历 MotionEvent 里的所有触摸点(通过 pointerCount),剥离掉特定平台的冗余信息,提取出 id、positionOnScreen (绝对坐标)、uptime,每根手指当前的 down (是否按下) 状态。 最终,它将这一帧的所有手指状态打包成了一个 PointerInputEvent。
假设你用两根手指同时按在屏幕上。MotionEventAdapter 会遍历这两个触控点,打包成一个 PointerInputEvent,里面包含了一个 pointers 列表,元素个数为 2。
四、核心分发枢纽:PointerInputEventProcessor
转换好的 PointerInputEvent 被送到了 PointerInputEventProcessor.process() 中。这是 Compose 事件分发的大脑。它负责协调状态变化计算、命中测试和事件分发三大步骤。
4、1 PointerInputEventProcessor.kt — process() 方法
// PointerInputEventProcessor.kt
internal class PointerInputEventProcessor(val root: LayoutNode) {
private val hitPathTracker = HitPathTracker(root.coordinates)
private val hitResult = HitTestResult()
private val pointerInputChangeEventProducer = PointerInputChangeEventProducer()
fun process(
pointerEvent: PointerInputEvent,
positionCalculator: PositionCalculator,
isInBounds: Boolean = true,
): ProcessResult {
// ★ 第一步:生成 PointerInputChange(状态变化计算)
val internalPointerEvent =
pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)
// 判断是否为 hover 事件
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
}
}
// ★ 第二步:命中测试 (Hit Test) —— 只在手指刚按下时执行
for (i in 0 until internalPointerEvent.changes.size()) {
val pointerInputChange = internalPointerEvent.changes.valueAt(i)
if (isHover || pointerInputChange.changedToDownIgnoreConsumed()) {
root.hitTest(pointerInputChange.position, hitResult, pointerInputChange.type)
if (hitResult.isNotEmpty()) {
hitPathTracker.addHitPath(
pointerId = pointerInputChange.id,
pointerInputNodes = hitResult,
prunePointerIdsAndChangesNotInNodesList =
pointerInputChange.changedToDownIgnoreConsumed(),
)
hitResult.clear()
}
}
}
// ★ 第三步:三阶段分发
val dispatchedToSomething =
hitPathTracker.dispatchChanges(internalPointerEvent, isInBounds)
// 检查是否有移动被消费、任何变化被消费
val anyMovementConsumed = ...
val anyChangeConsumed = ...
return ProcessResult(dispatchedToSomething, anyMovementConsumed, anyChangeConsumed)
}
}
4、2 深入解析 pointerInputChangeEventProducer.produce() —— 状态变化计算的核心
这是整个事件分发链条中最容易被忽略,却最关键的一步。Compose 不直接处理"绝对事件",它只处理"变化量"。produce() 方法的职责就是:将当前帧的指针状态,与上一帧的指针状态做对比,生成包含"前一状态"和"当前状态"的 PointerInputChange 对象。
// PointerInputEventProcessor.kt
private class PointerInputChangeEventProducer {
// ★ 核心数据结构:用 LongSparseArray 存储【上一帧】每根手指的状态
// key = pointerId.value (Long),value = PointerInputData(上一帧的时间、屏幕坐标、按下状态)
private val previousPointerInputData: LongSparseArray<PointerInputData> = LongSparseArray()
fun produce(
pointerInputEvent: PointerInputEvent,
positionCalculator: PositionCalculator,
): InternalPointerEvent {
// 预分配 changes 容量,避免扩容
val changes: LongSparseArray<PointerInputChange> =
LongSparseArray(pointerInputEvent.pointers.size)
// ★ 遍历本次事件中的每一根手指
pointerInputEvent.pointers.fastForEach {
val previousTime: Long
val previousPosition: Offset
val previousDown: Boolean
// ★ 关键逻辑:查找这根手指在【上一帧】的状态
val previousData = previousPointerInputData[it.id.value]
if (previousData == null) {
// ===== 情况一:这是一根【全新的手指】(第一次按下) =====
// 没有历史数据,"前一状态"就用当前状态填充
previousTime = it.uptime // 前一时间 = 当前时间
previousPosition = it.position // 前一位置 = 当前位置
previousDown = false // 前一按下状态 = false(之前没按下)
} else {
// ===== 情况二:这根手指【之前就存在】(正在移动或抬起) =====
previousTime = previousData.uptime
previousDown = previousData.down
// ★ 注意!前一位置需要从【屏幕坐标】转换为【本地坐标】
// 因为存储时用的是屏幕坐标(防止组件移动导致坐标失效)
previousPosition = positionCalculator.screenToLocal(previousData.positionOnScreen)
}
// ★ 构建 PointerInputChange —— 这就是后续所有手势处理的基本单元
changes.put(
it.id.value,
PointerInputChange(
id = it.id, // 手指 ID
uptimeMillis = it.uptime, // 当前时间
position = it.position, // 当前位置(本地坐标)
pressed = it.down, // 当前是否按下
pressure = it.pressure, // 当前压力值
previousUptimeMillis = previousTime, // 前一时间
previousPosition = previousPosition, // 前一位置(本地坐标)
previousPressed = previousDown, // 前一按下状态
isInitiallyConsumed = false, // ★ 初始未消费!
type = it.type, // 指针类型(Touch/Mouse/Stylus)
historical = it.historical, // 历史采样点
scrollDelta = it.scrollDelta, // 滚轮偏移量
originalEventPosition = it.originalEventPosition,
),
)
// ★ 更新 previousPointerInputData,为下一帧做准备
if (it.down) {
// 手指还在按着 → 存储当前状态(注意:存的是屏幕坐标!)
previousPointerInputData.put(
it.id.value,
PointerInputData(it.uptime, it.positionOnScreen, it.down),
)
} else {
// 手指已抬起 → 移除这根手指的历史记录
previousPointerInputData.remove(it.id.value)
}
}
return InternalPointerEvent(changes, pointerInputEvent)
}
// 内部数据类:存储一根手指在某一帧的快照
private class PointerInputData(
val uptime: Long,
val positionOnScreen: Offset, // ★ 注意:是屏幕坐标,不是本地坐标
val down: Boolean,
)
}
4、3 用具体例子理解 produce() 的工作过程
假设用户用一根手指点击屏幕上的一个按钮,产生了 3 帧事件:
第 1 帧:手指按下 (ACTION_DOWN),位置 (100, 200)
pointerInputEvent.pointers = [{ id=1, position=(100,200), down=true }]
previousPointerInputData 中查找 id=1 → null(新手指!)
→ previousDown = false, previousPosition = (100, 200)
生成 PointerInputChange:
id=1, position=(100,200), pressed=true
previousPosition=(100,200), previousPressed=false ← changedToDown() 为 true!
isConsumed=false
存入 previousPointerInputData: { 1 → (uptime, screenPos=(100,200), down=true) }
第 2 帧:手指移动 (ACTION_MOVE),位置 (105, 205)
pointerInputEvent.pointers = [{ id=1, position=(105,205), down=true }]
previousPointerInputData 中查找 id=1 → 找到了!{ uptime=t1, screenPos=(100,200), down=true }
→ previousDown = true, previousPosition = screenToLocal(100,200) = (100, 200)
生成 PointerInputChange:
id=1, position=(105,205), pressed=true
previousPosition=(100,200), previousPressed=true ← 可以算出移动距离 (5, 5)
isConsumed=false
更新 previousPointerInputData: { 1 → (uptime, screenPos=(105,205), down=true) }
当处在按下状态,就需要更新previousPointerInputData,此时是移动状态,为什么也需要更新previousPointerInputData?
因为在移动的时候,手指也是在按下,并没有抬起。
第 3 帧:手指抬起 (ACTION_UP),位置 (105, 205)
pointerInputEvent.pointers = [{ id=1, position=(105,205), down=false }]
previousPointerInputData 中查找 id=1 → 找到了!{ uptime=t2, screenPos=(105,205), down=true }
→ previousDown = true, previousPosition = screenToLocal(105,205)
生成 PointerInputChange:
id=1, position=(105,205), pressed=false
previousPosition=(105,205), previousPressed=true ← changedToUp() 为 true!
isConsumed=false
手指抬起 (down=false) → 从 previousPointerInputData 中移除 id=1
4、4 为什么要存屏幕坐标 (positionOnScreen) 而不是本地坐标?
这是一个非常精妙的设计。注意源码中:
- 存储时:
PointerInputData(it.uptime, it.positionOnScreen, it.down)—— 用的是 屏幕坐标 - 读取时:
positionCalculator.screenToLocal(previousData.positionOnScreen)—— 再转回 本地坐标
原因:在两帧之间,Compose 组件可能发生了重新布局(比如列表滚动导致组件位移了)。如果直接存本地坐标,下一帧读取时,这个坐标相对于组件的位置就不对了。而屏幕坐标是不变的绝对参照系,每次使用时再根据组件最新的布局位置转换,就能保证上一帧的位置始终准确。
4、5 PointerInputChange
之前的小节讲解了PointerInputChange的创建过程,PointerInputChange里面具有什么呢?它包含了极其丰富的上下文信息:
@Immutable
class PointerInputChange(
// 这根手指的唯一 ID
val id: PointerId,
// ---------------- 历史状态 ----------------
// 上一次事件发生时的绝对时间戳
val previousUptimeMillis: Long,
// 上一次事件发生时,这根手指的位置
val previousPosition: Offset,
// 上一次事件发生时,这根手指是否按在屏幕上
val previousPressed: Boolean,
// ---------------- 当前状态 ----------------
// 当前事件发生的绝对时间戳
val uptimeMillis: Long,
// 当前这根手指的位置
val position: Offset,
// 当前这根手指是否按在屏幕上
val pressed: Boolean,
// ---------------- 消费状态 ----------------
// 是否已经被事件树上的其他节点消费了?
isConsumed: Boolean,
// ... 类型信息(手指、鼠标、触控笔等)
val type: PointerType = PointerType.Touch,
) {
// 记录消费状态的对象(内部包装)
private var consumed: ConsumedData = ConsumedData(isConsumed, isConsumed)
// 判断事件是否被消费
val isConsumed: Boolean get() = consumed.downChange || consumed.positionChange
// 将这根手指的当前事件标记为“已消费”
fun consume() {
consumed.downChange = true
consumed.positionChange = true
}
}
常用的扩展方法与属性
为了方便开发者判断手势,Compose 在 PointerInputChange 上提供了很多极其好用的扩展方法。这也是我们在业务代码里经常看到 event.changes.first().xxx 的原因。
1、状态判断:按下、抬起、移动
在安卓中,我们判断 MotionEvent.ACTION_DOWN 或 ACTION_MOVE。在 Compose 中,手势的状态被转化成了布尔值的逻辑比对。因为我们拿到的 PointerInputChange 中已经包含了该手指在“上一帧”和“当前帧”的状态,所以框架提供了非常直观的扩展属性:
// 是否是刚刚按下?(之前没按,现在按了,且没被消费)
fun PointerInputChange.changedToDown() = !isConsumed && !previousPressed && pressed
// 是否按下?(不判断是否消费)
fun PointerInputChange.changedToDownIgnoreConsumed() = !previousPressed && pressed
// 是否是刚刚抬起?(之前按了,现在没按,且没被消费)
fun PointerInputChange.changedToUp() = !isConsumed && previousPressed && !pressed
// 是否抬起?(不判断是否消费)
fun PointerInputChange.changedToUpIgnoreConsumed() = previousPressed && !pressed
// 当前位置 - 上次位置 = 手指移动的距离
private fun PointerInputChange.positionChangeInternal(ignoreConsumed: Boolean = false): Offset {
val previousPosition = previousPosition
val currentPosition = position
val offset = currentPosition - previousPosition
return if (!ignoreConsumed && isConsumed) Offset.Zero else offset
}
可能有人会有疑问,为什么通过前后两帧的对比就能判断是按下或者抬起?请返回去再看下4、2小节和4、3小节。
2、消费机制:阻止事件向上传递
当你处理了某个事件后(比如你实现了一个按钮,用户按下了它),你需要“消费”这个事件,这样它的父容器就不会再响应这个事件了:
// 消费整个事件
change.consume()
// 判断位置是否发生了变化
fun PointerInputChange.positionChange() = this.positionChangeInternal(false)
// 仅判断位置是否发生了变化(忽略是否被消费)
fun PointerInputChange.positionChangedIgnoreConsumed() =
this.positionChangeInternal(true) != Offset.Companion.Zero
// 获取位置的差值(位移距离)
// 只有在这个位移还没被其他人消费的情况下才返回实际差值,否则返回 Offset.Zero
private fun PointerInputChange.positionChangeInternal(ignoreConsumed: Boolean = false): Offset {
val previousPosition = previousPosition
val currentPosition = position
val offset = currentPosition - previousPosition
return if (!ignoreConsumed && isConsumed) Offset.Zero else offset
}
整个第四章都是在介绍PointerInputChange,PointerInputChange非常重要,开发过程中,经常会用到它。在 Compose 的手势API中,大家几乎随时在和event.changes打交道。event.changes是个集合,集合内部就是PointerInputChange了。相信通过本章的介绍,大家应当可以理解PointerInputChange了。
五、命中测试 (Hit Test):寻找目标组件
命中测试的目的是:找到手指触摸位置下,所有对指针事件感兴趣的 Modifier.Node,并将它们从祖先到后代依次连成一条路径,记录到 HitTestResult 中。
5、1 命中测试的调用入口
在 process() 方法中,当检测到手指刚按下时(changedToDownIgnoreConsumed()),就会触发命中测试:
// PointerInputEventProcessor.process()
for (i in 0 until internalPointerEvent.changes.size()) {
val pointerInputChange = internalPointerEvent.changes.valueAt(i)
if (isHover || pointerInputChange.changedToDownIgnoreConsumed()) {
// ★ 从根 LayoutNode 开始命中测试
root.hitTest(pointerInputChange.position, hitResult, pointerInputChange.type)
if (hitResult.isNotEmpty()) {
// 将命中的节点路径添加到 HitPathTracker 中
hitPathTracker.addHitPath(
pointerId = pointerInputChange.id,
pointerInputNodes = hitResult, // hitResult 就是一个 List<Modifier.Node>
)
hitResult.clear()
}
}
}
5、2 LayoutNode.hitTest() —— 递归的起点
// LayoutNode.kt
internal fun hitTest(
pointerPosition: Offset,
hitTestResult: HitTestResult,
pointerType: PointerType = PointerType.Unknown,
isInLayer: Boolean = true,
) {
// ★ 将坐标从父坐标系转换为当前节点的 outerCoordinator 坐标系
val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition)
// ★ 将命中测试委托给 outerCoordinator(Modifier 链的最外层)
outerCoordinator.hitTest(
NodeCoordinator.PointerInputSource,
positionInWrapped,
hitTestResult,
pointerType,
isInLayer,
)
}
关键理解:每个 LayoutNode 有一个 Modifier 链(由多个 NodeCoordinator 串联)。outerCoordinator 是最外层的 Coordinator,InnerNodeCoordinator 是最内层的。命中测试从外向内穿透 Modifier 链,最终通过 InnerNodeCoordinator 跳转到子 LayoutNode。
5、3 NodeCoordinator.hitTest() —— 递归的核心引擎
这是整个命中测试最核心的方法,决定了"这个触摸点该不该被当前节点接收":
// NodeCoordinator.kt
fun hitTest(
hitTestSource: HitTestSource,
pointerPosition: Offset,
hitTestResult: HitTestResult,
pointerType: PointerType,
isInLayer: Boolean,
) {
// ★ 获取当前 Coordinator 上第一个 PointerInput 类型的 Modifier.Node
val head = head(hitTestSource.entityType())
if (!withinLayerBounds(pointerPosition)) {
// ========== 分支 A:触摸点在 Layer 裁剪区域之外 ==========
// 即使 clip 了,如果触摸目标太小,可能需要扩大触摸区域(最小触摸目标 48dp)
if (pointerType == PointerType.Touch) {
val distanceFromEdge =
distanceInMinimumTouchTarget(pointerPosition, minimumTouchTargetSize)
if (distanceFromEdge.fastIsFinite() &&
hitTestResult.isHitInMinimumTouchTargetBetter(distanceFromEdge, false)
) {
head.hitNear(hitTestSource, pointerPosition, hitTestResult, pointerType, false, distanceFromEdge)
}
}
} else if (head == null) {
// ========== 分支 B:在边界内,但当前 Coordinator 没有 PointerInput 节点 ==========
// 直接跳过,继续测试子节点/下一层 Coordinator
hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer)
} else if (isPointerInBounds(pointerPosition)) {
// ========== 分支 C:在边界内,且当前有 PointerInput 节点 → 真正命中! ==========
head.hit(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer)
} else {
// ========== 分支 D:在 Layer 内但不在 Content 边界内(最小触摸目标/扩展边界) ==========
head.outOfBoundsHit(hitTestSource, pointerPosition, hitTestResult, ...)
}
}
val head = head(hitTestSource.entityType())是获取当前节点上第一个PointerInput类型的 Modifier.Node节点。如下代码,显示的调用了pointerInput,head(hitTestSource.entityType())返回的head对象就不为空了。在命中测试的时候,首先会获取当前节点上第一个PointerInput类型的 Modifier.Node节点。
Box(
modifier = Modifier
.size(300.dp)
.pointerInput(Unit) { ... } // 显式声明的 PointerInput 节点 (Modifier.Node_A)
)
5、4 Modifier.Node.hit() —— 记录命中并沿 Modifier 链递归
当分支 C 确认"真正命中"后,调用 hit() 方法:
// NodeCoordinator.kt
private fun Modifier.Node?.hit(
hitTestSource: HitTestSource,
pointerPosition: Offset,
hitTestResult: HitTestResult,
pointerType: PointerType,
isInLayer: Boolean,
) {
if (this == null) {
// ★ Modifier 链遍历完了,转向子节点
hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer)
} else {
// ★ 将当前 Modifier.Node 记录到 HitTestResult 中
hitTestResult.hit(this, isInLayer) {
// ★ 在 lambda 中继续遍历 Modifier 链中的下一个 PointerInput 节点
nextUntil(hitTestSource.entityType(), Nodes.Layout)
.hit(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer)
}
}
}
nextUntil(entityType, Nodes.Layout) 的含义是:沿着修饰符链向内找下一个 PointerInput 类型的节点,但不要越过边界(即不要跨到下一个修饰符)。如果在当前 Coordinator 内没有更多 PointerInput 节点,就返回 null,触发 hitTestChild。
5、5 NodeCoordinator.hitTestChild() —— 穿透到下一层
// NodeCoordinator.kt
open fun hitTestChild(
hitTestSource: HitTestSource,
pointerPosition: Offset,
hitTestResult: HitTestResult,
pointerType: PointerType,
isInLayer: Boolean,
) {
val wrapped = wrapped // ★ wrapped 指向链中的下一个 NodeCoordinator
if (wrapped != null) {
// ★ 坐标转换:从当前 Coordinator 坐标系转到 wrapped 的坐标系
val positionInWrapped = wrapped.fromParentPosition(pointerPosition)
// ★ 递归调用下一层 Coordinator 的 hitTest
wrapped.hitTest(hitTestSource, positionInWrapped, hitTestResult, pointerType, isInLayer)
}
}
当 wrapped 指向 InnerNodeCoordinator(Modifier 链的最内层)时,就到了跳转子 LayoutNode 的关键节点。
5、6 InnerNodeCoordinator.hitTestChild() —— 从 Modifier 链跳转到子 LayoutNode
这是命中测试中最关键的桥梁,它实现了从当前 LayoutNode 的 Modifier 链跳转到子 LayoutNode 的递归:
// InnerNodeCoordinator.kt
override fun hitTestChild(
hitTestSource: HitTestSource,
pointerPosition: Offset,
hitTestResult: HitTestResult,
pointerType: PointerType,
isInLayer: Boolean,
) {
var inLayer = isInLayer
var hitTestChildren = false
if (hitTestSource.shouldHitTestChildren(layoutNode)) {
if (withinLayerBounds(pointerPosition)) {
hitTestChildren = true
} else if (pointerType == PointerType.Touch &&
distanceInMinimumTouchTarget(pointerPosition, minimumTouchTargetSize).fastIsFinite()
) {
inLayer = false
hitTestChildren = true
}
}
if (hitTestChildren) {
// ★ siblingHits 允许多个兄弟节点共享命中(比如重叠的组件)
hitTestResult.siblingHits {
// ★ 按 Z-Index 从高到低(最上层优先)倒序遍历子 LayoutNode
layoutNode.zSortedChildren.reversedAny { child ->
if (child.isPlaced) {
// ★ 对每个子 LayoutNode 递归调用 hitTest
hitTestSource.childHitTest(
child, pointerPosition, hitTestResult, pointerType, inLayer,
)
val wasHit = hitTestResult.hasHit()
val continueHitTest: Boolean
if (!wasHit) {
// 没命中,继续测试下一个兄弟
continueHitTest = true
} else if (child.outerCoordinator.shouldSharePointerInputWithSiblings()) {
// 命中了,但这个子节点愿意与兄弟共享事件
hitTestResult.acceptHits()
continueHitTest = true
} else {
// 命中了,且不需要共享 → 停止遍历
continueHitTest = false
}
!continueHitTest // reversedAny: 返回 true 停止遍历
} else {
false
}
}
}
}
}
关键:Z 序逆序 + 最先命中者优先
布局(两个重叠的 Box):
Box {
Box(Modifier.zIndex(0f).clickable { println("底层") }) { ... }
Box(Modifier.zIndex(1f).clickable { println("顶层") }) { ... }
}
触摸点在两个 Box 的重叠区域:
reversedAny 先遍历 zIndex=1 的顶层 Box
→ hitTest 命中
→ hitTestResult.hasHit() = true
→ shouldSharePointerInputWithSiblings() = false(默认不共享)
→ 停止遍历,底层 Box 不会被命中
5、7 用一个具体例子完整跟踪 Hit Test 递归过程
假设有如下布局:
// 用户代码
Box( // LayoutNode_A
modifier = Modifier
.size(300.dp)
.pointerInput(Unit) { ... } // 显式声明的 PointerInput 节点 (Modifier.Node_A)
) {
Button( // LayoutNode_B
onClick = { }, // Button 内部的 clickable 实际上就是一个 PointerInput 节点 (Modifier.Node_B)
modifier = Modifier.size(100.dp)
) {
Text("Click me") // LayoutNode_C(没有任何跟事件相关的 Modifier)
}
}
表面上看,Button 并没有直接写 .pointerInput,但我们点进 Button 的源码,会发现它内部使用了 Modifier.clickable(onClick)。再进一步点进 clickable 的源码,最终会落到这样一个节点:
// Clickable.kt 简化版
private class ClickableNode(
// ...
) : DelegatingNode(), PointerInputModifierNode {
// 内部通过 delegate 委托给了 SuspendingPointerInputModifierNode (也就是 pointerInput)
val pointerInputNode = delegate(SuspendingPointerInputModifierNode {
detectTapAndPress(
onTap = { onClick() },
// ...
)
})
// ...
}
因此,clickable 本质上就是一个 PointerInputModifierNode。
布局树与 Modifier 链结构:
LayoutNode_Root
└── LayoutNode_A (Box)
└── outerCoordinator -> [ Modifier.Node_A (pointerInput) ] -> innerCoordinator
└── LayoutNode_B (Button)
└── outerCoordinator -> [ Modifier.Node_B (ClickableNode/pointerInput) ] -> innerCoordinator
└── LayoutNode_C (Text)
└── outerCoordinator -> [ 无事件节点 ] -> innerCoordinator
用户点击了 Text 的中心区域 (也是 Button 的中心,Box 的中心):
Root.hitTest()启动。- 进入
LayoutNode_A的outerCoordinator(包含Modifier.Node_A)。 Modifier.Node_A.hit()检测到点击在 Box 范围内,命中。- 将
Modifier.Node_A存入HitTestResult。 - 沿着 Modifier 链向下,最终到达
InnerNodeCoordinator.hitTestChild()。 - 开始遍历
LayoutNode_A的子节点,找到LayoutNode_B。 - 对
LayoutNode_B调用hitTest()。 - 进入
LayoutNode_B的outerCoordinator(包含Modifier.Node_B)。 Modifier.Node_B.hit()检测到点击在 Button 范围内,命中。- 将
Modifier.Node_B存入HitTestResult。 - 沿着 Modifier 链到达
InnerNodeCoordinator.hitTestChild()。 - 遍历
LayoutNode_B的子节点,找到LayoutNode_C。 - 对
LayoutNode_C调用hitTest()。 LayoutNode_C没有pointerInputModifier,其head()为null。- 直接触发
hitTestChild(),但LayoutNode_C没有子节点,递归结束。 - 回溯:
LayoutNode_C返回未命中(或无需特殊处理)。LayoutNode_B收集到了命中结果,LayoutNode_A也收集到了。
最终的 HitTestResult(类似一个栈/列表):
[Modifier.Node_B, Modifier.Node_A] (按从子到父、从内到外的顺序排列)
这个路径随后会被存入 HitPathTracker 的树形结构中。
6、HitPathTracker —— 建树与三阶段分发
命中测试只是找到了"谁关心这个事件",真正的事件传递发生在 HitPathTracker.dispatchChanges() 中。Compose 采用了独特的三阶段分发(Initial、Main、Final)。
6、1 addHitPath() —— 建树
// HitPathTracker.kt
fun addHitPath(
pointerId: PointerId,
pointerInputNodes: List<Modifier.Node>, // 命中到的节点
prunePointerIdsAndChangesNotInNodesList: Boolean = false,
) {
var parent: NodeParent = root
var merging = true
for (pointerInputNode in pointerInputNodes) {
if (!pointerInputNode.isAttached) continue
if (merging) {
// 尝试合并到已有树节点
val existingNode = parent.children.firstOrNull { it.modifierNode == pointerInputNode }
if (existingNode != null) {
// 找到相同节点:这个指针也经过这个节点,加入 pointerIds 集合
existingNode.markIsIn()
existingNode.pointerIds.add(pointerId)
parent = existingNode
continue
} else {
merging = false // 之后的节点都是新建的
}
}
// 创建新树节点
val newNode = Node(pointerInputNode).apply {
pointerIds.add(pointerId)
}
parent.children.add(newNode)
parent = newNode
}
}
例子——两根手指触摸:
布局:
Box(Modifier.pointerInput(Unit) { ... }) { // Node A
Box(Modifier.pointerInput(Unit) { ... }) { // Node B
Text("Touch here")
}
Box(Modifier.pointerInput(Unit) { ... }) { } // Node C(与 B 同级)
}
食指触摸在 B 上:addHitPath(pointer=0, [A, B])
树结构:root → NodeA(pointers=[0]) → NodeB(pointers=[0])
中指触摸在 C 上:addHitPath(pointer=1, [A, C])
合并 A(已存在):NodeA.pointerIds = [0, 1]
新建 C:root → NodeA(pointers=[0,1]) → NodeB(pointers=[0])
→ NodeC(pointers=[1])
6、2 缓存机制 buildCache
为了提高性能,HitPathTracker 在分发前会构建缓存,将坐标转换等工作提前完成。
// HitPathTracker.kt
fun dispatchChanges(
internalPointerEvent: InternalPointerEvent,
isInBounds: Boolean = true,
): Boolean {
val changed =
root.buildCache(
internalPointerEvent.changes,
rootCoordinates,
internalPointerEvent,
isInBounds,
)
// 省略部分代码......
}
// HitPathTracker.kt - Node 类
override fun buildCache(
changes: LongSparseArray<PointerInputChange>,
parentCoordinates: LayoutCoordinates,
internalPointerEvent: InternalPointerEvent,
isInBounds: Boolean,
): Boolean {
// 先构建子节点的缓存
val childChanged = super.buildCache(changes, parentCoordinates, internalPointerEvent, isInBounds)
// 如果节点未附加,则返回
if (!modifierNode.isAttached) return true
// 获取节点的布局坐标
modifierNode.dispatchForKind(Nodes.PointerInput) { coordinates = it.layoutCoordinates }
// 如果没有坐标(可能节点已分离),则返回
if (coordinates == null) return true
// 遍历所有变化,过滤出与当前节点相关的 PointerId
for (j in 0 until changes.size()) {
val keyValue = changes.keyAt(j)
val change = changes.valueAt(j)
// 如果变化的 PointerId 在当前节点的命中列表中
if (pointerIds.contains(keyValue)) {
val prevPosition = change.previousPosition
val currentPosition = change.position
// 验证坐标有效性
if (prevPosition.isValid() && currentPosition.isValid()) {
// 转换坐标为相对于当前节点的坐标
relevantChanges.put(
keyValue,
change.copy(
previousPosition = coordinates!!.localPositionOf(parentCoordinates, prevPosition),
currentPosition = coordinates!!.localPositionOf(parentCoordinates, currentPosition),
),
)
}
}
}
// 如果没有相关变化,则清空并返回
if (relevantChanges.isEmpty()) {
pointerIds.clear()
children.clear()
return true // not hit
}
// 清理不在变化列表中的 PointerId
for (i in pointerIds.lastIndex downTo 0) {
val pointerId = pointerIds[i]
if (!changes.containsKey(pointerId.value)) {
pointerIds.removeAt(i)
}
}
// 构造 PointerEvent
val changesList = ArrayList<PointerInputChange>(relevantChanges.size())
for (i in 0 until relevantChanges.size()) {
changesList.add(relevantChanges.valueAt(i))
}
val event = PointerEvent(changesList, internalPointerEvent)
pointerEvent = event
return true
}
6、3 dispatchChanges —— 三阶段分发
Compose 将一次事件分成三个阶段进行分发。
// HitPathTracker.kt
internal class HitPathTracker(val rootCoordinates: LayoutCoordinates) {
internal val root: NodeParent = NodeParent()
fun dispatchChanges(
internalPointerEvent: InternalPointerEvent,
isInBounds: Boolean = true
): Boolean {
// ★ 三次遍历派发事件
var dispatched = root.dispatchMainEventPass(
internalPointerEvent.changes,
rootCoordinates,
internalPointerEvent,
isInBounds
)
if (root.dispatchFinalEventPass(internalPointerEvent)) {
dispatched = true
}
return dispatched
}
}
在 HitPathTracker.kt 中,Node(代表命中树上的一个节点)实现了事件的三阶段分发。
// HitPathTracker.kt 中 Node 类
override fun dispatchMainEventPass(
changes: LongSparseArray<PointerInputChange>,
parentCoordinates: LayoutCoordinates,
internalPointerEvent: InternalPointerEvent,
isInBounds: Boolean,
): Boolean {
return dispatchIfNeeded {
val event = pointerEvent!!
val size = coordinates!!.size
// ★ 1. Initial 阶段:隧道式(Tunneling pass)从外到内(父到子)
// 在调用子节点之前,先执行自身的 Initial 阶段
modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Initial, size)
}
// ★ 2. 递归调用子节点:将事件继续向下层(子组件)传递
if (modifierNode.isAttached) {
children.forEach {
it.dispatchMainEventPass(
relevantChanges,
coordinates!!,
internalPointerEvent,
isInBounds,
)
}
}
// ★ 3. Main 阶段:冒泡式(Bubbling pass)从内到外(子到父)
// 子节点递归调用完成后(此时已经执行过所有子节点的 Initial 和 Main),
// 函数栈开始回溯,执行自身的 Main 阶段
if (modifierNode.isAttached) {
modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Main, size)
}
}
}
}
override fun dispatchFinalEventPass(internalPointerEvent: InternalPointerEvent): Boolean {
val result = dispatchIfNeeded {
val event = pointerEvent!!
val size = coordinates!!.size
// ★ 4. Final 阶段:隧道式(Tunneling pass)再次从外到内(父到子)
modifierNode.dispatchForKind(Nodes.PointerInput) {
it.onPointerEvent(event, PointerEventPass.Final, size)
}
// 递归传递给子节点
if (modifierNode.isAttached) {
children.forEach { it.dispatchFinalEventPass(internalPointerEvent) }
}
}
// ... 清理缓存等
return result
}
从上面真实的源码可以清晰地看到事件的三大阶段是如何通过递归流转的:
Initial阶段(Tunneling 隧道式):在children.forEach之前触发。事件从外层(父组件)先收到,然后再传给内层(子组件)。此时父组件可以优先观察事件。Main阶段(Bubbling 冒泡式):在children.forEach之后触发。事件先由内层(子组件)处理,子组件处理完毕后,方法栈回溯,外层(父组件)才会收到Main阶段的回调。这是最主要的消费阶段,遵循子节点优先消费的原则。Final阶段(Tunneling 隧道式):这是一个单独的方法dispatchFinalEventPass。在整个dispatchMainEventPass(包含了 Initial 和 Main)结束后,再来一次从父到子的递归遍历,用于收尾、清理状态或执行基于未消费事件的最终动作。
流转顺序: 父节点 Initial -> 子节点 Initial -> 【最底层叶子节点响应事件】 -> 子节点 Main -> 父节点 Main -> 父节点 Final -> 子节点 Final。
6、4 三阶段分发目的
| Pass | 方向 | 目的 |
|---|---|---|
Initial初始阶段 | 从根向叶(隧道式下传) | 让祖先节点优先拦截,实现"父先于子"的拦截逻辑 |
Main重要节点 | 从叶向根(冒泡式上传) | 让叶子节点优先响应,实现"子先于父"的消费逻辑 |
Final最终阶段 | 从根向叶(隧道式下传) | 允许父节点在知道子节点是否消费后做出最终决策(如是否消费嵌套滚动) |
七、onPointerEvent
三阶段分发都是调用onPointerEvent。onPointerEvent是AbstractClickableNode里面的方法,ClickableNode 继承自 AbstractClickableNode。
7、1 链路入口:Modifier.clickable 扩展方法
在日常开发中,我们通常这样写:
Box(modifier = Modifier.clickable { println("点击") })
clickable返回了一个 ClickableElement 节点元素:
// Clickable.kt
fun Modifier.clickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
interactionSource: MutableInteractionSource? = null,
onClick: () -> Unit,
): Modifier {
return if (ComposeFoundationFlags.isNonComposedClickableEnabled) {
// 新版性能优化分支:直接链入 ClickableElement
this.then(
ClickableElement(
interactionSource = interactionSource,
indicationNodeFactory = null,
useLocalIndication = true,
enabled = enabled,
onClickLabel = onClickLabel,
role = role,
onClick = onClick,
)
)
} else {
// 旧版使用 composed {...} 的分支 (略)
}
}
ClickableElement 是一个 ModifierNodeElement,它的职责非常单一,就是在 Compose 节点树挂载时创建真正的逻辑处理节点——ClickableNode。
// Clickable.kt
private class ClickableElement(
/* 参数略 */
) : ModifierNodeElement<ClickableNode>() {
override fun create() = ClickableNode(/* 将参数透传 */)
override fun update(node: ClickableNode) { /* 状态更新时更新 Node */ }
}
7、2 核心架构:AbstractClickableNode 与 ClickableNode
ClickableNode 继承自 AbstractClickableNode。这两个类的分工非常明确:
AbstractClickableNode(基类):处理通用的逻辑,例如焦点 (Focus)、键盘事件 (KeyEvent)、悬停状态 (Hover) 以及水波纹 (Indication) 和 交互源 (InteractionSource) 的初始化,并对外提供PointerInputModifierNode的能力。ClickableNode(实现类):专注于具体的点击逻辑(按下、抬起、取消等触摸事件的状态转换)。
因为 AbstractClickableNode 实现了 PointerInputModifierNode 接口,所以当触摸事件分发到该节点时,会首先调用它的 onPointerEvent 方法:
// Clickable.kt
// 位于 AbstractClickableNode 类中
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize,
) {
centerOffset = bounds.center.toOffset()
initializeIndicationAndInteractionSourceIfNeeded() // 1. 初始化水波纹交互状态
// 2. 处理鼠标悬停(Hover)事件
if (enabled) {
if (pass == PointerEventPass.Main) {
when (pointerEvent.type) {
PointerEventType.Enter -> coroutineScope.launch { emitHoverEnter() }
PointerEventType.Exit -> coroutineScope.launch { emitHoverExit() }
}
}
}
// 3. 延迟创建专门的 PointerInputNode (协程分支使用)
if (pointerInputNode == null) {
// 这里的 createPointerInputNodeIfNeeded 交给子类实现
val node = createPointerInputNodeIfNeeded()
if (node != null) {
// 将其实例化为 Node 树上的委托节点
pointerInputNode = delegate(node)
}
}
// 4. 将事件转发给这个委托节点 (如果存在的话)
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
}
7、3 协程版与同步版的双轨制设计
在早期的 Compose 版本中,识别点击事件是完全交给 SuspendingPointerInputModifierNode(协程挂起)去处理的。但后来发现,Modifier.clickable 被极度频繁地使用,每次点击都派发协程开销过大。
于是,在较新版本的 ClickableNode 中,引入了 isSuspendingPointerInputEnabled 这个 Flag。
7、3、1 老版本基于协程的处理(SuspendingPointerInput)
如果标志位允许使用协程(旧行为),子类会实现 createPointerInputNodeIfNeeded,返回一个带协程环境的 Node:
// ClickableNode 类中
@OptIn(ExperimentalFoundationApi::class)
private val isSuspendingPointerInputEnabled =
!isDetectTapGesturesImmediateCoroutineDispatchEnabled ||
!ComposeFoundationFlags.isNonSuspendingPointerInputInClickableEnabled
override fun createPointerInputNodeIfNeeded(): SuspendingPointerInputModifierNode? =
if (isSuspendingPointerInputEnabled) {
// 协程版:创建 SuspendingPointerInputModifierNode 节点
SuspendingPointerInputModifierNode {
detectTapAndPress(
onPress = { offset ->
if (enabled) handlePressInteraction(offset) // 触发水波纹按下效果
},
onTap = { if (enabled) onClick() }, // 触发最终的 onClick 回调
)
}
} else {
null // 如果优化开启,直接返回 null,不创建协程节点!
}
在这个模式下:
- 事件通过
AbstractClickableNode的pointerInputNode?.onPointerEvent转发给底层的协程环境。 - 协程内部运行着
detectTapAndPress,使用awaitFirstDown、waitForUpOrCancellation等挂起函数等待事件。
7、3、2 新版本同步状态机优化(在 onPointerEvent 中直接处理)
如果系统开启了免协程优化 (isSuspendingPointerInputEnabled == false),createPointerInputNodeIfNeeded() 直接返回 null。
那么点击逻辑由谁处理?
答案是:ClickableNode 重写了 onPointerEvent!
它直接通过判断 PointerEvent 的属性(同步状态机),取代了繁重的协程挂起!
// ClickableNode 类中
private var downEvent: PointerInputChange? = null // 用于记录是否处于按下状态
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize,
) {
// 1. 调用父类 AbstractClickableNode 处理 Hover 等逻辑
super.onPointerEvent(pointerEvent, pass, bounds)
// 2. 如果当前还在用协程模式,这里直接 return。事件已经在 super 里面转发给协程 Node 去处理了。
if (isSuspendingPointerInputEnabled) {
return
}
// ============= 以下是新版的免协程处理逻辑 =============
if (pass == PointerEventPass.Main) {
val downEvent = this.downEvent
if (downEvent == null) {
// 如果之前没有按下的事件,检查这次事件是否是 Down
if (pointerEvent.isChangedToDown(requireUnconsumed = true)) {
val change = pointerEvent.changes[0]
change.consume() // 消费掉 Down 事件
this.downEvent = change // 记录下 Down 的状态
if (enabled) {
handlePressInteractionStart(change.position) // 触发水波纹按下
}
}
} else if (pointerEvent.changes.fastAll { it.changedToUp() }) {
// 之前已经按下了,并且所有手指都抬起了 (Up)
val up = pointerEvent.changes[0]
up.consume() // 消费掉 Up 事件
if (enabled) {
handlePressInteractionRelease(downEvent.position) // 触发水波纹抬起
onClick() // ✨【触发最终点击回调的地方】✨
}
this.downEvent = null // 状态重置
} else {
// 滑动出界或者事件被别的节点抢占消费了 (Cancel)
val touchPadding = getExtendedTouchPadding(bounds)
if (pointerEvent.changes.fastAny { it.isConsumed || it.isOutOfBounds(bounds, touchPadding) }) {
this.downEvent = null
handlePressInteractionCancel() // 触发水波纹取消
}
}
} else if (pass == PointerEventPass.Final && downEvent != null) {
// 在 Final 阶段再次检查事件是否被其他地方消费了
if (pointerEvent.changes.fastAny { it.isConsumed && it != downEvent }) {
downEvent = null
handlePressInteractionCancel()
}
}
}
这段逻辑是不是似曾相识?没错!这和你在安卓的onTouchEvent 中写 ACTION_DOWN、ACTION_UP 状态机代码如出一辙!
之所以在如此高层级的API里“开倒车”用回传统状态机,是因为 Modifier.clickable 作为最高频的基础组件,为它省下创建协程、派发任务的开销,对于提升整个列表滚动或页面渲染的性能意义重大。
7、4 handlePressInteraction 水波纹是如何触发的?
无论使用哪种方案,最终都需要触发按下(Press)的水波纹效果。
以 handlePressInteractionStart 为例:
// AbstractClickableNode.kt
protected fun handlePressInteractionStart(offset: Offset) {
interactionSource?.let { interactionSource ->
// 结束上一次未完成的点击状态
pressInteraction?.let { oldValue ->
val interaction = PressInteraction.Cancel(oldValue)
interactionSource.tryEmit(interaction)
}
// 发送新的 PressInteraction.Press 状态
val press = PressInteraction.Press(offset)
interactionSource.tryEmit(press)
pressInteraction = press
}
}
它向我们通过 Modifier.indication 绑定的 MutableInteractionSource 里面发射 (emit) 了一个 PressInteraction.Press 的对象。
而监听这个 InteractionSource 的涟漪效果 (Ripple Node) 收到这个对象后,就会在指定的位置(offset)画一个圆扩散出去。
7、5 流程
当我们使用 Modifier.clickable { onClick() } 时的调用全过程:
- 组合阶段:创建
ClickableElement,由于其实现,它会挂载ClickableNode到 Modifier 树上。 - 命中测试阶段:寻找谁处理事件,命中
ClickableNode。 - 事件分发阶段 (onPointerEvent):
- 先走到父类
AbstractClickableNode.onPointerEvent。 - 初始化波纹
InteractionSource,发射 Hover 相关的 Interaction。 - 走到子类
ClickableNode.onPointerEvent。 - 如果启用了新版优化:同步判断
pointerEvent中change的状态。- 发现 Down:保存状态
downEvent,向 InteractionSource 发射PressInteraction.Press。 - 发现 Up:消费事件,向 InteractionSource 发射
PressInteraction.Release,调用onClick()回调。
- 发现 Down:保存状态
- 如果使用旧版协程:交给
SuspendingPointerInputModifierNode。- 协程里的
detectTapAndPress被恢复执行。 awaitFirstDown()匹配到 Down。waitForUpOrCancellation()匹配到 Up。- 调用
onClick()回调。
- 协程里的
- 先走到父类
八、总结
- 原生入口:Compose 依赖
AndroidComposeView接管所有安卓触摸事件。 - 跨平台转换:
MotionEventAdapter将其转换为与平台无关的PointerInputEvent,并赋予每根手指跨平台的PointerId。 - 计算变化量:
PointerInputChangeEventProducer将当前帧的状态与上一帧对比,生成核心的PointerInputChange(记录了之前的位置、是否按下等)。 - 寻找目标(Hit Test):从根节点开始沿着修饰符链向下递归,找到触摸点覆盖的所覆盖的节点,构成一条从父到子的路径。
- 三阶段分发:
- Initial (父->子):父组件可以消费事件,拦截子组件的事件。
- Main (子->父):主要消费阶段,子组件优先消费。
- Final (父->子):子组件可以知道事件是否被消费。
- 协程响应:事件通过
onPointerEvent交给具体的节点,如果该节点是pointerInput协程节点,它会通过resume唤醒正在awaitPointerEvent()的挂起函数,执行开发者的手势处理逻辑。
通过这种设计,Compose 彻底消除了安卓视图系统事件分发中恼人的 onInterceptTouchEvent、 onTouchEvent,用协程取代了繁琐的事件状态机,实现了更加简洁、跨平台、且强大的手势处理机制。