Compose原理十一之手势协程化,从回调到挂起的桥接艺术

一、前言

Compose原理十之事件分发的最后,我们提了下Compose用协程取代了繁琐的事件状态机,也就是将手势协程化,本文将深入源码讲解协程如何与手势关联起来的。

二、痛点:安卓中的手势识别

如果要在传统的 Android 中手写一个双击识别,你的代码可能长这样:

java 复制代码
var isFirstTap = false
var lastTapTime = 0L
val DOUBLE_TAP_TIMEOUT = 300L

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            val currentTime = System.currentTimeMillis()
            if (isFirstTap && (currentTime - lastTapTime) < DOUBLE_TAP_TIMEOUT) {
                // 识别为双击!
                Log.d("Gesture", "Double Tap!")
                isFirstTap = false // 重置状态
            } else {
                // 记录第一次点击
                isFirstTap = true
                lastTapTime = currentTime
                
                // 还需要开个 Handler 延迟发送"单击"事件,如果超时没第二次点击才算单击
            }
        }
        MotionEvent.ACTION_UP -> { ... }
    }
    return true
}

传统方案的痛点:

  1. 状态碎片化 :你需要定义多个全局变量(isFirstTap, lastTapTime 等)来保存状态。
  2. 逻辑割裂 :代码被硬生生拆分到 ACTION_DOWNACTION_UP 等不同的 case 分支中,无法直观看出手势的完整生命周期。
  3. 定时器依赖 :严重依赖 Handler.postDelayed 来处理时间窗口(双击间隔、长按超时),定时器的启动和取消很容易出现时序问题。

三、协程关联手势

Compose 的破局之道,在于用协程将事件流"拉直" 。但是,底层的触摸事件(如安卓的 MotionEvent)依然是基于回调 的。Compose是如何将"回调"转变成"协程挂起"的?为什么我们可以调用 awaitPointerEvent()

要回答这个问题,我们需要从入口 Modifier.pointerInput 开始,逐步深入到底层实现。

3、1 入口:Modifier.pointerInput 做了什么?

我们在 Compose 中写手势处理代码,入口是这样的:

kotlin 复制代码
Modifier.pointerInput(Unit) {
    // 这个 lambda 的 receiver 是 PointerInputScope
    // receiver是什么东西?
    awaitPointerEventScope {
        // 这个 lambda 的 receiver 是 AwaitPointerEventScope
        val event = awaitPointerEvent() // 挂起等待事件
    }
}

那么 Modifier.pointerInput 究竟做了什么?来看源码:

kotlin 复制代码
// SuspendingPointerInputFilter.kt
fun Modifier.pointerInput(key1: Any?, block: PointerInputEventHandler): Modifier =
    this then SuspendPointerInputElement(key1 = key1, pointerInputEventHandler = block)

它创建了一个 SuspendPointerInputElement,这是一个 ModifierNodeElementSuspendPointerInputElement 的职责是创建真正干活的节点:

kotlin 复制代码
// SuspendingPointerInputFilter.kt

internal class SuspendPointerInputElement(
    val key1: Any? = null,
    val key2: Any? = null,
    val keys: Array<out Any?>? = null,
    val pointerInputEventHandler: PointerInputEventHandler,
) : ModifierNodeElement<SuspendingPointerInputModifierNodeImpl>() {

    override fun create(): SuspendingPointerInputModifierNodeImpl {
        // 创建实际处理事件的 Node
        return SuspendingPointerInputModifierNodeImpl(key1, key2, keys, pointerInputEventHandler)
    }

    override fun update(node: SuspendingPointerInputModifierNodeImpl) {
        // 重组时更新 Node
        node.update(key1, key2, keys, pointerInputEventHandler)
    }
    // ...
}

关键的实现者 SuspendingPointerInputModifierNodeImpl,它同时实现了好几个关键接口:

kotlin 复制代码
// SuspendingPointerInputFilter.kt

internal class SuspendingPointerInputModifierNodeImpl(
    private var key1: Any? = null,
    private var key2: Any? = null,
    private var keys: Array<out Any?>? = null,
    pointerInputEventHandler: PointerInputEventHandler,
) : Modifier.Node(),
    SuspendingPointerInputModifierNode, // 它是一个接收事件的 PointerInputModifierNode
    PointerInputScope,                  // 它本身就是 PointerInputScope(提供 awaitPointerEventScope 等方法)
    Density {                           // 提供密度信息
    
    // 处理事件的协程 Job,懒启动
    private var pointerInputJob: Job? = null
    
    // 当前正在等待事件的处理协程列表
    private val pointerHandlers =
        mutableVectorOf<PointerEventHandlerCoroutine<*>>()
    
    // ...
}

核心要点 :这个 Node 类既是事件的接收者PointerInputModifierNode,有 onPointerEvent 回调),又是手势协程的提供者PointerInputScope,提供 awaitPointerEventScope 等方法)。它充当了回调世界协程世界之间的桥梁。

3、2 闭包里的 receiver 是怎么来的?(Kotlin 语法补充)

很多同学对 Kotlin 的带接收者的 Lambda(Function with Receiver)可能比较陌生。我们先用一个极其简单的独立例子来解释什么是接收者以及它是怎么传进来的?

简单示例:自己写一个带接收者的函数

假设我们有一个 Robot(机器人)类,它有一个 move 方法:

kotlin 复制代码
class Robot {
    fun move(distance: Int) {
        println("机器人移动了 $distance 米")
    }
}

现在我们要写一个函数,让别人可以给 Robot 下达一系列指令:

kotlin 复制代码
// 注意参数 block 的类型:Robot.() -> Unit
// 这表示这个 lambda 代码块只能在 Robot 实例的上下文中执行
fun controlRobot(block: Robot.() -> Unit) {
    val myRobot = Robot() // 1、创建了一个实例
    
    // 2、把这个实例作为 Receiver(也就是 'this'),去执行传入的 block
    // 下面两行代码是等价的:
    // block(myRobot)
    myRobot.block()
}

// 开发者使用我们的函数:
controlRobot {
    // 在这个花括号里,隐藏的 'this' 就是上面框架创建的 'myRobot' 实例!
    // 所以你可以直接调用 Robot 的方法,而不需要写 myRobot.move()
    move(10)
    move(20)
}

回到 Compose 的手势源码中:

回顾我们在 Modifier.pointerInput 中写的代码:

kotlin 复制代码
Modifier.pointerInput(Unit) {
    // 为什么这里可以直接调用 awaitPointerEventScope?
    awaitPointerEventScope { ... }
}

看一看 Modifier.pointerInput 的方法签名:

kotlin 复制代码
fun Modifier.pointerInput(
    key1: Any?,
    block: suspend PointerInputScope.() -> Unit // 注意这里的 PointerInputScope.()
): Modifier

这和我们的 Robot 例子是一模一样的!开发者传入的这段代码(block),它的 Receiver 要求是一个 PointerInputScope 对象。正因为有了这个隐藏的 this,我们才能在闭包里直接调用属于 PointerInputScope 接口的 awaitPointerEventScope 方法。

那么,框架底层到底是在哪里"把真正的实例交给了这段代码"的?

答案就在上面的 SuspendingPointerInputModifierNodeImpl 类中。

  1. SuspendingPointerInputModifierNodeImpl 实现了 PointerInputScope 接口(它本身就是一个合法的 Receiver 实例)。
  2. 在底层的协程启动逻辑中,这个节点把自己当做了 this,去执行了开发者传入的 block

3、3 协程何时启动?------onPointerEvent 的懒加载

我们写的 Modifier.pointerInput(Unit) { ... } 里的 lambda 块并不是在 Compose 布局完成后就立即执行的。它是懒启动 的------只有当第一个触摸事件到来时,协程才会被启动。

这就是 onPointerEvent 方法的核心逻辑:

