mcpField
I
injecting-touch-gestures

injecting-touch-gestures

github.com/skydoves测试与质量33963 次安装22 周前更新
代码仓库

关于 injecting-touch-gestures

为编写 Android Compose 触摸手势测试提供指导,适用于需要模拟拖动、多点触控、受控速度滑动等操作的开发者。它强调使用 `performTouchInput` 和节点本地坐标,替代已弃用的 `performGesture`。

主要功能

  1. 01使用 performTouchInput 注入触摸事件
  2. 02通过节点本地坐标定位
  3. 03模拟滑动、长按和双击
  4. 04执行捏合及多点触控
  5. 05拆分手势并检查中间状态

适用场景

  1. 01将测试中的 performGesture 迁移到新接口
  2. 02测试可拖动组件和滑动操作
  3. 03验证捏合缩放或受控速度滑动
  4. 04避免因设备尺寸不同而产生坐标问题

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill injecting-touch-gestures

安装后在 Claude Code 中输入/injecting-touch-gestures确认已加载。

详细介绍

注入触摸手势——使用 performTouchInput,而不是 performGesture

在 Compose 测试中,performTouchInput { … } 是注入合成触摸事件的唯一入口。它会在 TouchInjectionScope 中运行一个代码块,坐标系为节点本地坐标系(原点位于节点左上角),批量处理代码块加入的所有事件,并在代码块返回时将它们作为一次不会触发重组的事件突发发送。旧版 performGesture { … } 已标记为 @Deprecated("Replaced by performTouchInput")(Actions.kt:337-355)——任何新代码中不得出现它。

何时使用此技能

  • 测试必须模拟部分手势、多点触控、可控制速度的滑动或双指缩放——任何比单击更复杂的操作。
  • 开发者需要相对于节点指定精确坐标(例如,从 center 精确拖动 100 px)。
  • 手指仍处于“按下”状态时,必须将手势拆分到两个 performTouchInput 代码块中。
  • 测试当前使用 performGesture { },需要迁移。
  • 测试使用硬编码的屏幕坐标模拟触摸,导致其在不同显示尺寸下不稳定。

何时不使用此技能

  • 在节点上简单单击——使用 ../clicking-and-scrolling/SKILL.md 中的 performClick()。它符合平台惯例,且更简洁。
  • 手势是悬停、右键单击或滚轮滚动——使用 ../injecting-mouse-and-keyboard/SKILL.md。
  • 流程是“单击按钮,然后断言出现 Snackbar”——使用 ../../assertions/asserting-node-state-and-text/SKILL.md 进行断言,而不是检查触摸状态。
  • 测试受滑动动画影响——先按照 ../../synchronization/testing-animations-deterministically/SKILL.md 暂停时钟。

前提条件

  • 按照 ../../setup/configuring-test-dependencies/SKILL.md 配置 androidx.compose.ui:ui-test-junit4(或用于 runComposeUiTest 的 ui-test)。
  • 接收节点携带生产代码中的 Modifier.testTag("…"),以便测试能够确定性地选中该节点。
  • 对于多点触控手势(pinch、multiTouchSwipe),目标可组合项通过 Modifier.pointerInput { … } 接收指针输入——通常使用 Modifier.transformable、Modifier.draggable2D 或自定义的 detectTransformGestures。

工作流

  1. 获取接收节点。 performTouchInput 是 SemanticsNodeInteraction 的扩展。先找到该节点。
rule.onNodeWithTag(DragSurfaceTag).performTouchInput { /* DSL */ }
  1. 使用节点局部坐标。 在代码块内,(0, 0) 是节点的左上角。使用 InjectionScope 中的几何辅助项(InjectionScope.kt:34-206):center、topLeft、topCenter、topRight、centerLeft、centerRight、bottomLeft、bottomCenter、bottomRight、width、height,以及 right == width - 1f 和 bottom == height - 1f(像素从 0 开始编号 — InjectionScope.kt:96-112)。使用 percentOffset(.5f, .5f) 进行相对定位。不得硬编码屏幕坐标。

  2. 选择合适的抽象级别。 高级辅助方法可覆盖大多数情况:

