Files
cmautobuy/client/src/pdd_device_service.py
T

340 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""uiautomator2 设备连接边界。
普通服务每个任务建立一次连接;持久服务只复用 Device 连接,不复用页面数据。
两种会话都只能在创建它们的线程中使用。
"""
from __future__ import annotations
import threading
import time
from contextlib import AbstractContextManager, contextmanager
from contextvars import ContextVar
from typing import Any, Callable, Iterator, Optional
from .adb_runtime import BundledAdbError, configure_bundled_adb_environment
from .performance_timing import current_performance_trace
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,
initial_app_state: dict[str, Any],
release: Callable[[], None],
) -> None:
self._device = device
self.serial = serial
self.initial_app_state = dict(initial_app_state)
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:
try:
configure_bundled_adb_environment()
except BundledAdbError as exc:
raise PddDeviceError("DEVICE_ADB_MISSING", str(exc)) from exc
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:
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()
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,
current,
lambda: self._release(checked_serial),
)
def _release(self, serial: str) -> None:
with self._state_lock:
if self._active_serial == serial:
self._active_serial = None
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()