ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Appium TouchAction手势API实战:从坐标原理到长按拖拽多指缩放封装

Appium TouchAction手势API实战:从坐标原理到长按拖拽多指缩放封装 做UI自动化测试的朋友应该都有这种感觉Appium 的官方文档里最容易被反复翻到又经常被误用的就是高级手势API尤其是 TouchAction。凡是需要模拟手指在屏幕上按压、移动、抬起、停留的操作比如滑动验证、长按菜单、拖拽排序最终都要落到这一组手势上。我在做Appium测试时一开始只会 click 和 sendKeys第一次接到一个滑动拼图验证码的需求时直接卡了半天把那几个方法彻底研究明白之后才算是补上了手势自动化这块最短的板。这篇是系列第11篇我按项目实战的习惯来写先讲清楚为什么基础操作替代不了 TouchAction再逐方法拆解参数和执行顺序然后给几个可以直接套用的完整案例最后把我在多台真机上踩过的手势坑和工程化封装方案一起放出来。适合已经会用 Appium 做元素定位和基础点击但对手势操作还没有系统整理过的同学也适合正在把一批手工用例转成自动化回归脚本的团队。1. 为什么基础click和swipe不够用必须掌握手势API1.1 自动化中的三类“手痒操作用例”我最早写Appium脚本时遇到操作第一反应都是click、sendKeys、driver.swipe三步走。听上去够用了可真到了业务测试里会碰到三类情况基础API完全顶不住。第一类是带轨迹的滑动。最常见的就是滑动拼图验证码、手势密码、九宫格解锁。这类case不是让你把屏幕从A滑到B而是要求手指按顺序经过中间若干个点中间不能断也不能跳。driver.swipe能做到首尾两点但做不到“依次经过多个坐标再抬起”。第二类是长按与二次动作组合。比如长按列表项后弹出删除按钮或者长按拖拽排序。长按本身可以用driver.longPressKey吗不行那是键盘不是触屏。长按拖动更是要求“按下、等待、移动、抬起”四个动作按顺序执行中间任何一步断掉元素就会回到原位。第三类是多点同时操作。双指缩放地图、两指旋转图片是典型的多指手势。这类case连“单指滑动”的思路都不适用必须同时构造多条手势链交给Appium执行。很多测试工程师在这类需求前卡住不是不会写断言而是根本没有对应的调用入口。这三类需求都有一个共同点它们关注的不只是“最终落在哪里”还关注“手指在这段时间里经历了什么”。而TouchAction恰恰就是用来描述这段“经历”的。1.2 TouchAction在Appium手势体系里的定位TouchAction的定位要放在Appium的历史版本里看。早期WebDriver协议里并没有标准的手势规范Appium为了覆盖移动端特有的触控操作自己做了一套基于JSON Wire Protocol的touch接口TouchAction就是这套接口的Java/ Python封装。你可以把它理解成一个“手指动作编排器”它把一次手滑拆解成多个原子动作按顺序链式调用最后统一交给Appium Server转成具体平台的原生指令。在Appium Java客户端里完整的引入路径是这样的import io.appium.java_client.TouchAction;不过随着依赖版本不同较新的客户端中TouchAction被移动到了Touch包下import io.appium.java_client.touch.TouchAction;这里先提醒一句很多老教程直接写new TouchAction(driver).press(x, y)在7.x之后的客户端里已经编译不过了坐标参数要换成PointOption长按参数要换成LongPressOptions。这个坑我后面会单独展开。TouchAction本身只负责单指手势。如果要模拟双指缩放、双指旋转这类多点操作需要配合MultiTouchAction使用。所以严格来说“Appium高级手势API”指的是两个类TouchAction负责单指动作MultiTouchAction负责把多个单指动作合并成一组同时执行。本篇重点讲TouchAction但在第3章案例里会带上MultiTouchAction因为它们在实际项目中经常一起出现。2. TouchAction链式调用的参数细节与执行逻辑2.1 方法逐个拆解press、waitAction、moveTo、release一个完整的手势本质上就是“按下 - 等待/移动 - 抬起”的循环。TouchAction把这几个阶段封装成了对应的方法下面是我在Java项目里的标准写法import io.appium.java_client.AppiumDriver; import io.appium.java_client.touch.TouchAction; import io.appium.java_client.touch.WaitOptions; import io.appium.java_client.touch.offset.PointOption; import java.time.Duration; TouchAction action new TouchAction(driver); action.press(PointOption.point(startX, startY)) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))) .moveTo(PointOption.point(endX, endY)) .release() .perform();逐个说参数。press(PointOption point)是手指按下的动作。它表示触屏的第一个触点通常习惯把起始坐标传进去。旧版写法是press(x, y)新版必须写PointOption.point(x, y)如果不加编译器会直接提示找不到方法。Press也可以传元素PressOptions.pressOptions().withElement(ElementOption.element(element))但这种用法要求元素必须可见且可点击否则Appium会一直等到超时。moveTo(PointOption point)是手指移动动作目标坐标就是手指最终要到达的坐标。这里要解释一个很多新手会误解的地方moveTo的坐标到底是相对坐标还是绝对坐标我在不同版本的Appium客户端里实测过行为并不完全一致。在部分老版本Android驱动上moveTo是相对press起点来计算偏移的在另外一些版本里又按绝对坐标解析。因此我现在的建议是**统一传绝对坐标并且用元素定位计算出来的真实坐标不要靠手感猜偏移量。**这样即使升级驱动最多是手势速度变化不会出现方向完全跑偏。waitAction(WaitOptions waitOptions)是让手指停留在当前位置的方法。不要小看它很多“滑动不像滑动”的问题都是因为没有加waitAction。press之后如果立即moveTo整套动作可能在几十毫秒内完成系统可能把它识别成一次快速点击或者忽略掉。我的习惯是在press后加一个200到500毫秒的waitActionmoveTo之后再根据场景决定是否需要等待。release()表示手指抬起。一个手势链必须以release结束否则Appium会认为手指一直按在屏幕上后续case全都无法正常点击。还有一个高频方法longPress它的本质是press waitAction的组合import io.appium.java_client.touch.LongPressOptions; import io.appium.java_client.touch.offset.ElementOption; new TouchAction(driver) .longPress(LongPressOptions.longPressOptions() .withElement(ElementOption.element(element)) .withDuration(Duration.ofMillis(1500))) .release() .perform();注意这里的withDuration是长按的总时长如果你需要用长按来触发菜单一般600到1500毫秒足够如果太短App可能会判定为普通点击。2.2 一次perform背后发生了什么每次调用perform()Appium客户端不是在本机执行手势而是把整条动作链编码成JSON指令发给Appium Server再由Server转换成底层平台能理解的命令。以Android为例TouchAction最终会映射到UiAutomator2驱动里的触摸操作在iOS上则映射到XCUITest的事件合成。这也是为什么TouchAction在不同实机上表现会略有差异屏幕分辨率不同、系统版本不同、甚至系统输入法弹起状态不同都会影响坐标命中。还有一个容易被忽略的点**perform是阻塞调用手势执行期间脚本会等结果返回。**如果手势过程中应用界面发生了跳转返回的可能是元素超时或找不到上下文。所以手势case里最好配合显式等待不要一直沿用无脑Thread.sleep。我用过一次非常典型的踩坑经历写一个下拉刷新手势press后加了1000毫秒waitmoveTo之后没有release而是想通过另一个click去结束。执行结果是click一直超时因为Appium认为手指还没抬起后续操作根本无法落下去。记住一个release对应一次完整触摸不release就别想干别的事。2.3 长按、双击、上下左右滑动的最小可用代码有了上面的基础我们可以封装一套高频手势的最小代码。比如上下左右滑动我一般这样写public void swipe(String direction) { Dimension size driver.manage().window().getSize(); int startX size.width / 2; int startY size.height / 2; int endX startX; int endY startY; int swipeDistance size.height / 4; switch (direction) { case up: startY (int) (size.height * 0.7); endY startY - swipeDistance; break; case down: startY (int) (size.height * 0.3); endY startY swipeDistance; break; case left: startX (int) (size.width * 0.8); endX startX - swipeDistance; break; case right: startX (int) (size.width * 0.2); endX startX swipeDistance; break; default: throw new IllegalArgumentException(不支持的滑动方向: direction); } new TouchAction(driver) .press(PointOption.point(startX, startY)) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))) .moveTo(PointOption.point(endX, endY)) .release() .perform(); }长按的代码上面已经给了。双击相对特殊因为它需要按两次而且按下的间隔要控制在系统能够识别为双击的范围内int x 200, y 400; new TouchAction(driver) .press(PointOption.point(x, y)) .release() .waitAction(WaitOptions.waitOptions(Duration.ofMillis(80))) .press(PointOption.point(x, y)) .release() .perform();这里的80毫秒是我在Android真机上调出来的经验值太快可能会被识别成一次长按太慢又会变成两次单击。不同设备会有些误差建议在项目里单独抽一个配置参数方便大家按机型微调。3. 真实项目案例从手动滑动到多指缩放的完整实现3.1 案例一手势密码绘制曾做过一个金融App的登录流程里面有九宫格手势密码。九宫格一般无法直接定位到每一个圆点因为它是画在同一个自定义View上的用UIAutomator抓取只能看到整个面板元素。这种情况下我采取的办法是先拿到整个九宫格面板的location和size再把九个点等分算出来。假设面板是正方形宽度为panelWidth左上角坐标是(panelStartX, panelStartY)九个点之间的间距就是panelWidth / 6。第一个点的坐标可以这样算int startX panelStartX panelWidth / 6; int startY panelStartY panelWidth / 6;第二个点就是startX panelWidth / 3以此类推。如果要做“从左上角滑到正中间再滑到右下角”的路径代码就是int gap panelWidth / 6; int[] xArr { panelStartX gap, panelStartX gap * 3, panelStartX gap * 5 }; int[] yArr { panelStartY gap, panelStartY gap * 3, panelStartY gap * 5 }; TouchAction action new TouchAction(driver); action.press(PointOption.point(xArr[0], yArr[0])) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))); int[] indices {0, 4, 8}; // 依次经过左上、正中、右下三个点 for (int i 1; i indices.length; i) { int index indices[i]; action.moveTo(PointOption.point(xArr[index % 3], yArr[index / 3])) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(200))); } action.release().perform();这里有个非常关键的经验**中间经过的每个点都必须加waitAction。**九宫格手势密码对滑动速度很敏感滑快了系统会认为你是“快速划过”导致最后一个点没有被激活解锁失败。3.2 案例二长按拖拽排序另一个高频场景是列表拖拽排序。比如任务列表里可以长按一个任务拖到指定位置。自动化要做的事情是长按源元素移动到目标元素的位置后释放。很多人在这一步栽过跟头目标元素可能在列表最底部直接长按拖过去中间要跨越很长距离。如果一次性moveToApp的拖拽逻辑会认为速度太快触发回弹。我的做法是分段移动new TouchAction(driver) .longPress(LongPressOptions.longPressOptions() .withElement(ElementOption.element(sourceElement)) .withDuration(Duration.ofMillis(1200))) .moveTo(PointOption.point(midX, midY)) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))) .moveTo(PointOption.point(targetX, targetY)) .release() .perform();中间的midX, midY建议取起点和目标的中间点。这样既模拟了真实拖拽的轨迹也降低了系统误判速度的风险。还有一个细节如果源元素和目标元素在同一个列表里最好先把mobileElement的坐标通过getLocation()和getSize()取出来不要直接用固定的屏幕坐标。因为列表只要滚动过一个像素元素的真实坐标就会变化。3.3 案例三双指缩放MultiTouchAction双指缩放是TouchAction体系里最容易让新手觉得高级的内容其实原理并不复杂。它的核心是构造两个TouchAction对象分别代表两根手指然后通过MultiTouchAction同时执行。以“放大”为例两根手指初始位置在屏幕中心附近然后分别向对角线方向移动TouchAction finger1 new TouchAction(driver) .press(PointOption.point(centerX - 50, centerY)) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))) .moveTo(PointOption.point(centerX - 200, centerY)) .release(); TouchAction finger2 new TouchAction(driver) .press(PointOption.point(centerX 50, centerY)) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))) .moveTo(PointOption.point(centerX 200, centerY)) .release(); MultiTouchAction multiTouch new MultiTouchAction(driver); multiTouch.add(finger1).add(finger2).perform();两根手指向相反方向运动就是放大如果从两侧往中间运动就是缩小。这个逻辑很好记。不过要提醒一下MultiTouchAction在iOS上的稳定性不如Android。XCUITest对多点触控的支持相对保守如果App用的是系统原生的手势比如系统地图的缩放我更建议直接用W3C Actions去模拟或者干脆用坐标搭配pinch类型的原生接口。在Android上这套代码我跑了很多轮稳定性还比较可观。3.4 移动端与iOS端的手势差异读了上面的例子你会发现很多代码是同时在Android和iOS上跑的。但实际执行效果会有差异。Android端用UIAutomator2驱动时TouchAction对WebView和原生控件的支持都还不错坐标解析也比较直白。iOS端用XCUITest驱动时有几个细节需要注意第一iOS对moveTo相对坐标的处理历史上和Android不同如果你从老项目迁移代码一定要在真机上验证方向第二长按时间参数在iOS上超过一定值可能会触发系统级菜单影响业务判断第三iOS的WebView中TouchAction的某些事件会失效这时可以退一步用driver.executeScript调用原生手势或者走W3C Actions。这些差异不是靠看文档能完全避开的最好的办法是在项目初始化阶段就定好一套“真机手势基线用例”每个操作系统版本都跑一遍把允许的坐标偏移和时间范围记录下来。4. 我踩过的那些TouchAction的坑附排查链路4.1 症状swipe偶尔失效根因在坐标基准有一个case印象特别深同一个滑动删除脚本在测试机上稳定通过换到开发手里的真机上就开始乱滑有时候甚至点到了别的App。排查下来发现脚本里直接写了press(540, 1200)这样的固定坐标。不同分辨率下540和1200根本不在同一个屏幕语义位置上。排查链路是这样的先在Appium日志里找到Recorder输出的实际touch事件坐标再用adb shell wm size看设备分辨率。一对比就发现我的固定坐标是按照1080x1920写的开发机的分辨率是1440x2560同样的坐标位置整体偏下了。解决办法也很简单把所有手势坐标全部改成比例坐标int startX size.width / 2; int startY (int) (size.height * 0.8); int endY (int) (size.height * 0.2);从此这条规则被我写进了团队的代码评审清单**凡是用到TouchAction的地方一律禁止出现裸数字坐标。**除非你能保证所有测试设备屏幕尺寸完全一致否则这就是定时炸弹。4.2 症状元素滑动报element not interacting问题出在可见性判断还有一次是做WebView里的长按复制菜单。我直接用press(ElementOption.element(element))去按一个文本节点结果执行时报element not interacting。这个报错很容易被理解成“定位不到元素”但其实元素在DOM里好好的只是它不在可交互状态。我当时先打开Appium Inspector点击元素高亮能正常定位再手动点击也能弹出菜单。那问题就锁定在“TouchAction对元素可见性要求更高”。WebView里很多文本元素在原生层面不可见必须按它外包的容器节点或者直接用坐标。最后我把定位策略改成了先找到文本节点的父容器再对容器坐标做偏移长按文本中心点。这里给出的经验是**TouchAction的press如果传入元素不只是定位那么简单它还会要求元素在底层视图树上是可点击的。**遇到element not interacting不要急着改定位器先在Inspector里确认元素是否真的可交互再决定要不要坐标准备。4.3 Appium版本升级后TouchAction还能不能用与W3C Actions的兼容对比这是很多老项目都要面对的现实问题。TouchAction这么好用为什么现在很多新代码开始用W3C Actions因为TouchAction依赖的底层接口不是W3C标准Appium官方在部分新版本中已经把它标记为废弃Deprecated并推荐使用标准的Actions接口。我自己维护的一批老脚本升级到Appium Java客户端8.x之后TouchAction的警告越来越多。而且同一个TouchAction手势在新驱动上的执行时间比旧版长尤其在iOS上偶尔会出现touch事件被系统吞掉的情况。为了保险起见我现在的策略是**老项目不强行重构但新写的用例全部用W3C Actions。**核心代码差异是构造动作序列import org.openqa.selenium.interactions.PointerInput; import org.openqa.selenium.interactions.Sequence; import org.openqa.selenium.interactions.PointerInput.Kind; import org.openqa.selenium.interactions.PointerInput.MouseButton; PointerInput finger new PointerInput(Kind.TOUCH, finger); Sequence sequence new Sequence(finger, 1) .addAction(finger.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), startX, startY)) .addAction(finger.createPointerDown(MouseButton.LEFT.asArg())) .addAction(finger.createPointerMove(Duration.ofMillis(600), PointerInput.Origin.viewport(), endX, endY)) .addAction(finger.createPointerUp(MouseButton.LEFT.asArg())); driver.perform(Collections.singletonList(sequence));从可维护性角度看W3C Actions是未来的方向TouchAction则更像是“陪伴我们多年的老朋友”。如果你是刚入门的新项目建议直接学W3C Actions如果你和我一样要维护历史资产那TouchAction的源码逻辑仍值得吃透。4.4 一份可复用的触屏坐标计算方式为了减少不同原因导致的坐标错乱我后来写了一个小工具类专门计算常用手势点位。核心思路是所有点位都基于屏幕宽度、高度和元素边界做百分比换算。public class GesturePoint { public static Point centerOf(WebElement element) { int x element.getLocation().getX() element.getSize().getWidth() / 2; int y element.getLocation().getY() element.getSize().getHeight() / 2; return new Point(x, y); } public static Point offset(Point point, int dx, int dy) { return new Point(point.x dx, point.y dy); } public static Point screenRatio(AppiumDriver driver, double xRatio, double yRatio) { Dimension size driver.manage().window().getSize(); return new Point((int) (size.width * xRatio), (int) (size.height * yRatio)); } }这个类定义了我所有手势动作的坐标来源避免脚本里出现一堆魔法数字。只要团队约定好“手势坐标只从工具类获取”踩坐标坑的概率就大幅下降。5. 落地工程化把手势操作封装成团队公共库5.1 抽取手势工具类的方法签名设计真正落地的时候我建议把TouchAction封装成一个独立的GestureHelper类不要让用例直接new TouchAction。原因很直白手势API的参数过于底层如果都散落在用例里后续调整一个默认等待时间你需要在几十个文件里改。我目前习惯的工具类方法签名长这样public class GestureHelper { private AppiumDriver driver; public GestureHelper(AppiumDriver driver) { this.driver driver; } public void swipe(String direction) { ... } public void swipe(Point start, Point end, long durationMs) { ... } public void scrollUntilVisible(By locator, int maxSwipes) { ... } public void longPress(WebElement element, long durationMs) { ... } public void drag(WebElement source, WebElement target, int steps) { ... } public void zoom(Point center, int distance) { ... } public void drawPattern(ListInteger pointSequence) { ... } }设计这些方法时有一个共同原则**把业务参数和屏幕物理坐标隔离开。**比如drag(WebElement source, WebElement target, int steps)只关心谁是源、谁是目标、分几步拖至于每一步具体移动到哪个x,y由工具类内部计算。这样用例阅读起来非常像业务操作一看就懂。5.2 滑动查找、滑动刷新等高频手势的封装所有手势封装里使用频率最高的就是“滑动查找”和“下拉刷新”。滑动查找通常用于长列表。目标元素可能在屏幕第二屏、第三屏不可能每次都用固定坐标滑死。封装逻辑是给定元素定位器最多滑动N次每次滑完判断元素是否存在存在就返回否则继续滑。参数里的maxSwipes一定要做上限目的是防止异常情况下脚本无限滑下去把case拖到超时。public boolean scrollUntilVisible(By locator, int maxSwipes) { for (int i 0; i maxSwipes; i) { if (!driver.findElements(locator).isEmpty()) { return true; } swipe(up); } return !driver.findElements(locator).isEmpty(); }下拉刷新要更谨慎。很多App在下拉刷新的过程中会展示一个loading动画如果手势还没结束loading就出现可能导致moveTo阶段被界面刷新打断。我的处理方式是下拉手势分两段执行第一段从屏幕70%高度滑到30%高度第二段再从30%高度滑到50%高度模拟“拉到位置后松手回弹”的效果new TouchAction(driver) .press(PointOption.point(startX, startY)) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(200))) .moveTo(PointOption.point(startX, endY)) .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))) .moveTo(PointOption.point(startX, resetY)) .release() .perform();这类动作封装完以后用例里不再需要关心具体的屏幕坐标和等待时长团队新成员也可以直接上手。5.3 关于手势自动化我想留给你的两个真实建议第一真机上的手势稳定性永远高于模拟器。模拟器的手势合成逻辑和真机不同尤其是长按拖拽、双指缩放这类复杂动作在模拟器上跑通了不代表真机没问题。我建议手势类用例至少要覆盖一台主流Android真机和一台iOS真机并且把它们作为每次Appium驱动升级后的回归重点。第二TouchAction的坐标问题很多时候不是定位错误而是App版本迭代导致控件位置变化。所以凡是和业务控件强相关的手势我都建议在脚本里加上“手势执行前截图”执行后再截图对比两次结果。这样一旦case挂掉你打开截图就能立刻看出是坐标偏移还是业务逻辑变化不用把时间都花在翻日志上。这些经验说白了都是用一次次失败换来的。做UI自动化测试最怕的不是写不出代码而是写出来的代码换个设备就“薛定谔地通过”。把手势API吃透、把封装做干净至少能把这种不确定性降一大半。
返回列表