kotlin 复制代码
// SuspendingPointerInputModifierNodeImpl

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

    // ★ 协程懒启动:只有当第一个事件到来时,才启动处理协程
    if (pointerInputJob == null) {
        // 'start = CoroutineStart.UNDISPATCHED' 确保协程立即执行,不会错过第一个事件
        pointerInputJob =
            coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
                // ★ 在这里执行我们传入的 lambda 块!
                // 注意:pointerInputEventHandler 就是我们传给 Modifier.pointerInput 的 block。
                // 因为我们在 SuspendingPointerInputModifierNodeImpl 内部调用这个方法,
                // 编译器会隐式地将 'this'(即当前的 Node 实例本身)作为 Receiver 传给它。
                // 就像我们上面的 Robot 例子里的 myRobot.block() 一样!
               if (_deprecatedPointerInputHandler != null) {
                  _deprecatedPointerInputHandler!!()
               } else {
                  with(pointerInputEventHandler) { invoke() }
               }
            }
    }

    // ★ 将事件分发给所有正在等待事件的挂起点
    dispatchPointerEvent(pointerEvent, pass)

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

为什么要分发给"所有"正在等待的协程? 因为在一个 pointerInput 块中,你可能会启动多个协程同时等待事件。比如在使用底层 API 构建复杂手势时,你可能会写出这样的代码:

kotlin 复制代码
Modifier.pointerInput(Unit) {
    coroutineScope {
        launch {
            awaitPointerEventScope {
                // 协程 A:专门负责监听按下并记录坐标
                val event = awaitPointerEvent()
            }
        }
        launch {
            awaitPointerEventScope {
                // 协程 B:专门负责监听双击
                val event = awaitPointerEvent()
            }
        }
    }
}

此时,协程 A 和协程 B 都在调用 awaitPointerEvent() 并挂起等待。当一个新的触摸事件到来时,它们都有权利知道这个事件。因此,底层的 onPointerEvent 必须把事件遍历分发给所有注册了等待的 handler(也就是被 awaitPointerEventScope 收集到的那些挂起点)。

注意 CoroutineStart.UNDISPATCHED 这个参数------它非常关键。它意味着协程不经过调度器,立即在当前线程上执行 ,直到遇到第一个挂起点。这保证了我们的 lambda 块里的代码(比如 awaitPointerEventScope { ... } 的初始化)能在第一个事件到来之前就准备好。

3、4 awaitPointerEventScope:创建事件等待者

当协程启动后,lambda 块开始执行。我们通常会调用 awaitPointerEventScope 来创建一个可以接收事件的受限作用域。

来看它的完整源码:

kotlin 复制代码
// SuspendingPointerInputModifierNodeImpl
override suspend fun <R> awaitPointerEventScope(
    block: suspend AwaitPointerEventScope.() -> R
): R = suspendCancellableCoroutine { continuation ->
    // 1. 用外层的 continuation 创建一个 PointerEventHandlerCoroutine
    //    它既是 AwaitPointerEventScope 的实现(提供 awaitPointerEvent 等方法),
    //    也是一个 Continuation(可以恢复外层的挂起)
    val handlerCoroutine = PointerEventHandlerCoroutine(continuation)
    
    synchronized(pointerHandlersLock) {
        // 2. 将这个处理协程注册到 pointerHandlers 列表中
        //    这样后续 onPointerEvent 分发事件时,就能找到它
        pointerHandlers += handlerCoroutine
        
        // 3. 创建并立即启动 block 协程
        //    receiver 是 handlerCoroutine(所以 block 内可以调用 awaitPointerEvent)
        //    completion 也是 handlerCoroutine(block 执行完成后,由它来恢复外层 continuation)
        block.createCoroutine(handlerCoroutine, handlerCoroutine).resume(Unit)
    }

    // 4. 如果外层协程被取消,连带取消内部的等待
    continuation.invokeOnCancellation { handlerCoroutine.cancel(it) }
}

3、4、1 为什么要使用 suspendCancellableCoroutine

在 Kotlin 协程中,suspendCancellableCoroutine 是一个极其重要的桥接 API。它的作用是:

强行把当前协程挂起,并交出 Continuation(也就是后续要执行的代码的控制权),直到有人调用 continuation.resume() 才会恢复执行。

在这里使用它,有几个深层原因:

  1. 挂起外部环境,保持生命周期同步

    当你调用 awaitPointerEventScope { ... } 时,你希望在这个 { ... } 块执行完毕之前,外面的代码(比如 Modifier.pointerInput 里的剩余代码)不要 继续往下走。 使用 suspendCancellableCoroutine 后,外部协程就被"冻结"了(挂起)。它的控制权(continuation)被传给了 PointerEventHandlerCoroutine。只有当传入的 block 全部执行完,内部调用 continuation.resume() 时,外部协程才会继续。

  2. 支持取消机制(Cancellation)

    注意名字里的 Cancellable。在 Compose 中,UI 组件的生命周期变化很快。如果你的组件在等待双击时突然被移除了,那么包裹它的父协程会被 Cancel。 suspendCancellableCoroutine 允许我们监听这种取消事件: continuation.invokeOnCancellation { handlerCoroutine.cancel(it) } 这保证了:如果外部环境说"别等了",我们就能立刻清理掉正在等待的手势监听器,防止内存泄漏。

正是因为这个作用,我们可以使用 suspendCancellableCoroutine 将异步回调转换成挂起函数,并支持取消操作。

3、4、2 详细梳理 awaitPointerEventScope 的执行步骤(结合实例)

为了彻底看懂这个复杂的桥接过程,我们结合一段最简单的手势代码来一步步走:

kotlin 复制代码
// 假设我们在 UI 中写了这样一段手势监听
Modifier.pointerInput(Unit) { // <--- 外层协程 (由 onPointerEvent 懒启动)
    println("1. 进入 pointerInput")
    
    awaitPointerEventScope { // <--- 调用 awaitPointerEventScope
        println("3. 开始执行 scope block")
        
        val event = awaitPointerEvent() // <--- 挂起,等待用户点击
        
        println("4. 收到事件: $event")
    } // <--- block 执行完毕
    
    println("5. 离开 pointerInput")
}

当第一次触摸事件到来,外层协程启动,代码执行到 awaitPointerEventScope { ... } 时,源码底层是这样流转的:

第 1 步:外层协程被拦截并挂起

