poco多线程设备串台问题
适用场景:使用 Airtest + Poco 进行多设备并发 UI 自动化测试
Airtest 通过全局变量
G.DEVICE 管理设备状态,导致多线程并发时出现"指定设备 A,操作却落到设备 B"的串台问题。本文提供通过依赖注入重写核心类的完整解决方案,实现设备实例与线程的绑定隔离。一、问题描述
在使用 Airtest + Poco 做多设备并发 UI 自动化时,会出现设备串台现象:给 Poco 指定了设备 A,实际点击操作却执行在设备 B 上。
典型表现:
- 多线程同时运行测试,每个线程连接不同设备
- 线程的
poco,airtest操作落到其他设备 - 单线程运行正常,并发即异常
直接上代码
UnityPoco
# -*- coding: utf-8 -*- # @Time : 2026/4/23 16:34 # @Author : Liam Li # @FileName: PrivateUnityPoco.py # @Software: PyCharm # @Description :线程安全的 UnityPoco 实现,不依赖全局 G.device from functools import wraps from poco.drivers.std import StdPoco from poco.agent import PocoAgent from poco.drivers.std.attributor import StdAttributor from poco.drivers.std.dumper import StdDumper from poco.drivers.std.screen import StdScreen from poco.freezeui.hierarchy import FrozenUIHierarchy from poco.sdk.interfaces.input import InputInterface from poco.utils.simplerpc.rpcclient import RpcClient from poco.utils.simplerpc.transport.tcp.main import TcpClient from airtest.core.helper import device_platform, logwrap def serializable_adapter(func): """序列化适配器装饰器""" class ISerializable(object): def to_json(self): raise NotImplementedError @wraps(func) def wrapped(*args): class PocoUIProxySerializable(ISerializable): def __init__(self, obj): self.obj = obj def to_json(self): return repr(self.obj) new_args = [PocoUIProxySerializable(a) for a in args] return func(*new_args) return wrapped @serializable_adapter @logwrap def record_ui(driver, action, ui, args): """记录 UI 操作""" return ui class SafeAirtestInput(InputInterface): """ 线程安全的 AirtestInput 实现 使用指定的设备实例而不是全局 current_device() """ def __init__(self, device): super(SafeAirtestInput, self).__init__() self.device = device # 使用传入的设备实例 self.default_touch_down_duration = 0.01 self._driver = None self.use_render_resolution = False self.render_resolution = None def add_preaction_cb(self, driver): """添加预操作回调""" self._driver = driver self._driver.add_pre_action_callback(record_ui) def get_target_pos(self, x, y): """获取目标位置的实际坐标""" offsetx, offsety, pw, ph = self._get_touch_resolution() pos = [x * pw + offsetx, y * ph + offsety] return pos def _get_touch_resolution(self): """ 获取设备的实际分辨率 关键修改:使用 self.device 而不是 current_device() """ if device_platform(self.device) == 'Android': if self.use_render_resolution: if self.render_resolution and len(self.render_resolution) == 4: return self.render_resolution else: return self.device.get_render_resolution() w, h = self.device.get_current_resolution() return 0, 0, w, h def setTouchDownDuration(self, duration): """设置触摸按下持续时间""" self.default_touch_down_duration = duration def getTouchDownDuration(self): """获取触摸按下持续时间""" return self.default_touch_down_duration def click(self, x, y): """点击操作 - 直接使用设备实例而不是全局 API""" pos = self.get_target_pos(x, y) # 直接调用设备的 touch 方法,不使用全局 API self.device.touch(pos, duration=self.default_touch_down_duration) def double_click(self, x, y): """双击操作 - 直接使用设备实例而不是全局 API""" pos = self.get_target_pos(x, y) # 直接调用设备的 double_click 方法 self.device.double_click(pos) def swipe(self, x1, y1, x2, y2, duration=2.0): """滑动操作 - 直接使用设备实例而不是全局 API""" if duration <= 0: raise ValueError("Operation duration cannot be less equal 0. Please provide a positive number.") # Android.swipe() 接收起点和终点坐标,不接受 vector 参数 # poco 传入的 x1,y1,x2,y2 是归一化坐标(0~1),需要转换为实际像素坐标 p1 = self.get_target_pos(x1, y1) p2 = self.get_target_pos(x2, y2) steps = int(duration * 40) + 1 self.device.swipe(p1, p2, duration=duration, steps=steps) def longClick(self, x, y, duration=2.0): """长按操作 - 直接使用设备实例而不是全局 API""" if duration <= 0: raise ValueError("Operation duration cannot be less equal 0. Please provide a positive number.") pos = self.get_target_pos(x, y) # 直接调用设备的 touch 方法,使用较长的 duration self.device.touch(pos, duration=duration) def applyMotionEvents(self, events): """ 应用运动事件序列 关键修改:使用 self.device 而不是 current_device() """ if device_platform(self.device) != 'Android': raise NotImplementedError # Android minitouch/maxtouch only, currently from airtest.core.android.touch_methods.base_touch import DownEvent, MoveEvent, UpEvent, SleepEvent mes = [] for e in events: t = e[0] if t == 'd': contact = e[2] x, y = e[1] pos = self.get_target_pos(x, y) me = DownEvent(pos, contact) elif t == 'm': contact = e[2] x, y = e[1] pos = self.get_target_pos(x, y) me = MoveEvent(pos, contact) elif t == 'u': contact = e[1] me = UpEvent(contact) elif t == 's': how_long = e[1] me = SleepEvent(how_long) else: raise ValueError('Unknown event type {}'.format(repr(t))) mes.append(me) self.device.touch_proxy.perform(mes, interval=0) class SafeStdPocoAgent(PocoAgent): """ 线程安全的 StdPocoAgent 实现 使用指定的设备实例 """ def __init__(self, addr, device, use_airtest_input=True): self.conn = TcpClient(addr) self.c = RpcClient(self.conn) self.c.DEBUG = False self.c.connect() hierarchy = FrozenUIHierarchy(StdDumper(self.c), StdAttributor(self.c)) screen = StdScreen(self.c) # 关键修改:使用 SafeAirtestInput 并传入设备实例 if use_airtest_input: inputs = SafeAirtestInput(device) else: from poco.drivers.std.inputs import StdInput inputs = StdInput(self.c) super(SafeStdPocoAgent, self).__init__(hierarchy, inputs, screen, None) @property def rpc(self): return self.c class SafeUnityPoco(StdPoco): """ 线程安全的 UnityPoco 实现 主要改进: 1. 不使用全局的 G.device 2. 所有操作都使用传入的 device 参数 3. 适用于多线程环境,每个线程可以有自己的设备实例 Args: device: Android 设备实例,必须提供 addr: Unity3D 游戏的端点地址,默认为 ("localhost", 5001) options: 其他 Poco 选项 Examples: from airtest.core.android import Android from terot_poco_ui.util.PrivateUnityPoco import SafeUnityPoco # 创建设备实例(不使用 connect_device) dev = Android("ef4bdc41", host=("10.20.244.218", 5039)) # 创建 poco 实例,绑定到指定设备 poco = SafeUnityPoco(device=dev) # 正常使用 btn = poco("m_Button_Close") btn.click() """ DEFAULT_PORT = 5001 DEFAULT_ADDR = ("localhost", DEFAULT_PORT) def __init__(self, device, addr=None, **options): if addr is None: addr = self.DEFAULT_ADDR if 'action_interval' not in options: options['action_interval'] = 0.5 if device is None: raise ValueError("device parameter is required for SafeUnityPoco") self.device = device # 获取设备平台 platform_name = device_platform(self.device) # 处理端口转发 ip = addr[0] port = addr[1] if platform_name == 'Android': # Android 设备需要端口转发 local_port, _ = self.device.adb.setup_forward('tcp:{}'.format(port)) ip = self.device.adb.host or 'localhost' port = local_port elif platform_name == 'IOS': # iOS 设备端口转发 port, _ = self.device.setup_forward(port) if self.device.is_local_device: ip = 'localhost' else: ip = self.device.ip else: # 其他平台尝试获取 IP try: ip = self.device.get_ip_address() except AttributeError: import socket try: ip = socket.gethostbyname(socket.gethostname()) except socket.gaierror: ip = 'localhost' # 创建线程安全的 agent agent = SafeStdPocoAgent((ip, port), self.device, use_airtest_input=True) # 初始化父类 options['reevaluate_volatile_attributes'] = True # 注意:这里直接调用 Poco 的初始化,跳过 StdPoco 的 __init__ from poco.pocofw import Poco Poco.__init__(self, agent, **options) def send_message(self, message): """发送消息到 Unity""" self.agent.rpc.call("SendMessage", message) def invoke(self, listener, **kwargs): """调用 Unity 方法""" callback = self.agent.rpc.call("Invoke", listener=listener, data=kwargs) value, error = callback.wait() if error is not None: raise Exception(error) return value
AndroidPoco
# -*- coding: utf-8 -*- # @Time : 2026/4/23 17:35 # @Author : Liam Li # @FileName: PrivateAndroidPoco.py # @Software: PyCharm # @Description :线程安全的 AndroidUiautomationPoco 实现,不依赖全局 G.device import os import requests import time import warnings import threading import atexit from airtest.core.android.ime import YosemiteIme from airtest.core.error import AdbShellError, AirtestError from airtest.core.helper import device_platform, logwrap from functools import wraps from hrpc.client import RpcClient from hrpc.transport.http import HttpTransport from poco.pocofw import Poco from poco.agent import PocoAgent from poco.sdk.Attributor import Attributor from poco.sdk.interfaces.screen import ScreenInterface from poco.sdk.interfaces.input import InputInterface from poco.utils.hrpc.hierarchy import RemotePocoHierarchy from poco.utils import six from poco.drivers.android.utils.installation import install, uninstall __all__ = ['SafeAndroidUiautomationPoco'] # 包名常量 PocoServicePackage = 'com.netease.open.pocoservice' PocoServicePackageTest = 'com.netease.open.pocoservice.test' UiAutomatorPackage = 'com.github.uiautomator' def serializable_adapter(func): """序列化适配器装饰器""" class ISerializable(object): def to_json(self): raise NotImplementedError @wraps(func) def wrapped(*args): class PocoUIProxySerializable(ISerializable): def __init__(self, obj): self.obj = obj def to_json(self): return repr(self.obj) new_args = [PocoUIProxySerializable(a) for a in args] return func(*new_args) return wrapped @serializable_adapter @logwrap def record_ui(driver, action, ui, args): """记录 UI 操作""" return ui class SafeAirtestInput(InputInterface): """ 线程安全的 AirtestInput 实现 使用指定的设备实例而不是全局 current_device() """ def __init__(self, device): super(SafeAirtestInput, self).__init__() self.device = device # 使用传入的设备实例 self.default_touch_down_duration = 0.01 self._driver = None self.use_render_resolution = False self.render_resolution = None def add_preaction_cb(self, driver): """添加预操作回调""" self._driver = driver self._driver.add_pre_action_callback(record_ui) def get_target_pos(self, x, y): """获取目标位置的实际坐标""" offsetx, offsety, pw, ph = self._get_touch_resolution() pos = [x * pw + offsetx, y * ph + offsety] return pos def _get_touch_resolution(self): """ 获取设备的实际分辨率 关键修改:使用 self.device 而不是 current_device() """ if device_platform(self.device) == 'Android': if self.use_render_resolution: if self.render_resolution and len(self.render_resolution) == 4: return self.render_resolution else: return self.device.get_render_resolution() w, h = self.device.get_current_resolution() return 0, 0, w, h def setTouchDownDuration(self, duration): """设置触摸按下持续时间""" self.default_touch_down_duration = duration def getTouchDownDuration(self): """获取触摸按下持续时间""" return self.default_touch_down_duration def click(self, x, y): """点击操作 - 直接使用设备实例而不是全局 API""" pos = self.get_target_pos(x, y) # 直接调用设备的 touch 方法,不使用全局 API self.device.touch(pos, duration=self.default_touch_down_duration) def double_click(self, x, y): """双击操作 - 直接使用设备实例而不是全局 API""" pos = self.get_target_pos(x, y) # 直接调用设备的 double_click 方法 self.device.double_click(pos) def swipe(self, x1, y1, x2, y2, duration=2.0): """滑动操作 - 直接使用设备实例而不是全局 API""" if duration <= 0: raise ValueError("Operation duration cannot be less equal 0. Please provide a positive number.") # Android.swipe() 接收起点和终点坐标,不接受 vector 参数 # poco 传入的 x1,y1,x2,y2 是归一化坐标(0~1),需要转换为实际像素坐标 p1 = self.get_target_pos(x1, y1) p2 = self.get_target_pos(x2, y2) steps = int(duration * 40) + 1 self.device.swipe(p1, p2, duration=duration, steps=steps) def longClick(self, x, y, duration=2.0): """长按操作 - 直接使用设备实例而不是全局 API""" if duration <= 0: raise ValueError("Operation duration cannot be less equal 0. Please provide a positive number.") pos = self.get_target_pos(x, y) # 直接调用设备的 touch 方法,使用较长的 duration self.device.touch(pos, duration=duration) def applyMotionEvents(self, events): """ 应用运动事件序列 关键修改:使用 self.device 而不是 current_device() """ if device_platform(self.device) != 'Android': raise NotImplementedError # Android minitouch/maxtouch only, currently from airtest.core.android.touch_methods.base_touch import DownEvent, MoveEvent, UpEvent, SleepEvent mes = [] for e in events: t = e[0] if t == 'd': contact = e[2] x, y = e[1] pos = self.get_target_pos(x, y) me = DownEvent(pos, contact) elif t == 'm': contact = e[2] x, y = e[1] pos = self.get_target_pos(x, y) me = MoveEvent(pos, contact) elif t == 'u': contact = e[1] me = UpEvent(contact) elif t == 's': how_long = e[1] me = SleepEvent(how_long) else: raise ValueError('Unknown event type {}'.format(repr(t))) mes.append(me) self.device.touch_proxy.perform(mes, interval=0) class AndroidRpcClient(RpcClient): """Android RPC 客户端""" def __init__(self, endpoint): self.endpoint = endpoint super(AndroidRpcClient, self).__init__(HttpTransport) def initialize_transport(self): return HttpTransport(self.endpoint, self) class ScreenWrapper(ScreenInterface): """屏幕接口包装器""" def __init__(self, screen): super(ScreenWrapper, self).__init__() self.screen = screen def getScreen(self, width): # Android上PocoService的实现为仅返回b64编码的图像,格式固定位jpg b64img = self.screen.getScreen(width) return b64img, 'jpg' def getPortSize(self): return self.screen.getPortSize() class SafeAndroidPocoAgent(PocoAgent): """ 线程安全的 AndroidPocoAgent 实现 使用指定的设备实例 """ def __init__(self, endpoint, ime, device, use_airtest_input=False): self.client = AndroidRpcClient(endpoint) remote_poco = self.client.remote('poco-uiautomation-framework') dumper = remote_poco.dumper selector = remote_poco.selector attributor = remote_poco.attributor hierarchy = RemotePocoHierarchy(dumper, selector, attributor) # 关键修改:使用 SafeAirtestInput 并传入设备实例 if use_airtest_input: inputer = SafeAirtestInput(device) else: inputer = remote_poco.inputer super(SafeAndroidPocoAgent, self).__init__(hierarchy, inputer, ScreenWrapper(remote_poco.screen), None) class KeepRunningInstrumentationThread(threading.Thread): """保持 pocoservice 运行的后台线程""" def __init__(self, poco, port_to_ping): super(KeepRunningInstrumentationThread, self).__init__() self._stop_event = threading.Event() self.poco = poco self.port_to_ping = port_to_ping self.daemon = True def stop(self): self._stop_event.set() def stopped(self): return self._stop_event.is_set() def run(self): while not self.stopped(): if not self.stopped(): self.poco._start_instrument(self.port_to_ping) # 尝试重启 time.sleep(1) class SafeAndroidUiautomationPoco(Poco): """ 线程安全的 AndroidUiautomationPoco 实现 主要改进: 1. 不使用全局的 G.device 和 default_device() 2. 所有操作都使用传入的 device 参数 3. 适用于多线程环境,每个线程可以有自己的设备实例 Args: device: Android 设备实例,必须提供 using_proxy: 是否使用 adb forward 连接设备,默认 True force_restart: 是否强制重启 poco-service,默认 False use_airtest_input: 是否使用 airtest 的输入方法,默认 False options: 其他 Poco 选项 Examples: from airtest.core.android import Android from terot_poco_ui.util.PrivateAndroidPoco import SafeAndroidUiautomationPoco # 创建设备实例(不使用 connect_device) dev = Android("ef4bdc41", host=("10.20.244.218", 5039)) # 创建 poco 实例,绑定到指定设备 poco = SafeAndroidUiautomationPoco(device=dev) # 正常使用 poco('android:id/title').click() """ def __init__(self, device, using_proxy=True, force_restart=False, use_airtest_input=False, **options): # 加这个参数为了不在最新的pocounit方案中每步都截图 self.screenshot_each_action = True if options.get('screenshot_each_action') is False: self.screenshot_each_action = False # 关键修改:强制要求 device 参数 if device is None: raise ValueError("device parameter is required for SafeAndroidUiautomationPoco") self.device = device self.adb_client = self.device.adb if using_proxy: self.device_ip = self.adb_client.host or "127.0.0.1" else: self.device_ip = self.device.get_ip_address() # save current top activity (@nullable) try: current_top_activity_package = self.device.get_top_activity_name() except AirtestError as e: # 在一些极端情况下,可能获取不到top activity的信息 print(e) current_top_activity_package = None if current_top_activity_package is not None: current_top_activity_package = current_top_activity_package.split('/')[0] # install ime self.ime = YosemiteIme(self.adb_client) # install this_dir = os.path.dirname(os.path.realpath(__file__)) # 注意:这里需要使用原生的 poco 库路径 poco_lib_dir = os.path.dirname(os.path.dirname(os.path.dirname( os.path.dirname(os.path.realpath(__file__))))) apk_path = os.path.join(poco_lib_dir, '.venv', 'lib', 'site-packages', 'poco', 'drivers', 'android', 'lib', 'pocoservice-debug.apk') # 如果找不到,尝试从当前环境查找 if not os.path.exists(apk_path): import poco.drivers.android android_driver_dir = os.path.dirname(poco.drivers.android.__file__) apk_path = os.path.join(android_driver_dir, 'lib', 'pocoservice-debug.apk') self._instrument_proc = None self._install_service(apk_path) # forward self.forward_list = [] if using_proxy: p0, _ = self.adb_client.setup_forward("tcp:10080") p1, _ = self.adb_client.setup_forward("tcp:10081") self.forward_list.extend(["tcp:%s" % p0, "tcp:%s" % p1]) else: p0 = 10080 p1 = 10081 # start ready = self._start_instrument(p0, force_restart=force_restart) if not ready: # 之前启动失败就卸载重装,现在改为尝试kill进程或卸载uiautomator self._kill_uiautomator() ready = self._start_instrument(p0) if current_top_activity_package is not None: current_top_activity2 = self.device.get_top_activity_name() if current_top_activity2 is None or current_top_activity_package not in current_top_activity2: self.device.start_app(current_top_activity_package, activity=True) if not ready: raise RuntimeError("unable to launch SafeAndroidUiautomationPoco") if ready: # 首次启动成功后,在后台线程里监控这个进程的状态,保持让它不退出 self._keep_running_thread = KeepRunningInstrumentationThread(self, p0) self._keep_running_thread.start() endpoint = "http://{}:{}".format(self.device_ip, p1) # 关键修改:创建 SafeAndroidPocoAgent 并传入设备实例 agent = SafeAndroidPocoAgent(endpoint, self.ime, self.device, use_airtest_input) super(SafeAndroidUiautomationPoco, self).__init__(agent, **options) def _install_service(self, apk_path): """安装 poco service""" updated = install(self.adb_client, apk_path) return updated def _is_running(self, package_name): """ 使用 ps |grep 检查进程是否存在 :param package_name: 包名或正则表达式 :return: pid or None """ cmd = r' |echo $(grep -E {package_name})'.format(package_name=package_name) if self.device.sdk_version > 25: cmd = r'ps -A' + cmd else: cmd = r'ps' + cmd processes = self.adb_client.shell(cmd).splitlines() for ps in processes: if ps: ps = ps.split() return ps[1] return None def _start_instrument(self, port_to_ping, force_restart=False): """启动 instrumentation""" if not force_restart: try: state = requests.get('http://{}:{}/uiautomation/connectionState'.format(self.device_ip, port_to_ping), timeout=10) state = state.json() if state.get('connected'): # skip starting instrumentation if UiAutomation Service already connected. return True except: pass if self._instrument_proc is not None: if self._instrument_proc.poll() is None: self._instrument_proc.kill() self._instrument_proc = None ready = False # 启动instrument之前,先把主类activity启动起来,不然instrumentation可能失败 self.adb_client.shell('am start -n {}/.TestActivity'.format(PocoServicePackage)) instrumentation_cmd = [ 'am', 'instrument', '-w', '-e', 'debug', 'false', '-e', 'class', '{}.InstrumentedTestAsLauncher'.format(PocoServicePackage), '{}/androidx.test.runner.AndroidJUnitRunner'.format(PocoServicePackage)] self._instrument_proc = self.adb_client.start_shell(instrumentation_cmd) def cleanup_proc(proc): def wrapped(): try: proc.kill() except: pass return wrapped atexit.register(cleanup_proc(self._instrument_proc)) time.sleep(2) for i in range(10): try: requests.get('http://{}:{}'.format(self.device_ip, port_to_ping), timeout=10) ready = True break except requests.exceptions.Timeout: break except requests.exceptions.ConnectionError: if self._instrument_proc.poll() is not None: warnings.warn("[pocoservice.apk] instrumentation test server process is no longer alive") stdout = self._instrument_proc.stdout.read() stderr = self._instrument_proc.stderr.read() print('[pocoservice.apk] stdout: {}'.format(stdout)) print('[pocoservice.apk] stderr: {}'.format(stderr)) time.sleep(1) print("still waiting for uiautomation ready.") try: self.adb_client.shell( ['monkey', '-p', PocoServicePackage, '-c', 'android.intent.category.LAUNCHER', '1']) except Exception as e: pass self.adb_client.shell('am start -n {}/.TestActivity'.format(PocoServicePackage)) instrumentation_cmd = [ 'am', 'instrument', '-w', '-e', 'debug', 'false', '-e', 'class', '{}.InstrumentedTestAsLauncher'.format(PocoServicePackage), '{}/androidx.test.runner.AndroidJUnitRunner'.format(PocoServicePackage)] self._instrument_proc = self.adb_client.start_shell(instrumentation_cmd) continue return ready def _kill_uiautomator(self): """ poco-service无法与其他instrument启动的apk同时存在,因此在启动前,需要杀掉一些可能的进程 """ pid = self._is_running("uiautomator") if pid: warnings.warn('{} should not run together with "uiautomator". "uiautomator" will be killed.' .format(self.__class__.__name__)) self.adb_client.shell(['am', 'force-stop', PocoServicePackage]) try: self.adb_client.shell(['kill', pid]) except AdbShellError: # 没有root权限 uninstall(self.adb_client, UiAutomatorPackage) def on_pre_action(self, action, ui, args): """操作前的回调""" if self.screenshot_each_action: # airtest log用 from airtest.core.api import snapshot msg = repr(ui) if not isinstance(msg, six.text_type): msg = msg.decode('utf-8') snapshot(msg=msg) def stop_running(self): """停止运行 PocoService""" print('[pocoservice.apk] stopping PocoService') self._keep_running_thread.stop() self._keep_running_thread.join(3) self.remove_forwards() self.adb_client.shell(['am', 'force-stop', PocoServicePackage]) def remove_forwards(self): """移除端口转发""" for p in self.forward_list: self.adb_client.remove_forward(p) self.forward_list = []
使用方式
from PrivateUnityPoco import SafeUnityPoco as UnityPoco from PrivateAndroidPoco import SafeAndroidUiautomationPoco as AndroidPoco dev = Android("ef334bdc41", host=("101.220.244.218", 5039)) # 创建 UnityPoco实例,绑定到指定设备 poco = UnityPoco(device=dev) # 创建 AndroidPoco实例,绑定到指定设备 poco = AndroidPoco(device=dev)

浙公网安备 33010602011771号