450 lines
16 KiB
Python
450 lines
16 KiB
Python
# coding: utf-8
|
||
"""
|
||
iOS 自动化操作基础类
|
||
|
||
提供统一的基础设施用于实现各种 iOS 自动化任务,包括:
|
||
- WDA 客户端管理
|
||
- 统一的元素查找和操作
|
||
- 弹窗处理
|
||
- 日志记录
|
||
- 会话管理
|
||
"""
|
||
|
||
from typing import List, Optional, Tuple, Dict, Any
|
||
import time
|
||
import sys
|
||
|
||
# 使用绝对导入替代相对导入
|
||
try:
|
||
from utils_ios.wda import wda
|
||
except ImportError:
|
||
# 如果在 DroidBot 包外运行,尝试直接导入
|
||
import wda
|
||
|
||
try:
|
||
import logzero
|
||
if not (hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()):
|
||
log_format = '[%(levelname)1.1s %(asctime)s %(module)s:%(lineno)d] %(message)s'
|
||
logzero.setup_default_logger(formatter=logzero.LogFormatter(fmt=log_format))
|
||
logger = logzero.logger
|
||
except ImportError:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class ElementFinder:
|
||
"""统一的控件查找器
|
||
|
||
提供多种方式查找和操作 iOS 界面元素
|
||
在每次操作前自动处理 Alert 弹窗
|
||
"""
|
||
|
||
def __init__(self, client: wda.Client, popup_handler: Optional['PopupHandler'] = None, health_monitor=None):
|
||
"""
|
||
初始化控件查找器
|
||
|
||
Args:
|
||
client: WDA Client 实例
|
||
popup_handler: PopupHandler 实例(可选,用于自动处理 Alert)
|
||
health_monitor: WDAHealthMonitor 实例(可选,用于在 WDA 卡死时快速失败)
|
||
"""
|
||
self.client = client
|
||
self.popup_handler = popup_handler
|
||
self.health_monitor = health_monitor
|
||
|
||
def _check_health_fast_fail(self):
|
||
"""检查 WDA 健康状态,异常时等待恢复后继续,恢复失败才抛出异常
|
||
|
||
原行为:检测到 WDA 下线 → 立刻抛 RuntimeError
|
||
新行为:检测到 WDA 下线 → 调用 trigger_recovery_and_wait 阻塞等待恢复
|
||
→ 恢复成功则透明继续(调用方重试操作)
|
||
→ 恢复失败(超次数/超时)才抛 RuntimeError
|
||
"""
|
||
if self.health_monitor and not self.health_monitor.check_health(timeout=2.0):
|
||
logger.warning("WDA 检测到不健康,等待自动恢复...")
|
||
recovered = self.health_monitor.trigger_recovery_and_wait(timeout=120)
|
||
if not recovered:
|
||
raise RuntimeError("WDA 恢复失败,放弃当前任务")
|
||
|
||
def _auto_handle_alert(self) -> int:
|
||
"""
|
||
自动处理 Alert(如果配置了 popup_handler)
|
||
|
||
Returns:
|
||
处理的 Alert 数量
|
||
"""
|
||
if self.popup_handler:
|
||
return self.popup_handler.handle_alert(max_attempts=5)
|
||
return 0
|
||
|
||
def find_and_click(self, labels: List[str], element_type: str = 'Button',
|
||
timeout: float = 1.0, auto_handle_alert: bool = True) -> Tuple[bool, Optional[str]]:
|
||
"""
|
||
查找并点击控件(操作前自动处理 Alert)
|
||
|
||
Args:
|
||
labels: 要查找的标签列表(按优先级)
|
||
element_type: 控件类型(如 'Button', 'StaticText' 等)
|
||
timeout: 每个标签的查找超时时间
|
||
auto_handle_alert: 是否在操作前自动处理 Alert
|
||
|
||
Returns:
|
||
(是否成功, 匹配的标签)
|
||
"""
|
||
# 操作前先处理 Alert
|
||
if auto_handle_alert:
|
||
alerts_handled = self._auto_handle_alert()
|
||
if alerts_handled > 0:
|
||
logger.debug(f"操作前处理了 {alerts_handled} 个 Alert")
|
||
|
||
self._check_health_fast_fail()
|
||
|
||
for label in labels:
|
||
try:
|
||
# 使用线程超时保护 click_exists,避免 WDA HTTP 请求阻塞过久
|
||
# WDA 底层 HTTP_TIMEOUT 默认 180s,click_exists 的 timeout 仅控制元素等待
|
||
import concurrent.futures
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||
future = executor.submit(
|
||
lambda l=label: self.client(label=l, type=element_type).click_exists(timeout=timeout)
|
||
)
|
||
clicked = future.result(timeout=timeout + 10)
|
||
if clicked:
|
||
logger.debug(f"点击成功: {label} ({element_type})")
|
||
return True, label
|
||
except concurrent.futures.TimeoutError:
|
||
logger.debug(f"查找控件超时 (>{timeout + 10:.1f}s): {label}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"查找控件异常: {label} - {e}")
|
||
continue
|
||
logger.debug(f"未找到控件: {labels}")
|
||
return False, None
|
||
|
||
def check_exists(self, texts: List[str],
|
||
check_types: List[str] = None,
|
||
auto_handle_alert: bool = True) -> Tuple[bool, Optional[str]]:
|
||
"""
|
||
检查文本是否存在(检查前自动处理 Alert)
|
||
|
||
Args:
|
||
texts: 要检查的文本列表
|
||
check_types: 检查类型列表,如 ['label', 'name', 'value']
|
||
auto_handle_alert: 是否在检查前自动处理 Alert
|
||
|
||
Returns:
|
||
(是否存在, 匹配的文本)
|
||
"""
|
||
# 检查前先处理 Alert
|
||
if auto_handle_alert:
|
||
alerts_handled = self._auto_handle_alert()
|
||
if alerts_handled > 0:
|
||
logger.debug(f"检查前处理了 {alerts_handled} 个 Alert")
|
||
|
||
self._check_health_fast_fail()
|
||
|
||
if check_types is None:
|
||
check_types = ['label', 'name', 'value']
|
||
|
||
for text in texts:
|
||
for check_type in check_types:
|
||
try:
|
||
if check_type == 'label' and self.client(labelContains=text).exists:
|
||
return True, text
|
||
elif check_type == 'name' and self.client(nameContains=text).exists:
|
||
return True, text
|
||
elif check_type == 'value' and self.client(valueContains=text).exists:
|
||
return True, text
|
||
except Exception:
|
||
pass
|
||
return False, None
|
||
|
||
def wait_for_element(self, labels: List[str], element_type: str = 'Button',
|
||
timeout: float = 10.0, auto_handle_alert: bool = True) -> Tuple[bool, Optional[str]]:
|
||
"""
|
||
等待控件出现(等待期间自动处理 Alert)
|
||
|
||
Args:
|
||
labels: 要等待的标签列表
|
||
element_type: 控件类型
|
||
timeout: 总超时时间
|
||
auto_handle_alert: 是否在等待期间自动处理 Alert
|
||
|
||
Returns:
|
||
(是否找到, 匹配的标签)
|
||
"""
|
||
start = time.time()
|
||
while time.time() - start < timeout:
|
||
# 等待期间处理 Alert
|
||
if auto_handle_alert:
|
||
self._auto_handle_alert()
|
||
|
||
self._check_health_fast_fail()
|
||
|
||
for label in labels:
|
||
if self.client(label=label, type=element_type).exists:
|
||
logger.debug(f"找到元素: {label} ({element_type})")
|
||
return True, label
|
||
time.sleep(0.5)
|
||
|
||
logger.debug(f"等待元素超时: {labels}")
|
||
return False, None
|
||
|
||
|
||
class PopupHandler:
|
||
"""统一弹窗处理器
|
||
|
||
处理系统 Alert 和常规弹窗,提供自动检查和处理机制
|
||
"""
|
||
|
||
# 常见的允许/确认按钮文案
|
||
ALLOW_BUTTONS = [
|
||
# 中文
|
||
"允许", "好", "确定", "始终允许", "使用App时允许",
|
||
"允许一次", "仅在使用应用期间", "始终", "15分钟后需要",
|
||
# 英文
|
||
# "Allow", "OK", "Yes", "Always Allow", "Allow While Using App",
|
||
# "Allow Once", "While Using the App", "Always",
|
||
]
|
||
|
||
# 常见的拒绝/取消按钮文案
|
||
DENY_BUTTONS = [
|
||
# 中文
|
||
"不允许", "取消", "稍后", "以后", "暂不",
|
||
# 英文
|
||
# "Don't Allow", "Cancel", "Later", "Not Now", "Deny",
|
||
]
|
||
|
||
def __init__(self, client: wda.Client, finder: ElementFinder, default_alert_strategy: str = "allow"):
|
||
"""
|
||
初始化弹窗处理器
|
||
|
||
Args:
|
||
client: WDA Client 实例
|
||
finder: ElementFinder 实例
|
||
default_alert_strategy: 默认的 Alert 处理策略 - "allow" 或 "deny"
|
||
"""
|
||
self.client = client
|
||
self.finder = finder
|
||
self.default_alert_strategy = default_alert_strategy
|
||
|
||
def handle_alert(self, strategy: str = None, max_attempts: int = 5) -> int:
|
||
"""
|
||
处理系统 Alert 弹窗(支持多次尝试)
|
||
|
||
使用 alert.accept()/alert.dismiss() 直接处理,
|
||
通过返回的 status 字段判断是否成功(status=0 表示成功)
|
||
|
||
Args:
|
||
strategy: 处理策略 - "allow" 或 "deny",None 则使用默认策略
|
||
max_attempts: 最大尝试次数
|
||
|
||
Returns:
|
||
处理的 Alert 数量
|
||
"""
|
||
if strategy is None:
|
||
strategy = self.default_alert_strategy
|
||
|
||
handled_count = 0
|
||
|
||
for _ in range(max_attempts):
|
||
try:
|
||
alert = self.client.alert
|
||
# 使用线程超时保护 alert.exists 检查,避免 WDA 阻塞过久
|
||
import concurrent.futures
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||
future = executor.submit(lambda: alert.exists)
|
||
try:
|
||
alert_exists = future.result(timeout=30)
|
||
except concurrent.futures.TimeoutError:
|
||
logger.debug("检查 Alert 超时 (>30s),跳过")
|
||
break
|
||
|
||
if not alert_exists:
|
||
break
|
||
|
||
# 直接使用 accept/dismiss 方法
|
||
try:
|
||
if strategy == "allow":
|
||
result = alert.accept()
|
||
else:
|
||
result = alert.dismiss()
|
||
|
||
# 检查返回状态
|
||
# 正常返回: {'value': None, 'sessionId': '...', 'status': 0}
|
||
# status=0 表示成功
|
||
if isinstance(result, dict) and result.get('status') == 0:
|
||
handled_count += 1
|
||
logger.debug(f"处理 Alert: 使用 {strategy} 方法成功")
|
||
time.sleep(0.3)
|
||
continue
|
||
else:
|
||
# status 非 0,可能失败了
|
||
logger.debug(f"处理 Alert 返回状态: {result}")
|
||
break
|
||
|
||
except Exception as e:
|
||
# 如果出现异常,说明可能没有 Alert 或处理失败
|
||
logger.debug(f"处理 Alert 异常: {e}")
|
||
break
|
||
|
||
except Exception as e:
|
||
logger.debug(f"检查 Alert 异常: {e}")
|
||
break
|
||
|
||
return handled_count
|
||
|
||
def dismiss_popups(self, max_attempts: int = 3, button_list: List[str] = None, auto_handle_alert = False):
|
||
"""
|
||
自动处理常规弹窗
|
||
|
||
Args:
|
||
max_attempts: 最多尝试处理的弹窗数量
|
||
button_list: 自定义按钮列表,默认使用 ALLOW_BUTTONS
|
||
auto_handle_alert: 是否先进行alert检查和处理,默认关闭
|
||
"""
|
||
if button_list is None:
|
||
button_list = self.ALLOW_BUTTONS
|
||
|
||
for _ in range(max_attempts):
|
||
clicked, label = self.finder.find_and_click(button_list, timeout=0.5, auto_handle_alert=auto_handle_alert)
|
||
if not clicked:
|
||
break
|
||
logger.debug(f"关闭弹窗: {label}")
|
||
time.sleep(0.3)
|
||
|
||
|
||
class IOSAutomationBase:
|
||
"""iOS 自动化操作基础类
|
||
|
||
提供统一的初始化、会话管理和基础操作方法
|
||
子类可以继承此类来实现具体的自动化任务
|
||
|
||
自动处理 Alert 机制:
|
||
- 所有控件操作前自动检查并处理 Alert
|
||
- 可通过 default_alert_strategy 配置默认处理策略
|
||
"""
|
||
|
||
def __init__(self, server_url: str, default_alert_strategy: str = "allow", health_monitor=None, **kwargs):
|
||
"""
|
||
初始化基础类
|
||
|
||
Args:
|
||
server_url: WDA Server URL
|
||
default_alert_strategy: 默认的 Alert 处理策略 - "allow" 或 "deny"
|
||
health_monitor: WDAHealthMonitor 实例(可选)
|
||
**kwargs: 子类可以传入额外的配置参数
|
||
"""
|
||
self.server_url = server_url
|
||
self.default_alert_strategy = default_alert_strategy
|
||
self.health_monitor = health_monitor
|
||
self.config = kwargs
|
||
self.client: Optional[wda.Client] = None
|
||
self.finder: Optional[ElementFinder] = None
|
||
self.popup_handler: Optional[PopupHandler] = None
|
||
|
||
def _init_session(self):
|
||
"""初始化 WDA 会话和工具类(ElementFinder 自动关联 PopupHandler)"""
|
||
# 根据传入的连接参数选择连接方式:
|
||
# - http: 格式 → 使用 HTTP Client 连接
|
||
# - UDID 或空字符串 → 使用 USBClient 通过 USB 连接
|
||
if self.server_url and self.server_url.startswith("http:"):
|
||
self.client = wda.Client(self.server_url)
|
||
else:
|
||
udid = self.server_url or ""
|
||
self.client = wda.USBClient(udid=udid)
|
||
# 先创建 PopupHandler
|
||
self.popup_handler = PopupHandler(self.client, None, self.default_alert_strategy)
|
||
# 创建 ElementFinder 并关联 PopupHandler(用于自动处理 Alert)
|
||
self.finder = ElementFinder(self.client, self.popup_handler, self.health_monitor)
|
||
# 更新 PopupHandler 的 finder 引用
|
||
self.popup_handler.finder = self.finder
|
||
logger.debug(f"会话初始化完成: {self.server_url}, Alert策略: {self.default_alert_strategy}")
|
||
|
||
def _check_wda_ready(self, timeout: float = 30.0) -> bool:
|
||
"""
|
||
检查 WDA 是否就绪
|
||
|
||
Args:
|
||
timeout: 等待超时时间
|
||
|
||
Returns:
|
||
WDA 是否就绪
|
||
"""
|
||
try:
|
||
if not self.client.wait_ready(timeout=timeout):
|
||
logger.error("WDA 未就绪")
|
||
return False
|
||
logger.debug("WDA 已就绪")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"检查 WDA 状态失败: {e}")
|
||
return False
|
||
|
||
def _unlock_device(self) -> bool:
|
||
"""
|
||
解锁设备(如果已锁定)
|
||
|
||
Returns:
|
||
是否成功解锁
|
||
"""
|
||
try:
|
||
if self.client.locked():
|
||
self.client.unlock()
|
||
logger.info("设备已解锁")
|
||
return True
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"解锁设备失败: {e}")
|
||
return False
|
||
|
||
def _launch_app(self, bundle_id: str) -> bool:
|
||
"""
|
||
启动应用
|
||
|
||
Args:
|
||
bundle_id: 应用的 Bundle ID
|
||
|
||
Returns:
|
||
是否成功启动
|
||
"""
|
||
try:
|
||
self.client.session(bundle_id)
|
||
logger.info(f"启动应用: {bundle_id}")
|
||
time.sleep(2) # 等待应用启动
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"启动应用失败: {bundle_id} - {e}")
|
||
return False
|
||
|
||
def _open_url(self, url: str, wait_time: float = 5.0) -> bool:
|
||
"""
|
||
打开 URL(如 URL Scheme)
|
||
|
||
Args:
|
||
url: 要打开的 URL
|
||
wait_time: 打开后等待时间
|
||
|
||
Returns:
|
||
是否成功打开
|
||
"""
|
||
try:
|
||
self.client.open_url(url)
|
||
logger.debug(f"打开 URL: {url}")
|
||
self.client.wait_ready(wait_time)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"打开 URL 失败: {url} - {e}")
|
||
return False
|
||
|
||
def execute(self, *args, **kwargs) -> bool:
|
||
"""
|
||
执行自动化任务的主方法
|
||
|
||
子类必须实现此方法来定义具体的任务流程
|
||
|
||
Returns:
|
||
任务是否成功完成
|
||
"""
|
||
raise NotImplementedError("子类必须实现 execute() 方法")
|