kotlin 复制代码
override suspend fun <R> awaitPointerEventScope(
    block: suspend AwaitPointerEventScope.() -> R
): R = suspendCancellableCoroutine { continuation ->
    // ...

当代码运行到 suspendCancellableCoroutine 时,上面例子中的"外层协程"就立刻停在了这里 ,不会打印 "5. 离开 pointerInput"。 此时框架把外层协程未来的"控制权"打包成了一个对象,叫做 continuation 传了进来。

第 2 步:创建"多面手" PointerEventHandlerCoroutine

kotlin 复制代码
    val handlerCoroutine = PointerEventHandlerCoroutine(continuation)

框架拿着这个控制权 continuation,创建了一个内部对象 handlerCoroutine。这个对象非常牛,它有三重身份:

  • 它是 AwaitPointerEventScope(所以它提供了 awaitPointerEvent 方法供你在 block 里调用)。
  • 它保存了 continuation(它知道在一切结束后,怎么去唤醒外面那层还在傻等的协程)。
  • 它本身也是一个 Continuation(作为执行内部 block 的载体)。

第 3 步:注册监听

kotlin 复制代码
    synchronized(pointerHandlersLock) {
        pointerHandlers += handlerCoroutine

把这个 handlerCoroutine 加入到 pointerHandlers 集合中。这一步就是去前台挂号:"我这里准备好接收 PointerEvent 了,以后有触摸事件遍历分发给我"。

第 4 步:立即启动你传入的 block

kotlin 复制代码
synchronized(pointerHandlersLock) {
    // 省略第三步的代码......
    // 第 4 步:立即启动你传入的 block
    block.createCoroutine(handlerCoroutine, handlerCoroutine).resume(Unit)
}

这是最容易产生误解的一行代码,它用到了 Kotlin 底层的协程 API(createCoroutine 和它的扩展方法 resume)。我们需要理清这里的"唤醒"关系,回答一个关键问题:**这里的 .resume(Unit) 唤醒了谁?**它唤醒外层协程了吗?

绝对没有。外层协程此时还在挂起。

让我们拆解这行代码:

  1. block.createCoroutine(receiver, completion)

    • block 是你写的内部代码 { println("3..."); val event = awaitPointerEvent(); ... }
    • receiver = handlerCoroutine:把 handlerCoroutine 当作 this 传给内部代码,这样你就能调 awaitPointerEvent() 了。
    • completion = handlerCoroutine:这是一个回调,相当于给内部代码绑了一个"终点站" 。它的意思是:当 block 里的代码彻底执行完毕(或者抛异常)时,系统要调用 handlerCoroutine.resumeWith()
  2. createCoroutine 返回的是一个全新创建的、处于初始状态(未启动)的内部 Continuation 对象

  3. 最后的 .resume(Unit)

    • 这里的 resume 是作用在这个全新创建的内部 Continuation 上的!
    • 它的作用是点火启动 这个内部协程。它让 block 里的代码开始执行(打印出 "3. 开始执行 scope block")。
    • **请注意:**它启动的是内部的 block并没有唤醒外层被 suspendCancellableCoroutine 挂起的那个 continuation!外层协程此时依然在外面苦苦等待。

后续发展:内外交接

这就像是一个套娃,外层在等内层,内层在等事件:

  1. 刚刚的 .resume(Unit) 点火后,内部 block 开始跑。

  2. 跑着跑着,遇到了 val event = awaitPointerEvent()

  3. 内部 block 挂起了(等待用户点击)。此时,主线程的控制权交还给了 Compose 框架,去继续处理别的 UI 渲染了。

  4. 用户手指点下屏幕,底层框架遍历 pointerHandlers,找到了我们的窗口,把事件递了进来,唤醒了内部 block

  5. 内部 block 继续跑,打印 "4. 收到事件: $event"

  6. 此时 block 里面的代码全跑完了。系统会检查当初绑定的 completion 对象(也就是那个"终点站")。

  7. 系统调用了 handlerCoroutine.resumeWith(Result)

  8. 那么 handlerCoroutine.resumeWith 里面写了什么呢?去看源码:

    kotlin 复制代码
    override fun resumeWith(result: Result<R>) {
        synchronized(pointerHandlersLock) { pointerHandlers -= this }
        // ★ 这里才是真正的唤醒外层!
        completion.resumeWith(result) // 这里的 completion 是当初 suspendCancellableCoroutine 给的外层控制权
    }
  9. 这一刻,外层协程终于被唤醒! 它从 suspendCancellableCoroutine 中苏醒过来,继续往下走,打印出 "5. 离开 pointerInput"

3、5 awaitPointerEvent:挂起等待事件

现在我们进入最核心的部分。当开发者在 awaitPointerEventScope 块内调用 awaitPointerEvent() 时,发生了什么?

PointerEventHandlerCoroutineAwaitPointerEventScope 的实现类。它是 SuspendingPointerInputModifierNodeImpl内部类,完整源码如下:

kotlin 复制代码
// SuspendingPointerInputModifierNodeImpl 的内部类

private inner class PointerEventHandlerCoroutine<R>(
    private val completion: Continuation<R>
) : AwaitPointerEventScope,          // 提供 awaitPointerEvent 等 API
    Density by this@SuspendingPointerInputModifierNodeImpl,
    Continuation<R> {                 // 作为 block 的完成回调

    // ★ 核心字段:保存正在等待事件的协程挂起点
    private var pointerAwaiter: CancellableContinuation<PointerEvent>? = null
    // 记录等待的是哪个阶段的事件
    private var awaitPass: PointerEventPass = PointerEventPass.Main

    override val currentEvent: PointerEvent
        get() = this@SuspendingPointerInputModifierNodeImpl.currentEvent

    override val size: IntSize
        get() = this@SuspendingPointerInputModifierNodeImpl.boundsSize

    override val viewConfiguration: ViewConfiguration
        get() = this@SuspendingPointerInputModifierNodeImpl.viewConfiguration

    override val extendedTouchPadding: Size
        get() = this@SuspendingPointerInputModifierNodeImpl.extendedTouchPadding

    // ★★★ 最核心的方法:被事件分发系统调用,唤醒正在等待的协程 ★★★
    fun offerPointerEvent(event: PointerEvent, pass: PointerEventPass) {
        if (pass == awaitPass) {       // 只有匹配的 Pass 才会唤醒
            pointerAwaiter?.run {
                pointerAwaiter = null
                resume(event)          // ★ 唤醒协程!event 作为 awaitPointerEvent() 的返回值
            }
        }
    }

    fun cancel(cause: Throwable?) {
        pointerAwaiter?.cancel(cause)
        pointerAwaiter = null
    }

    // context 必须是 EmptyCoroutineContext,因为这是 RestrictsSuspension 协程
    override val context: CoroutineContext = EmptyCoroutineContext

    // block 执行完成时的回调:从 pointerHandlers 中移除自身,并恢复外层 continuation
    override fun resumeWith(result: Result<R>) {
        synchronized(pointerHandlersLock) { pointerHandlers -= this }
        completion.resumeWith(result)
    }

    // ★★★ 开发者调用的挂起函数 ★★★
    override suspend fun awaitPointerEvent(
        pass: PointerEventPass
    ): PointerEvent =
        suspendCancellableCoroutine { continuation ->
            awaitPass = pass              // 记录要等待的 Pass
            pointerAwaiter = continuation // ★ 保存挂起点,等待 offerPointerEvent 来唤醒
        }

   // withTimeoutOrNull 的实现
   override suspend fun <T> withTimeoutOrNull(
      timeMillis: Long,
      block: suspend AwaitPointerEventScope.() -> T,
   ): T? {
      return try {
         withTimeout(timeMillis, block)
      } catch (_: PointerEventTimeoutCancellationException) {
         null
      }
   }

   // withTimeout 的实现
   override suspend fun <T> withTimeout(
      timeMillis: Long,
      block: suspend AwaitPointerEventScope.() -> T,
   ): T {
      if (timeMillis <= 0L) {
         pointerAwaiter?.resumeWithException(
            PointerEventTimeoutCancellationException(timeMillis)
         )
      }

      val job = coroutineScope.launch {
         // 延迟两次:第二次微延迟确保超时回调的优先级低于输入事件
         delay(timeMillis - WITH_TIMEOUT_MICRO_DELAY_MILLIS)
         delay(WITH_TIMEOUT_MICRO_DELAY_MILLIS)

         pointerAwaiter?.resumeWithException(
            PointerEventTimeoutCancellationException(timeMillis)
         )
      }
      try {
         return block()
      } finally {
         job.cancel(CancelTimeoutCancellationException)
      }
   }
}

看到了吗?当你调用 awaitPointerEvent() 时,它内部调用了 Kotlin 的挂起原语 suspendCancellableCoroutine。在这个 lambda 中,它做了一件非常核心的事情:

它把当前的协程控制权 continuation,赋值给了内部变量 pointerAwaiter

赋值完成后,协程就停在这个挂起点,不再往下执行了。主线程去忙别的事情了。

那么它是怎么拿到返回值的呢?回想一下之前提到的 offerPointerEvent 方法。当触摸事件真正发生时,底层系统会将事件传递过来,最终调用到 offerPointerEvent

kotlin 复制代码
    fun offerPointerEvent(event: PointerEvent, pass: PointerEventPass) {
        if (pass == awaitPass) {
            pointerAwaiter?.run {
                pointerAwaiter = null
                resume(event) // ★ 这里真正唤醒了因为 awaitPointerEvent 而挂起的协程!
            }
        }
    }

这里逻辑非常清晰:

  1. 取出之前保存的 pointerAwaiter(即那个被挂起的 Continuation)。
  2. 调用 continuation.resume(event)
  • 一旦 resume(event) 被调用,那个因为 awaitPointerEvent 里面调用了 suspendCancellableCoroutine 而停住的内层协程就会被立刻唤醒
  • 不仅被唤醒,resume() 传进去的参数 event,就会直接作为 suspendCancellableCoroutine 函数的返回值返回出来。

当你写下:

kotlin 复制代码
val event = awaitPointerEvent()
println(event.changes.first().position)

时,代码能先停留在第一行,等用户手指摸了屏幕,代码接着往下走,并且 event 变量里就已经装满了最新的触摸坐标数据!

3、6 事件分发:从 onPointerEventofferPointerEvent

现在让我们把整个链路串起来。当用户手指触摸屏幕后,事件是怎么一步步到达我们的协程代码的?

kotlin 复制代码
// SuspendingPointerInputModifierNodeImpl

// 事件分发的入口
private fun dispatchPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass) {
    forEachCurrentPointerHandler(pass) { it.offerPointerEvent(pointerEvent, pass) }
}

// 遍历所有已注册的处理协程
private inline fun forEachCurrentPointerHandler(
    pass: PointerEventPass,
    block: (PointerEventHandlerCoroutine<*>) -> Unit,
) {
    // 拷贝一份,避免在分发过程中修改集合
    synchronized(pointerHandlersLock) { dispatchingPointerHandlers.addAll(pointerHandlers) }
    try {
        when (pass) {
            // Initial 和 Final 阶段:正序遍历(从外到内 / 从外到内)
            PointerEventPass.Initial,
            PointerEventPass.Final -> dispatchingPointerHandlers.forEach(block)
            // Main 阶段:倒序遍历(从内到外)
            PointerEventPass.Main -> dispatchingPointerHandlers.forEachReversed(block)
        }
    } finally {
        dispatchingPointerHandlers.clear()
    }
}

3、7 PointerEvent 结构剖析:事件里到底装了什么?

有必要弄清楚刚刚通过 awaitPointerEvent() 拿到的 event 到底是个什么东西。在 Compose 的手势 API 中,你随时都在和 event.changes 打交道。其实在Compose原理十之事件分发中介绍过,这里再来回顾下。

当调用 val event = awaitPointerEvent() 后,得到的是一个 PointerEvent 对象。看看它的源码结构:

kotlin 复制代码
expect class PointerEvent internal constructor(
    changes: List<PointerInputChange>,
    internalPointerEvent: InternalPointerEvent?
) {
    /**
     * 描述了与此事件相关的所有指针(手指)的变化情况。
     * 比如你两个手指按在屏幕上,这个 List 就会有两个元素。
     */
    val changes: List<PointerInputChange>

    // ... 其他属性(如 buttons, keyboardModifiers,主要用于鼠标/键盘)
}

可以发现,PointerEvent 最重要的属性就是 changes: List<PointerInputChange> 。 因为在多点触控的场景下,屏幕上可能同时有多根手指(Pointer)。changes 列表里的每一个 PointerInputChange 对象,就代表了某一根手指在"上一帧"到"当前帧"之间发生的变化。

3、7、1 event.changes 是在哪里被构建出来的?

很多同学可能会好奇,这套精巧的 PointerInputChange 结构,是如何从安卓的 MotionEvent 转换过来的?它经历了以下三个核心步骤:

1、降维打击:从 MotionEvent 到 PointerInputEvent

起点位于 AndroidComposeView.dispatchTouchEvent。当安卓将 MotionEvent 传递给 Compose 时,Compose 会使用 MotionEventAdapter 对其进行降维转换:

kotlin 复制代码
// AndroidComposeView.android.kt
override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
    // 1. 将原生的 MotionEvent 转换为平台无关的 PointerInputEvent
    val pointerInputEvent = motionEventAdapter.convertToPointerInputEvent(motionEvent, this)
    if (pointerInputEvent != null) {
        // 2. 将事件交给处理器进行分发
        return pointerInputEventProcessor.process(pointerInputEvent, this, isInBounds)
    }
    // 省略部分代码......
}

