Files
cmautobuy/client/src/pdd_device_service.py
T

340 lines
12 KiB
Python
Raw Normal View History

"""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()