340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""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()
|