MotionEventAdapter 会遍历 MotionEvent 里的所有触摸点(通过 pointerCount),剥离掉特定平台的冗余信息,提取出 idpositionOnScreen (绝对坐标)、uptime,每根手指当前的 down (是否按下) 状态。 最终,它将这一帧的所有手指状态打包成了一个 PointerInputEvent

kotlin 复制代码
// PointerInputEvent.android.kt
internal actual class PointerInputEvent(
    val uptime: Long,
    val pointers: List<PointerInputEventData>, // 这一帧的所有手指原始数据
    val motionEvent: MotionEvent
)

2、核心对比:产生 Change 的时刻

拿到了包含绝对坐标和按压状态的 PointerInputEvent 后,事件流转到了 PointerInputEventProcessor.process。 在这里,发生了最核心的"历史对比"。Compose 需要知道每根手指相对于上一帧发生了什么变化

kotlin 复制代码
// 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)
    }
}

看!这就是 previousPositionpreviousPressed 的来源。通过这种"差异对比",Compose 将绝对的状态数据,转化成了更容易描述手势行为的"变化量(Change)"。

3、命中测试与最终的 PointerEvent

生成的 InternalPointerEvent 中包含了全局的 changes(基于根节点坐标)。随后:

  1. PointerInputEventProcessor 会利用 HitPathTracker 进行命中测试(HitTest),找到哪些组件被手指触碰到了。
  2. 事件沿着组件树向下分发。
  3. 当事件到达你写的 Modifier.pointerInput 节点时,框架会根据你这个节点的相对位置和大小,从 InternalPointerEvent 中裁剪并转换出只属于你这个节点局部坐标系PointerEvent,并交给 offerPointerEvent 唤醒你的协程。

这就是你在 awaitPointerEvent() 中拿到的那个 event 的前世今生。

现在你应该明白为什么处理手势的代码总是长这样了:

kotlin 复制代码
awaitPointerEventScope {
    val event = awaitPointerEvent()
    
    // 1. 获取第一根手指的信息
    val firstPointer = event.changes.first()
    
    // 2. 判断这根手指是不是刚刚抬起
    if (firstPointer.changedToUp()) {
        println("手指抬起了!")
    }
    
    // 3. 计算这根手指移动了多少距离
    val dragDistance = firstPointer.positionChange()
    
    // 4. 消费掉这个事件,告诉别人"我处理过了"
    firstPointer.consume()
}

3、8 @RestrictsSuspension:为什么只能调 awaitPointerEvent

细心的你可能已经发现了,在 awaitPointerEventScope { } 块内调用 delay()withContext() 或任何外部的挂起函数。

如果你仔细看源码,会发现 AwaitPointerEventScope 接口上有一个特别的注解:

kotlin 复制代码
// SuspendingPointerInputFilter.kt

/**
 * Receiver scope for awaiting pointer events in a call to
 * [PointerInputScope.awaitPointerEventScope].
 *
 * This is a restricted suspension scope. Code in this scope is always called un-dispatched and may
 * only suspend for calls to [awaitPointerEvent]. These functions resume synchronously and the
 * caller may mutate the result **before** the next await call to affect the next stage of the input
 * processing pipeline.
 */