辅助方法默认值功能
click(position = center)在中心点击down(); move(); up()(TouchInjectionScope.kt:372)
longClick(position = center, durationMillis = vc.longPressTimeoutMillis + 100)比长按超时时间多 100 ms按下、按住、释放(TouchInjectionScope.kt:390)
doubleClick(position = center, delayMillis = …)双击间隔最小值与最大值的中间值click(); advanceEventTime(delay); click()(TouchInjectionScope.kt:417)
swipe(start, end, durationMillis = 200)线性滑动,持续 200 ms在两个点之间线性插值(TouchInjectionScope.kt:445)
swipeUp / swipeDown / swipeLeft / swipeRight沿穿过中心的轴滑动从边缘到边缘滑动(TouchInjectionScope.kt:649+)
swipeWithVelocity(start, end, endVelocity, durationMillis)根据可行性计算调整路径,使最终速度符合要求(TouchInjectionScope.kt:617)
pinch(start0, end0, start1, end1, durationMillis = 400)双指同时操作,持续 400 ms两条同时进行的线性路径(TouchInjectionScope.kt:574)
multiTouchSwipe(curves, durationMillis, keyTimes)—每个指针 ID 对应一条曲线(TouchInjectionScope.kt:491)
  1. 部分手势使用低级事件。 以下方法均位于 TouchInjectionScope 中(TouchInjectionScope.kt):

    • down(pointerId = 0, position) — 在 position 处按下指针。
    • moveTo(pointerId = 0, position, delayMillis = eventPeriodMillis) — 将移动事件加入队列。
    • moveBy(pointerId = 0, delta, delayMillis = eventPeriodMillis) — 相对移动形式。
    • updatePointerTo(pointerId, position) / updatePointerBy(pointerId, delta) — 调整指针位置,但不将移动事件加入队列;适用于将多个指针更新合并到一次 move(delayMillis) 调用中。
    • move(delayMillis = eventPeriodMillis) — 发送移动事件,将所有待处理的指针更新合并在一起。
    • up(pointerId = 0) — 释放指针。
    • cancel(delayMillis = eventPeriodMillis) — 发送等效于 MotionEvent.ACTION_CANCEL 的事件。
    • currentPosition(pointerId = 0): Offset? — 查询指针的最后已知位置。
  2. 如果需要在手势过程中处理重组,请将手势拆分到多个代码块中。 同一节点上的多个 performTouchInput 调用会共享指针状态 — 保持“按下”状态的指针会继续保持按下。利用这一点,可在手势经过的某一帧断言状态。

rule.onNodeWithTag(Tag).performTouchInput { down(center) }
// recomposition + assertions happen here
rule.onNodeWithTag(Tag).performTouchInput { moveBy(Offset(0f, 100f)); up() }

后续调用若针对不同节点,会将指针位置映射到该节点的局部坐标系中(TouchInjectionScope.kt:50-55),因此位置仍是局部坐标,而非全局屏幕坐标。

  1. 信任事件批处理机制。 在一次 performTouchInput 代码块中加入队列的所有事件,都会在代码块返回时作为一个批次发送;重组不会在代码块内部穿插发生(Actions.kt:362-377)。因此所有坐标都会预先解析 — 也因此,移动的目标不会导致一个代码块中的手势失效。

模式

模式:将 performGesture 替换为 performTouchInput
// WRONG
@Suppress("DEPRECATION")
rule.onNodeWithTag(Tag).performGesture { swipeUp() }
// WRONG because: performGesture is @Deprecated("Replaced by performTouchInput") (Actions.kt:337).
// The replaceWith metadata in the @Deprecated annotation explicitly suggests performTouchInput.
// RIGHT
rule.onNodeWithTag(Tag).performTouchInput { swipeUp() }

这项迁移不可妥协。performGesture 仅为二进制兼容性而保留。

模式:硬编码屏幕坐标与节点局部坐标
// WRONG
rule.onNodeWithTag(Tag).performTouchInput {
    down(Offset(500f, 800f))
    moveTo(Offset(500f, 700f))
    up()
}
// WRONG because: (500, 800) only happens to land on the node on certain device profiles.
// On a tablet or in landscape the node sits elsewhere, and on a small phone it may not even
// be inside the visible bounds — the test becomes device-shaped.
// RIGHT
rule.onNodeWithTag(Tag).performTouchInput {
    down(center)
    moveBy(Offset(0f, -100f))
    up()
}

center、bottomCenter、topRight 等值根据节点的 visibleSize 计算得出(InjectionScope.kt:49-50,:118-185)。测试在不同屏幕尺寸下都能原样运行。

模式:来自 DraggableTest 的实际滑动

DraggableTest.kt:114-146 中的标准水平拖动模式:

@Test
fun draggable_horizontalDrag() {
    var total = 0f
    setDraggableContent { Modifier.draggable(Orientation.Horizontal) { total += it } }
    rule.onNodeWithTag(draggableBoxTag).performTouchInput {
        this.swipe(
            start = this.center,
            end = Offset(this.center.x + 100f, this.center.y),
            durationMillis = 100,
        )
    }
    val lastTotal = rule.runOnIdle {
        assertThat(total).isGreaterThan(0)
        total
    }
}

