做客户端自动化测试的人估计都遇到过这种尴尬局面Web端脚本跑得飞快Selenium 那边已经填完表单、点完按钮可一到真机验证就得切回手动模式——拿起手机划两下点几个键再回电脑补截图。时间一长自动化就变成了“半自动”。后来我在某个跨端项目里试着把 QtScrcpy、Selenium、ADB 三条线彻底打通用一套 Python 脚本同时驱动浏览器和安卓真机QtScrcpy 负责把手机画面实时投到电脑上辅助调试与取证整套流程才算真正跑顺。这篇就把这套方案的思路、关键参数、脚本骨架和踩坑记录完整写出来适合正在做 App 验收、真机回归、Web 与安卓混合流程测试的团队参考。1. 方案全景为什么把 QtScrcpy、Selenium、ADB 绑在一起用1.1 三个工具各管哪一段先用最直白的话定位这三个角色它们之间是互补关系不是替代关系。Selenium浏览器自动化框架管的是电脑端的 Web 页面。点击、填表、断言页面元素、拿接口数据都在这一层。ADB安卓调试桥管的是手机本体。安装卸载应用、模拟点击滑动、拉起指定页面、清缓存、截屏录屏都是它说了算。QtScrcpy基于 ADB 协议的开源投屏控制工具把安卓手机画面实时映射到电脑桌面鼠标键盘可以直接反向操作手机。我见过不少人把 QtScrcpy 当成普通投屏软件用这是浪费。它底层走的是 ADB 的 screenrecord、screencap 机制和输入事件注入本质上就是 ADB 操作的图形化客户端。在自动化测试里它的核心价值不是抢 Selenium 或 ADB 的活而是充当可视化监控层脚本跑起来时你盯着 QtScrcpy 窗口就能确认手机上每一步到底点没点对出问题当场肉眼定位排查效率高得多。1.2 这套组合解决的三个痛点先说说我在真实项目里反复遇到的三类问题你就明白为什么非要这么组合。痛点一是真机验证靠肉眼。Web 端自动化断言很成熟元素出现没出现、文本对不对脚本一查便知。但 App 端登录成功没有、有没有系统弹窗拦截、页面渲染完没完脚本看不见。有了 QtScrcpy 实时画面人可以在关键节点介入确认必要时候还能手动补两下操作再让脚本继续。痛点二是操作链断裂。很多业务是“电脑上发起、手机上确认”的跨端流程典型如扫码登录、短信验证、订单确认。用户先对着电脑屏幕扫码弹出来的却是在手机上点确认。只靠 Selenium 只能处理电脑侧只靠 ADB 又拿不到网页状态两头各跑各的覆盖不了真实业务。把 Selenium 和 ADB 编进同一个测试流程才有办法模拟完整用户链路。痛点三是设备初始化重复劳动。每个测试用例跑之前都要清数据、装包、解锁屏幕、拉起指定页面。这些动作用 ADB 写成批处理脚本几秒钟完成且稳定复现手工操作至少几十秒还容易漏步骤。配上 QtScrcpy 的窗口看着执行过程比黑盒执行踏实。2. 环境准备先把设备和工具链打通2.1 软件清单与版本匹配建议在动手写脚本前先确认软件版本很多怪问题都出在版本不匹配上。我整理了一张常用的版本参考表照这个选型能少踩一半坑。软件建议版本说明Python3.10 及以上3.7 也行但 f-string 调试体验和第三方库兼容性差一些Selenium4.x 系列4.0 之后 API 更干净自带不少等待工具QtScrcpy2.x 系列2.1 以后对高刷屏支持更稳Android SDK Platform-Tools33.0.0 及以上版本太老容易漏命令参数ChromeDriver与 Chrome 版本严格对应用 Selenium 管浏览器时最容易翻车的地方Python 环境建议用虚拟环境管理避免多个项目互相污染。Selenium 4 的库安装很简单一条 pip 命令搞定但 ChromeDriver 要特别注意它跟 Chrome 浏览器版本必须严格匹配主版本号不一致浏览器直接拒绝启动。我的习惯是先在终端跑chromedriver --version 确认可执行再写 WebDriver 初始化代码。2.2 ADB 连接与 QtScrcpy 稳定显示的关键配置设备连接这块我的建议是脚本调试阶段一律用 USB 线调试稳定后再考虑无线方案。USB 连接出问题九成出在三个方面线不行、电脑 USB 口供电不足、设备没解锁信任。连上之后先用adb devices 确认设备状态。正常情况下状态列显示 device如果是 unauthorized 说明手机上的 USB 调试授权弹窗没点确认如果是 offline 就拔线重插一次或者执行 adb kill-server 再 adb start-server 重启 ADB 服务。解锁屏幕这步经常被忽略。手机锁屏状态下很多命令执行了也没效果。保险做法是设备连上后先跑adb shell wm dismiss-keyguard adb shell input keyevent 82这两条能让大部分手机进入可用状态keyevent 82 对应菜单键能把锁屏界面往上一推。QtScrcpy 启动参数别用默认值硬跑。我常用的启动命令是scrcpy --max-fps 30 --bit-rate 4M --always-on-top限定帧率到 30 帧、码率到 4M画面足够看清操作反馈也不会把 CPU 吃满导致电脑卡顿--always-on-top 让窗口随时置顶调试时盯着脚本输出和手机画面都方便。多台设备同时接电脑时必须带设备序列号启动否则工具不知道该投哪一台scrcpy --serial 设备序列号 --max-fps 303. 核心脚本设计Selenium 与 ADB 命令的编排方法3.1 整体流程与数据桥接这套方案在代码层面的核心是把 Selenium 和 ADB 当成两个可调用的服务而不是两个独立程序。脚本主流程是这样的Selenium 层完成网页操作比如打开注册页、填邮箱、提交表单。网页操作产生结果二维码图片、验证码文本、订单号把结果保存成文件或变量。DeviceController 层用 ADB 在真机上执行对应动作比如拉起 App、点击确认按钮、截图保存。QtScrcpy 作为旁观窗口脚本运行时人工确认每一步动作是否符合预期。最后统一收报告把 Web 截图、真机截图、日志归档到一起。关键在于第 2 步的数据桥接。我在 Python 里用一个 DeviceController 类把设备操作全部收敛起来脚本主体只跟这个类打交道不直接拼 ADB 命令字符串维护起来清爽很多。3.2 用 Python 封装高频 ADB 指令集我封装的 DeviceController 类核心方法就五个tap、swipe、text、screencap、start_app。代码骨架如下import subprocess import time import os class DeviceController: def __init__(self, serialNone): self.serial serial self.base_cmd adb if serial: self.base_cmd fadb -s {serial} def execute(self, args, timeout10): cmd f{self.base_cmd} {args} result subprocess.run(cmd, shellTrue, capture_outputTrue, textTrue, timeouttimeout) if result.returncode ! 0: raise RuntimeError(fADB 命令失败: {cmd}\n{result.stderr}) return result.stdout.strip() def tap(self, x, y): self.execute(fshell input tap {int(x)} {int(y)}) time.sleep(1) def swipe(self, x1, y1, x2, y2, duration300): self.execute(fshell input swipe {x1} {y1} {x2} {y2} {duration}) time.sleep(1) def screencap(self, save_path): remote_path /sdcard/tmp_screen.png self.execute(fshell screencap -p {remote_path}) self.execute(fpull {remote_path} {save_path}) os.remove(remote_path) if os.path.exists(remote_path) else None def start_app(self, package_name, activity_nameNone): if activity_name: self.execute(fshell am start -n {package_name}/{activity_name}) else: self.execute(fshell monkey -p {package_name} -c android.intent.category.LAUNCHER 1) time.sleep(3)注意几个细节。第一每条 ADB 命令都必须设超时执行卡住时不至于让整个用例挂死。第二tap 和 swipe 结束之后固定 sleep 一下让界面动画有时间跑完不然下一步操作容易打在旧界面上。第三screencap 先截到手机 /sdcard 再 pull 回电脑直接 screencap 到电脑路径在部分机型上不生效。3.3 跨端参数传递的关键点数据怎么从 Selenium 手上传给 ADB 手这步是整套方案最容易写乱的地方。我的做法分成三类页面文本类Selenium 通过 text 属性取回来直接做变量传给 DeviceController 的 text 方法用来在 App 端输入同样内容。但要注意 adb shell input text 对中文支持很差后面常见问题部分专门讲。图片文件类网页产生的二维码、验证码用 Selenium 截图保存成 PNG再把路径传给 ADB 去推送或比对。坐标位置类Web 端拿不到手机端元素坐标手机端坐标必须靠 uiautomator dump 拿。执行下面这行拿到当前界面的控件树adb shell uiautomator dump /sdcard/ui.xml adb pull /sdcard/ui.xml ./ui.xml解析这个 XML 找到目标控件的 bounds 属性比如“激活通知”按钮的 bounds 是 [200,800][400,880]中心点就是 ((200400)/2, (800880)/2) (300, 840)拿着这个坐标再去 tap。这套方法比写死坐标通用得多因为它在每次执行时动态读取当前界面布局。4. 实操演示一个完整的自动注册测试用例4.1 测试场景设定与断言策略为了把方案讲透我设定一个常见场景某产品同时有 Web 端注册入口和 App 端激活入口用户注册后需要在手机 App 上点击“激活通知”才算完成整个流程。自动化要做的就是模拟这条完整链路。断言分两层。Web 层的断言用 Selenium 原生能力注册成功后的跳转 URL、欢迎语文本、隐藏的错误提示。真机层的断言不能只看截图——静态截图没法自动判断对错我用的办法是再次执行uiautomator dump检查激活通知是否变成了“已激活”或者目标页面是否出现了指定文本节点。这个断言比截图可靠也好归档。4.2 脚本分步实现与注释解析完整流程大概长这样from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from xml.etree import ElementTree import time def get_center_from_xml(xml_path, target_text): tree ElementTree.parse(xml_path) for node in tree.iter(node): if node.get(text) target_text: bounds node.get(bounds) # bounds 形如 [200,800][400,880] import re nums list(map(int, re.findall(r\d, bounds))) return ((nums[0] nums[2]) // 2, (nums[1] nums[3]) // 2) return None # 初始化 Selenium driver webdriver.Chrome() wait WebDriverWait(driver, 15) device DeviceController() def test_register_flow(): try: # 第一步Selenium 完成 Web 注册 driver.get(http://example.com/demo-register) wait.until(EC.visibility_of_element_located((By.ID, email))).send_keys(testexample.com) driver.find_element(By.ID, password).send_keys(Abc123456) driver.find_element(By.ID, submit_btn).click() # 等待跳转到成功页 wait.until(EC.url_contains(register_success)) success_text driver.find_element(By.CLASS_NAME, welcome_msg).text assert 注册成功 in success_text # 第二步ADB 拉起 App device.execute(shell am force-stop com.example.app) device.start_app(com.example.app, com.example.app.MainActivity) # 第三步读取界面控件树找到激活按钮 device.execute(shell uiautomator dump /sdcard/ui.xml) device.execute(pull /sdcard/ui.xml ./ui.xml) click_point get_center_from_xml(./ui.xml, 激活通知) assert click_point is not None, 激活按钮未找到 # 第四步点击激活按钮 device.tap(click_point[0], click_point[1]) # 第五步真机断言 time.sleep(2) device.execute(shell uiautomator dump /sdcard/ui.xml) device.execute(pull /sdcard/ui.xml ./ui.xml) final_point get_center_from_xml(./ui.xml, 已激活) assert final_point is not None, 激活状态未更新 # 第六步真机截图取证 device.screencap(f./reports/register_activated.png) finally: driver.quit()这个脚本看着不长但该有的环节都有了。核心思路上Web 和真机不串行等待对方空转而是在关键节点主动拉取状态确保数据同步。第五步断言用“已激活”节点是否存在做判断比比对像素靠谱得多也避免了按钮文案变化导致截图比对误判。4.3 失败重试与截图取证自动化跑起来之后关键是失败时能留下足够信息。我的策略很简单每个关键步骤包一层 try失败后立即执行设备截屏和当前页面 HTML 保存文件名带上用例名和时间戳。def safe_step(step_name, func): try: return func() except Exception: ts time.strftime(%Y%m%d_%H%M%S) driver.save_screenshot(f./reports/{step_name}_{ts}_web.png) device.screencap(f./reports/{step_name}_{ts}_device.png) with open(f./reports/{step_name}_{ts}.html, w, encodingutf-8) as f: f.write(driver.page_source) raise真机侧有时候点击没生效不是脚本错了是 App 启动动画还没结束。这种偶发问题我习惯加简单重试拿到坐标点之后tap 一次等两秒再 dump 一次界面如果目标状态没出现再 tap 一次最多三次。常规的时间等待是“等等看”重试机制是“没等到就再看一次”后者在真机上稳定得多。5. 常见问题与排查实录5.1 设备频繁断连、adb devices 时有时无这是真机测试最多见的毛病。优先排查线材很多便宜的 USB 线只支持充电数据线识别不出来或者跑着跑着就掉线。其次是供电电脑前面的 USB 口供电不稳换到主机背面或者用带供电的 HUB 会好很多。还有一个容易被忽略的是手机锁屏休眠执行长时间用例时屏幕一灭部分机型会断开 ADB 连接。解决方式是开发者选项里把“充电时屏幕不休眠”开开配上 adb shell svc power stayon true 临时保持唤醒。碰到 adb devices 空列表时别急着换线先执行 adb kill-server adb start-server 重开服务很多临时故障都能恢复。如果还是不行拔掉手机重新授权一次。5.2 Selenium 与 ADB 时序不同步Web 页面跳转慢、App 启动慢、动画没播完脚本照固定 sleep 时间执行很容易出现页面还没就绪就点了下一个按钮。这个问题的根子是“时间等待”写死了解决办法只有一条把 sleep 换成轮询等待。Web 端用 WebDriverWait这是 Selenium 自带的标准做法。真机端就自己写轮询函数每 0.5 秒 dump 一次界面检查目标文本或控件出现才继续最多等 15 秒超时。这样不管机器多卡脚本总能在正确时机执行下一步。5.3 点击坐标失效、界面位置变化不同分辨率手机、不同屏幕尺寸同一按钮的坐标完全不一样。写死坐标的最多在测试机上能跑换台机型就崩。两个成熟解法一是通过 uiautomator dump 的 bounds 动态计算目标中心点这个方法前面代码里已经实现了二是拿到屏幕尺寸按比例换算adb shell wm size比如测试机分辨率是 1080x2400按钮在 540x1200目标机分辨率 1440x3200按钮坐标大约就是 720x1600。这个转换系数在分辨率比例一致时够用但布局差异大的 App 还是得靠控件树定位。能用控件树定位就不建议用比例法代码更清晰、鲁棒性也高。5.4 高频命令与常用参数速查表操作命令或参数备注查看设备adb devices状态为 device 才可用点亮屏幕adb shell input keyevent 224224 是 WAKEUP解锁adb shell wm dismiss-keyguard有些机型需要先点亮拉起应用adb shell monkey -p 包名 -c android.intent.category.LAUNCHER 1不会指定具体 Activity 时首选输入英文/数字adb shell input text abc123中文输入不稳定模拟点击adb shell input tap x y坐标单位像素模拟滑动adb shell input swipe x1 y1 x2 y2 300最后是时长毫秒截屏adb shell screencap -p /sdcard/s.png配合 adb pull 拉回本地录屏adb shell screenrecord --time-limit 10 /sdcard/v.mp4默认输出 mp4查看当前界面控件树adb shell uiautomator dump /sdcard/ui.xml再 pull 回电脑解析清应用数据adb shell pm clear 包名测试前必做强制停止 Appadb shell am force-stop 包名配合清数据QtScrcpy 三个顺手快捷键也记录一下全屏切换是 CtrlF旋转画面是 CtrlR直接剪贴板复制到设备是 CtrlV调试时效率能提不少。6. 实操心得与后续扩展6.1 我在实际项目中踩过的坑这套方案我跑了两个迭代版本积累了一些常规文档里看不到的经验逐个分享。第一adb shell input text 对中文输入的支持非常差直接输入中文经常吞字符。我后来用剪贴板方式绕开电脑端先把中文文本写入本地剪贴板再执行 adb shell ime enable 切到 ADBKeyboard 输入法最后通过广播把剪贴板内容送进焦点输入框。没有 ADBKeyboard 环境时退一步用英文标识或编号代替中文输入测试数据设计上就要规避。第二QtScrcpy 画面与设备实际显示存在肉眼难以察觉的延迟。我踩过一次脚本在手机上点了“下一步”QtScrcpy 画面还停在上一页我以为是点击没生效连续手动点了几次造成重复提交。从那以后我明确一条规矩脚本判断永远以 uiautomator dump 的控件树为准QuScrcpy 只做人工监控不做断言依据。第三Selenium 和 ADB 偶尔会打架。有次脚本跑着跑着报 adb 端口被占用排查半天发现是 WebDriver 服务抢占了一个端口号ADB 的 5037 也凑热闹。解决方式是先把固定端口统一规划WebDriver 用 9515 或自定义ADB 保持 5037不用默认随机端口冲突概率大减。第四用例收尾环境清理。注册类用例如果不在 teardown 里清数据第二次跑就会遇到账号已注册之类的冲突。我固定在每个用例结束前执行adb shell pm clear 包名再删除 Web 端测试账号记录环境回到干净状态重复执行才稳定。6.2 从脚本到自动化平台如果项目已经在跑 CI这套脚本可以直接升级成自动化平台的一部分。Python 侧的 pytest 管理用例Allure 生成测试报告配合 Jenkins 或类似工具定时跑回归报告里把 Web 截图和真机截图挂在一起问题归属一目了然。设备管理也可以做一层用一个 JSON 文件记录每个测试机的序列号、型号、分辨率多机并行时按用例标签分配设备资源利用率高不少。后续还能扩展的方向一是 OCR 识别真机截图直接从像素里捡文案比 uiautomator dump 拿到更多信息二是把 QtScrcpy 的录屏能力用起来跑完的用例自动生成一段真机操作视频作为 bug 复现证据比静态截图有力得多。这两块我团队已经在做跑通后再单独写一篇。最后再分享一个小心得这套方案刚开始搭的时候别急着把所有用例都自动化。把一条最核心的跨端流程跑通比如注册激活让团队看到脚本自己点完网页又点手机、最后自动归档截图的完整过程后面推广阻力会小很多。工具组合是固定的真正值钱的是把流程编排顺的手感这东西只能靠一次一次跑用例练出来。