@RestrictsSuspension
@JvmDefaultWithCompatibility
interface AwaitPointerEventScope : Density {
    val size: IntSize
    val extendedTouchPadding: Size
    val currentEvent: PointerEvent
    val viewConfiguration: ViewConfiguration

    suspend fun awaitPointerEvent(
        pass: PointerEventPass = PointerEventPass.Main
    ): PointerEvent

    suspend fun <T> withTimeoutOrNull(
        timeMillis: Long,
        block: suspend AwaitPointerEventScope.() -> T,
    ): T? = block()

    suspend fun <T> withTimeout(
        timeMillis: Long,
        block: suspend AwaitPointerEventScope.() -> T,
    ): T = block()
}

@RestrictsSuspension 的作用是限制在这个作用域内,只能调用该接口自身定义的 suspend 函数 。你不能awaitPointerEventScope { } 块内调用 delay()withContext() 或任何外部的挂起函数。

为什么要这样设计?

因为触摸事件的分发是具有时效性 的。事件分发系统在分发一个事件时,需要在同一帧内 完成 Initial → Main → Final 三个阶段。在 awaitPointerEventresume 唤醒后,开发者的代码必须同步执行完毕 (直到下一个 awaitPointerEvent),才能保证事件的消费状态(consume())能及时反馈给分发树上的其他节点。

如果允许 delay(100) 这样的操作,事件就会"滞留"在某个中间状态,整个分发流水线就卡住了。

同时注意 PointerEventHandlerCoroutinecontext 字段:

kotlin 复制代码
override val context: CoroutineContext = EmptyCoroutineContext

使用 EmptyCoroutineContext 意味着没有 ContinuationInterceptor(调度器) ,所以 resume 后的代码不会被重新调度到其他线程,而是在当前线程同步执行 。这和 @RestrictsSuspension 配合,共同保证了事件处理的同步性。

3、9 完整流转图示

用一张完整的流程图来理解整个过程:

scss 复制代码
用户手指触摸屏幕
       ↓
Android 系统产生 MotionEvent
       ↓
Compose 事件分发系统将其转为 PointerEvent
       ↓
调用 SuspendingPointerInputModifierNodeImpl.onPointerEvent()
       ↓
   ┌─ 第一次调用?
   │    是 → coroutineScope.launch { 执行用户的 lambda 块 }
   │         用户的 lambda 中调用 awaitPointerEventScope { ... }
   │         → 创建 PointerEventHandlerCoroutine,注册到 pointerHandlers
   │         → 执行 block,遇到 awaitPointerEvent() 挂起
   │         → pointerAwaiter = continuation(保存挂起点)
   │    否 → 跳过启动步骤
   └─────────────────────────────┐
                                 ↓
              dispatchPointerEvent(event, pass)
                                 ↓
              遍历所有 pointerHandlers
                                 ↓
              对每个 handler 调用 offerPointerEvent(event, pass)
                                 ↓
              if (pass == awaitPass) → pointerAwaiter.resume(event)
                                 ↓
              协程被唤醒!awaitPointerEvent() 返回 event
                                 ↓
              开发者的代码继续执行(处理事件、消费事件等)
                                 ↓
              遇到下一个 awaitPointerEvent() → 再次挂起,等待下一个事件

四、核心 API 解析:语义化的手势积木

理解了底层的协程桥接原理后,我们来看看 Compose 如何利用这个机制,封装出高阶的、语义化的手势 API。

手势的处理本质上是事件序列的匹配。我们来看看 Compose 中手势的源码,体会它们是如何利用awaitPointerEventScopeawaitPointerEvent 组合出复杂逻辑的。

4、1 awaitFirstDown ------ 等待手指按下

kotlin 复制代码
// 文件:TapGestureDetector.kt

suspend fun AwaitPointerEventScope.awaitFirstDown(
    requireUnconsumed: Boolean = true,
    pass: PointerEventPass = PointerEventPass.Main,
): PointerInputChange {
    var event: PointerEvent
    do {
        event = awaitPointerEvent(pass)  // ← 挂起等待任意触摸事件
    } while (
        !event.isChangedToDown(requireUnconsumed)  // ← 循环直到是"按下"事件
    )
    return event.changes[0]  // ← 返回第一个按下的触点
}

逐行解读

代码 作用
1 var event: PointerEvent 声明变量,准备接收事件
2 event = awaitPointerEvent(pass) 挂起,等待任意触摸事件到来
3 !event.isChangedToDown(...) 检查:这个事件是不是"手指刚按下"?
4 while(...) 如果不是按下事件(比如是移动事件),继续等待
5 return event.changes[0] 确认是按下事件后,返回该触点信息

4、2 isChangedToDown 的判断逻辑

kotlin 复制代码
// TapGestureDetector.kt
internal fun PointerEvent.isChangedToDown(
    requireUnconsumed: Boolean,
    onlyPrimaryMouseButton: Boolean = firstDownRefersToPrimaryMouseButtonOnly(),
): Boolean {
    // 如果是鼠标事件,且要求只响应主键(左键)
    val onlyPrimaryButtonCausesDown =
        onlyPrimaryMouseButton && changes.fastAll { it.type == PointerType.Mouse }
    if (onlyPrimaryButtonCausesDown && !buttons.isPrimaryPressed) return false

    // 所有触点都从"没按下"变成"按下"
    return changes.fastAll {
        if (requireUnconsumed) it.changedToDown()  // 未消费的按下
        else it.changedToDownIgnoreConsumed()       // 即使已消费也算
    }
}

通俗理解:

