一、整体架构总览┌─────────────────────────────────────────────────────┐ │ Android Framework │ │ Activity.dispatchTouchEvent │ └──────────────────────┬──────────────────────────────┘ │ MotionEvent ▼ ┌─────────────────────────────────────────────────────┐ │ AndroidComposeView │ │ (ViewGroup, 事件入口) │ │ dispatchTouchEvent() → handleMotionEvent() │ └──────────────────────┬──────────────────────────────┘ │ InternalPointerEvent ▼ ┌─────────────────────────────────────────────────────┐ │ PointerInputEventProcessor │ │ 1. Hit Testing命中测试 │ │ 2. Hit Path Dispatch路径分发 │ └──────────────────────┬──────────────────────────────┘ │ ┌───────────┴───────────┐ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ │ HitPathTracker │ │ PointerInputNode │ │ (管理命中路径) │ │ (每个 pointerInput│ │ │ │ Modifier 对应) │ └──────────────────┘ └──────────────────┘ │ ▼ ┌──────────────────┐ │ 用户手势检测协程 │ │ detectTapGestures │ │ detectDragGestures│ └──────────────────┘二、第一步事件入口 — AndroidComposeViewAndroidComposeView是一个ViewGroup是 Compose 在传统 View 体系中的宿主。// AndroidComposeView.android.kt简化overridefundispatchTouchEvent(motionEvent:MotionEvent):Boolean{// 1. 将 MotionEvent 转为 Compose 内部的 PointerInputEventvalpointerInputEventmotionEventAdapter.convertToPointerInputEvent(motionEvent)// 2. 交给 PointerInputEventProcessor 处理valresultpointerInputEventProcessor.process(pointerInputEvent)// 3. 根据处理结果决定是否消费告诉 View 体系returnresult.dispatchedToAPointerInputModifier}MotionEvent → PointerInputEvent 的转换MotionEvent PointerInputEvent ───────── ────────────────── actionMasked (DOWN/MOVE/UP) → uptime getX(i) / getY(i) → ListPointerInputEventData pointerId → ├── id: PointerId toolType → ├── pointerType (Touch/Mouse/Stylus) ├── position: Offset └── down: Boolean三、第二步命中测试Hit Testing3.1 什么时候做只在ACTION_DOWN或新手指POINTER_DOWN时做命中测试后续的 MOVE / UP 沿已建立的路径分发。// PointerInputEventProcessor.kt简化funprocess(event:PointerInputEvent):ProcessResult{// 只对新按下的指针做 Hit Testevent.pointers.forEach{pointerData-if(pointerData.down!previouslyDown(pointerData.id)){valhitResultroot.hitTest(pointerData.position)hitPathTracker.addHitPath(pointerData.id,hitResult)}}// 分发returnhitPathTracker.dispatchChanges(event)}3.2 命中测试的遍历顺序// LayoutNode.hitTest()简化funhitTest(point:Offset,result:MutableListModifier.Node){// 1. 坐标变换全局 → 本地vallocalPointglobalToLocal(point)// 2. 边界检查if(!isInBounds(localPoint))return// ← 默认裁剪// 3. 先递归子节点后绘制的在上面 → z 序高 → 优先命中children.reversed().forEach{child-// reversed 从最上层开始child.hitTest(localPoint,result)}// 4. 再把自身的 pointerInput 节点加入结果pointerInputModifierNodes.forEach{node-result.add(node)}}最终结果是一条从叶子到根的有序路径命中路径 (Hit Path): Leaf → B → A → Root (最内) (最外)3.3 影响命中测试的因素因素说明布局边界默认只有point在 LayoutNode 的 size 范围内才命中clipToBounds默认为 true子节点超出父边界不命中z-index / 绘制顺序后绘制的先检测与 View 一致minimumInteractiveComponentSizeMaterial 会自动将触摸区域扩大到 48dppointerInput的存在没有pointerInput的节点不参与命中路径四、第三步三个 Pass 分发命中路径建立后每一帧的事件在同一帧内同步走完三个 Pass。4.1 三个 Pass假设命中路径Root → A → B → Leaf Pass 1: Initial 父 → 子 Root → A → B → Leaf Pass 2: Main 子 → 父 Leaf → B → A → Root ← 默认 Pass 3: Final 父 → 子 Root → A → B → LeafenumclassPointerEventPass{Initial,// 隧道父先看可抢先拦截Main,// 冒泡子先看常规手势处理默认Final// 隧道收尾发现被抢后取消自身}4.2 源码实现// HitPathTracker.kt简化核心逻辑internalclassNode{fundispatchMainEventPass(event:InternalPointerEvent){// ① Initial先处理自己父再递归子dispatchToNode(event,PointerEventPass.Initial)children.forEach{it.dispatchMainEventPass(event)}// ② Main递归完子节点后处理自己 子先父后dispatchToNode(event,PointerEventPass.Main)}fundispatchFinalEventPass(event:InternalPointerEvent){// ③ Final先处理自己父再递归子dispatchToNode(event,PointerEventPass.Final)children.forEach{it.dispatchFinalEventPass(event)}}}fundispatchChanges(event:InternalPointerEvent){root.dispatchMainEventPass(event)// Initial Mainroot.dispatchFinalEventPass(event)// Final}4.3 各 Pass 的典型场景Pass典型使用者做什么InitialScrollableContainer、Pager、pointerInteropFilter父级判断要不要抢占手势Mainclickable、draggable、detectTapGestures等子节点优先处理处理完标记 consumedFinalawaitPointerSlopOrCancellation内部发现被抢后取消追踪View 互操作同步状态五、消费机制Consume5.1 核心规则打标记 ≠ 阻断传递// PointerInputChangefunconsume()// 标记为已消费valisConsumed:Boolean// 查询是否已被消费与 View 体系的根本差异View 的onTouchEvent() return true会阻断后续分发Compose 的consume()只是打标记所有命中节点都会收到事件。5.2 正确的协商模式Modifier.pointerInput(Unit){awaitPointerEventScope{while(true){valeventawaitPointerEvent()// Mainevent.changes.forEach{change-if(!change.isConsumed){// 没人要我来处理change.consume()}else{// 已经有人处理了我不管了}}}}}5.3 consume 对每个 Pass 的影响Initial Main Final (Root→Leaf) (Leaf→Root) (Root→Leaf) Root: isConsumedfalse isConsumedtrue ← isConsumedtrue A: isConsumedfalse isConsumedtrue ← isConsumedtrue B: isConsumedfalse isConsumedtrue ← isConsumedtrue Leaf: isConsumedfalse consume() ✅ isConsumedtrueInitial 阶段没人消费 →isConsumed falseMain 阶段 Leaf 最先执行调用consume()→ 标记为 true后续 B、A、Root 在 Main 阶段看到isConsumed true→ 跳过Final 阶段所有人看到isConsumed true→ 可以做善后六、手势检测协程模型pointerInput内部是一个协程使用awaitPointerEventScope挂起等待事件Modifier.pointerInput(Unit){// PointerInputScope提供 size, viewConfiguration 等上下文awaitPointerEventScope{// AwaitPointerEventScope挂起等待事件while(true){valdownawaitFirstDown()// 挂起等待未消费的 DOWNvalchangeawaitTouchSlopOrCancellation(down.id){change,over-change.consume()// 消费掉 slop}if(change!null){drag(change.id){dragChange-// 持续追踪拖拽dragChange.consume()}}}}}挂起原理// SuspendingPointerInputModifierNode简化suspendfunawaitPointerEvent(pass:PointerEventPass):PointerEvent{returnsuspendCancellableCoroutine{continuation-// 注册回调当 dispatchToNode 到达指定 pass 时 resumethis.continuationcontinuationthis.passpass}}当HitPathTracker分发到这个节点的对应 Pass 时resume协程。内置手势检测器的封装层次最底层 awaitPointerEvent() — 原始事件 awaitFirstDown() — 等待 DOWN waitForUpOrCancellation() — 等待 UP awaitTouchSlopOrCancellation() — 等待超过触摸阈值 drag() / awaitDragOrCancellation() — 拖拽跟踪 中间层 detectTapGestures() — 单击/双击/长按 detectDragGestures() — 拖拽 detectTransformGestures() — 缩放/旋转 最高层Modifier clickable() — 含涟漪、语义、焦点 draggable() — 含状态、方向 scrollable() — 含 Fling、嵌套滚动 transformable() — 含手势状态七、与 Android View 事件分发的对比维度View 体系Compose分发入口ViewGroup.dispatchTouchEvent()AndroidComposeView.dispatchTouchEvent()命中测试dispatchTouchEvent递归 onInterceptTouchEventhitTest()遍历 LayoutNode 树拦截onInterceptTouchEvent return true→ 子节点收到 CANCELInitialpass consume()→ 子节点仍收到事件消费onTouchEvent return true→ 阻断后续分发consume()→ 打标记不阻断分发模型独占式一旦某 View 消费其他 View 不再收到协商式所有命中节点都收到靠isConsumed协商多 Pass2 阶段Intercept Bubble3 阶段Initial Main Final取消机制发ACTION_CANCEL给被抢的 View无 CANCEL 事件靠 Final pass 自行检测 consumed嵌套滚动NestedScrollingChild/Parent手动实现Modifier.nestedScrollonPreScroll/onPostScroll八、嵌套滚动NestedScroll嵌套滚动是独立于PointerEventPass的另一条协商链路用户手指滑动 → scrollable 计算出 delta │ ▼ ┌──── NestedScroll 链 ────┐ │ │ onPreScroll (父→子) │ 父级先消费一部分 │ │ selfScroll │ 自身消费 │ │ onPostScroll (子→父) │ 剩余交给父级 │ │ Fling: onPreFling / onPostFling│ 惯性同理 └─────────────────────────┘valnestedScrollConnectionobject:NestedScrollConnection{overridefunonPreScroll(available:Offset,source:NestedScrollSource):Offset{// 父级想提前消费多少valconsumeddoSomething(available)returnconsumed// 返回消费掉的部分}overridefunonPostScroll(consumed:Offset,available:Offset,source:NestedScrollSource):Offset{// 子级消费后还剩多少returnOffset.Zero}}Modifier.nestedScroll(nestedScrollConnection)九、完整事件流程示例以一个简单的Button被点击为例1. 手指按下ACTION_DOWN │ ├─ Activity.dispatchTouchEvent() │ └─ DecorView → ... → ComposeView │ └─ AndroidComposeView.dispatchTouchEvent() │ └─ motionEventAdapter.convert() → PointerInputEvent │ └─ pointerInputEventProcessor.process() │ ├─ Hit Testing │ └─ root.hitTest(position) │ └─ 递归遍历 LayoutNode 树z-order: 上层优先 │ └─ 命中路径: Surface → Column → Button(clickable) → Text │ ├─ 三 Pass 分发DOWN 事件 │ ├─ Initial (父→子): Surface → Column → Button → Text │ │ └─ 没人在 Initial 消费 │ ├─ Main (子→父): Text → Button → Column → Surface │ │ └─ Button 的 clickable 内部 │ │ awaitFirstDown() resume → 记录 down但暂不 consume │ │ 开始等待 UPwaitForUpOrCancellation │ └─ Final (父→子): 没有需要善后的 │ 2. 手指抬起ACTION_UP │ ├─ 不再做 Hit Test复用已有路径 │ ├─ 三 Pass 分发UP 事件 │ ├─ Initial: 没人消费 │ ├─ Main: │ │ └─ Button 的 clickable 内部 │ │ waitForUpOrCancellation() resume │ │ → change.consume() │ │ → 触发 onClick 回调 │ │ → 启动涟漪动画 │ └─ Final: 没有需要善后的 │ 3. 路径清理 └─ hitPathTracker 移除该 pointerId 的路径十、常见坑与最佳实践❌ 坑 1父子同时响应Box(Modifier.clickable{/* 父 */}){Button(onClick{/* 子 */}){Text(Click)}}// ✅ 默认没问题Button 在 Main 先 consumeBox 的 clickable 检查 isConsumed 跳过// ❌ 但如果用 pointerInput 手写且忘了检查 isConsumed → 都会响应❌ 坑 2以为 Initial consume 能阻止子节点// 父Modifier.pointerInput(Unit){awaitPointerEventScope{valeventawaitPointerEvent(PointerEventPass.Initial)event.changes.forEach{it.consume()}}}// 子Modifier.pointerInput(Unit){detectTapGestures{/* 这里还会触发吗 */}}// ✅ 不会触发因为 detectTapGestures 内部用了 awaitFirstDown(requireUnconsumed true)// ❌ 但如果子节点用 awaitFirstDown(requireUnconsumed false)照样触发❌ 坑 3pointerInput 的 key 不对导致不更新// ❌ lambda 捕获的 state 不会更新Modifier.pointerInput(Unit){detectTapGestures{onClick(currentState)}// currentState 永远是初始值}// ✅ 用 state 作为 keystate 变化时协程重启Modifier.pointerInput(currentState){detectTapGestures{onClick(currentState)}}// ✅ 或者用 rememberUpdatedStatevalupdatedOnClickbyrememberUpdatedState(onClick)Modifier.pointerInput(Unit){detectTapGestures{updatedOnClick()}}✅ 最佳实践总结场景推荐方案普通点击Modifier.clickable {}需要区分单击/双击/长按detectTapGestures()拖拽Modifier.draggable()或detectDragGestures()父级抢占拦截awaitPointerEvent(PointerEventPass.Initial)consume()嵌套滚动冲突Modifier.nestedScroll() 手写 PassCompose ↔ View 混合AndroidViewModifier.pointerInteropFilter一句话总结Compose 事件分发 AndroidComposeView 接收 MotionEvent→DOWN 时 hitTest 建立命中路径→每帧沿路径走 Initial父→子→ Main子→父→ Final父→子三个 Pass→节点通过consume()打标记协商谁来处理本质是一个协商式、非阻断的分发模型。