2026-08-07 16:12:39 +08:00
|
|
|
|
"""uiautomator2 设备连接边界。
|
|
|
|
|
|
|
2026-08-10 18:00:38 +08:00
|
|
|
|
普通服务每个任务建立一次连接;持久服务只复用 Device 连接,不复用页面数据。
|
|
|
|
|
|
两种会话都只能在创建它们的线程中使用。
|
2026-08-07 16:12:39 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import threading
|
2026-08-10 18:00:38 +08:00
|
|
|
|
import time
|
|
|
|
|
|
from contextlib import AbstractContextManager, contextmanager
|
|
|
|
|
|
from contextvars import ContextVar
|
|
|
|
|
|
from typing import Any, Callable, Iterator, Optional
|
2026-08-07 16:12:39 +08:00
|
|
|
|
|
2026-08-11 12:01:07 +08:00
|
|
|
|
from .adb_runtime import BundledAdbError, configure_bundled_adb_environment
|
2026-08-10 17:40:44 +08:00
|
|
|
|
from .performance_timing import current_performance_trace
|
|
|
|
|
|
|
2026-08-07 16:12:39 +08:00
|
|
|
|
|
|
|
|
|
|
PDD_PACKAGE_NAME = "com.xunmeng.pinduoduo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PddDeviceError(RuntimeError):
|
|
|
|
|
|
"""设备连接失败,并携带供任务状态使用的稳定错误码。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
|
self.code = code
|
|
|
|
|
|
self.message = message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PddDeviceSession(AbstractContextManager[Any]):
|
|
|
|
|
|
"""一个只能由创建线程使用的 uiautomator2 Device 会话。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
device: Any,
|
|
|
|
|
|
serial: str,
|
2026-08-10 17:40:44 +08:00
|
|
|
|
initial_app_state: dict[str, Any],
|
2026-08-07 16:12:39 +08:00
|
|
|
|
release: Callable[[], None],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self._device = device
|
|
|
|
|
|
self.serial = serial
|
2026-08-10 17:40:44 +08:00
|
|
|
|
self.initial_app_state = dict(initial_app_state)
|
2026-08-07 16:12:39 +08:00
|
|
|
|
self._release = release
|
|
|
|
|
|
self._owner_thread_id = threading.get_ident()
|
|
|
|
|
|
self._closed = False
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def device(self) -> Any:
|
|
|
|
|
|
"""返回 Device;跨线程或会话关闭后访问会明确失败。"""
|
|
|
|
|
|
|
|
|
|
|
|
if self._closed:
|
|
|
|
|
|
raise PddDeviceError("DEVICE_SESSION_CLOSED", "设备会话已经关闭")
|
|
|
|
|
|
if threading.get_ident() != self._owner_thread_id:
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
"DEVICE_THREAD_VIOLATION",
|
|
|
|
|
|
"uiautomator2 Device 只能在创建会话的工作线程中使用",
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._device
|
|
|
|
|
|
|
|
|
|
|
|
def __enter__(self) -> Any:
|
|
|
|
|
|
return self.device
|
|
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
|
|
|
|
|
|
if not self._closed:
|
|
|
|
|
|
self._closed = True
|
|
|
|
|
|
self._release()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PddDeviceService:
|
|
|
|
|
|
"""连接并校验一台已保存的 Android 设备。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
connector: Optional[Callable[[str], Any]] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self._connector = connector or self._default_connector
|
|
|
|
|
|
self._state_lock = threading.Lock()
|
|
|
|
|
|
self._active_serial: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _default_connector(serial: str) -> Any:
|
2026-08-11 12:01:07 +08:00
|
|
|
|
try:
|
|
|
|
|
|
configure_bundled_adb_environment()
|
|
|
|
|
|
except BundledAdbError as exc:
|
|
|
|
|
|
raise PddDeviceError("DEVICE_ADB_MISSING", str(exc)) from exc
|
2026-08-07 16:12:39 +08:00
|
|
|
|
try:
|
|
|
|
|
|
import uiautomator2 as u2
|
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
"DEVICE_U2_MISSING",
|
|
|
|
|
|
"未安装 uiautomator2,请先安装 client/requirements.txt",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
return u2.connect(serial)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _validate_serial(serial: str) -> str:
|
|
|
|
|
|
value = str(serial or "").strip()
|
|
|
|
|
|
if not value or any(character.isspace() for character in value):
|
|
|
|
|
|
raise PddDeviceError("DEVICE_ADDRESS_INVALID", "Android 设备号无效")
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
def connect(self, serial: str) -> PddDeviceSession:
|
|
|
|
|
|
"""连接设备并返回单线程独占会话。
|
|
|
|
|
|
|
|
|
|
|
|
调用方必须使用 ``with service.connect(serial) as device``。同一个服务
|
|
|
|
|
|
已有活动会话时会拒绝第二次连接。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
checked_serial = self._validate_serial(serial)
|
|
|
|
|
|
with self._state_lock:
|
|
|
|
|
|
if self._active_serial is not None:
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
"DEVICE_IN_USE",
|
|
|
|
|
|
f"设备 {self._active_serial} 正在执行其他自动化任务",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._active_serial = checked_serial
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-08-10 17:40:44 +08:00
|
|
|
|
trace = current_performance_trace()
|
|
|
|
|
|
if trace is None:
|
|
|
|
|
|
device = self._connector(checked_serial)
|
|
|
|
|
|
else:
|
|
|
|
|
|
with trace.stage("uiautomator2_connect"):
|
|
|
|
|
|
device = self._connector(checked_serial)
|
|
|
|
|
|
if trace is None:
|
|
|
|
|
|
current = device.app_current()
|
|
|
|
|
|
else:
|
|
|
|
|
|
with trace.stage("first_app_current"):
|
|
|
|
|
|
current = device.app_current()
|
2026-08-07 16:12:39 +08:00
|
|
|
|
if not isinstance(current, dict):
|
|
|
|
|
|
raise RuntimeError("uiautomator2 未返回有效的设备状态")
|
|
|
|
|
|
except PddDeviceError:
|
|
|
|
|
|
self._release(checked_serial)
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._release(checked_serial)
|
|
|
|
|
|
details = str(exc).lower()
|
|
|
|
|
|
code = (
|
|
|
|
|
|
"DEVICE_OFFLINE"
|
|
|
|
|
|
if any(word in details for word in ("offline", "not found", "disconnected"))
|
|
|
|
|
|
else "DEVICE_CONNECT_FAILED"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
code,
|
|
|
|
|
|
f"无法连接 Android 设备 {checked_serial}:{exc}",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
return PddDeviceSession(
|
|
|
|
|
|
device,
|
|
|
|
|
|
checked_serial,
|
2026-08-10 17:40:44 +08:00
|
|
|
|
current,
|
2026-08-07 16:12:39 +08:00
|
|
|
|
lambda: self._release(checked_serial),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _release(self, serial: str) -> None:
|
|
|
|
|
|
with self._state_lock:
|
|
|
|
|
|
if self._active_serial == serial:
|
|
|
|
|
|
self._active_serial = None
|
2026-08-10 18:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PersistentPddDeviceService(PddDeviceService):
|
|
|
|
|
|
"""在固定工作线程内复用同一台设备的 Device 连接。
|
|
|
|
|
|
|
|
|
|
|
|
``connect`` 每次仍调用 ``app_current`` 做健康检查并建立全新的任务会话。
|
|
|
|
|
|
缓存连接失效时最多重连一次;页面 XML、选择器和业务数据均不缓存。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
connector: Optional[Callable[[str], Any]] = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
ttl_seconds: float = 90.0,
|
|
|
|
|
|
monotonic: Callable[[], float] = time.monotonic,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
super().__init__(connector)
|
|
|
|
|
|
if ttl_seconds <= 0:
|
|
|
|
|
|
raise ValueError("设备会话 TTL 必须大于 0")
|
|
|
|
|
|
self._ttl_seconds = float(ttl_seconds)
|
|
|
|
|
|
self._monotonic = monotonic
|
|
|
|
|
|
self._owner_thread_id: Optional[int] = None
|
|
|
|
|
|
self._cached_serial: Optional[str] = None
|
|
|
|
|
|
self._cached_device: Any = None
|
|
|
|
|
|
self._last_used_at = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def ttl_seconds(self) -> float:
|
|
|
|
|
|
return self._ttl_seconds
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def has_cached_device(self) -> bool:
|
|
|
|
|
|
"""只供生命周期管理和测试判断,不返回 Device 对象。"""
|
|
|
|
|
|
|
|
|
|
|
|
return self._cached_device is not None
|
|
|
|
|
|
|
|
|
|
|
|
def connect(self, serial: str) -> PddDeviceSession:
|
|
|
|
|
|
"""在所有者线程取得独占任务会话,并按需复用底层连接。"""
|
|
|
|
|
|
|
|
|
|
|
|
self._assert_owner_thread()
|
|
|
|
|
|
checked_serial = self._validate_serial(serial)
|
|
|
|
|
|
with self._state_lock:
|
|
|
|
|
|
if self._active_serial is not None:
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
"DEVICE_IN_USE",
|
|
|
|
|
|
f"设备 {self._active_serial} 正在执行其他自动化任务",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._active_serial = checked_serial
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
device, current = self._healthy_device(checked_serial)
|
|
|
|
|
|
except PddDeviceError:
|
|
|
|
|
|
self._release(checked_serial)
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._release(checked_serial)
|
|
|
|
|
|
details = str(exc).lower()
|
|
|
|
|
|
code = (
|
|
|
|
|
|
"DEVICE_OFFLINE"
|
|
|
|
|
|
if any(
|
|
|
|
|
|
word in details
|
|
|
|
|
|
for word in ("offline", "not found", "disconnected")
|
|
|
|
|
|
)
|
|
|
|
|
|
else "DEVICE_CONNECT_FAILED"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
code,
|
|
|
|
|
|
f"无法连接 Android 设备 {checked_serial}:{exc}",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
return PddDeviceSession(
|
|
|
|
|
|
device,
|
|
|
|
|
|
checked_serial,
|
|
|
|
|
|
current,
|
|
|
|
|
|
lambda: self._release_persistent(checked_serial),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def release_cached(self) -> None:
|
|
|
|
|
|
"""在所有者线程丢弃缓存连接;可重复调用。"""
|
|
|
|
|
|
|
|
|
|
|
|
self._assert_owner_thread()
|
|
|
|
|
|
with self._state_lock:
|
|
|
|
|
|
if self._active_serial is not None:
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
"DEVICE_IN_USE",
|
|
|
|
|
|
"设备任务尚未到达安全结束点,暂不能释放连接",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._discard_cached()
|
|
|
|
|
|
|
|
|
|
|
|
def _healthy_device(self, serial: str) -> tuple[Any, dict[str, Any]]:
|
|
|
|
|
|
now = self._monotonic()
|
|
|
|
|
|
expired = (
|
|
|
|
|
|
self._cached_device is not None
|
|
|
|
|
|
and now - self._last_used_at >= self._ttl_seconds
|
|
|
|
|
|
)
|
|
|
|
|
|
if self._cached_serial != serial or expired:
|
|
|
|
|
|
self._discard_cached()
|
|
|
|
|
|
|
|
|
|
|
|
if self._cached_device is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
current = self._read_app_state(self._cached_device)
|
|
|
|
|
|
return self._cached_device, current
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# 旧连接失效时只丢弃并重连一次,不能在这里无限重试。
|
|
|
|
|
|
self._discard_cached()
|
|
|
|
|
|
|
|
|
|
|
|
trace = current_performance_trace()
|
|
|
|
|
|
if trace is None:
|
|
|
|
|
|
device = self._connector(serial)
|
|
|
|
|
|
else:
|
|
|
|
|
|
with trace.stage("uiautomator2_connect"):
|
|
|
|
|
|
device = self._connector(serial)
|
|
|
|
|
|
current = self._read_app_state(device)
|
|
|
|
|
|
self._cached_serial = serial
|
|
|
|
|
|
self._cached_device = device
|
|
|
|
|
|
self._last_used_at = now
|
|
|
|
|
|
return device, current
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _read_app_state_without_trace(device: Any) -> dict[str, Any]:
|
|
|
|
|
|
current = device.app_current()
|
|
|
|
|
|
if not isinstance(current, dict):
|
|
|
|
|
|
raise RuntimeError("uiautomator2 未返回有效的设备状态")
|
|
|
|
|
|
return current
|
|
|
|
|
|
|
|
|
|
|
|
def _read_app_state(self, device: Any) -> dict[str, Any]:
|
|
|
|
|
|
trace = current_performance_trace()
|
|
|
|
|
|
if trace is None:
|
|
|
|
|
|
return self._read_app_state_without_trace(device)
|
|
|
|
|
|
with trace.stage("first_app_current"):
|
|
|
|
|
|
return self._read_app_state_without_trace(device)
|
|
|
|
|
|
|
|
|
|
|
|
def _release_persistent(self, serial: str) -> None:
|
|
|
|
|
|
self._assert_owner_thread()
|
|
|
|
|
|
with self._state_lock:
|
|
|
|
|
|
if self._active_serial == serial:
|
|
|
|
|
|
self._active_serial = None
|
|
|
|
|
|
self._last_used_at = self._monotonic()
|
|
|
|
|
|
|
|
|
|
|
|
def _discard_cached(self) -> None:
|
|
|
|
|
|
# uiautomator2 Device 没有稳定的 close API;清除唯一引用即可让底层
|
|
|
|
|
|
# HTTP 客户端按库自身生命周期回收,不能猜测调用私有方法。
|
|
|
|
|
|
self._cached_serial = None
|
|
|
|
|
|
self._cached_device = None
|
|
|
|
|
|
self._last_used_at = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_owner_thread(self) -> None:
|
|
|
|
|
|
current = threading.get_ident()
|
|
|
|
|
|
if self._owner_thread_id is None:
|
|
|
|
|
|
self._owner_thread_id = current
|
|
|
|
|
|
elif self._owner_thread_id != current:
|
|
|
|
|
|
raise PddDeviceError(
|
|
|
|
|
|
"DEVICE_THREAD_VIOLATION",
|
|
|
|
|
|
"持久 uiautomator2 Device 只能在固定工作线程中使用和释放",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_CURRENT_DEVICE_SERVICE: ContextVar[Optional[PersistentPddDeviceService]] = (
|
|
|
|
|
|
ContextVar("pdd_device_service", default=None)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
|
def bind_thread_device_service(
|
|
|
|
|
|
service: PersistentPddDeviceService,
|
|
|
|
|
|
) -> Iterator[None]:
|
|
|
|
|
|
"""让当前工作线程创建的采集、采购和核单适配器使用同一连接服务。"""
|
|
|
|
|
|
|
|
|
|
|
|
token = _CURRENT_DEVICE_SERVICE.set(service)
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_CURRENT_DEVICE_SERVICE.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def current_thread_device_service() -> Optional[PersistentPddDeviceService]:
|
|
|
|
|
|
"""返回当前工作线程绑定的服务;未绑定时返回 ``None``。"""
|
|
|
|
|
|
|
|
|
|
|
|
return _CURRENT_DEVICE_SERVICE.get()
|