awaitFirstDown 就像一个耐心的门卫

  • 他坐在门口等人来(awaitPointerEvent 挂起)
  • 有人经过时他看一眼:是不是有人"刚进门"?(isChangedToDown
  • 如果只是路过(移动事件)或者出门(抬起事件),继续等
  • 直到确实有人进门了(手指按下),才放行(return

4、3 waitForUpOrCancellation ------ 等待手指抬起或手势取消

kotlin 复制代码
// TapGestureDetector.kt
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(
    pass: PointerEventPass = PointerEventPass.Main
): PointerInputChange? {
    while (true) {
        val event = awaitPointerEvent(pass)
        
        // 检查 1:所有手指都抬起了吗?
        if (event.changes.fastAll { it.changedToUp() }) {
            return event.changes[0]  // ✅ 正常抬起,返回 UP 事件
        }

        // 检查 2:事件被消费了,或者手指移出了区域?
        if (event.changes.fastAny { 
            it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding) 
        }) {
            return null  // ❌ 手势被取消
        }

        // 检查 3:在 Final pass 中,事件被其他手势消费了?
        val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
        if (consumeCheck.changes.fastAny { it.isConsumed }) {
            return null  // ❌ 被下游消费,取消
        }
    }
}

逐行解读

kotlin 复制代码
while (true)  ← 无限循环,持续监听
    │
    ├─ awaitPointerEvent(Main)  ← 在 Main pass 等待事件
    │    │
    │    ├─ 所有手指都 UP 了? → return 最后一个 UP(成功!)
    │    │
    │    └─ 有手指被消费 or 移出边界? → return null(取消!)
    │
    └─ awaitPointerEvent(Final)  ← 再在 Final pass 检查一次
         │
         └─ 有手指被消费? → return null(被其他手势抢走了!)

为什么要检查两次(Main + Final)?

Compose 的事件分发有三个 pass:InitialMainFinal

scss 复制代码
                    事件到来
                       │
            ┌──────────┼──────────┐
            ▼          ▼          ▼
        Initial      Main       Final
        (预处理)    (主处理)    (后处理)

Main pass 检查时,下游的组件可能还没来得及消费事件。所以需要在 Final pass 再检查一次:

  • Main pass:你先看看事件,觉得可能是你的
  • Final pass:等别人都看完了,确认没有人抢走这个事件

如果有人在 Final pass 消费了事件(例如一个嵌套的 Scrollable 认定这是滚动手势),那么你的点击手势就被取消了。

4、4 waitForLongPress ------ 等待长按或超时

kotlin 复制代码
// 文件:TapGestureDetector.kt
internal suspend fun AwaitPointerEventScope.waitForLongPress(
    pass: PointerEventPass = PointerEventPass.Main
): LongPressResult {
    var result: LongPressResult = LongPressResult.Canceled
    try {
        // 关键:用 withTimeout 设置长按超时
        withTimeout(viewConfiguration.longPressTimeoutMillis) {
            while (true) {
                val event = awaitPointerEvent(pass)
                
                // 手指在超时前抬起 → 不是长按,是普通点击
                if (event.changes.fastAll { it.changedToUp() }) {
                    result = LongPressResult.Released(event.changes[0])
                    break
                }

                // 深度按压(3D Touch)→ 立即触发长按
                if (event.isDeepPress) {
                    result = LongPressResult.Success
                    break
                }

                // 手指移出区域或被消费 → 取消
                if (event.changes.fastAny {
                    it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
                }) {
                    result = LongPressResult.Canceled
                    break
                }

                // Final pass 二次检查
                val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
                if (consumeCheck.changes.fastAny { it.isConsumed }) {
                    result = LongPressResult.Canceled
                    break
                }
            }
        }
    } catch (_: PointerEventTimeoutCancellationException) {
        // 超时了!说明手指按住没动超过 longPressTimeoutMillis → 长按成功
        return LongPressResult.Success
    }
    return result
}

LongPressResult 密封类

kotlin 复制代码
internal sealed class LongPressResult {
    /** 长按触发成功 */
    object Success : LongPressResult()

    /** 手指在超时前抬起(普通点击) */
    class Released(val finalUpChange: PointerInputChange) : LongPressResult()

    /** 手势被取消 */
    object Canceled : LongPressResult()
}

核心机制:withTimeout 实现超时检测

kotlin 复制代码
// 文件:SuspendingPointerInputFilter.kt

override suspend fun <T> withTimeout(
    timeMillis: Long,
    block: suspend AwaitPointerEventScope.() -> T,
): T {
    if (timeMillis <= 0L) {
        pointerAwaiter?.resumeWithException(
            PointerEventTimeoutCancellationException(timeMillis)
        )
    }

    val job = coroutineScope.launch {
        // 延迟两次:确保超时 continuation 的优先级低于输入事件
        delay(timeMillis - WITH_TIMEOUT_MICRO_DELAY_MILLIS)
        delay(WITH_TIMEOUT_MICRO_DELAY_MILLIS)  // 二次微延迟

        // 超时后,向正在等待事件的协程抛异常
        pointerAwaiter?.resumeWithException(
            PointerEventTimeoutCancellationException(timeMillis)
        )
    }
    try {
        return block()
    } finally {
        job.cancel(CancelTimeoutCancellationException)
    }
}

通俗理解:

waitForLongPress 就像一个带闹钟的计时器

  1. 启动一个闹钟(withTimeout),设定 longPressTimeoutMillis(默认约 400ms)
  2. 在闹钟响之前,持续检查:
    • 手指抬起了?→ 返回 Released(是普通点击,不是长按)
    • 手指移出区域?→ 返回 Canceled(手势取消)
    • 用力按压?→ 返回 Success(3D Touch 直接触发长按)
  3. 如果闹钟响了(PointerEventTimeoutCancellationException),说明手指按住没动够长时间 → 长按成功!

4、5 awaitSecondDown ------ 等待双击的第二次按下

kotlin 复制代码
// 文件:TapGestureDetector.kt
private suspend fun AwaitPointerEventScope.awaitSecondDown(
    firstUp: PointerInputChange
): PointerInputChange? =
    withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
        val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
        var change: PointerInputChange
        // 第二次按下不能太快(要超过 doubleTapMinTimeMillis)
        do {
            change = awaitFirstDown()
        } while (change.uptimeMillis < minUptime)
        change
    }

逐行解读

代码 作用
1 withTimeoutOrNull(doubleTapTimeoutMillis) 在双击超时时间内等待,超时返回 null
2 val minUptime = firstUp.uptimeMillis + doubleTapMinTimeMillis 计算第二次按下的最早允许时间
3 change = awaitFirstDown() 等待任意按下事件
4 while (change.uptimeMillis < minUptime) 如果按下太快(小于最小间隔),忽略,继续等
5 change 返回有效的第二次按下事件

时间轴示意:

复制代码
第一次 UP                             双击超时
  │                                      │
  ├─── doubleTapMinTimeMillis ───┤       │
  │   (这个区间内的按下被忽略)    │       │
  │                              │       │
  │                        可以接受第二次按下 │
  ▼                              ▼       ▼
──┼──────────────┼───────────────┼───────┼──→ 时间
  │              │               │       │
  │    太快,忽略   │   有效双击区间   │  超时  │

4、6 awaitEachGesture ------ 手势循环检测器

kotlin 复制代码
// ForEachGesture.kt
suspend fun PointerInputScope.awaitEachGesture(
    block: suspend AwaitPointerEventScope.() -> Unit
) {
    val currentContext = currentCoroutineContext()
    awaitPointerEventScope {           // ← 进入 AwaitPointerEventScope
        while (currentContext.isActive) {  // ← 无限循环
            try {
                block()                    // ← 执行一次手势检测

                // 等待所有手指抬起,准备检测下一个手势
                awaitAllPointersUp()
            } catch (e: CancellationException) {
                if (currentContext.isActive) {
                    // 手势被取消(比如被其他手势抢走),但协程还活着
                    // → 等所有手指抬起后继续下一轮检测
                    awaitAllPointersUp()
                } else {
                    // 整个 pointerInput 被取消 → 向上传播异常
                    throw e
                }
            }
        }
    }
}

核心设计:为什么需要无限循环?

用户会不断地触摸屏幕,每次触摸都是一个新的手势:

scss 复制代码
手势 1              手势 2              手势 3
按下→移动→抬起     按下→抬起           按下→长按→抬起
       │                 │                    │
       ▼                 ▼                    ▼
  block() 第1次      block() 第2次       block() 第3次
  执行完毕           执行完毕            执行完毕
       │                 │                    │
       ▼                 ▼                    ▼
  awaitAllPointersUp  awaitAllPointersUp  awaitAllPointersUp
  等待所有手指抬起    等待所有手指抬起     等待所有手指抬起
       │                 │                    │
       ▼                 ▼                    ▼
  继续下一轮循环      继续下一轮循环       继续下一轮循环

为什么不用旧的 forEachGesture?

旧的 forEachGesture 在每次循环时会退出 awaitPointerEventScope 再重新进入,这会导致在两个手势之间丢失事件

kotlin 复制代码
// ❌ 旧方式:每次循环都重建 awaitPointerEventScope
suspend fun PointerInputScope.forEachGesture(block: ...) {
    while (isActive) {
        block()           // block 里面调用 awaitPointerEventScope { ... }
        awaitAllPointersUp()  // 退出后再进入 → 中间的事件可能丢失!
    }
}

// ✅ 新方式:只有一个 awaitPointerEventScope,循环在里面
suspend fun PointerInputScope.awaitEachGesture(block: ...) {
    awaitPointerEventScope {     // 只进入一次!
        while (isActive) {
            block()              // block 直接在同一个 scope 中执行
            awaitAllPointersUp()
        }
    }
}

4、7 awaitAllPointersUp ------ 等待所有手指抬起

kotlin 复制代码
// 文件:ForEachGesture.kt

internal suspend fun AwaitPointerEventScope.awaitAllPointersUp(
    pass: PointerEventPass = PointerEventPass.Final
) {
    if (!allPointersUp()) {  // 如果还有手指按着
        do {
            val events = awaitPointerEvent(pass)  // 等待事件
        } while (events.changes.fastAny { it.pressed })  // 直到没有手指按着
    }
}

