关于 injecting-touch-gestures
为编写 Android Compose 触摸手势测试提供指导,适用于需要模拟拖动、多点触控、受控速度滑动等操作的开发者。它强调使用 `performTouchInput` 和节点本地坐标,替代已弃用的 `performGesture`。
主要功能
- 01使用 performTouchInput 注入触摸事件
- 02通过节点本地坐标定位
- 03模拟滑动、长按和双击
- 04执行捏合及多点触控
- 05拆分手势并检查中间状态
适用场景
- 01将测试中的 performGesture 迁移到新接口
- 02测试可拖动组件和滑动操作
- 03验证捏合缩放或受控速度滑动
- 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。
工作流
- 获取接收节点。
performTouchInput是SemanticsNodeInteraction的扩展。先找到该节点。
rule.onNodeWithTag(DragSurfaceTag).performTouchInput { /* DSL */ }
-
使用节点局部坐标。 在代码块内,
(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)进行相对定位。不得硬编码屏幕坐标。 -
选择合适的抽象级别。 高级辅助方法可覆盖大多数情况:
| 辅助方法 | 默认值 | 功能 |
|---|---|---|
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) |
-
部分手势使用低级事件。 以下方法均位于
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?— 查询指针的最后已知位置。
-
如果需要在手势过程中处理重组,请将手势拆分到多个代码块中。 同一节点上的多个
performTouchInput调用会共享指针状态 — 保持“按下”状态的指针会继续保持按下。利用这一点,可在手势经过的某一帧断言状态。
rule.onNodeWithTag(Tag).performTouchInput { down(center) }
// recomposition + assertions happen here
rule.onNodeWithTag(Tag).performTouchInput { moveBy(Offset(0f, 100f)); up() }
后续调用若针对不同节点,会将指针位置映射到该节点的局部坐标系中(TouchInjectionScope.kt:50-55),因此位置仍是局部坐标,而非全局屏幕坐标。
- 信任事件批处理机制。 在一次
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