1. 单机串行跑Appium的瓶颈到底卡在哪
如果你已经用pytest+Appium写过一阵子UI自动化,大概率经历过这个场景:本地一条用例跑完,切到下一台设备,再跑一遍,整个回归套件跑完要四十多分钟。设备越多,等待越久,因为默认情况下pytest是单进程串行执行的,一台设备跑完才轮到下一台。这个模式在用例量少的时候还能忍,一旦用例上百条、设备上到三五台,时间成本就变得非常刺眼。
我在实际项目里做过一次统计:一套包含120条用例的Appium回归套件,单设备串行跑完平均耗时38分钟。如果手上有4台真机,理论上并行跑可以把时间压到10分钟左右,但直接开4个终端手动跑,又会遇到端口冲突、Appium Server互相抢占、日志混在一起分不清是哪台设备的问题。这就是多设备并发要解决的核心痛点——不是"能不能跑",而是"怎么让多台设备同时跑且互不干扰"。
这里要先厘清一个概念:多设备并发不等于多线程。很多人一上来就说"用多线程跑Appium",但真正落地时有两条路可以走。一条是用Python的threading或concurrent.futures在单个进程内开多个线程,每个线程绑定一台设备和一个Appium Server端口;另一条是用pytest-xdist做进程级并行,每个worker进程负责一台设备。两者各有适用场景,后面会详细对比。
这篇文章面向的是已经能跑通单设备Appium+pytest、想进一步做多设备并发的同学。如果你还没搭好基础环境,建议先把单设备的用例跑通再来看并发,否则排查问题时变量太多,会很痛苦。接下来我会从架构设计、端口分配、设备管理、用例隔离、踩坑排查几个维度,把多设备并发这件事拆开讲透。
2. 多设备并发的两种架构路线:线程池 vs 进程池
2.1 线程池方案:轻量但要注意GIL和资源竞争
线程池方案的核心思路是在一个Python进程里启动N个线程,每个线程独立创建自己的webdriver.Remote连接,指向不同端口的Appium Server。代码结构大概是这样:
import threading from appium import webdriver devices = [ {"udid": "device1_udid", "port": 4723, "systemPort": 8200}, {"udid": "device2_udid", "port": 4725, "systemPort": 8201}, {"udid": "device3_udid", "port": 4727, "systemPort": 8202}, ] def run_device(device): caps = { "platformName": "Android", "udid": device["udid"], "systemPort": device["systemPort"], "appPackage": "com.example.app", "appActivity": ".MainActivity", "noReset": True, } driver = webdriver.Remote(f"http://127.0.0.1:{device['port']}/wd/hub", caps) try: # 执行测试逻辑 pass finally: driver.quit() threads = [threading.Thread(target=run_device, args=(d,)) for d in devices] for t in threads: t.start() for t in threads: t.join()这个方案的好处是启动快、内存占用小、调试直观。但问题也很明显:Python有GIL,虽然Appium的通信是IO密集型(网络请求等待占大头),GIL在IO等待时会释放,所以线程池跑Appium实际上是可行的。但如果你在用例里做了大量CPU计算(比如图像比对、复杂断言),GIL就会成为瓶颈。
另一个坑是共享资源竞争。比如多个线程同时写同一个日志文件、同时操作同一个全局变量、同时读写同一个测试数据文件,都会出问题。我踩过一次坑:三个线程同时往一个Excel结果文件里写数据,最后文件内容错乱,行都对不上。后来改成每个线程写独立的临时文件,最后合并,才解决。
2.2 进程池方案:pytest-xdist的隔离优势
pytest-xdist是pytest生态里做并行测试的标准方案,它通过-n参数指定worker进程数,每个worker是一个独立的Python进程,天然隔离内存空间和全局状态。
pytest -n 3 --dist=loadfile--dist=loadfile表示按文件粒度分配用例,同一个文件里的用例会分到同一个worker,避免同一台设备的用例被拆散。这个参数在多设备场景下很关键,因为你需要保证"一台设备对应一个worker",而不是用例随机飘。
进程池的优势是隔离彻底,一个worker崩了不影响其他worker,日志天然分开,全局变量不共享。代价是启动开销大一些,每个进程都要重新import模块、初始化driver,内存占用也更高。另外,进程间通信比线程间麻烦,如果你需要汇总所有设备的结果,得通过文件或消息队列来传递。
2.3 怎么选:看你的用例特征
| 维度 | 线程池 | 进程池(xdist) |
|---|---|---|
| 隔离性 | 弱,共享内存 | 强,独立进程 |
| 启动速度 | 快 | 慢 |
| 内存占用 | 低 | 高 |
| 调试难度 | 低 | 中 |
| 适合场景 | 用例轻、IO密集 | 用例重、需要强隔离 |
| 结果汇总 | 直接共享变量 | 需文件/队列 |
我的建议是:如果你的用例主要是UI操作和等待,CPU计算少,线程池够用且更轻;如果你需要跑大量用例、要求稳定性高、不想处理共享状态,直接上xdist。下面两节分别展开讲实操。
3. 线程池落地的关键:端口、设备与driver的绑定关系
3.1 Appium Server端口规划不能随便拍脑袋
每台设备需要一个独立的Appium Server实例,每个实例监听不同端口。默认4723被占用后,下一个用4725、4727这样隔开,是因为Appium还会用到一些相邻端口做内部通信,连续端口容易冲突。我一般按奇数递增分配:
BASE_PORT = 4723 devices = [] for i, udid in enumerate(udid_list): devices.append({ "udid": udid, "port": BASE_PORT + i * 2, "systemPort": 8200 + i, })systemPort是Android设备上UiAutomator2的通信端口,也必须每台设备不同,否则会报"port already in use"。这个参数很多人第一次做并发时会漏掉,导致第二台设备启动时直接失败。
启动Appium Server可以用命令行,也可以用Python的subprocess:
import subprocess def start_appium(port): cmd = f"appium -p {port} --log-level error" return subprocess.Popen(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)注意:启动Appium Server后要留几秒等它初始化完成,直接连会报连接拒绝。我一般用
time.sleep(3)或者轮询端口是否可连。
3.2 设备发现与动态分配
硬编码设备列表在设备固定的场景下没问题,但如果你的设备池是动态的(比如CI环境里设备可能增减),就需要用adb devices动态获取:
import subprocess def get_connected_devices(): result = subprocess.run(["adb", "devices"], capture_output=True, text=True) lines = result.stdout.strip().split("\n")[1:] return [line.split("\t")[0] for line in lines if "\tdevice" in line]拿到设备列表后,按数量启动对应数量的Appium Server和线程。这里有个细节:设备状态要确认是device而不是offline或unauthorized,否则连上去也是白连。
3.3 driver生命周期管理
每个线程里的driver必须在该线程内创建和销毁,不能跨线程共享。我见过有人为了省事,在主线程创建好driver传给子线程用,结果各种诡异报错。Appium的driver不是线程安全的,必须一线程一driver。
def run_device(device): driver = None try: driver = webdriver.Remote( f"http://127.0.0.1:{device['port']}/wd/hub", build_caps(device) ) execute_test_cases(driver) except Exception as e: log_error(device["udid"], e) finally: if driver: driver.quit()finally里quit很重要,否则Appium Server会残留session,下次连接可能失败。
4. pytest-xdist多设备并发的工程化配置
4.1 conftest.py里按worker分配设备
xdist给每个worker分配一个唯一ID,可以通过worker_id这个fixture拿到。利用它来给每个worker绑定不同设备:
# conftest.py import pytest DEVICES = [ {"udid": "udid1", "port": 4723, "systemPort": 8200}, {"udid": "udid2", "port": 4725, "systemPort": 8201}, {"udid": "udid3", "port": 4727, "systemPort": 8202}, ] @pytest.fixture(scope="session") def device(request, worker_id): if worker_id == "master": # 不用-n时,默认用第一台 return DEVICES[0] index = int(worker_id.replace("gw", "")) return DEVICES[index % len(DEVICES)] @pytest.fixture(scope="session") def driver(device): d = webdriver.Remote( f"http://127.0.0.1:{device['port']}/wd/hub", build_caps(device) ) yield d d.quit()scope="session"让driver在整个worker生命周期内复用,避免每条用例都重启App,能省大量时间。但要注意用例之间的状态清理,否则会互相污染。
4.2 用例文件粒度与设备绑定策略
--dist=loadfile保证同一个文件的用例分到同一个worker,这样driver的session级复用才有意义。如果你的用例文件很多,每个文件用例少,可以考虑--dist=loadscope按模块分配。
启动命令:
pytest -n 3 --dist=loadfile -v --alluredir=./results-n 3对应3台设备。设备数和worker数必须一致,多了会有的worker拿不到设备,少了浪费设备。
4.3 结果汇总与Allure报告合并
xdist跑完后,每个worker会生成自己的Allure结果文件,需要合并:
allure generate ./results -o ./report --clean如果每个worker写到不同目录,先合并目录再生成。我一般让所有worker写同一个--alluredir,Allure的结果文件是按UUID命名的,不会冲突。
提示:xdist并行时,
logging并带上worker_id前缀,方便排查。
5. 并发跑起来之后才会遇到的坑
5.1 端口冲突的三种表现和排查方法
端口冲突是多设备并发最常见的坑,表现有三种:Appium Server启动失败、driver连接超时、用例跑到一半突然断连。排查步骤:
lsof -i :4723看端口是否被占用adb devices确认设备在线- 检查
systemPort是否重复 - 看Appium日志里有没有"UiAutomator2 server cannot be initialized"
我有一次折腾了两小时,最后发现是两台设备的systemPort都设成了8200,第二台启动时UiAutomator2服务起不来,driver连接一直超时。这个参数一定要确保唯一。
5.2 设备性能差异导致的超时误判
不同设备的响应速度差异很大,老设备点一个按钮要等3秒,新设备1秒就响应了。如果你用统一的implicitly_wait,老设备上很容易超时。我的做法是按设备性能分组,或者用显式等待配合较长的超时:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC WebDriverWait(driver, 15).until( EC.element_to_be_clickable((By.ID, "submit_btn")) )15秒的超时对新设备没影响(元素出现就继续),对老设备给了足够余量。
5.3 日志混乱与结果错位
并发跑的时候,如果不做日志隔离,三个线程的输出交织在一起,根本没法看。解决方案是每个线程/worker写独立日志文件:
import logging def setup_logger(udid): logger = logging.getLogger(udid) handler = logging.FileHandler(f"logs/{udid}.log") logger.addHandler(handler) return logger结果文件同理,每个设备写独立的JSON或CSV,最后合并。我踩过的坑是三个线程同时写一个文件,最后行数对不上,排查了半天才发现是并发写导致的。
5.4 Appium Server残留进程清理
跑完一轮后,如果driver没正常quit,Appium Server会残留session,下次跑可能报"session not created"。建议在测试开始前和结束后都做一次清理:
pkill -f "appium -p" adb shell am force-stop com.example.app或者在Python里用subprocess调用清理命令。这个习惯能省掉很多莫名其妙的失败。
6. 从能跑到跑得稳:并发测试的稳定性优化
6.1 重试机制:不是所有失败都是真失败
UI自动化天然不稳定,网络抖动、设备卡顿、App偶发ANR都会导致用例失败。在并发场景下,这种不稳定会被放大。加一层重试能显著提升通过率:
@pytest.mark.flaky(reruns=2, reruns_delay=3) def test_login(driver): ...pytest-rerunfailures插件支持用例级重试。但要注意,重试会拉长整体时间,reruns不要设太大,2次足够。另外,重试的用例要确保是幂等的,否则可能因为状态残留导致重试也失败。
6.2 资源隔离:数据、账号、文件都要分开
多设备并发时,如果多台设备用同一个测试账号登录,服务端可能会踢掉前一个session,导致用例失败。测试数据也一样,多台设备同时操作同一条数据会互相干扰。
我的做法是:每台设备分配独立的测试账号和数据前缀。比如设备1用test_user_1,设备2用test_user_2,数据里带上设备标识。这样即使并发跑,也不会互相影响。
6.3 并发度不是越高越好
设备越多,并发度越高,但稳定性会下降。我实测下来,4台设备并发是比较稳的甜点区,再往上,Appium Server的资源占用、ADB的通信压力、机器的CPU和内存都会成为瓶颈。如果你的机器配置一般,2-3台并发可能比4台更快,因为减少了资源竞争导致的超时重试。
判断并发度是否合适,看两个指标:整体耗时是否随设备数线性下降、失败率是否明显上升。如果加到第5台设备后耗时没怎么降、失败率反而涨了,说明到瓶颈了。
6.4 CI环境下的并发注意事项
在CI里跑多设备并发,有几个额外要注意的点。一是CI机器的USB端口数量有限,设备多了要接USB Hub,但Hub供电不足会导致设备掉线。二是CI环境的ADB版本要和本地一致,版本不匹配会出现设备识别问题。三是并发跑的时候CI机器的负载会很高,建议给CI机器留足够的CPU和内存余量,否则Appium Server自己都会卡。
我在CI上跑4设备并发时,把CI机器的配置从4核8G升到8核16G,失败率从15%降到了3%以下。这个投入是值得的。
7. 一些实际项目里攒下来的经验
多设备并发这件事,工具和代码只是基础,真正决定成败的是细节管理。我做了几个项目下来,最大的体会是:并发测试的稳定性问题,八成来自资源隔离没做好。端口、设备、账号、数据、日志、结果文件,每一样都要确保独立,任何一处共享都可能成为并发失败的根源。
另一个体会是,不要一上来就追求高并发。先把2台设备跑稳,再逐步加到3台、4台,每加一台观察一轮稳定性。直接上4台然后天天排查随机失败,效率反而低。
还有一点关于线程池和xdist的选择:如果你的团队对pytest生态熟悉,直接上xdist,工程化程度高,维护成本低。如果只是临时跑个并发、用例不多,线程池写起来更快。没有绝对优劣,看场景。
最后说个容易被忽略的点:Appium Server的版本要和driver版本匹配。并发场景下版本不匹配的问题会被放大,因为多台设备同时连接,版本兼容性问题更容易暴露。建议固定版本,不要频繁升级,升级前先在单设备上验证。