internal fun AwaitPointerEventScope.allPointersUp(): Boolean =
    !currentEvent.changes.fastAny { it.pressed }  // 检查当前是否所有手指都抬起

4、8 detectTapGestures ------ 完整的手势检测流程

现在让我们把所有积木拼在一起,逐步解读 detectTapGestures 的完整源码。

kotlin 复制代码
// 文件:TapGestureDetector.kt

suspend fun PointerInputScope.detectTapGestures(
    onDoubleTap: ((Offset) -> Unit)? = null,    // 双击回调(可选)
    onLongPress: ((Offset) -> Unit)? = null,    // 长按回调(可选)
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,  // 按下回调
    onTap: ((Offset) -> Unit)? = null,          // 单击回调(可选)
)

四个参数全部可选,你可以只传你关心的回调。

完整源码 + 逐段注释

kotlin 复制代码
suspend fun PointerInputScope.detectTapGestures(
    onDoubleTap: ((Offset) -> Unit)? = null,
    onLongPress: ((Offset) -> Unit)? = null,
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
    onTap: ((Offset) -> Unit)? = null,
) = coroutineScope {
    // ========== 第 1 步:初始化 ==========
    val pressScope = PressGestureScopeImpl(this@detectTapGestures)
    
    // ========== 第 2 步:进入手势循环 ==========
    awaitEachGesture {
        
        // ========== 第 3 步:等待手指按下 ==========
        val down = awaitFirstDown()
        down.consume()  // 消费按下事件,防止父组件响应
        
        // ========== 第 4 步:重置 PressScope 状态 ==========
        var resetJob = launch(start = coroutineStartForCurrentDispatchBehavior) { 
            pressScope.reset() 
        }
        
        // ========== 第 5 步:通知"按下"回调 ==========
        if (onPress !== NoPressGesture)
            launchAwaitingReset(resetJob) { pressScope.onPress(down.position) }
        
        val upOrCancel: PointerInputChange?
        val cancelOrReleaseJob: Job?

        // ========== 第 6 步:等待抬起或长按 ==========
        if (onLongPress == null) {
            // 没有长按监听 → 简单地等待抬起或取消
            upOrCancel = waitForUpOrCancellation()
        } else {
            // 有长按监听 → 用带超时的等待
            upOrCancel = when (val longPressResult = waitForLongPress()) {
                LongPressResult.Success -> {
                    // 长按触发!
                    onLongPress.invoke(down.position)
                    consumeUntilUp()  // 消费后续所有事件
                    launchAwaitingReset(resetJob) { pressScope.release() }
                    return@awaitEachGesture  // 本轮手势结束
                }
                is LongPressResult.Released -> longPressResult.finalUpChange  // 手指抬起
                is LongPressResult.Canceled -> null  // 手势取消
            }
        }

        // ========== 第 7 步:处理抬起或取消 ==========
        if (upOrCancel == null) {
            // 手势取消
            cancelOrReleaseJob = launchAwaitingReset(resetJob) {
                pressScope.cancel()
            }
        } else {
            // 手指正常抬起
            upOrCancel.consume()
            cancelOrReleaseJob = launchAwaitingReset(resetJob) { 
                pressScope.release() 
            }
        }

        // ========== 第 8 步:判断是单击还是双击 ==========
        if (upOrCancel != null) {
            if (onDoubleTap == null) {
                // 没有双击监听 → 直接触发单击
                onTap?.invoke(upOrCancel.position)
            } else {
                // 有双击监听 → 等待可能的第二次按下
                val secondDown = awaitSecondDown(upOrCancel)

                if (secondDown == null) {
                    // 超时没有第二次按下 → 单击
                    onTap?.invoke(upOrCancel.position)
                } else {
                    // ========== 第 9 步:检测到第二次按下 ==========
                    resetJob = launch(start = coroutineStartForCurrentDispatchBehavior) {
                        cancelOrReleaseJob.join()
                        pressScope.reset()
                    }
                    if (onPress !== NoPressGesture) {
                        launchAwaitingReset(resetJob) { 
                            pressScope.onPress(secondDown.position) 
                        }
                    }

                    // ========== 第 10 步:等待第二次抬起 ==========
                    val secondUp = if (onLongPress == null) {
                        waitForUpOrCancellation()
                    } else {
                        when (val longPressResult = waitForLongPress()) {
                            LongPressResult.Success -> {
                                // 第二次按下变成了长按
                                // 注意:第一次单击不会被回调!
                                onLongPress.invoke(secondDown.position)
                                consumeUntilUp()
                                launchAwaitingReset(resetJob) { pressScope.release() }
                                return@awaitEachGesture
                            }
                            is LongPressResult.Released -> longPressResult.finalUpChange
                            is LongPressResult.Canceled -> null
                        }
                    }
                    
                    if (secondUp != null) {
                        // ========== 第 11 步:双击成功!==========
                        secondUp.consume()
                        launchAwaitingReset(resetJob) { pressScope.release() }
                        onDoubleTap(secondUp.position)  // 🎉 双击回调
                    } else {
                        // 第二次手势取消 → 回退为单击
                        launchAwaitingReset(resetJob) { pressScope.cancel() }
                        onTap?.invoke(upOrCancel.position)
                    }
                }
            }
        }
    }
}

完整流程图

scss 复制代码
                            ┌─────────────────────┐
                            │   awaitEachGesture   │  ← 手势循环
                            │      (while循环)      │
                            └──────────┬──────────┘
                                       │
                            ┌──────────▼──────────┐
                            │   awaitFirstDown()   │  ← 挂起等待按下
                            │     down.consume()   │
                            └──────────┬──────────┘
                                       │
                            ┌──────────▼──────────┐
                            │  onPress(position)   │  ← 通知按下回调
                            └──────────┬──────────┘
                                       │
                          ┌────────────┴────────────┐
                    有 onLongPress?              无 onLongPress
                          │                         │
                ┌─────────▼─────────┐    ┌─────────▼──────────┐
                │  waitForLongPress  │    │ waitForUpOrCancel  │
                └────┬────┬────┬────┘    └─────────┬──────────┘
                     │    │    │                    │
              Success │  Released │  Canceled      │
                     │    │    │                    │
          ┌──────────┘    │    └──────────┐         │
          ▼               ▼               ▼         ▼
     onLongPress    upOrCancel=UP    upOrCancel=null   upOrCancel
     consumeUntilUp  (继续判断)      pressScope.cancel  (继续判断)
     return           │                │               │
                      └──────┬─────────┘               │
                             │                         │
                    ┌────────▼────────┐                │
                    │ upOrCancel!=null │◄───────────────┘
                    └───┬────────┬────┘
                        │        │
                  有 onDoubleTap?   无 onDoubleTap
                        │               │
              ┌─────────▼────────┐  ┌───▼───┐
              │ awaitSecondDown  │  │ onTap │
              └──┬──────────┬────┘  └───────┘
                 │          │
           secondDown=null  secondDown!=null
                 │          │
            ┌────▼────┐  ┌──▼──────────────┐
            │  onTap  │  │ waitForUpOrCancel │
            └─────────┘  │  (第二次按下的)    │
                         └──┬──────────┬────┘
                            │          │
                      secondUp!=null  secondUp=null
                            │          │
                      ┌─────▼─────┐ ┌──▼──────────┐
                      │ onDoubleTap│ │ onTap(第1次) │
                      │  🎉 双击! │ │  回退为单击   │
                      └───────────┘ └──────────────┘

4、9 PressGestureScopeImpl ------ 按压状态管理器

kotlin 复制代码
// TapGestureDetector.kt
internal class PressGestureScopeImpl(density: Density) : PressGestureScope, Density by density {
    private var isReleased = false
    private var isCanceled = false
    private val mutex = Mutex(locked = false)