值得借鉴的要点:this.center 可明确指定接收者(嵌套代码块时很方便);durationMillis = 100 可加快测试;断言在 runOnIdle 中执行(skydoves 的热门观点 #5)。

模式:捏合缩放

来自 TransformableTest.kt:128-141:

rule.onNodeWithTag(TEST_TAG).performTouchInput {
    val leftStartX = center.x - 10
    val leftEndX = visibleSize.toSize().width * EDGE_FUZZ_FACTOR
    val rightStartX = center.x + 10
    val rightEndX = visibleSize.toSize().width * (1 - EDGE_FUZZ_FACTOR)

    pinch(
        Offset(leftStartX, center.y),
        Offset(leftEndX, center.y),
        Offset(rightStartX, center.y),
        Offset(rightEndX, center.y),
    )
}

pinch(start0, end0, start1, end1) 会在 durationMillis = 400 的时间内并行插值两个指针(TouchInjectionScope.kt:574-589)。使用 EDGE_FUZZ_FACTOR(例如 0.05f)而不是 0f,避免指针恰好落在右侧或底部边缘——right == width - 1f,参见 InjectionScope.kt:96-102。

模式:拆分手势以便在手势过程中进行断言
@Test fun longPress_thenAssert_thenRelease() {
    rule.setContent { /* … */ }

    // Hold finger down at center, do not release.
    rule.onNodeWithTag(Tag).performTouchInput { down(center) }

    // Wait for the long-press timeout to elapse via the test clock.
    rule.mainClock.advanceTimeBy(viewConfiguration.longPressTimeoutMillis + 100)
    rule.onNodeWithTag(Tag).assertHasIndicationOfLongPress()  // your assertion

    // Release in a second block; the pointer is still "down" between blocks.
    rule.onNodeWithTag(Tag).performTouchInput { up() }
}

指针状态会在代码块之间保留;重组器会在代码块之间运行。CombinedClickableTest 就是通过这种方式测试长按时触发触觉反馈的路径(例如 CombinedClickableTest.kt:446-471)。

模式:控制速度的快速滑动
rule.onNodeWithTag(Tag).performTouchInput {
    swipeWithVelocity(
        start = bottomCenter,
        end = topCenter,
        endVelocity = 4_000f,         // px/second
        durationMillis = 200,
    )
}

swipeWithVelocity 会调整路径,使最终速度与目标值相差不超过约 0.1(TouchInjectionScope.kt:617-637)。持续时间必须足够长,确保至少有 3 个输入事件(最低约为 40 ms);如果输入条件不可行,API 会抛出 IllegalArgumentException 并提供修复建议。

强制规则

  • 必须使用 performTouchInput { … }。不得使用 performGesture { … }——它已标记为 @Deprecated(Actions.kt:337-355)。此规则不可协商。
  • 必须使用节点局部坐标系表示坐标,例如 center、topLeft、bottomRight、percentOffset(...) 等。不得硬编码屏幕像素坐标——测试会受到设备尺寸影响。
  • 必须通过 rule.runOnIdle { … } 执行手势后的状态断言(skydoves 的热门观点 #5)。在测试线程读取 state 会与处理手势的重组器发生竞争。
  • 如果测试依赖持续时间(长按、双击间隔),必须在拆分的 performTouchInput 代码块之间使用 rule.mainClock.advanceTimeBy(...) 推进测试时钟。不得使用 Thread.sleep——参见 ../../synchronization/synchronizing-with-idle/SKILL.md。
  • **优先:**每次拖动都从 center 开始,并使用 moveBy 指定相对偏移量。绝对路径(moveTo(topLeft))虽然可行,但可读性较差,且在 RTL 布局下会出错。
  • **优先:**线性单向移动时使用 swipe;仅在测试快速滑动行为时使用 swipeWithVelocity;仅在指针数量大于 2 或需要非线性曲线时使用 multiTouchSwipe。

验证

  • 测试源集中不包含 performGesture:git grep -n 'performGesture' src/androidTest src/test 应无结果。
  • performTouchInput 代码块内部或之间不含 Thread.sleep(skydoves 的热门观点 #7)。
  • 测试中没有硬编码的 Offset(<screen-pixel>, <screen-pixel>)——每个位置都通过 center、top*、bottom*、centerLeft 等解析。
  • 触摸手势后的断言在 rule.runOnIdle { … } 中执行。
  • 针对正在修改的测试,./gradlew :app:connectedDebugAndroidTest(或 :app:testDebugUnitTest)运行通过。

参考资料

  • Compose 测试概览:https://developer.android.com/develop/ui/compose/testing
  • Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt — performTouchInput(Actions.kt:399)、performGesture 弃用说明(Actions.kt:337-355)。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TouchInjectionScope.kt — DSL 接口(down、moveTo、moveBy、up、cancel、click、longClick、doubleClick、swipe、swipeUp / Down / Left / Right、swipeWithVelocity、pinch、multiTouchSwipe)。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InjectionScope.kt — 几何辅助方法(center、topLeft、topCenter、bottomRight、width、height、right、bottom、percentOffset、visibleSize、advanceEventTime)。
  • compose/foundation/foundation/src/androidDeviceTest/.../DraggableTest.kt — 标准的 swipe(start, end, durationMillis)(DraggableTest.kt:114-146)。
  • compose/foundation/foundation/src/androidDeviceTest/.../TransformableTest.kt — 四点手势 pinch(...)(TransformableTest.kt:128-141)。
  • compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt — performTouchInput { longClick() },用于触觉反馈测试的分步 down(center) / up() 块。
  • compose/foundation/foundation/src/androidDeviceTest/.../ScrollableAreaTest.kt — 底层的 down(); moveBy() 流程。
  • skydoves — compose-performance-skills:https://github.com/skydoves/compose-performance-skills

评论

支持 Markdown