    /** 手势取消时调用 */
    fun cancel() {
        isCanceled = true
        if (mutex.isLocked) {
            mutex.unlock()  // 解锁,让 tryAwaitRelease 恢复执行
        }
    }

    /** 所有手指抬起时调用 */
    fun release() {
        isReleased = true
        if (mutex.isLocked) {
            mutex.unlock()  // 解锁,让 tryAwaitRelease 恢复执行
        }
    }

    /** 新手势开始时调用,重置状态 */
    suspend fun reset() {
        mutex.lock()       // 上锁,后续的 tryAwaitRelease 会等待
        isReleased = false
        isCanceled = false
    }

    override suspend fun awaitRelease() {
        if (!tryAwaitRelease()) {
            throw GestureCancellationException("The press gesture was canceled.")
        }
    }

    override suspend fun tryAwaitRelease(): Boolean {
        if (!isReleased && !isCanceled) {
            mutex.lock()    // 如果还没释放也没取消,挂起等待
            mutex.unlock()  // 被唤醒后立即解锁
        }
        return isReleased   // 返回是否是正常释放(而非取消)
    }
}

4、9、1 Mutex 的巧妙用法

这里的 Mutex 不是用来保护共享资源的,而是被当作挂起/恢复机制使用:

scss 复制代码
时间线:

1. reset() 被调用 → mutex.lock() → 上锁 ✅
2. onPress 中用户调用 tryAwaitRelease() → mutex.lock() → 已经上锁了!挂起等待... 💤
3. 手指抬起 → release() 被调用 → mutex.unlock() → 步骤2 被唤醒 ✅
4. tryAwaitRelease() 恢复 → mutex.unlock() → return true(isReleased)

通俗理解:

Mutex 在这里就像一扇只能一个人通过的旋转门

  • reset() 先进去把门锁了
  • tryAwaitRelease() 想进门,发现门锁了,就在门口等着
  • release() 把门打开了,等着的人就通过了
  • 通过后发现 isReleased = true,说明是正常释放

4、10 consumeUntilUp ------ 长按后的事件清理

kotlin 复制代码
// TapGestureDetector.kt
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
    do {
        val event = awaitPointerEvent()
        event.changes.fastForEach { it.consume() }  // 消费所有变化
    } while (event.changes.fastAny { it.pressed })   // 直到没有手指按着
}

当长按被触发后,需要"吃掉"后续所有事件,防止其他手势处理器再响应。这就像喊了一声"这个手势是我的!",然后把所有后续事件都标记为已消费。

4、11 detectTapAndPress ------ 简化版(无双击/长按)

如果不需要双击和长按检测,Compose 提供了一个更简单的版本:

kotlin 复制代码
// TapGestureDetector.kt
internal suspend fun PointerInputScope.detectTapAndPress(
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
    onTap: ((Offset) -> Unit)? = null,
) {
    val pressScope = PressGestureScopeImpl(this)
    coroutineScope {
        awaitEachGesture {
            val resetJob = launch(start = coroutineStartForCurrentDispatchBehavior) { 
                pressScope.reset() 
            }

            val down = awaitFirstDown().also { it.consume() }

            if (onPress !== NoPressGesture) {
                launchAwaitingReset(resetJob) { pressScope.onPress(down.position) }
            }

            val up = waitForUpOrCancellation()
            if (up == null) {
                launchAwaitingReset(resetJob) { pressScope.cancel() }
            } else {
                up.consume()
                launchAwaitingReset(resetJob) { pressScope.release() }
                onTap?.invoke(up.position)
            }
        }
    }
}

对比 detectTapGestures,这个版本:

  • ❌ 没有 waitForLongPress(不检测长按)
  • ❌ 没有 awaitSecondDown(不检测双击)
  • ✅ 只有 awaitFirstDownwaitForUpOrCancellation(按下 → 抬起/取消)
  • ✅ 逻辑更简单,性能更好

4、12 launchAwaitingReset ------ 等待重置完成

kotlin 复制代码
// TapGestureDetector.kt
private fun CoroutineScope.launchAwaitingReset(
    resetJob: Job,
    start: CoroutineStart = coroutineStartForCurrentDispatchBehavior,
    block: suspend CoroutineScope.() -> Unit,
): Job = launch(start = start) {
    if (isDetectTapGesturesImmediateCoroutineDispatchEnabled) {
        resetJob.join()  // 等待 reset 完成
    }
    block()
}

为什么要先 resetJob.join()

PressGestureScopeImpl.reset() 会调用 mutex.lock()。如果上一轮的 release()cancel() 还没执行完(还没 unlock),reset() 就会卡住。launchAwaitingReset 确保 reset 先完成,再执行新的 onPress/release/cancel

4、13 coroutineStartForCurrentDispatchBehavior ------ 立即 vs 延迟分发

kotlin 复制代码
private val coroutineStartForCurrentDispatchBehavior
    get() = if (isDetectTapGesturesImmediateCoroutineDispatchEnabled) {
        CoroutineStart.UNDISPATCHED  // 立即在当前线程执行
    } else {
        CoroutineStart.DEFAULT       // 通过调度器分发
    }
  • UNDISPATCHED:协程启动后立即执行到第一个挂起点,不经过调度器。更快,但可能有时序问题。
  • DEFAULT:协程被调度后才执行,更安全但有微小延迟。

4、14 核心 API 速查表

函数 作用 返回值
awaitPointerEvent(pass) 挂起等待任意触摸事件 PointerEvent
awaitFirstDown() 挂起等待手指按下 PointerInputChange
waitForUpOrCancellation() 挂起等待手指抬起或取消 PointerInputChange?(null=取消)
waitForLongPress() 挂起等待长按或手指抬起 LongPressResult
awaitSecondDown(firstUp) 在双击超时内等待第二次按下 PointerInputChange?(null=超时)
awaitEachGesture { } 无限循环检测手势 不返回(一直循环)
consumeUntilUp() 消费所有事件直到手指全部抬起

五、总结

Compose 对事件分发系统的协程化改造,是现代声明式 UI 的一次精妙设计:

  1. 底层桥接 :通过 suspendCancellableCoroutine 将安卓原生的回调机制转换为协程的挂起/恢复机制。
  2. 同步保障 :通过 @RestrictsSuspension 保证了事件在挂起恢复后的处理依然是同步、原子的,不破坏分发树的时序。
  3. 自动资源管理withTimeout 替代手动计时器,异常自动清理。
  4. 高阶封装 :基于协程构建了 awaitFirstDownwaitForUpOrCancellation 等语义化极强的 API,让开发者可以用编写同步代码的心智负担,轻松组合出诸如双击、长按、缩放等复杂的自定义手势。

一句话总结:Compose 把"在回调地狱中追踪状态"变成了"在协程中像写故事一样描述手势"。

相关推荐
sTone873754 小时前
web后端开发概念: VO 和 PO
java·后端·架构
裴云飞4 小时前
Compose原理十之事件分发
架构
ray_liang4 小时前
彻底治愈AI“失忆”和胡说八道的真正办法
后端·架构
在西安放羊的牛油果4 小时前
我把 2000 行下单代码,重构成了一套交易前端架构
前端·设计模式·架构
im_AMBER4 小时前
今日开发反思:编辑器大纲跳转与数据持久化实践
前端·架构
用户497242617329321 小时前
Amazing:基于 Agent-Teams 的 AI 协同开发范式,让团队效率提升 10 倍
架构
吾日三省Java1 天前
Spring Cloud架构下的日志追踪:传统MDC vs 王炸SkyWalking
java·后端·架构
lizhongxuan1 天前
AI小镇 - 涌现
算法·架构
偷油师傅1 天前
拆解 OpenClaw - 06:安全模型
架构